Merge pull request #210 from bnema/fix/hybrid-remote-stopped-resurrection
fix(remote): resurrect inactive sessions across hosts
45 files · +1530 -906
M .gitignore +1 -0
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | *.test | |
| 3 | 3 | .superpowers/ | |
| 4 | 4 | docs/superpowers/ | |
| 5 | + | .agent-reviews/ | |
| 5 | 6 | .worktrees/ | |
| 6 | 7 | scripts/demo/out/ | |
| 7 | 8 | /.worktrees/ | |
M docs/durable-session-recovery.md +1 -1
| @@ -4,7 +4,7 @@ vev opens named-session state only while holding `$XDG_RUNTIME_DIR/vev/lifecycle | |||
| 4 | 4 | ||
| 5 | 5 | ## Session states | |
| 6 | 6 | ||
| 7 | - | `stopped` is safe fresh metadata, `restoring` is validation in progress, and `degraded` preserves uncertain state without starting a replacement shell. A degraded session remains visible in `vev ls`, but attach is refused until an explicit recovery action succeeds. | |
| 7 | + | `stopped` is safe fresh metadata, `restoring` is validation in progress, and `degraded` preserves uncertain state without starting a replacement shell. A degraded session remains visible in `vev ls`, but attach is refused until an explicit recovery action succeeds. A session reserved for durable purge is hidden from listings and pickers until deletion either completes or leaves a retained broken record. | |
| 8 | 8 | ||
| 9 | 9 | ## Recovery commands | |
| 10 | 10 | ||
M docs/remote-resilience.md +4 -4
| @@ -61,10 +61,10 @@ version 5 records restore normally. Older binaries reject version 5 records, so | |||
| 61 | 61 | a downgrade also resets sessions when the older binary can read the catalogue, | |
| 62 | 62 | or fails closed when it cannot. Back up the vev state before a rollback. | |
| 63 | 63 | ||
| 64 | - | Remote catalogues are bounded and versioned. Complete catalogues contain ordered | |
| 65 | - | tab IDs, names, state, active tab, attachment state, and MRU sequence. Stopped | |
| 66 | - | tab metadata is retained across daemon restart; legacy count-only catalogues are | |
| 67 | - | read-only compatibility rows and cannot provide exact tab identity. | |
| 64 | + | Remote catalogues are bounded and versioned. The current schema is mandatory | |
| 65 | + | and contains lifecycle IDs, ordered typed tab records, state, active tab, | |
| 66 | + | attachment state, and MRU sequence. Peers without the exact schema are rejected | |
| 67 | + | as version-incompatible rather than exposed as partial picker rows. | |
| 68 | 68 | ||
| 69 | 69 | Remote picker previews are bounded styled-cell snapshots fetched without | |
| 70 | 70 | attachment or PTY mutation. They are held in a short-lived in-memory cache only; | |
M internal/adapters/remote/catalog.go +82 -39
| @@ -30,7 +30,6 @@ var ( | |||
| 30 | 30 | errCatalogDecode = errors.New("remote catalog: invalid JSON") | |
| 31 | 31 | errCatalogTrailing = errors.New("remote catalog: trailing bytes after JSON") | |
| 32 | 32 | errCatalogRequired = errors.New("remote catalog: missing required fields") | |
| 33 | - | errCatalogSession = errors.New("remote catalog: invalid session") | |
| 34 | 33 | errCatalogSSH = errors.New("remote catalog: ssh command failed") | |
| 35 | 34 | errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit") | |
| 36 | 35 | ) | |
| @@ -112,65 +111,118 @@ func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCa | |||
| 112 | 111 | return catalog, nil | |
| 113 | 112 | } | |
| 114 | 113 | ||
| 114 | + | type catalogVersionEnvelope struct { | |
| 115 | + | ProtocolVersion *uint16 `json:"protocol_version"` | |
| 116 | + | SchemaVersion *uint16 `json:"schema_version"` | |
| 117 | + | Sessions json.RawMessage `json:"sessions"` | |
| 118 | + | } | |
| 119 | + | ||
| 115 | 120 | type catalogEnvelope struct { | |
| 116 | - | ProtocolVersion *uint16 `json:"protocol_version"` | |
| 117 | - | SchemaVersion *uint16 `json:"schema_version"` | |
| 118 | - | Sessions *[]ports.RemoteCatalogSession `json:"sessions"` | |
| 121 | + | ProtocolVersion *uint16 `json:"protocol_version"` | |
| 122 | + | SchemaVersion *uint16 `json:"schema_version"` | |
| 123 | + | Sessions *[]catalogSessionEnvelope `json:"sessions"` | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | type catalogSessionEnvelope struct { | |
| 127 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"` | |
| 128 | + | Name *string `json:"name"` | |
| 129 | + | State *ports.RemoteCatalogSessionState `json:"state"` | |
| 130 | + | Ephemeral *bool `json:"ephemeral"` | |
| 131 | + | Tabs *[]catalogTabEnvelope `json:"tabs"` | |
| 132 | + | Attached *bool `json:"attached"` | |
| 133 | + | LastUsedSeq uint64 `json:"last_used_seq,omitempty"` | |
| 134 | + | ActiveTabID string `json:"active_tab_id,omitempty"` | |
| 135 | + | Reason string `json:"reason,omitempty"` | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | type catalogTabEnvelope struct { | |
| 139 | + | ID *string `json:"id"` | |
| 140 | + | Index *uint16 `json:"index"` | |
| 141 | + | Name *string `json:"name"` | |
| 142 | + | Detail string `json:"detail,omitempty"` | |
| 143 | + | Attention bool `json:"attention,omitempty"` | |
| 119 | 144 | } | |
| 120 | 145 | ||
| 121 | 146 | func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) { | |
| 122 | - | dec := json.NewDecoder(bytes.NewReader(raw)) | |
| 123 | - | var envelope catalogEnvelope | |
| 124 | - | if err := dec.Decode(&envelope); err != nil { | |
| 125 | - | return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogDecode, err) | |
| 147 | + | var version catalogVersionEnvelope | |
| 148 | + | if err := decodeCatalogEnvelope(raw, false, &version); err != nil { | |
| 149 | + | return ports.RemoteCatalog{}, err | |
| 126 | 150 | } | |
| 127 | - | if envelope.ProtocolVersion == nil || envelope.Sessions == nil { | |
| 151 | + | if version.ProtocolVersion == nil { | |
| 128 | 152 | return ports.RemoteCatalog{}, errCatalogRequired | |
| 129 | 153 | } | |
| 130 | - | if err := rejectTrailingJSON(dec); err != nil { | |
| 131 | - | return ports.RemoteCatalog{}, err | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | schemaVersion := uint16(0) | |
| 135 | - | if envelope.SchemaVersion != nil { | |
| 136 | - | schemaVersion = *envelope.SchemaVersion | |
| 154 | + | if version.SchemaVersion == nil { | |
| 155 | + | return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{ | |
| 156 | + | Got: 0, | |
| 157 | + | Want: ports.RemoteCatalogSchemaVersion, | |
| 158 | + | Kind: "catalog", | |
| 159 | + | } | |
| 137 | 160 | } | |
| 138 | - | if *envelope.ProtocolVersion != ports.ProtocolVersion { | |
| 161 | + | if *version.ProtocolVersion != ports.ProtocolVersion { | |
| 139 | 162 | return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{ | |
| 140 | - | Got: *envelope.ProtocolVersion, | |
| 163 | + | Got: *version.ProtocolVersion, | |
| 141 | 164 | Want: ports.ProtocolVersion, | |
| 142 | 165 | Kind: "protocol", | |
| 143 | 166 | } | |
| 144 | 167 | } | |
| 145 | - | if schemaVersion != 0 && schemaVersion != ports.RemoteCatalogSchemaVersion { | |
| 168 | + | if *version.SchemaVersion != ports.RemoteCatalogSchemaVersion { | |
| 146 | 169 | return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{ | |
| 147 | - | Got: schemaVersion, | |
| 170 | + | Got: *version.SchemaVersion, | |
| 148 | 171 | Want: ports.RemoteCatalogSchemaVersion, | |
| 149 | 172 | Kind: "catalog", | |
| 150 | 173 | } | |
| 151 | 174 | } | |
| 152 | 175 | ||
| 153 | - | sessions := *envelope.Sessions | |
| 154 | - | if sessions == nil { | |
| 155 | - | sessions = []ports.RemoteCatalogSession{} | |
| 176 | + | var envelope catalogEnvelope | |
| 177 | + | if err := decodeCatalogEnvelope(raw, true, &envelope); err != nil { | |
| 178 | + | return ports.RemoteCatalog{}, err | |
| 179 | + | } | |
| 180 | + | if envelope.ProtocolVersion == nil || envelope.SchemaVersion == nil || envelope.Sessions == nil { | |
| 181 | + | return ports.RemoteCatalog{}, errCatalogRequired | |
| 182 | + | } | |
| 183 | + | sessions := make([]ports.RemoteCatalogSession, 0, len(*envelope.Sessions)) | |
| 184 | + | for _, session := range *envelope.Sessions { | |
| 185 | + | if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil { | |
| 186 | + | return ports.RemoteCatalog{}, errCatalogRequired | |
| 187 | + | } | |
| 188 | + | tabs := make([]ports.RemoteCatalogTab, 0, len(*session.Tabs)) | |
| 189 | + | for _, tab := range *session.Tabs { | |
| 190 | + | if tab.ID == nil || tab.Index == nil || tab.Name == nil { | |
| 191 | + | return ports.RemoteCatalog{}, errCatalogRequired | |
| 192 | + | } | |
| 193 | + | tabs = append(tabs, ports.RemoteCatalogTab{ | |
| 194 | + | ID: *tab.ID, Index: *tab.Index, Name: *tab.Name, | |
| 195 | + | Detail: tab.Detail, Attention: tab.Attention, | |
| 196 | + | }) | |
| 197 | + | } | |
| 198 | + | sessions = append(sessions, ports.RemoteCatalogSession{ | |
| 199 | + | LifecycleID: *session.LifecycleID, Name: *session.Name, State: *session.State, | |
| 200 | + | Ephemeral: *session.Ephemeral, Tabs: tabs, Attached: *session.Attached, | |
| 201 | + | LastUsedSeq: session.LastUsedSeq, ActiveTabID: session.ActiveTabID, Reason: session.Reason, | |
| 202 | + | }) | |
| 156 | 203 | } | |
| 157 | 204 | catalog := ports.RemoteCatalog{ | |
| 158 | 205 | ProtocolVersion: *envelope.ProtocolVersion, | |
| 159 | - | SchemaVersion: schemaVersion, | |
| 206 | + | SchemaVersion: *envelope.SchemaVersion, | |
| 160 | 207 | Sessions: sessions, | |
| 161 | 208 | } | |
| 162 | 209 | if err := ports.ValidateRemoteCatalog(catalog); err != nil { | |
| 163 | - | if schemaVersion == 0 { | |
| 164 | - | // Keep the old adapter error taxonomy for pre-parity peers and | |
| 165 | - | // direct CLI compatibility. Current-schema payloads retain the | |
| 166 | - | // typed validation error for callers that need to classify it. | |
| 167 | - | return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSession, err) | |
| 168 | - | } | |
| 169 | 210 | return ports.RemoteCatalog{}, err | |
| 170 | 211 | } | |
| 171 | 212 | return catalog, nil | |
| 172 | 213 | } | |
| 173 | 214 | ||
| 215 | + | func decodeCatalogEnvelope(raw []byte, strict bool, dst any) error { | |
| 216 | + | dec := json.NewDecoder(bytes.NewReader(raw)) | |
| 217 | + | if strict { | |
| 218 | + | dec.DisallowUnknownFields() | |
| 219 | + | } | |
| 220 | + | if err := dec.Decode(dst); err != nil { | |
| 221 | + | return fmt.Errorf("%w: %w", errCatalogDecode, err) | |
| 222 | + | } | |
| 223 | + | return rejectTrailingJSON(dec) | |
| 224 | + | } | |
| 225 | + | ||
| 174 | 226 | func rejectTrailingJSON(dec *json.Decoder) error { | |
| 175 | 227 | var extra json.RawMessage | |
| 176 | 228 | err := dec.Decode(&extra) | |
| @@ -183,15 +235,6 @@ func rejectTrailingJSON(dec *json.Decoder) error { | |||
| 183 | 235 | return fmt.Errorf("%w: %w", errCatalogTrailing, err) | |
| 184 | 236 | } | |
| 185 | 237 | ||
| 186 | - | func validCatalogState(state string) bool { | |
| 187 | - | switch state { | |
| 188 | - | case "up", "down", "broken": | |
| 189 | - | return true | |
| 190 | - | default: | |
| 191 | - | return false | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | ||
| 195 | 238 | // sanitizeCatalogDiagnostic strips terminal escapes and control bytes while | |
| 196 | 239 | // preserving useful printable diagnostic text for errors and logs. | |
| 197 | 240 | func sanitizeCatalogDiagnostic(raw string) string { | |
M internal/adapters/remote/catalog_cache.go +28 -44
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | const ( | |
| 19 | 19 | catalogCacheFileName = "remote-catalog-cache.json" | |
| 20 | - | catalogCacheFileVersion = 2 | |
| 20 | + | catalogCacheFileVersion = 3 | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir. | |
| @@ -35,15 +35,14 @@ type catalogCacheHost struct { | |||
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | type catalogCacheSession struct { | |
| 38 | - | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |
| 39 | - | Name *string `json:"name"` | |
| 40 | - | State *string `json:"state"` | |
| 41 | - | Ephemeral *bool `json:"ephemeral"` | |
| 42 | - | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` | |
| 43 | - | Tabs *uint16 `json:"tabs,omitempty"` | |
| 44 | - | TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"` | |
| 45 | - | ActiveTabID *string `json:"active_tab_id,omitempty"` | |
| 46 | - | Attached *bool `json:"attached"` | |
| 38 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"` | |
| 39 | + | Name *string `json:"name"` | |
| 40 | + | State *ports.RemoteCatalogSessionState `json:"state"` | |
| 41 | + | Ephemeral *bool `json:"ephemeral"` | |
| 42 | + | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` | |
| 43 | + | Tabs *[]ports.RemoteCatalogTab `json:"tabs"` | |
| 44 | + | ActiveTabID *string `json:"active_tab_id,omitempty"` | |
| 45 | + | Attached *bool `json:"attached"` | |
| 47 | 46 | } | |
| 48 | 47 | ||
| 49 | 48 | type fileCatalogCache struct { | |
| @@ -107,32 +106,28 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { | |||
| 107 | 106 | Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)), | |
| 108 | 107 | } | |
| 109 | 108 | for _, session := range *host.Sessions { | |
| 110 | - | if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil && session.TabList == nil { | |
| 109 | + | if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil { | |
| 111 | 110 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields") | |
| 112 | 111 | } | |
| 113 | - | if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) { | |
| 112 | + | if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) { | |
| 114 | 113 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |
| 115 | 114 | } | |
| 115 | + | tabs := make([]ports.RemoteCatalogTab, len(*session.Tabs)) | |
| 116 | + | copy(tabs, *session.Tabs) | |
| 116 | 117 | decoded := ports.RemoteCatalogSession{ | |
| 117 | - | Name: *session.Name, | |
| 118 | - | State: *session.State, | |
| 119 | - | Ephemeral: *session.Ephemeral, | |
| 120 | - | Attached: *session.Attached, | |
| 118 | + | LifecycleID: *session.LifecycleID, | |
| 119 | + | Name: *session.Name, | |
| 120 | + | State: *session.State, | |
| 121 | + | Ephemeral: *session.Ephemeral, | |
| 122 | + | Tabs: tabs, | |
| 123 | + | Attached: *session.Attached, | |
| 121 | 124 | } | |
| 122 | 125 | if session.LastUsedSeq != nil { | |
| 123 | 126 | decoded.LastUsedSeq = *session.LastUsedSeq | |
| 124 | 127 | } | |
| 125 | - | if session.LifecycleID != nil { | |
| 126 | - | decoded.LifecycleID = *session.LifecycleID | |
| 127 | - | } | |
| 128 | 128 | if session.ActiveTabID != nil { | |
| 129 | 129 | decoded.ActiveTabID = *session.ActiveTabID | |
| 130 | 130 | } | |
| 131 | - | if session.TabList != nil { | |
| 132 | - | decoded.Tabs = append([]ports.RemoteCatalogTab(nil), (*session.TabList)...) | |
| 133 | - | } else { | |
| 134 | - | decoded.Tabs = int(*session.Tabs) | |
| 135 | - | } | |
| 136 | 131 | entry.Sessions = append(entry.Sessions, decoded) | |
| 137 | 132 | } | |
| 138 | 133 | entries = append(entries, entry) | |
| @@ -160,26 +155,18 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error | |||
| 160 | 155 | state := session.State | |
| 161 | 156 | ephemeral := session.Ephemeral | |
| 162 | 157 | attached := session.Attached | |
| 163 | - | row := catalogCacheSession{Name: &name, State: &state, Ephemeral: &ephemeral, Attached: &attached} | |
| 158 | + | id := session.LifecycleID | |
| 159 | + | tabs := make([]ports.RemoteCatalogTab, len(session.Tabs)) | |
| 160 | + | copy(tabs, session.Tabs) | |
| 161 | + | row := catalogCacheSession{LifecycleID: &id, Name: &name, State: &state, Ephemeral: &ephemeral, Tabs: &tabs, Attached: &attached} | |
| 164 | 162 | if session.LastUsedSeq != 0 { | |
| 165 | 163 | lastUsed := session.LastUsedSeq | |
| 166 | 164 | row.LastUsedSeq = &lastUsed | |
| 167 | 165 | } | |
| 168 | - | if session.LifecycleID != (domain.SessionLifecycleID{}) { | |
| 169 | - | id := session.LifecycleID | |
| 170 | - | row.LifecycleID = &id | |
| 171 | - | } | |
| 172 | 166 | if session.ActiveTabID != "" { | |
| 173 | 167 | active := session.ActiveTabID | |
| 174 | 168 | row.ActiveTabID = &active | |
| 175 | 169 | } | |
| 176 | - | if tabs := ports.CatalogTabs(session); tabs != nil { | |
| 177 | - | copyTabs := append([]ports.RemoteCatalogTab(nil), tabs...) | |
| 178 | - | row.TabList = ©Tabs | |
| 179 | - | } else { | |
| 180 | - | count := ports.SaturateUint16(ports.CatalogTabCount(session)) | |
| 181 | - | row.Tabs = &count | |
| 182 | - | } | |
| 183 | 170 | sessions = append(sessions, row) | |
| 184 | 171 | } | |
| 185 | 172 | hosts = append(hosts, catalogCacheHost{ | |
| @@ -221,13 +208,13 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po | |||
| 221 | 208 | } | |
| 222 | 209 | sessions := make(map[string]struct{}, len(entry.Sessions)) | |
| 223 | 210 | for _, session := range entry.Sessions { | |
| 224 | - | if !utf8.ValidString(session.Name) || !utf8.ValidString(session.State) { | |
| 211 | + | if !utf8.ValidString(session.Name) || !utf8.ValidString(string(session.State)) { | |
| 225 | 212 | return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8") | |
| 226 | 213 | } | |
| 227 | 214 | if err := domain.ValidateSessionName(session.Name); err != nil { | |
| 228 | 215 | return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err) | |
| 229 | 216 | } | |
| 230 | - | if !validCatalogState(session.State) { | |
| 217 | + | if !session.State.Valid() { | |
| 231 | 218 | return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State) | |
| 232 | 219 | } | |
| 233 | 220 | if _, duplicate := sessions[session.Name]; duplicate { | |
| @@ -235,12 +222,9 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po | |||
| 235 | 222 | } | |
| 236 | 223 | sessions[session.Name] = struct{}{} | |
| 237 | 224 | copySession := session | |
| 238 | - | if tabs := ports.CatalogTabs(session); tabs != nil { | |
| 239 | - | copyTabs := make([]ports.RemoteCatalogTab, len(tabs)) | |
| 240 | - | for i, tab := range tabs { | |
| 241 | - | copyTabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name} | |
| 242 | - | } | |
| 243 | - | copySession.Tabs = copyTabs | |
| 225 | + | copySession.Tabs = make([]ports.RemoteCatalogTab, len(session.Tabs)) | |
| 226 | + | for i, tab := range session.Tabs { | |
| 227 | + | copySession.Tabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name} | |
| 244 | 228 | } | |
| 245 | 229 | copyEntry.Sessions = append(copyEntry.Sessions, copySession) | |
| 246 | 230 | } | |
M internal/adapters/remote/catalog_cache_test.go +29 -16
| @@ -3,6 +3,7 @@ package remote | |||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "reflect" | |
| 6 | 7 | "slices" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "testing" | |
| @@ -30,8 +31,11 @@ func TestCatalogCacheStoreLoad(t *testing.T) { | |||
| 30 | 31 | Host: "zebra", | |
| 31 | 32 | FetchedAt: fetchedAt, | |
| 32 | 33 | Sessions: []ports.RemoteCatalogSession{ | |
| 33 | - | {Name: "work", State: "up", Tabs: 2}, | |
| 34 | - | {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true}, | |
| 34 | + | { | |
| 35 | + | LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", | |
| 36 | + | Tabs: []ports.RemoteCatalogTab{{ID: "work-1", Detail: "dynamic", Attention: true}, {ID: "work-2", Index: 1}}, | |
| 37 | + | }, | |
| 38 | + | {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true}, | |
| 35 | 39 | }, | |
| 36 | 40 | }, | |
| 37 | 41 | { | |
| @@ -58,6 +62,12 @@ func TestCatalogCacheStoreLoad(t *testing.T) { | |||
| 58 | 62 | if alpha < 0 || work < 0 || alpha >= work { | |
| 59 | 63 | t.Fatalf("stored sessions are not ordered: %s", raw) | |
| 60 | 64 | } | |
| 65 | + | if !strings.Contains(string(raw), `"version":3`) || !strings.Contains(string(raw), `"tabs":[{"id":"work-1"`) { | |
| 66 | + | t.Fatalf("stored cache does not pin the v3 typed-tab contract: %s", raw) | |
| 67 | + | } | |
| 68 | + | if strings.Contains(string(raw), `"detail"`) || strings.Contains(string(raw), `"attention"`) { | |
| 69 | + | t.Fatalf("stored cache contains dynamic tab fields: %s", raw) | |
| 70 | + | } | |
| 61 | 71 | got, err := cache.Load() | |
| 62 | 72 | if err != nil { | |
| 63 | 73 | t.Fatalf("Load() error = %v", err) | |
| @@ -72,8 +82,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) { | |||
| 72 | 82 | Host: "zebra", | |
| 73 | 83 | FetchedAt: fetchedAt, | |
| 74 | 84 | Sessions: []ports.RemoteCatalogSession{ | |
| 75 | - | {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true}, | |
| 76 | - | {Name: "work", State: "up", Tabs: 2}, | |
| 85 | + | {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true}, | |
| 86 | + | {LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", Tabs: []ports.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}}, | |
| 77 | 87 | }, | |
| 78 | 88 | }, | |
| 79 | 89 | } | |
| @@ -113,17 +123,20 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | |||
| 113 | 123 | name string | |
| 114 | 124 | raw []byte | |
| 115 | 125 | }{ | |
| 116 | - | {name: "truncated", raw: []byte(`{"version":1,"hosts":[`)}, | |
| 117 | - | {name: "trailing JSON", raw: []byte(`{"version":1,"hosts":[]} {}`)}, | |
| 118 | - | {name: "unknown version", raw: []byte(`{"version":3,"hosts":[]}`)}, | |
| 119 | - | {name: "missing hosts", raw: []byte(`{"version":1}`)}, | |
| 120 | - | {name: "null hosts", raw: []byte(`{"version":1,"hosts":null}`)}, | |
| 121 | - | {name: "null sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)}, | |
| 122 | - | {name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)}, | |
| 123 | - | {name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)}, | |
| 124 | - | {name: "duplicate hosts", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)}, | |
| 125 | - | {name: "duplicate sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false},{"name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`)}, | |
| 126 | - | {name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")}, | |
| 126 | + | {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)}, | |
| 127 | + | {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)}, | |
| 128 | + | {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[]}`)}, | |
| 129 | + | {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)}, | |
| 130 | + | {name: "missing hosts", raw: []byte(`{"version":3}`)}, | |
| 131 | + | {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)}, | |
| 132 | + | {name: "null sessions", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)}, | |
| 133 | + | {name: "zero fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)}, | |
| 134 | + | {name: "negative fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)}, | |
| 135 | + | {name: "duplicate hosts", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)}, | |
| 136 | + | {name: "missing exact session fields", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"up","ephemeral":false,"tabs":[],"attached":false}]}]}`)}, | |
| 137 | + | {name: "count-only tabs", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`)}, | |
| 138 | + | {name: "unknown field", raw: []byte(`{"version":3,"hosts":[],"future":true}`)}, | |
| 139 | + | {name: "invalid utf8", raw: []byte("{\"version\":3,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")}, | |
| 127 | 140 | } | |
| 128 | 141 | for _, tc := range cases { | |
| 129 | 142 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -268,5 +281,5 @@ func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) { | |||
| 268 | 281 | } | |
| 269 | 282 | ||
| 270 | 283 | func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool { | |
| 271 | - | return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && slices.Equal(a.Sessions, b.Sessions) | |
| 284 | + | return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && reflect.DeepEqual(a.Sessions, b.Sessions) | |
| 272 | 285 | } | |
M internal/adapters/remote/catalog_test.go +74 -11
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "os/exec" | |
| 9 | + | "reflect" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "testing" | |
| 11 | 12 | "time" | |
| @@ -37,6 +38,7 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) { | |||
| 37 | 38 | gotArgs = append([]string(nil), args...) | |
| 38 | 39 | return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{ | |
| 39 | 40 | ProtocolVersion: ports.ProtocolVersion, | |
| 41 | + | SchemaVersion: ports.RemoteCatalogSchemaVersion, | |
| 40 | 42 | Sessions: []ports.RemoteCatalogSession{}, | |
| 41 | 43 | })) | |
| 42 | 44 | }, | |
| @@ -82,8 +84,9 @@ func TestRemoteCatalogClientDecode(t *testing.T) { | |||
| 82 | 84 | ||
| 83 | 85 | valid := ports.RemoteCatalog{ | |
| 84 | 86 | ProtocolVersion: ports.ProtocolVersion, | |
| 87 | + | SchemaVersion: ports.RemoteCatalogSchemaVersion, | |
| 85 | 88 | Sessions: []ports.RemoteCatalogSession{ | |
| 86 | - | {Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true}, | |
| 89 | + | {LifecycleID: [16]byte{1}, Name: "work", State: "up", Ephemeral: false, Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}}, Attached: true}, | |
| 87 | 90 | }, | |
| 88 | 91 | } | |
| 89 | 92 | validJSON := mustCatalogJSON(t, valid) | |
| @@ -109,18 +112,41 @@ func TestRemoteCatalogClientDecode(t *testing.T) { | |||
| 109 | 112 | }, | |
| 110 | 113 | { | |
| 111 | 114 | name: "mismatching protocol version", | |
| 112 | - | stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion + 1, Sessions: []ports.RemoteCatalogSession{}}), | |
| 115 | + | stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion + 1, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{}}), | |
| 113 | 116 | check: func(t *testing.T, err error) { | |
| 114 | 117 | t.Helper() | |
| 115 | 118 | var mismatch *ports.RemoteCatalogVersionMismatchError | |
| 116 | 119 | if !errors.As(err, &mismatch) { | |
| 117 | 120 | t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err) | |
| 118 | 121 | } | |
| 119 | - | if mismatch.Got != ports.ProtocolVersion+1 || mismatch.Want != ports.ProtocolVersion { | |
| 122 | + | if mismatch.Kind != "protocol" || mismatch.Got != ports.ProtocolVersion+1 || mismatch.Want != ports.ProtocolVersion { | |
| 120 | 123 | t.Fatalf("mismatch = %#v", mismatch) | |
| 121 | 124 | } | |
| 122 | 125 | }, | |
| 123 | 126 | }, | |
| 127 | + | { | |
| 128 | + | name: "obsolete count-only schema is classified before session decoding", | |
| 129 | + | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":2,"sessions":[{"tabs":1}]}`, | |
| 130 | + | ports.ProtocolVersion), | |
| 131 | + | check: func(t *testing.T, err error) { | |
| 132 | + | t.Helper() | |
| 133 | + | var mismatch *ports.RemoteCatalogVersionMismatchError | |
| 134 | + | if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 2 || mismatch.Want != ports.RemoteCatalogSchemaVersion { | |
| 135 | + | t.Fatalf("error = %v, want obsolete catalogue version mismatch", err) | |
| 136 | + | } | |
| 137 | + | }, | |
| 138 | + | }, | |
| 139 | + | { | |
| 140 | + | name: "unknown catalogue schema", | |
| 141 | + | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":4,"sessions":[]}`, ports.ProtocolVersion), | |
| 142 | + | check: func(t *testing.T, err error) { | |
| 143 | + | t.Helper() | |
| 144 | + | var mismatch *ports.RemoteCatalogVersionMismatchError | |
| 145 | + | if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 4 || mismatch.Want != ports.RemoteCatalogSchemaVersion { | |
| 146 | + | t.Fatalf("error = %v, want unknown catalogue version mismatch", err) | |
| 147 | + | } | |
| 148 | + | }, | |
| 149 | + | }, | |
| 124 | 150 | { | |
| 125 | 151 | name: "malformed json", | |
| 126 | 152 | stdout: `{"protocol_version":`, | |
| @@ -131,6 +157,24 @@ func TestRemoteCatalogClientDecode(t *testing.T) { | |||
| 131 | 157 | stdout: `{"protocol_version":19,"sessions":[`, | |
| 132 | 158 | wantErr: errCatalogDecode, | |
| 133 | 159 | }, | |
| 160 | + | { | |
| 161 | + | name: "count-only tabs rejected", | |
| 162 | + | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}`, | |
| 163 | + | ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion), | |
| 164 | + | wantErr: errCatalogDecode, | |
| 165 | + | }, | |
| 166 | + | { | |
| 167 | + | name: "unknown fields rejected", | |
| 168 | + | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[],"future":true}`, | |
| 169 | + | ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion), | |
| 170 | + | wantErr: errCatalogDecode, | |
| 171 | + | }, | |
| 172 | + | { | |
| 173 | + | name: "missing required session field", | |
| 174 | + | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","tabs":[],"attached":false}]}`, | |
| 175 | + | ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion), | |
| 176 | + | wantErr: errCatalogRequired, | |
| 177 | + | }, | |
| 134 | 178 | { | |
| 135 | 179 | name: "trailing non-whitespace", | |
| 136 | 180 | stdout: strings.TrimSpace(validJSON) + `{"extra":true}`, | |
| @@ -141,20 +185,35 @@ func TestRemoteCatalogClientDecode(t *testing.T) { | |||
| 141 | 185 | stdout: `{"sessions":[]}` + "\n", | |
| 142 | 186 | wantErr: errCatalogRequired, | |
| 143 | 187 | }, | |
| 188 | + | { | |
| 189 | + | name: "missing catalogue schema", | |
| 190 | + | stdout: fmt.Sprintf(`{"protocol_version":%d,"sessions":[]}`+"\n", ports.ProtocolVersion), | |
| 191 | + | check: func(t *testing.T, err error) { | |
| 192 | + | t.Helper() | |
| 193 | + | var mismatch *ports.RemoteCatalogVersionMismatchError | |
| 194 | + | if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 0 || mismatch.Want != ports.RemoteCatalogSchemaVersion { | |
| 195 | + | t.Fatalf("error = %v, want missing catalogue version mismatch", err) | |
| 196 | + | } | |
| 197 | + | }, | |
| 198 | + | }, | |
| 144 | 199 | { | |
| 145 | 200 | name: "missing sessions", | |
| 146 | - | stdout: fmt.Sprintf(`{"protocol_version":%d}`+"\n", ports.ProtocolVersion), | |
| 201 | + | stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d}`+"\n", ports.ProtocolVersion, ports.RemoteCatalogSchemaVersion), | |
| 147 | 202 | wantErr: errCatalogRequired, | |
| 148 | 203 | }, | |
| 149 | 204 | { | |
| 150 | - | name: "invalid session name", | |
| 151 | - | stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "bad name", State: "up"}}}), | |
| 152 | - | wantErr: errCatalogSession, | |
| 205 | + | name: "invalid session name", | |
| 206 | + | stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{ | |
| 207 | + | LifecycleID: [16]byte{1}, Name: "bad name", State: "up", Tabs: []ports.RemoteCatalogTab{}, | |
| 208 | + | }}}), | |
| 209 | + | wantErr: ports.ErrInvalidRemoteCatalog, | |
| 153 | 210 | }, | |
| 154 | 211 | { | |
| 155 | - | name: "invalid session state", | |
| 156 | - | stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "weird"}}}), | |
| 157 | - | wantErr: errCatalogSession, | |
| 212 | + | name: "invalid session state", | |
| 213 | + | stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{ | |
| 214 | + | LifecycleID: [16]byte{1}, Name: "work", State: "weird", Tabs: []ports.RemoteCatalogTab{}, | |
| 215 | + | }}}), | |
| 216 | + | wantErr: ports.ErrRemoteCatalogUnknownState, | |
| 158 | 217 | }, | |
| 159 | 218 | { | |
| 160 | 219 | name: "remote exit with stderr", | |
| @@ -198,11 +257,14 @@ func TestRemoteCatalogClientDecode(t *testing.T) { | |||
| 198 | 257 | if got.ProtocolVersion != tt.want.ProtocolVersion { | |
| 199 | 258 | t.Fatalf("ProtocolVersion = %d, want %d", got.ProtocolVersion, tt.want.ProtocolVersion) | |
| 200 | 259 | } | |
| 260 | + | if got.SchemaVersion != tt.want.SchemaVersion { | |
| 261 | + | t.Fatalf("SchemaVersion = %d, want %d", got.SchemaVersion, tt.want.SchemaVersion) | |
| 262 | + | } | |
| 201 | 263 | if len(got.Sessions) != len(tt.want.Sessions) { | |
| 202 | 264 | t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions)) | |
| 203 | 265 | } | |
| 204 | 266 | for i := range tt.want.Sessions { | |
| 205 | - | if got.Sessions[i] != tt.want.Sessions[i] { | |
| 267 | + | if !reflect.DeepEqual(got.Sessions[i], tt.want.Sessions[i]) { | |
| 206 | 268 | t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i]) | |
| 207 | 269 | } | |
| 208 | 270 | } | |
| @@ -231,6 +293,7 @@ func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) { | |||
| 231 | 293 | commandCtx = ctx | |
| 232 | 294 | cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{ | |
| 233 | 295 | ProtocolVersion: ports.ProtocolVersion, | |
| 296 | + | SchemaVersion: ports.RemoteCatalogSchemaVersion, | |
| 234 | 297 | Sessions: []ports.RemoteCatalogSession{}, | |
| 235 | 298 | })) | |
| 236 | 299 | return cmd | |
M internal/app/remote_hosts.go +3 -3
| @@ -205,11 +205,11 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [ | |||
| 205 | 205 | Attached: session.Attached, | |
| 206 | 206 | } | |
| 207 | 207 | switch session.State { | |
| 208 | - | case "up": | |
| 208 | + | case ports.RemoteCatalogSessionUp: | |
| 209 | 209 | info.State = ports.SessionUp | |
| 210 | - | case "down": | |
| 210 | + | case ports.RemoteCatalogSessionDown: | |
| 211 | 211 | info.State = ports.SessionDown | |
| 212 | - | case "broken": | |
| 212 | + | case ports.RemoteCatalogSessionBroken: | |
| 213 | 213 | info.State = ports.SessionBroken | |
| 214 | 214 | default: | |
| 215 | 215 | slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State) | |
M internal/app/remote_test.go +4 -4
| @@ -265,10 +265,10 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) { | |||
| 265 | 265 | wantState ports.SessionState | |
| 266 | 266 | wantTabs uint16 | |
| 267 | 267 | }{ | |
| 268 | - | {name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: 2}, wantState: ports.SessionUp, wantTabs: 2}, | |
| 269 | - | {name: "down", session: ports.RemoteCatalogSession{Name: "dev", State: "down", Tabs: 2}, wantState: ports.SessionDown, wantTabs: 2}, | |
| 270 | - | {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2}, | |
| 271 | - | {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2}, | |
| 268 | + | {name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: ports.SessionUp, wantTabs: 2}, | |
| 269 | + | {name: "down", session: ports.RemoteCatalogSession{Name: "dev", State: "down", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: ports.SessionDown, wantTabs: 2}, | |
| 270 | + | {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: ports.SessionBroken, wantTabs: 2}, | |
| 271 | + | {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: []ports.RemoteCatalogTab{{}, {}}}, wantState: ports.SessionBroken, wantTabs: 2}, | |
| 272 | 272 | } | |
| 273 | 273 | for _, tt := range tests { | |
| 274 | 274 | t.Run(tt.name, func(t *testing.T) { | |
M internal/domain/lifecycle.go +25 -3
| @@ -110,7 +110,9 @@ func (s TabSelector) Resolve(tabs []TabSelectorTab) (int, bool) { | |||
| 110 | 110 | ||
| 111 | 111 | // RemoteSessionTarget is the structured identity used by picker-based remote | |
| 112 | 112 | // access. Endpoint and DisplayOrigin are intentionally separate values: the | |
| 113 | - | // latter is presentation-only and is never parsed back into a route. | |
| 113 | + | // latter is presentation-only and is never parsed back into a route. Stopped | |
| 114 | + | // records the selected down-row resume intent; it remains set while that exact | |
| 115 | + | // lifecycle is created and attached as a live runtime. | |
| 114 | 116 | type RemoteSessionTarget struct { | |
| 115 | 117 | Endpoint string | |
| 116 | 118 | DisplayOrigin string | |
| @@ -137,8 +139,11 @@ func (t RemoteSessionTarget) Validate() error { | |||
| 137 | 139 | return err | |
| 138 | 140 | } | |
| 139 | 141 | if t.Stopped { | |
| 140 | - | if t.LiveTabID != "" || t.StoppedTab.Validate() != nil { | |
| 141 | - | return errors.New("down remote target requires an ordinal or stable tab selector") | |
| 142 | + | if t.LiveTabID != "" { | |
| 143 | + | return errors.New("down remote target cannot carry a live tab selector") | |
| 144 | + | } | |
| 145 | + | if t.StoppedTab != (TabSelector{}) && t.StoppedTab.Validate() != nil { | |
| 146 | + | return errors.New("down remote target has an invalid tab selector") | |
| 142 | 147 | } | |
| 143 | 148 | return nil | |
| 144 | 149 | } | |
| @@ -151,6 +156,23 @@ func (t RemoteSessionTarget) Validate() error { | |||
| 151 | 156 | return nil | |
| 152 | 157 | } | |
| 153 | 158 | ||
| 159 | + | // ResolveTab resolves the selected tab against authoritative metadata. A down | |
| 160 | + | // record without retained tab metadata carries no selector and may choose only | |
| 161 | + | // an unambiguous default tab: the empty durable record before creation or its | |
| 162 | + | // sole fresh tab immediately afterward. It never guesses among multiple tabs. | |
| 163 | + | func (t RemoteSessionTarget) ResolveTab(tabs []TabSelectorTab) (int, bool) { | |
| 164 | + | if t.Validate() != nil { | |
| 165 | + | return 0, false | |
| 166 | + | } | |
| 167 | + | if t.Stopped { | |
| 168 | + | if t.StoppedTab == (TabSelector{}) { | |
| 169 | + | return 0, len(tabs) <= 1 | |
| 170 | + | } | |
| 171 | + | return t.StoppedTab.Resolve(tabs) | |
| 172 | + | } | |
| 173 | + | return NewStableTabSelector(t.LiveTabID).Resolve(tabs) | |
| 174 | + | } | |
| 175 | + | ||
| 154 | 176 | // ValidateRemoteDisplayOrigin validates presentation data without imposing | |
| 155 | 177 | // SSH route grammar. Aliases, users, ports, and bracketed IPv6 remain opaque. | |
| 156 | 178 | func ValidateRemoteDisplayOrigin(origin string) error { | |
M internal/domain/lifecycle_test.go +28 -0
| @@ -21,6 +21,34 @@ func TestOrdinalTabSelectorPreservesUnnamedTabs(t *testing.T) { | |||
| 21 | 21 | } | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | + | func TestRemoteSessionTargetResolveTab(t *testing.T) { | |
| 25 | + | base := RemoteSessionTarget{Endpoint: "host", DisplayOrigin: "host", LifecycleID: SessionLifecycleID{1}, SessionName: "work"} | |
| 26 | + | tests := []struct { | |
| 27 | + | name string | |
| 28 | + | target RemoteSessionTarget | |
| 29 | + | tabs []TabSelectorTab | |
| 30 | + | want int | |
| 31 | + | ok bool | |
| 32 | + | }{ | |
| 33 | + | {name: "down without metadata", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, ok: true}, | |
| 34 | + | {name: "down fresh default tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "fresh"}}, ok: true}, | |
| 35 | + | {name: "down without selector rejects ambiguity", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}}, | |
| 36 | + | {name: "down stable selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: NewStableTabSelector("two")}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true}, | |
| 37 | + | {name: "down invalid selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: TabSelector{Kind: TabSelectorByStableID, Ordinal: 1}}, tabs: []TabSelectorTab{{ID: "one"}}}, | |
| 38 | + | {name: "live stable tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true}, | |
| 39 | + | {name: "live missing tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "missing"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: -1}, | |
| 40 | + | {name: "live duplicate tab rejected", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "two"}, {ID: "two"}}}, | |
| 41 | + | } | |
| 42 | + | for _, test := range tests { | |
| 43 | + | t.Run(test.name, func(t *testing.T) { | |
| 44 | + | got, ok := test.target.ResolveTab(test.tabs) | |
| 45 | + | if got != test.want || ok != test.ok { | |
| 46 | + | t.Fatalf("ResolveTab() = %d, %t, want %d, %t", got, ok, test.want, test.ok) | |
| 47 | + | } | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 24 | 52 | func TestValidateTabStableIDRejectsUnsafeIdentity(t *testing.T) { | |
| 25 | 53 | tests := []struct { | |
| 26 | 54 | name string | |
M internal/ports/remote.go +66 -149
| @@ -2,7 +2,6 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "encoding/json" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "log/slog" | |
| @@ -64,7 +63,7 @@ type RemotePreviewClient interface { | |||
| 64 | 63 | const ( | |
| 65 | 64 | // RemoteCatalogSchemaVersion is independent from the IPC protocol. A | |
| 66 | 65 | // catalogue can be rejected without changing the attachment wire layout. | |
| 67 | - | RemoteCatalogSchemaVersion uint16 = 2 | |
| 66 | + | RemoteCatalogSchemaVersion uint16 = 3 | |
| 68 | 67 | ||
| 69 | 68 | RemoteCatalogMaxHosts = 64 | |
| 70 | 69 | RemoteCatalogMaxSessions = 256 | |
| @@ -75,10 +74,9 @@ const ( | |||
| 75 | 74 | RemoteCatalogMaxCatalogBytes = 1 << 20 | |
| 76 | 75 | ) | |
| 77 | 76 | ||
| 78 | - | // RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for | |
| 79 | - | // immediate remote discovery at daemon startup. The legacy-shaped Sessions | |
| 80 | - | // field is retained as a source-compatible container, but cache writers strip | |
| 81 | - | // dynamic Detail and Attention fields before persistence. | |
| 77 | + | // RemoteCatalogCacheEntry is one immutable exact-schema host snapshot persisted | |
| 78 | + | // for immediate remote discovery at daemon startup. Cache writers strip dynamic | |
| 79 | + | // Detail and Attention fields before persistence. | |
| 82 | 80 | type RemoteCatalogCacheEntry struct { | |
| 83 | 81 | Host string | |
| 84 | 82 | FetchedAt time.Time | |
| @@ -102,112 +100,53 @@ type RemoteCatalogTab struct { | |||
| 102 | 100 | Attention bool `json:"attention,omitempty"` | |
| 103 | 101 | } | |
| 104 | 102 | ||
| 105 | - | // RemoteCatalogSession is one session in the remote discovery catalog. State | |
| 106 | - | // is an explicit string contract (up|down|broken), not SessionState. | |
| 107 | - | // | |
| 108 | - | // Tabs is interface-typed only to keep old v0.x callers that supplied a count | |
| 109 | - | // source-compatible while the versioned schema carries []RemoteCatalogTab. | |
| 110 | - | // New code must use CatalogTabs and never infer routing identity from a count. | |
| 103 | + | // RemoteCatalogSessionState is the JSON catalogue lifecycle contract. It is | |
| 104 | + | // deliberately independent from SessionState, whose numeric values are part of | |
| 105 | + | // the binary client/daemon protocol. | |
| 106 | + | type RemoteCatalogSessionState string | |
| 107 | + | ||
| 108 | + | const ( | |
| 109 | + | RemoteCatalogSessionUp RemoteCatalogSessionState = "up" | |
| 110 | + | RemoteCatalogSessionDown RemoteCatalogSessionState = "down" | |
| 111 | + | RemoteCatalogSessionBroken RemoteCatalogSessionState = "broken" | |
| 112 | + | ) | |
| 113 | + | ||
| 114 | + | // Valid reports whether the state is one of the bounded catalogue values. | |
| 115 | + | func (s RemoteCatalogSessionState) Valid() bool { | |
| 116 | + | switch s { | |
| 117 | + | case RemoteCatalogSessionUp, RemoteCatalogSessionDown, RemoteCatalogSessionBroken: | |
| 118 | + | return true | |
| 119 | + | default: | |
| 120 | + | return false | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // RemoteCatalogSession is one exact-identity session in the remote discovery | |
| 125 | + | // catalogue. Tabs is always an ordered typed snapshot; older count-only peers | |
| 126 | + | // are rejected at the schema seam. | |
| 111 | 127 | type RemoteCatalogSession struct { | |
| 112 | - | LifecycleID domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |
| 128 | + | LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"` | |
| 113 | 129 | Name string `json:"name"` | |
| 114 | - | State string `json:"state"` | |
| 130 | + | State RemoteCatalogSessionState `json:"state"` | |
| 115 | 131 | Ephemeral bool `json:"ephemeral"` | |
| 116 | - | Tabs any `json:"tabs"` | |
| 132 | + | Tabs []RemoteCatalogTab `json:"tabs"` | |
| 117 | 133 | Attached bool `json:"attached"` | |
| 118 | 134 | LastUsedSeq uint64 `json:"last_used_seq,omitempty"` | |
| 119 | 135 | ActiveTabID string `json:"active_tab_id,omitempty"` | |
| 120 | 136 | Reason string `json:"reason,omitempty"` | |
| 121 | 137 | } | |
| 122 | 138 | ||
| 123 | - | // MarshalJSON omits a zero lifecycle ID for legacy count-only catalogues. | |
| 124 | - | // Encoding/json does not apply omitempty to a zero array that implements | |
| 125 | - | // encoding.TextMarshaler, so the pointer is made explicit here. | |
| 126 | - | func (s RemoteCatalogSession) MarshalJSON() ([]byte, error) { | |
| 127 | - | type envelope struct { | |
| 128 | - | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |
| 129 | - | Name string `json:"name"` | |
| 130 | - | State string `json:"state"` | |
| 131 | - | Ephemeral bool `json:"ephemeral"` | |
| 132 | - | Tabs any `json:"tabs"` | |
| 133 | - | Attached bool `json:"attached"` | |
| 134 | - | LastUsedSeq uint64 `json:"last_used_seq,omitempty"` | |
| 135 | - | ActiveTabID string `json:"active_tab_id,omitempty"` | |
| 136 | - | Reason string `json:"reason,omitempty"` | |
| 137 | - | } | |
| 138 | - | var lifecycle *domain.SessionLifecycleID | |
| 139 | - | if s.LifecycleID != (domain.SessionLifecycleID{}) { | |
| 140 | - | id := s.LifecycleID | |
| 141 | - | lifecycle = &id | |
| 142 | - | } | |
| 143 | - | return json.Marshal(envelope{LifecycleID: lifecycle, Name: s.Name, State: s.State, Ephemeral: s.Ephemeral, Tabs: s.Tabs, Attached: s.Attached, LastUsedSeq: s.LastUsedSeq, ActiveTabID: s.ActiveTabID, Reason: s.Reason}) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // UnmarshalJSON preserves the legacy numeric tab-count representation while | |
| 147 | - | // decoding the current array representation into concrete tab values. | |
| 148 | - | func (s *RemoteCatalogSession) UnmarshalJSON(data []byte) error { | |
| 149 | - | type envelope struct { | |
| 150 | - | LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"` | |
| 151 | - | Name string `json:"name"` | |
| 152 | - | State string `json:"state"` | |
| 153 | - | Ephemeral bool `json:"ephemeral"` | |
| 154 | - | Tabs json.RawMessage `json:"tabs"` | |
| 155 | - | Attached bool `json:"attached"` | |
| 156 | - | LastUsedSeq uint64 `json:"last_used_seq"` | |
| 157 | - | ActiveTabID string `json:"active_tab_id"` | |
| 158 | - | Reason string `json:"reason"` | |
| 159 | - | } | |
| 160 | - | var raw envelope | |
| 161 | - | if err := json.Unmarshal(data, &raw); err != nil { | |
| 162 | - | return err | |
| 163 | - | } | |
| 164 | - | *s = RemoteCatalogSession{LifecycleID: raw.LifecycleID, Name: raw.Name, State: raw.State, Ephemeral: raw.Ephemeral, Attached: raw.Attached, LastUsedSeq: raw.LastUsedSeq, ActiveTabID: raw.ActiveTabID, Reason: raw.Reason} | |
| 165 | - | if len(raw.Tabs) == 0 || string(raw.Tabs) == "null" { | |
| 166 | - | s.Tabs = 0 | |
| 167 | - | return nil | |
| 168 | - | } | |
| 169 | - | var tabs []RemoteCatalogTab | |
| 170 | - | if err := json.Unmarshal(raw.Tabs, &tabs); err == nil { | |
| 171 | - | s.Tabs = tabs | |
| 172 | - | return nil | |
| 173 | - | } | |
| 174 | - | var count uint16 | |
| 175 | - | if err := json.Unmarshal(raw.Tabs, &count); err != nil { | |
| 176 | - | return err | |
| 177 | - | } | |
| 178 | - | s.Tabs = int(count) | |
| 179 | - | return nil | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | // CatalogTabs returns a copied typed tab list. Legacy count-only entries have | |
| 183 | - | // no stable tab identity and therefore return nil. | |
| 139 | + | // CatalogTabs returns an independent copy of the ordered tab snapshot. | |
| 184 | 140 | func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab { | |
| 185 | - | tabs, ok := session.Tabs.([]RemoteCatalogTab) | |
| 186 | - | if !ok { | |
| 187 | - | return nil | |
| 188 | - | } | |
| 189 | - | return slices.Clone(tabs) | |
| 141 | + | return slices.Clone(session.Tabs) | |
| 190 | 142 | } | |
| 191 | 143 | ||
| 192 | - | // CatalogTabCount returns the count represented by either schema form. | |
| 144 | + | // CatalogTabCount returns the typed tab count. | |
| 193 | 145 | func CatalogTabCount(session RemoteCatalogSession) int { | |
| 194 | - | switch tabs := session.Tabs.(type) { | |
| 195 | - | case []RemoteCatalogTab: | |
| 196 | - | return len(tabs) | |
| 197 | - | case uint16: | |
| 198 | - | return int(tabs) | |
| 199 | - | case uint32: | |
| 200 | - | return int(tabs) | |
| 201 | - | case int: | |
| 202 | - | return tabs | |
| 203 | - | case float64: | |
| 204 | - | return int(tabs) | |
| 205 | - | default: | |
| 206 | - | return 0 | |
| 207 | - | } | |
| 146 | + | return len(session.Tabs) | |
| 208 | 147 | } | |
| 209 | 148 | ||
| 210 | - | // SaturateUint16 clamps n to the uint16 range for legacy command output only. | |
| 149 | + | // SaturateUint16 clamps a catalogue tab count to the list protocol range. | |
| 211 | 150 | func SaturateUint16(n int) uint16 { | |
| 212 | 151 | if n <= 0 { | |
| 213 | 152 | return 0 | |
| @@ -219,11 +158,11 @@ func SaturateUint16(n int) uint16 { | |||
| 219 | 158 | } | |
| 220 | 159 | ||
| 221 | 160 | // RemoteCatalog is the independently versioned JSON envelope returned by | |
| 222 | - | // remote-catalog --json. ProtocolVersion remains for legacy peers; new peers | |
| 223 | - | // must also provide SchemaVersion. | |
| 161 | + | // remote-catalog --json. Both protocol and catalogue schema versions are | |
| 162 | + | // mandatory and must match exactly. | |
| 224 | 163 | type RemoteCatalog struct { | |
| 225 | 164 | ProtocolVersion uint16 `json:"protocol_version"` | |
| 226 | - | SchemaVersion uint16 `json:"schema_version,omitempty"` | |
| 165 | + | SchemaVersion uint16 `json:"schema_version"` | |
| 227 | 166 | Sessions []RemoteCatalogSession `json:"sessions"` | |
| 228 | 167 | } | |
| 229 | 168 | ||
| @@ -250,23 +189,23 @@ var ( | |||
| 250 | 189 | ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason") | |
| 251 | 190 | ) | |
| 252 | 191 | ||
| 253 | - | // ValidateRemoteCatalog applies all limits before a catalogue is cached or | |
| 254 | - | // rendered. It is deliberately strict for the current schema; zero schema is | |
| 255 | - | // the read-only compatibility form used by pre-parity v0.x peers. | |
| 192 | + | // ValidateRemoteCatalog applies the exact current schema and all bounds before | |
| 193 | + | // a catalogue is cached or rendered. | |
| 256 | 194 | func ValidateRemoteCatalog(c RemoteCatalog) error { | |
| 257 | 195 | if c.ProtocolVersion != ProtocolVersion { | |
| 258 | 196 | return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: ProtocolVersion, Kind: "protocol"} | |
| 259 | 197 | } | |
| 260 | - | if c.SchemaVersion != 0 && c.SchemaVersion != RemoteCatalogSchemaVersion { | |
| 198 | + | if c.SchemaVersion != RemoteCatalogSchemaVersion { | |
| 261 | 199 | return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"} | |
| 262 | 200 | } | |
| 263 | 201 | if len(c.Sessions) > RemoteCatalogMaxSessions { | |
| 264 | 202 | return fmt.Errorf("%w: too many sessions", ErrRemoteCatalogTooLarge) | |
| 265 | 203 | } | |
| 266 | - | seen := make(map[domain.SessionLifecycleID]string, len(c.Sessions)) | |
| 204 | + | seenLifecycles := make(map[domain.SessionLifecycleID]string, len(c.Sessions)) | |
| 205 | + | seenNames := make(map[string]struct{}, len(c.Sessions)) | |
| 267 | 206 | bytes := 0 | |
| 268 | 207 | for _, session := range c.Sessions { | |
| 269 | - | if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(session.State) || !validRemoteCatalogText(session.Reason) { | |
| 208 | + | if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(string(session.State)) || !validRemoteCatalogText(session.Reason) { | |
| 270 | 209 | return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog) | |
| 271 | 210 | } | |
| 272 | 211 | if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes { | |
| @@ -275,7 +214,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error { | |||
| 275 | 214 | if err := domain.ValidateSessionName(session.Name); err != nil { | |
| 276 | 215 | return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err) | |
| 277 | 216 | } | |
| 278 | - | if !validRemoteCatalogState(session.State) { | |
| 217 | + | if !session.State.Valid() { | |
| 279 | 218 | return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State) | |
| 280 | 219 | } | |
| 281 | 220 | if session.ActiveTabID != "" { | |
| @@ -286,24 +225,24 @@ func ValidateRemoteCatalog(c RemoteCatalog) error { | |||
| 286 | 225 | if session.Reason != "" && !validRemoteCatalogReason(session.Reason) { | |
| 287 | 226 | return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason) | |
| 288 | 227 | } | |
| 289 | - | if c.SchemaVersion != 0 { | |
| 290 | - | if session.LifecycleID == (domain.SessionLifecycleID{}) { | |
| 291 | - | return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog) | |
| 292 | - | } | |
| 293 | - | if prior, exists := seen[session.LifecycleID]; exists { | |
| 294 | - | return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name) | |
| 295 | - | } | |
| 296 | - | seen[session.LifecycleID] = session.Name | |
| 228 | + | if session.LifecycleID == (domain.SessionLifecycleID{}) { | |
| 229 | + | return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog) | |
| 230 | + | } | |
| 231 | + | if _, exists := seenNames[session.Name]; exists { | |
| 232 | + | return fmt.Errorf("%w: duplicate session name %q", ErrInvalidRemoteCatalog, session.Name) | |
| 233 | + | } | |
| 234 | + | seenNames[session.Name] = struct{}{} | |
| 235 | + | if prior, exists := seenLifecycles[session.LifecycleID]; exists { | |
| 236 | + | return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name) | |
| 237 | + | } | |
| 238 | + | seenLifecycles[session.LifecycleID] = session.Name | |
| 239 | + | if session.Tabs == nil { | |
| 240 | + | return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog) | |
| 297 | 241 | } | |
| 298 | 242 | tabs := CatalogTabs(session) | |
| 299 | 243 | if len(tabs) == 0 { | |
| 300 | - | if c.SchemaVersion != 0 { | |
| 301 | - | if session.ActiveTabID != "" { | |
| 302 | - | return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog) | |
| 303 | - | } | |
| 304 | - | if session.State == "up" && session.Tabs == nil { | |
| 305 | - | return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog) | |
| 306 | - | } | |
| 244 | + | if session.ActiveTabID != "" { | |
| 245 | + | return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog) | |
| 307 | 246 | } | |
| 308 | 247 | } else { | |
| 309 | 248 | if len(tabs) > RemoteCatalogMaxTabsPerSess { | |
| @@ -320,7 +259,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error { | |||
| 320 | 259 | if tab.Index != uint16(i) { | |
| 321 | 260 | return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog) | |
| 322 | 261 | } | |
| 323 | - | if session.State == "up" && tab.ID == "" { | |
| 262 | + | if session.State == RemoteCatalogSessionUp && tab.ID == "" { | |
| 324 | 263 | return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog) | |
| 325 | 264 | } | |
| 326 | 265 | if tab.ID != "" { | |
| @@ -335,7 +274,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error { | |||
| 335 | 274 | bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail) | |
| 336 | 275 | } | |
| 337 | 276 | if session.ActiveTabID != "" { | |
| 338 | - | if session.State != "up" { | |
| 277 | + | if session.State != RemoteCatalogSessionUp { | |
| 339 | 278 | return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog) | |
| 340 | 279 | } | |
| 341 | 280 | if _, ok := tabIDs[session.ActiveTabID]; !ok { | |
| @@ -351,10 +290,8 @@ func ValidateRemoteCatalog(c RemoteCatalog) error { | |||
| 351 | 290 | return nil | |
| 352 | 291 | } | |
| 353 | 292 | ||
| 354 | - | // ValidateRemoteCatalogCacheEntries validates the bounded cache DTO before it | |
| 355 | - | // is loaded into picker state or written to disk. A cache may contain legacy | |
| 356 | - | // count-only sessions, but any complete typed snapshot must carry lifecycle | |
| 357 | - | // identity and the same tab invariants as a live catalog. | |
| 293 | + | // ValidateRemoteCatalogCacheEntries validates the bounded exact-schema cache | |
| 294 | + | // DTO before it is loaded into picker state or written to disk. | |
| 358 | 295 | func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error { | |
| 359 | 296 | if len(entries) > RemoteCatalogMaxHosts { | |
| 360 | 297 | return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge) | |
| @@ -371,18 +308,7 @@ func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error | |||
| 371 | 308 | return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog) | |
| 372 | 309 | } | |
| 373 | 310 | seenHosts[entry.Host] = struct{}{} | |
| 374 | - | complete := false | |
| 375 | - | for _, session := range entry.Sessions { | |
| 376 | - | if session.LifecycleID != (domain.SessionLifecycleID{}) || CatalogTabs(session) != nil { | |
| 377 | - | complete = true | |
| 378 | - | break | |
| 379 | - | } | |
| 380 | - | } | |
| 381 | - | schema := uint16(0) | |
| 382 | - | if complete { | |
| 383 | - | schema = RemoteCatalogSchemaVersion | |
| 384 | - | } | |
| 385 | - | catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: schema, Sessions: entry.Sessions} | |
| 311 | + | catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: RemoteCatalogSchemaVersion, Sessions: entry.Sessions} | |
| 386 | 312 | if err := ValidateRemoteCatalog(catalog); err != nil { | |
| 387 | 313 | return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err) | |
| 388 | 314 | } | |
| @@ -402,15 +328,6 @@ func validRemoteCatalogText(value string) bool { | |||
| 402 | 328 | return true | |
| 403 | 329 | } | |
| 404 | 330 | ||
| 405 | - | func validRemoteCatalogState(state string) bool { | |
| 406 | - | switch state { | |
| 407 | - | case "up", "down", "broken": | |
| 408 | - | return true | |
| 409 | - | default: | |
| 410 | - | return false | |
| 411 | - | } | |
| 412 | - | } | |
| 413 | - | ||
| 414 | 331 | func validRemoteCatalogReason(reason string) bool { | |
| 415 | 332 | switch reason { | |
| 416 | 333 | case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_down", "session_broken", "identity_changed", "not_found", "timeout", "malformed": | |
M internal/ports/remote_state_test.go +32 -5
| @@ -7,13 +7,17 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | func TestRemoteCatalogStateContract(t *testing.T) { | |
| 10 | - | require.Equal(t, uint16(2), RemoteCatalogSchemaVersion) | |
| 10 | + | require.Equal(t, uint16(3), RemoteCatalogSchemaVersion) | |
| 11 | 11 | ||
| 12 | - | for _, state := range []string{"up", "down", "broken"} { | |
| 13 | - | t.Run(state, func(t *testing.T) { | |
| 12 | + | for _, state := range []RemoteCatalogSessionState{RemoteCatalogSessionUp, RemoteCatalogSessionDown, RemoteCatalogSessionBroken} { | |
| 13 | + | t.Run(string(state), func(t *testing.T) { | |
| 14 | 14 | err := ValidateRemoteCatalog(RemoteCatalog{ | |
| 15 | 15 | ProtocolVersion: ProtocolVersion, | |
| 16 | - | Sessions: []RemoteCatalogSession{{Name: "work", State: state, Tabs: 1}}, | |
| 16 | + | SchemaVersion: RemoteCatalogSchemaVersion, | |
| 17 | + | Sessions: []RemoteCatalogSession{{ | |
| 18 | + | LifecycleID: [16]byte{1}, Name: "work", State: state, | |
| 19 | + | Tabs: []RemoteCatalogTab{{ID: "tab-1"}}, | |
| 20 | + | }}, | |
| 17 | 21 | }) | |
| 18 | 22 | require.NoError(t, err) | |
| 19 | 23 | }) | |
| @@ -21,7 +25,30 @@ func TestRemoteCatalogStateContract(t *testing.T) { | |||
| 21 | 25 | ||
| 22 | 26 | err := ValidateRemoteCatalog(RemoteCatalog{ | |
| 23 | 27 | ProtocolVersion: ProtocolVersion, | |
| 24 | - | Sessions: []RemoteCatalogSession{{Name: "work", State: "running", Tabs: 1}}, | |
| 28 | + | SchemaVersion: RemoteCatalogSchemaVersion, | |
| 29 | + | Sessions: []RemoteCatalogSession{{ | |
| 30 | + | LifecycleID: [16]byte{1}, Name: "work", State: RemoteCatalogSessionState("running"), | |
| 31 | + | Tabs: []RemoteCatalogTab{{ID: "tab-1"}}, | |
| 32 | + | }}, | |
| 25 | 33 | }) | |
| 26 | 34 | require.ErrorIs(t, err, ErrRemoteCatalogUnknownState) | |
| 35 | + | ||
| 36 | + | err = ValidateRemoteCatalog(RemoteCatalog{ProtocolVersion: ProtocolVersion, Sessions: []RemoteCatalogSession{}}) | |
| 37 | + | var mismatch *RemoteCatalogVersionMismatchError | |
| 38 | + | require.ErrorAs(t, err, &mismatch) | |
| 39 | + | require.Equal(t, "catalog", mismatch.Kind) | |
| 40 | + | require.Equal(t, RemoteCatalogSchemaVersion, mismatch.Want) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestValidateRemoteCatalogRejectsDuplicateSessionNames(t *testing.T) { | |
| 44 | + | err := ValidateRemoteCatalog(RemoteCatalog{ | |
| 45 | + | ProtocolVersion: ProtocolVersion, | |
| 46 | + | SchemaVersion: RemoteCatalogSchemaVersion, | |
| 47 | + | Sessions: []RemoteCatalogSession{ | |
| 48 | + | {LifecycleID: [16]byte{1}, Name: "work", State: RemoteCatalogSessionUp, Tabs: []RemoteCatalogTab{}}, | |
| 49 | + | {LifecycleID: [16]byte{2}, Name: "work", State: RemoteCatalogSessionDown, Tabs: []RemoteCatalogTab{}}, | |
| 50 | + | }, | |
| 51 | + | }) | |
| 52 | + | require.ErrorIs(t, err, ErrInvalidRemoteCatalog) | |
| 53 | + | require.ErrorContains(t, err, "duplicate session name") | |
| 27 | 54 | } | |
M internal/usecase/daemon/coderabbit_regression_test.go +156 -8
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/picker" | |
| 17 | 18 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 18 | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| @@ -37,7 +38,7 @@ func TestRestoreAmbiguousBadVersionLoadFailurePreservesCheckpoint(t *testing.T) | |||
| 37 | 38 | require.Equal(t, "checkpoint load failed", persisted.DegradedReason) | |
| 38 | 39 | require.Empty(t, repository.deleted) | |
| 39 | 40 | d.mu.Lock() | |
| 40 | - | entry := d.stopped[record.Name] | |
| 41 | + | entry := d.inactive[record.Name] | |
| 41 | 42 | d.mu.Unlock() | |
| 42 | 43 | require.Equal(t, ports.SessionBroken, entry.state) | |
| 43 | 44 | require.Equal(t, persisted, entry.record) | |
| @@ -59,7 +60,7 @@ func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) { | |||
| 59 | 60 | require.True(t, ok) | |
| 60 | 61 | require.Equal(t, "checkpoint load failed", persisted.DegradedReason) | |
| 61 | 62 | d.mu.Lock() | |
| 62 | - | entry := d.stopped[record.Name] | |
| 63 | + | entry := d.inactive[record.Name] | |
| 63 | 64 | d.mu.Unlock() | |
| 64 | 65 | require.Equal(t, ports.SessionBroken, entry.state) | |
| 65 | 66 | require.Equal(t, persisted, entry.record) | |
| @@ -118,7 +119,7 @@ func TestRestoreFailureCannotOverwriteDiscardReplacement(t *testing.T) { | |||
| 118 | 119 | require.Nil(t, persisted.Committed) | |
| 119 | 120 | require.Empty(t, persisted.DegradedReason) | |
| 120 | 121 | d.mu.Lock() | |
| 121 | - | entry := d.stopped[record.Name] | |
| 122 | + | entry := d.inactive[record.Name] | |
| 122 | 123 | d.mu.Unlock() | |
| 123 | 124 | require.Equal(t, persisted, entry.record) | |
| 124 | 125 | require.Equal(t, ports.SessionDown, entry.state) | |
| @@ -182,7 +183,7 @@ func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) { | |||
| 182 | 183 | require.True(t, ok) | |
| 183 | 184 | require.Equal(t, replacement, persisted) | |
| 184 | 185 | d.mu.Lock() | |
| 185 | - | entry := d.stopped[record.Name] | |
| 186 | + | entry := d.inactive[record.Name] | |
| 186 | 187 | d.mu.Unlock() | |
| 187 | 188 | require.Equal(t, tt.state, entry.state) | |
| 188 | 189 | require.Equal(t, replacement, entry.record) | |
| @@ -224,7 +225,7 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) { | |||
| 224 | 225 | d := newTestDaemon(t, nil, stubClock{}) | |
| 225 | 226 | d.catalogue = catalogue | |
| 226 | 227 | d.persistEnabled = true | |
| 227 | - | d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionDown, make(chan struct{})) | |
| 228 | + | d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, make(chan struct{})) | |
| 228 | 229 | ||
| 229 | 230 | result := make(chan error, 1) | |
| 230 | 231 | go func() { | |
| @@ -275,7 +276,7 @@ func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T) | |||
| 275 | 276 | d.catalogue = catalogue | |
| 276 | 277 | d.persistEnabled = true | |
| 277 | 278 | d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil) | |
| 278 | - | d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionDown, nil) | |
| 279 | + | d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil) | |
| 279 | 280 | ||
| 280 | 281 | target := picker.Target{ | |
| 281 | 282 | Name: record.Name, | |
| @@ -293,7 +294,7 @@ func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T) | |||
| 293 | 294 | replacement.UpdatedAt = replacement.CreatedAt | |
| 294 | 295 | require.NoError(t, catalogue.Replace(replacement.Name, replacement)) | |
| 295 | 296 | d.mu.Lock() | |
| 296 | - | d.stopped[replacement.Name] = stoppedSessionFromRecord(replacement, ports.SessionDown, nil) | |
| 297 | + | d.inactive[replacement.Name] = inactiveSessionFromRecord(replacement, ports.SessionDown, nil) | |
| 297 | 298 | d.mu.Unlock() | |
| 298 | 299 | close(catalogue.release) | |
| 299 | 300 | ||
| @@ -303,7 +304,154 @@ func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T) | |||
| 303 | 304 | d.mu.Lock() | |
| 304 | 305 | defer d.mu.Unlock() | |
| 305 | 306 | require.Nil(t, d.findByNameLocked(record.Name)) | |
| 306 | - | require.Equal(t, replacement.IncarnationID, d.stopped[record.Name].incarnation) | |
| 307 | + | require.Equal(t, replacement.IncarnationID, d.inactive[record.Name].incarnation) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func TestRemoteInactiveRouteRejectsReplacementDuringCatalogueRead(t *testing.T) { | |
| 311 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 312 | + | record := durableRecoveryRecord(1) | |
| 313 | + | record.Name = "stopped" | |
| 314 | + | record.Committed = nil | |
| 315 | + | record.TabRecords = nil | |
| 316 | + | record.TabNames = nil | |
| 317 | + | catalogue := &blockingResumeCatalogue{ | |
| 318 | + | durableRecoveryCatalogue: newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), | |
| 319 | + | entered: make(chan struct{}), | |
| 320 | + | release: make(chan struct{}), | |
| 321 | + | } | |
| 322 | + | d.catalogue = catalogue | |
| 323 | + | d.persistEnabled = true | |
| 324 | + | d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil) | |
| 325 | + | d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil) | |
| 326 | + | ||
| 327 | + | target := domain.RemoteSessionTarget{ | |
| 328 | + | Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: record.IncarnationID, | |
| 329 | + | SessionName: record.Name, Stopped: true, | |
| 330 | + | } | |
| 331 | + | transport, _ := newCapturingTransport(t) | |
| 332 | + | result := make(chan error, 1) | |
| 333 | + | go func() { | |
| 334 | + | _, _, err := d.routeWithContext(context.Background(), ports.Hello{ | |
| 335 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name, | |
| 336 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, | |
| 337 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 338 | + | }, transport) | |
| 339 | + | result <- err | |
| 340 | + | }() | |
| 341 | + | ||
| 342 | + | <-catalogue.entered | |
| 343 | + | replacement := record | |
| 344 | + | replacement.IncarnationID = domain.IncarnationID{9} | |
| 345 | + | replacement.CreatedAt++ | |
| 346 | + | replacement.UpdatedAt = replacement.CreatedAt | |
| 347 | + | require.NoError(t, catalogue.Replace(replacement.Name, replacement)) | |
| 348 | + | d.mu.Lock() | |
| 349 | + | d.inactive[replacement.Name] = inactiveSessionFromRecord(replacement, ports.SessionDown, nil) | |
| 350 | + | d.mu.Unlock() | |
| 351 | + | close(catalogue.release) | |
| 352 | + | ||
| 353 | + | var protocol *protoErr | |
| 354 | + | require.ErrorAs(t, <-result, &protocol) | |
| 355 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) | |
| 356 | + | require.Empty(t, catalogue.MetadataUpdates()) | |
| 357 | + | d.mu.Lock() | |
| 358 | + | defer d.mu.Unlock() | |
| 359 | + | require.Nil(t, d.findByNameLocked(record.Name)) | |
| 360 | + | require.Equal(t, replacement.IncarnationID, d.inactive[record.Name].incarnation) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestRemoteInactiveRouteRejectsTabAuthorityChangeDuringCatalogueRead(t *testing.T) { | |
| 364 | + | for _, test := range []struct { | |
| 365 | + | name string | |
| 366 | + | record domain.CatalogueRecord | |
| 367 | + | target domain.RemoteSessionTarget | |
| 368 | + | mutate func(*domain.CatalogueRecord) | |
| 369 | + | updateMap bool | |
| 370 | + | }{ | |
| 371 | + | { | |
| 372 | + | name: "stable selector replacement", | |
| 373 | + | record: domain.CatalogueRecord{TabNames: []string{"main"}, TabRecords: []domain.CatalogueTabRecord{{StableID: "tab-1", Name: "main"}}}, | |
| 374 | + | target: domain.RemoteSessionTarget{Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1")}, | |
| 375 | + | mutate: func(record *domain.CatalogueRecord) { | |
| 376 | + | record.TabRecords[0].StableID = "tab-2" | |
| 377 | + | }, | |
| 378 | + | updateMap: true, | |
| 379 | + | }, | |
| 380 | + | { | |
| 381 | + | name: "ordinal selector replacement", | |
| 382 | + | record: domain.CatalogueRecord{TabNames: []string{"main"}, TabRecords: []domain.CatalogueTabRecord{{Name: "main"}}}, | |
| 383 | + | target: domain.RemoteSessionTarget{Stopped: true, StoppedTab: domain.NewOrdinalTabSelector(0, "main", 1)}, | |
| 384 | + | mutate: func(record *domain.CatalogueRecord) { | |
| 385 | + | record.TabNames[0] = "other" | |
| 386 | + | record.TabRecords[0].Name = "other" | |
| 387 | + | }, | |
| 388 | + | updateMap: true, | |
| 389 | + | }, | |
| 390 | + | { | |
| 391 | + | name: "catalogue-only metadata change", | |
| 392 | + | record: domain.CatalogueRecord{TabNames: []string{"main"}, TabRecords: []domain.CatalogueTabRecord{{StableID: "tab-1", Name: "main"}}}, | |
| 393 | + | target: domain.RemoteSessionTarget{Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1")}, | |
| 394 | + | mutate: func(record *domain.CatalogueRecord) { | |
| 395 | + | record.TabRecords[0].StableID = "tab-2" | |
| 396 | + | }, | |
| 397 | + | }, | |
| 398 | + | } { | |
| 399 | + | t.Run(test.name, func(t *testing.T) { | |
| 400 | + | record := durableRecoveryRecord(1) | |
| 401 | + | record.Name = "stopped" | |
| 402 | + | record.Committed = nil | |
| 403 | + | record.TabNames = append([]string(nil), test.record.TabNames...) | |
| 404 | + | record.TabRecords = append([]domain.CatalogueTabRecord(nil), test.record.TabRecords...) | |
| 405 | + | catalogue := &blockingResumeCatalogue{ | |
| 406 | + | durableRecoveryCatalogue: newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), | |
| 407 | + | entered: make(chan struct{}), | |
| 408 | + | release: make(chan struct{}), | |
| 409 | + | } | |
| 410 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 411 | + | d.catalogue = catalogue | |
| 412 | + | d.persistEnabled = true | |
| 413 | + | d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil) | |
| 414 | + | d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil) | |
| 415 | + | ||
| 416 | + | target := test.target | |
| 417 | + | target.Endpoint = "remote" | |
| 418 | + | target.DisplayOrigin = "remote" | |
| 419 | + | target.LifecycleID = record.IncarnationID | |
| 420 | + | target.SessionName = record.Name | |
| 421 | + | transport, _ := newCapturingTransport(t) | |
| 422 | + | result := make(chan error, 1) | |
| 423 | + | go func() { | |
| 424 | + | _, _, err := d.routeWithContext(context.Background(), ports.Hello{ | |
| 425 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name, | |
| 426 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, | |
| 427 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 428 | + | }, transport) | |
| 429 | + | result <- err | |
| 430 | + | }() | |
| 431 | + | ||
| 432 | + | <-catalogue.entered | |
| 433 | + | replacement := record | |
| 434 | + | replacement.TabNames = append([]string(nil), record.TabNames...) | |
| 435 | + | replacement.TabRecords = append([]domain.CatalogueTabRecord(nil), record.TabRecords...) | |
| 436 | + | test.mutate(&replacement) | |
| 437 | + | replacement.UpdatedAt++ | |
| 438 | + | require.NoError(t, catalogue.Replace(replacement.Name, replacement)) | |
| 439 | + | if test.updateMap { | |
| 440 | + | d.mu.Lock() | |
| 441 | + | d.inactive[replacement.Name] = inactiveSessionFromRecord(replacement, ports.SessionDown, nil) | |
| 442 | + | d.mu.Unlock() | |
| 443 | + | } | |
| 444 | + | close(catalogue.release) | |
| 445 | + | ||
| 446 | + | var protocol *protoErr | |
| 447 | + | require.ErrorAs(t, <-result, &protocol) | |
| 448 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) | |
| 449 | + | require.Empty(t, catalogue.MetadataUpdates()) | |
| 450 | + | d.mu.Lock() | |
| 451 | + | defer d.mu.Unlock() | |
| 452 | + | require.Nil(t, d.findByNameLocked(record.Name)) | |
| 453 | + | }) | |
| 454 | + | } | |
| 307 | 455 | } | |
| 308 | 456 | ||
| 309 | 457 | func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) { | |
M internal/usecase/daemon/control.go +13 -51
| @@ -706,38 +706,16 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 706 | 706 | } | |
| 707 | 707 | e.d.mu.Lock() | |
| 708 | 708 | sessions := sessionsSnapshot(e.d.sessions) | |
| 709 | - | stopped := make([]stoppedSession, 0, len(e.d.stopped)) | |
| 710 | - | for _, entry := range e.d.stopped { | |
| 711 | - | if !entry.purging { | |
| 709 | + | stopped := make([]inactiveSession, 0, len(e.d.inactive)) | |
| 710 | + | for _, entry := range e.d.inactive { | |
| 711 | + | if entry.visible() { | |
| 712 | 712 | stopped = append(stopped, entry) | |
| 713 | 713 | } | |
| 714 | 714 | } | |
| 715 | 715 | e.d.mu.Unlock() | |
| 716 | 716 | ||
| 717 | - | // Test doubles and pre-parity daemons may not have durable lifecycle | |
| 718 | - | // identities. Keep their count-only catalogue form readable so direct CLI | |
| 719 | - | // discovery remains compatible; every production session has a non-zero | |
| 720 | - | // incarnation and therefore takes the strict complete-schema path below. | |
| 721 | - | legacy := false | |
| 722 | - | for _, sess := range sessions { | |
| 723 | - | if sess.incarnation == (domain.IncarnationID{}) { | |
| 724 | - | legacy = true | |
| 725 | - | break | |
| 726 | - | } | |
| 727 | - | } | |
| 728 | - | if !legacy { | |
| 729 | - | for _, entry := range stopped { | |
| 730 | - | if entry.incarnation == (domain.IncarnationID{}) { | |
| 731 | - | legacy = true | |
| 732 | - | break | |
| 733 | - | } | |
| 734 | - | } | |
| 735 | - | } | |
| 736 | - | if legacy { | |
| 737 | - | return e.remoteCatalogLegacy(sessions) | |
| 738 | - | } | |
| 739 | - | ||
| 740 | 717 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped)) | |
| 718 | + | liveNames := make(map[string]struct{}, len(sessions)) | |
| 741 | 719 | for _, sess := range sessions { | |
| 742 | 720 | e.d.refreshSessionFocusedTitles(sess) | |
| 743 | 721 | snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false}) | |
| @@ -748,7 +726,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 748 | 726 | row := ports.RemoteCatalogSession{ | |
| 749 | 727 | LifecycleID: snap.incarnation, | |
| 750 | 728 | Name: snap.name, | |
| 751 | - | State: "up", | |
| 729 | + | State: ports.RemoteCatalogSessionUp, | |
| 752 | 730 | Ephemeral: snap.ephemeral, | |
| 753 | 731 | Tabs: tabs, | |
| 754 | 732 | Attached: snap.attached, | |
| @@ -758,16 +736,20 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 758 | 736 | row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID | |
| 759 | 737 | } | |
| 760 | 738 | rows = append(rows, row) | |
| 739 | + | liveNames[row.Name] = struct{}{} | |
| 761 | 740 | } | |
| 762 | 741 | for _, entry := range stopped { | |
| 742 | + | if _, live := liveNames[entry.name]; live { | |
| 743 | + | continue | |
| 744 | + | } | |
| 763 | 745 | tabs, err := remoteCatalogStoppedTabs(entry) | |
| 764 | 746 | if err != nil { | |
| 765 | 747 | return "", err | |
| 766 | 748 | } | |
| 767 | - | state := "down" | |
| 749 | + | state := ports.RemoteCatalogSessionDown | |
| 768 | 750 | reason := "" | |
| 769 | - | if entry.state == ports.SessionBroken || entry.record.DegradedReason != "" { | |
| 770 | - | state = "broken" | |
| 751 | + | if entry.broken() { | |
| 752 | + | state = ports.RemoteCatalogSessionBroken | |
| 771 | 753 | reason = "session_broken" | |
| 772 | 754 | } | |
| 773 | 755 | rows = append(rows, ports.RemoteCatalogSession{ | |
| @@ -797,26 +779,6 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 797 | 779 | return marshalListing(catalog) | |
| 798 | 780 | } | |
| 799 | 781 | ||
| 800 | - | func (e controlExec) remoteCatalogLegacy(sessions []*session) (string, error) { | |
| 801 | - | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)) | |
| 802 | - | for _, sess := range sessions { | |
| 803 | - | snap := sess.snapshotView(viewOptions{}) | |
| 804 | - | rows = append(rows, ports.RemoteCatalogSession{ | |
| 805 | - | Name: snap.name, | |
| 806 | - | State: "up", | |
| 807 | - | Ephemeral: snap.ephemeral, | |
| 808 | - | Tabs: ports.SaturateUint16(snap.tabCount), | |
| 809 | - | Attached: snap.attached, | |
| 810 | - | }) | |
| 811 | - | } | |
| 812 | - | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | |
| 813 | - | catalog := ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: rows} | |
| 814 | - | if err := ports.ValidateRemoteCatalog(catalog); err != nil { | |
| 815 | - | return "", err | |
| 816 | - | } | |
| 817 | - | return marshalListing(catalog) | |
| 818 | - | } | |
| 819 | - | ||
| 820 | 782 | func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) { | |
| 821 | 783 | if len(view.tabs) > ports.RemoteCatalogMaxTabsPerSess { | |
| 822 | 784 | return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name) | |
| @@ -834,7 +796,7 @@ func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) { | |||
| 834 | 796 | return tabs, nil | |
| 835 | 797 | } | |
| 836 | 798 | ||
| 837 | - | func remoteCatalogStoppedTabs(entry stoppedSession) ([]ports.RemoteCatalogTab, error) { | |
| 799 | + | func remoteCatalogStoppedTabs(entry inactiveSession) ([]ports.RemoteCatalogTab, error) { | |
| 838 | 800 | records := entry.tabRecords | |
| 839 | 801 | if len(records) == 0 && len(entry.tabNames) != 0 { | |
| 840 | 802 | records = make([]domain.CatalogueTabRecord, len(entry.tabNames)) | |
M internal/usecase/daemon/control_test.go +20 -19
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | + | "fmt" | |
| 8 | 9 | "io" | |
| 9 | 10 | "log/slog" | |
| 10 | 11 | "strings" | |
| @@ -719,6 +720,7 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |||
| 719 | 720 | func TestRemoteCatalogJSONOutput(t *testing.T) { | |
| 720 | 721 | d := newTestDaemon(t, nil, stubClock{}) | |
| 721 | 722 | work := addControlSession(d, "work", "t_work", "p_work") | |
| 723 | + | work.incarnation[0] = 1 | |
| 722 | 724 | work.ephemeral = false | |
| 723 | 725 | work.registerAttachment(&attachedClient{}) | |
| 724 | 726 | work.mruAt.Store(2) | |
| @@ -729,11 +731,14 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||
| 729 | 731 | work.mu.Unlock() | |
| 730 | 732 | ||
| 731 | 733 | build := addControlSession(d, "build", "t_build", "p_build") | |
| 734 | + | build.incarnation[0] = 2 | |
| 732 | 735 | build.ephemeral = true | |
| 733 | 736 | build.mruAt.Store(1) | |
| 734 | 737 | ||
| 735 | 738 | d.mu.Lock() | |
| 736 | - | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionDown} | |
| 739 | + | d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: ports.SessionDown} | |
| 740 | + | // Live runtime authority wins if both registries contain the same name. | |
| 741 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: domain.IncarnationID{4}, state: ports.SessionDown} | |
| 737 | 742 | d.mu.Unlock() | |
| 738 | 743 | ||
| 739 | 744 | listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | |
| @@ -757,20 +762,19 @@ func TestRemoteCatalogJSONOutput(t *testing.T) { | |||
| 757 | 762 | ||
| 758 | 763 | raw := map[string]json.RawMessage{} | |
| 759 | 764 | require.NoError(t, json.Unmarshal([]byte(result.Output), &raw)) | |
| 760 | - | require.Len(t, raw, 2) | |
| 765 | + | require.Len(t, raw, 3) | |
| 761 | 766 | _, hasProtocol := raw["protocol_version"] | |
| 767 | + | _, hasSchema := raw["schema_version"] | |
| 762 | 768 | _, hasSessions := raw["sessions"] | |
| 763 | - | require.True(t, hasProtocol && hasSessions) | |
| 769 | + | require.True(t, hasProtocol && hasSchema && hasSessions) | |
| 764 | 770 | ||
| 765 | 771 | require.Equal(t, []ports.RemoteCatalogSession{ | |
| 766 | - | {Name: "build", State: "up", Ephemeral: true, Tabs: 1, Attached: false}, | |
| 767 | - | {Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true}, | |
| 772 | + | {LifecycleID: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []ports.RemoteCatalogTab{{ID: "t_build", Name: "1", Detail: " (sh)"}}, ActiveTabID: "t_build", LastUsedSeq: 1}, | |
| 773 | + | {LifecycleID: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []ports.RemoteCatalogTab{}}, | |
| 774 | + | {LifecycleID: work.incarnation, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "t_work", Name: "1", Detail: " (sh)"}, {ID: "t_work_2", Index: 1, Name: "2", Detail: " (sh)"}}, Attached: true, ActiveTabID: "t_work", LastUsedSeq: 2}, | |
| 768 | 775 | }, catalog.Sessions) | |
| 769 | 776 | ||
| 770 | - | for _, session := range catalog.Sessions { | |
| 771 | - | require.Equal(t, "up", session.State) | |
| 772 | - | require.NotEqual(t, "old", session.Name) | |
| 773 | - | } | |
| 777 | + | require.Equal(t, ports.RemoteCatalogSessionDown, catalog.Sessions[1].State) | |
| 774 | 778 | ||
| 775 | 779 | listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | |
| 776 | 780 | require.True(t, listAfter.OK, listAfter.Text) | |
| @@ -814,24 +818,21 @@ func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) { | |||
| 814 | 818 | }}, catalog.Sessions) | |
| 815 | 819 | } | |
| 816 | 820 | ||
| 817 | - | func TestRemoteCatalogTabCountSaturates(t *testing.T) { | |
| 821 | + | func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) { | |
| 818 | 822 | d := newTestDaemon(t, nil, stubClock{}) | |
| 819 | 823 | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 820 | 824 | sess.mu.Lock() | |
| 821 | - | base := sess.tabs[0] | |
| 822 | - | sess.tabs = make([]*tab, int(^uint16(0))+1) | |
| 825 | + | sess.tabs = make([]*tab, ports.RemoteCatalogMaxTabsPerSess+1) | |
| 823 | 826 | for i := range sess.tabs { | |
| 824 | - | sess.tabs[i] = base | |
| 827 | + | sess.tabs[i] = newTabWithStableID( | |
| 828 | + | fmt.Sprintf("tab-%d", i), fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 22}, | |
| 829 | + | ) | |
| 825 | 830 | } | |
| 826 | 831 | sess.mu.Unlock() | |
| 827 | 832 | ||
| 828 | 833 | result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true}) | |
| 829 | - | require.True(t, result.OK, result.Text) | |
| 830 | - | var catalog ports.RemoteCatalog | |
| 831 | - | require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog)) | |
| 832 | - | require.Equal(t, []ports.RemoteCatalogSession{{ | |
| 833 | - | Name: "work", State: "up", Ephemeral: true, Tabs: int(^uint16(0)), Attached: false, | |
| 834 | - | }}, catalog.Sessions) | |
| 834 | + | require.False(t, result.OK) | |
| 835 | + | require.Contains(t, result.Text, "too many tabs") | |
| 835 | 836 | } | |
| 836 | 837 | ||
| 837 | 838 | func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |
M internal/usecase/daemon/daemon.go +56 -26
| @@ -71,7 +71,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24} | |||
| 71 | 71 | type Daemon struct { | |
| 72 | 72 | mu sync.Mutex | |
| 73 | 73 | sessions map[domain.SessionID]*session | |
| 74 | - | stopped map[string]stoppedSession | |
| 74 | + | inactive map[string]inactiveSession | |
| 75 | 75 | // creating reserves names while durable creation I/O runs without mu. | |
| 76 | 76 | creating map[string]struct{} | |
| 77 | 77 | nextID uint64 | |
| @@ -348,9 +348,11 @@ func (p *parkingAttachment) closeDone() { | |||
| 348 | 348 | p.doneOnce.Do(func() { close(p.done) }) | |
| 349 | 349 | } | |
| 350 | 350 | ||
| 351 | - | // session is a single multiplexed session. It owns one or more full-screen | |
| 352 | - | ||
| 353 | - | type stoppedSession struct { | |
| 351 | + | // inactiveSession is the durable authority for a named session without a live | |
| 352 | + | // runtime. It remains present while healthy-down, restoring, broken, degraded, | |
| 353 | + | // or purge-fenced; callers must use its predicates instead of inferring those | |
| 354 | + | // states from map membership. | |
| 355 | + | type inactiveSession struct { | |
| 354 | 356 | name string | |
| 355 | 357 | cwd string | |
| 356 | 358 | createdAt int64 | |
| @@ -364,6 +366,34 @@ type stoppedSession struct { | |||
| 364 | 366 | restoreDone chan struct{} | |
| 365 | 367 | } | |
| 366 | 368 | ||
| 369 | + | func (s inactiveSession) restorePending() bool { | |
| 370 | + | if s.restoreDone == nil { | |
| 371 | + | return false | |
| 372 | + | } | |
| 373 | + | select { | |
| 374 | + | case <-s.restoreDone: | |
| 375 | + | return false | |
| 376 | + | default: | |
| 377 | + | return true | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func (s inactiveSession) broken() bool { | |
| 382 | + | return s.state == ports.SessionBroken || s.record.DegradedReason != "" | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func (s inactiveSession) visible() bool { | |
| 386 | + | return !s.purging | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func (s inactiveSession) canResume() bool { | |
| 390 | + | return s.visible() && s.state == ports.SessionDown && !s.broken() | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func (s inactiveSession) sameLifecycle(other inactiveSession) bool { | |
| 394 | + | return s.name == other.name && s.createdAt == other.createdAt && s.incarnation == other.incarnation | |
| 395 | + | } | |
| 396 | + | ||
| 367 | 397 | type Option func(*Daemon) | |
| 368 | 398 | ||
| 369 | 399 | // WithRuntimeObserver accepts only a composition-root serialized observer. | |
| @@ -591,7 +621,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 591 | 621 | paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background()) | |
| 592 | 622 | d := &Daemon{ | |
| 593 | 623 | sessions: make(map[domain.SessionID]*session), | |
| 594 | - | stopped: make(map[string]stoppedSession), | |
| 624 | + | inactive: make(map[string]inactiveSession), | |
| 595 | 625 | creating: make(map[string]struct{}), | |
| 596 | 626 | parked: make(map[uint64]*parkedAttachment), | |
| 597 | 627 | parking: make(map[uint64]*parkingAttachment), | |
| @@ -652,9 +682,9 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 652 | 682 | for _, r := range records { | |
| 653 | 683 | if d.catalogueRecordsProvided { | |
| 654 | 684 | state, done := initialSessionState(r) | |
| 655 | - | d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done) | |
| 685 | + | d.inactive[r.Name] = inactiveSessionFromRecord(r, state, done) | |
| 656 | 686 | } else { | |
| 657 | - | d.stopped[r.Name] = stoppedSessionFromRecord(r, ports.SessionDown, nil) | |
| 687 | + | d.inactive[r.Name] = inactiveSessionFromRecord(r, ports.SessionDown, nil) | |
| 658 | 688 | } | |
| 659 | 689 | if !hasCreatedAt || r.CreatedAt > maxCreatedAt { | |
| 660 | 690 | maxCreatedAt = r.CreatedAt | |
| @@ -1009,7 +1039,7 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 1009 | 1039 | defer func() { _ = tr.Close() }() | |
| 1010 | 1040 | ||
| 1011 | 1041 | d.mu.Lock() | |
| 1012 | - | infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped)) | |
| 1042 | + | infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.inactive)) | |
| 1013 | 1043 | liveNames := make(map[string]struct{}, len(d.sessions)) | |
| 1014 | 1044 | for _, s := range d.sessions { | |
| 1015 | 1045 | if s == nil { | |
| @@ -1028,14 +1058,12 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 1028 | 1058 | s.mu.Unlock() | |
| 1029 | 1059 | infos = append(infos, info) | |
| 1030 | 1060 | } | |
| 1031 | - | for name, stopped := range d.stopped { | |
| 1032 | - | if _, live := liveNames[name]; live { | |
| 1061 | + | for name, inactive := range d.inactive { | |
| 1062 | + | if _, live := liveNames[name]; live || !inactive.visible() { | |
| 1033 | 1063 | continue | |
| 1034 | 1064 | } | |
| 1035 | 1065 | state := ports.SessionDown | |
| 1036 | - | if stopped.purging || stopped.state == ports.SessionBroken { | |
| 1037 | - | // Purge is the dominant externally visible state: restoration must | |
| 1038 | - | // never make a deletion-reserved record appear attachable. | |
| 1066 | + | if inactive.broken() { | |
| 1039 | 1067 | state = ports.SessionBroken | |
| 1040 | 1068 | } | |
| 1041 | 1069 | infos = append(infos, ports.SessionInfo{Name: name, State: state}) | |
| @@ -1065,7 +1093,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 1065 | 1093 | d.mu.Lock() | |
| 1066 | 1094 | target := d.findByNameLocked(k.Name) | |
| 1067 | 1095 | if target == nil { | |
| 1068 | - | if _, ok := d.stopped[k.Name]; ok { | |
| 1096 | + | if _, ok := d.inactive[k.Name]; ok { | |
| 1069 | 1097 | d.mu.Unlock() | |
| 1070 | 1098 | // Stopped sessions use the same catalogue-first, incarnation-second | |
| 1071 | 1099 | // deletion order as live and offline purges. | |
| @@ -1330,7 +1358,7 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |||
| 1330 | 1358 | d.mu.Lock() | |
| 1331 | 1359 | var ( | |
| 1332 | 1360 | done chan struct{} | |
| 1333 | - | stopped stoppedSession | |
| 1361 | + | stopped inactiveSession | |
| 1334 | 1362 | ok bool | |
| 1335 | 1363 | ) | |
| 1336 | 1364 | if sess := d.findByNameLocked(name); sess != nil { | |
| @@ -1338,12 +1366,14 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |||
| 1338 | 1366 | done = sess.restoreDone | |
| 1339 | 1367 | sess.mu.Unlock() | |
| 1340 | 1368 | } else { | |
| 1341 | - | stopped, ok = d.stopped[name] | |
| 1342 | - | if !ok || stopped.purging { | |
| 1369 | + | stopped, ok = d.inactive[name] | |
| 1370 | + | if !ok || !stopped.visible() { | |
| 1343 | 1371 | d.mu.Unlock() | |
| 1344 | 1372 | return nil | |
| 1345 | 1373 | } | |
| 1346 | - | done = stopped.restoreDone | |
| 1374 | + | if stopped.restorePending() { | |
| 1375 | + | done = stopped.restoreDone | |
| 1376 | + | } | |
| 1347 | 1377 | } | |
| 1348 | 1378 | d.mu.Unlock() | |
| 1349 | 1379 | ||
| @@ -1360,11 +1390,11 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |||
| 1360 | 1390 | if d.findByNameLocked(name) != nil { | |
| 1361 | 1391 | return nil | |
| 1362 | 1392 | } | |
| 1363 | - | stopped, ok = d.stopped[name] | |
| 1393 | + | stopped, ok = d.inactive[name] | |
| 1364 | 1394 | if !ok || stopped.record.Name == "" { | |
| 1365 | 1395 | return nil | |
| 1366 | 1396 | } | |
| 1367 | - | if stopped.state == ports.SessionBroken { | |
| 1397 | + | if stopped.broken() { | |
| 1368 | 1398 | return &protoErr{ports.ErrInternal, "session durable state is broken: " + name} | |
| 1369 | 1399 | } | |
| 1370 | 1400 | if stopped.record.Committed == nil { | |
| @@ -1383,8 +1413,8 @@ func (d *Daemon) validateExactSessionTargetLocked(target ports.ExactSessionTarge | |||
| 1383 | 1413 | } | |
| 1384 | 1414 | return nil | |
| 1385 | 1415 | } | |
| 1386 | - | stopped, ok := d.stopped[target.SessionName] | |
| 1387 | - | if !ok || stopped.purging || stopped.incarnation != target.LifecycleID { | |
| 1416 | + | stopped, ok := d.inactive[target.SessionName] | |
| 1417 | + | if !ok || !stopped.visible() || stopped.incarnation != target.LifecycleID { | |
| 1388 | 1418 | return &protoErr{ports.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName} | |
| 1389 | 1419 | } | |
| 1390 | 1420 | return nil | |
| @@ -1537,10 +1567,10 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T | |||
| 1537 | 1567 | sess := d.findByNameLocked(h.Name) | |
| 1538 | 1568 | created := false | |
| 1539 | 1569 | if sess == nil { | |
| 1540 | - | stopped, ok := d.stopped[h.Name] | |
| 1541 | - | if !ok || stopped.purging { | |
| 1570 | + | stopped, ok := d.inactive[h.Name] | |
| 1571 | + | if !ok || !stopped.canResume() { | |
| 1542 | 1572 | d.mu.Unlock() | |
| 1543 | - | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | |
| 1573 | + | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such resumable session: " + h.Name} | |
| 1544 | 1574 | } | |
| 1545 | 1575 | cwd := d.dirOrHome(stopped.cwd) | |
| 1546 | 1576 | env := h.Env | |
| @@ -1548,7 +1578,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T | |||
| 1548 | 1578 | env = copyEnvironment(d.baseEnv) | |
| 1549 | 1579 | } | |
| 1550 | 1580 | var err error | |
| 1551 | - | sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, env, stopped.tabNames) | |
| 1581 | + | sess, err = d.resumeInactiveSessionLocked(h.Name, cwd, sz, env, stopped, stopped.tabNames) | |
| 1552 | 1582 | if err != nil { | |
| 1553 | 1583 | d.mu.Unlock() | |
| 1554 | 1584 | return nil, nil, err | |
M internal/usecase/daemon/durable_recovery_test.go +33 -33
| @@ -301,8 +301,8 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||
| 301 | 301 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 302 | 302 | ||
| 303 | 303 | d.mu.Lock() | |
| 304 | - | healthyEntry := d.stopped[healthy.Name] | |
| 305 | - | brokenEntry := d.stopped[broken.Name] | |
| 304 | + | healthyEntry := d.inactive[healthy.Name] | |
| 305 | + | brokenEntry := d.inactive[broken.Name] | |
| 306 | 306 | d.mu.Unlock() | |
| 307 | 307 | require.Equal(t, ports.SessionDown, healthyEntry.state) | |
| 308 | 308 | require.Equal(t, ports.SessionBroken, brokenEntry.state) | |
| @@ -344,14 +344,14 @@ func TestCatalogueRestoreResetsOnlyIncompatibleCheckpoint(t *testing.T) { | |||
| 344 | 344 | require.Empty(t, fresh.DegradedReason) | |
| 345 | 345 | require.Empty(t, fresh.TabNames) | |
| 346 | 346 | require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted) | |
| 347 | - | require.Equal(t, ports.SessionDown, d.stopped[incompatible.Name].state) | |
| 348 | - | require.Equal(t, fresh, d.stopped[incompatible.Name].record) | |
| 347 | + | require.Equal(t, ports.SessionDown, d.inactive[incompatible.Name].state) | |
| 348 | + | require.Equal(t, fresh, d.inactive[incompatible.Name].record) | |
| 349 | 349 | ||
| 350 | 350 | restoredSibling, ok, err := catalogue.Record(healthy.Name) | |
| 351 | 351 | require.NoError(t, err) | |
| 352 | 352 | require.True(t, ok) | |
| 353 | 353 | require.Equal(t, healthy, restoredSibling) | |
| 354 | - | require.Equal(t, ports.SessionDown, d.stopped[healthy.Name].state) | |
| 354 | + | require.Equal(t, ports.SessionDown, d.inactive[healthy.Name].state) | |
| 355 | 355 | require.Equal(t, 1, repository.loads[healthy.Name]) | |
| 356 | 356 | } | |
| 357 | 357 | ||
| @@ -366,7 +366,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te | |||
| 366 | 366 | } | |
| 367 | 367 | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 368 | 368 | d.mu.Lock() | |
| 369 | - | restoreDone := d.stopped[record.Name].restoreDone | |
| 369 | + | restoreDone := d.inactive[record.Name].restoreDone | |
| 370 | 370 | d.mu.Unlock() | |
| 371 | 371 | ||
| 372 | 372 | restoreErr := d.restoreRecord(context.Background(), record) | |
| @@ -379,7 +379,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te | |||
| 379 | 379 | require.Nil(t, fresh.Committed) | |
| 380 | 380 | require.Empty(t, fresh.DegradedReason) | |
| 381 | 381 | d.mu.Lock() | |
| 382 | - | entry := d.stopped[record.Name] | |
| 382 | + | entry := d.inactive[record.Name] | |
| 383 | 383 | d.mu.Unlock() | |
| 384 | 384 | require.Equal(t, ports.SessionDown, entry.state) | |
| 385 | 385 | require.Equal(t, fresh, entry.record) | |
| @@ -392,7 +392,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te | |||
| 392 | 392 | ||
| 393 | 393 | d.finishRecordRestore(record, restoreErr, restoreDone) | |
| 394 | 394 | d.mu.Lock() | |
| 395 | - | entry = d.stopped[record.Name] | |
| 395 | + | entry = d.inactive[record.Name] | |
| 396 | 396 | d.mu.Unlock() | |
| 397 | 397 | require.Equal(t, ports.SessionDown, entry.state) | |
| 398 | 398 | require.Equal(t, fresh, entry.record) | |
| @@ -452,7 +452,7 @@ func TestRestoreLoadFailuresRetainCheckpointWithoutDeletion(t *testing.T) { | |||
| 452 | 452 | require.Equal(t, record.IncarnationID, persisted.IncarnationID) | |
| 453 | 453 | require.Equal(t, record.Committed, persisted.Committed) | |
| 454 | 454 | require.Equal(t, "checkpoint validation failed", persisted.DegradedReason) | |
| 455 | - | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) | |
| 455 | + | require.Equal(t, ports.SessionBroken, d.inactive[record.Name].state) | |
| 456 | 456 | require.Empty(t, repository.deleted) | |
| 457 | 457 | }) | |
| 458 | 458 | } | |
| @@ -470,9 +470,9 @@ func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | |||
| 470 | 470 | ||
| 471 | 471 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 472 | 472 | ||
| 473 | - | require.Len(t, d.stopped, count) | |
| 473 | + | require.Len(t, d.inactive, count) | |
| 474 | 474 | for _, record := range records { | |
| 475 | - | require.Equal(t, ports.SessionDown, d.stopped[record.Name].state) | |
| 475 | + | require.Equal(t, ports.SessionDown, d.inactive[record.Name].state) | |
| 476 | 476 | require.Equal(t, 1, repository.loads[record.Name]) | |
| 477 | 477 | } | |
| 478 | 478 | } | |
| @@ -486,7 +486,7 @@ func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) { | |||
| 486 | 486 | // discovery API and therefore performs exactly one direct catalogue lookup. | |
| 487 | 487 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 488 | 488 | ||
| 489 | - | require.Equal(t, ports.SessionDown, d.stopped[record.Name].state) | |
| 489 | + | require.Equal(t, ports.SessionDown, d.inactive[record.Name].state) | |
| 490 | 490 | require.Equal(t, 1, repository.loads[record.Name]) | |
| 491 | 491 | } | |
| 492 | 492 | ||
| @@ -499,7 +499,7 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) { | |||
| 499 | 499 | ||
| 500 | 500 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 501 | 501 | ||
| 502 | - | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) | |
| 502 | + | require.Equal(t, ports.SessionBroken, d.inactive[record.Name].state) | |
| 503 | 503 | got, _, _ := catalogue.Record(record.Name) | |
| 504 | 504 | require.Equal(t, "checkpoint validation failed", got.DegradedReason) | |
| 505 | 505 | } | |
| @@ -648,13 +648,13 @@ func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T) | |||
| 648 | 648 | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| 649 | 649 | tt.setup(d, catalogue, record) | |
| 650 | 650 | d.mu.Lock() | |
| 651 | - | before := d.stopped[record.Name] | |
| 651 | + | before := d.inactive[record.Name] | |
| 652 | 652 | d.mu.Unlock() | |
| 653 | 653 | ||
| 654 | 654 | d.finishRecordRestore(record, errors.New("invalid checkpoint"), before.restoreDone) | |
| 655 | 655 | ||
| 656 | 656 | d.mu.Lock() | |
| 657 | - | after := d.stopped[record.Name] | |
| 657 | + | after := d.inactive[record.Name] | |
| 658 | 658 | d.mu.Unlock() | |
| 659 | 659 | require.Equal(t, before.record, after.record) | |
| 660 | 660 | require.Equal(t, before.state, after.state) | |
| @@ -666,7 +666,7 @@ func TestPersistAndRegisterRestoredSessionRegistrationFailurePreservesStoppedEnt | |||
| 666 | 666 | record := durableRecoveryRecord(0) | |
| 667 | 667 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| 668 | 668 | d.mu.Lock() | |
| 669 | - | before := d.stopped[record.Name] | |
| 669 | + | before := d.inactive[record.Name] | |
| 670 | 670 | collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 671 | 671 | d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}} | |
| 672 | 672 | d.mu.Unlock() | |
| @@ -678,7 +678,7 @@ func TestPersistAndRegisterRestoredSessionRegistrationFailurePreservesStoppedEnt | |||
| 678 | 678 | require.ErrorContains(t, err, "registry rejected") | |
| 679 | 679 | require.False(t, registered) | |
| 680 | 680 | d.mu.Lock() | |
| 681 | - | after := d.stopped[record.Name] | |
| 681 | + | after := d.inactive[record.Name] | |
| 682 | 682 | d.mu.Unlock() | |
| 683 | 683 | require.Equal(t, before, after, "failed runtime publication must preserve the exact stopped authority and barrier") | |
| 684 | 684 | require.Equal(t, before.restoreDone, after.restoreDone) | |
| @@ -705,14 +705,14 @@ func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T | |||
| 705 | 705 | ||
| 706 | 706 | d.mu.Lock() | |
| 707 | 707 | for _, record := range tc.records { | |
| 708 | - | d.stopped[record.Name] = stoppedSession{ | |
| 708 | + | d.inactive[record.Name] = inactiveSession{ | |
| 709 | 709 | name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt, | |
| 710 | 710 | incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq, | |
| 711 | 711 | tabNames: append([]string(nil), record.TabNames...), record: record, | |
| 712 | 712 | state: ports.SessionDown, restoreDone: make(chan struct{}), | |
| 713 | 713 | } | |
| 714 | 714 | } | |
| 715 | - | beforeStopped, hadStopped := d.stopped[tc.sessName] | |
| 715 | + | beforeStopped, hadStopped := d.inactive[tc.sessName] | |
| 716 | 716 | collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 717 | 717 | d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}} | |
| 718 | 718 | d.mu.Unlock() | |
| @@ -723,7 +723,7 @@ func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T | |||
| 723 | 723 | require.ErrorContains(t, err, "registry rejected") | |
| 724 | 724 | require.ElementsMatch(t, beforeRecords, mustDurableRecords(t, catalogue), "failed runtime publication must leave no created record or metadata mutation") | |
| 725 | 725 | d.mu.Lock() | |
| 726 | - | afterStopped, stillStopped := d.stopped[tc.sessName] | |
| 726 | + | afterStopped, stillStopped := d.inactive[tc.sessName] | |
| 727 | 727 | d.mu.Unlock() | |
| 728 | 728 | require.Equal(t, hadStopped, stillStopped) | |
| 729 | 729 | if hadStopped { | |
| @@ -769,8 +769,8 @@ func TestSessionRecoveryCommand(t *testing.T) { | |||
| 769 | 769 | ||
| 770 | 770 | _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard") | |
| 771 | 771 | require.NoError(t, err) | |
| 772 | - | require.Equal(t, ports.SessionDown, d.stopped[degraded.Name].state) | |
| 773 | - | require.Equal(t, fresh, d.stopped[degraded.Name].record) | |
| 772 | + | require.Equal(t, ports.SessionDown, d.inactive[degraded.Name].state) | |
| 773 | + | require.Equal(t, fresh, d.inactive[degraded.Name].record) | |
| 774 | 774 | } | |
| 775 | 775 | ||
| 776 | 776 | func TestListShowsDegraded(t *testing.T) { | |
| @@ -789,18 +789,18 @@ func TestListShowsDegraded(t *testing.T) { | |||
| 789 | 789 | }, listed.Sessions) | |
| 790 | 790 | } | |
| 791 | 791 | ||
| 792 | - | func TestListPurgingDominatesRestoringState(t *testing.T) { | |
| 792 | + | func TestListHidesPurgingInactiveSession(t *testing.T) { | |
| 793 | 793 | record := durableRecoveryRecord(0) | |
| 794 | 794 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| 795 | 795 | d.mu.Lock() | |
| 796 | - | entry := d.stopped[record.Name] | |
| 796 | + | entry := d.inactive[record.Name] | |
| 797 | 797 | entry.purging = true | |
| 798 | 798 | entry.state = ports.SessionDown | |
| 799 | - | d.stopped[record.Name] = entry | |
| 799 | + | d.inactive[record.Name] = entry | |
| 800 | 800 | d.mu.Unlock() | |
| 801 | 801 | ||
| 802 | 802 | listed := listSessions(t, d) | |
| 803 | - | require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionBroken}}, listed.Sessions) | |
| 803 | + | require.Empty(t, listed.Sessions) | |
| 804 | 804 | } | |
| 805 | 805 | ||
| 806 | 806 | func TestAttachWaitsForRestore(t *testing.T) { | |
| @@ -825,7 +825,7 @@ func TestAttachWaitsForRestore(t *testing.T) { | |||
| 825 | 825 | ||
| 826 | 826 | d.setStoppedRecovery(record, ports.SessionBroken) | |
| 827 | 827 | d.mu.Lock() | |
| 828 | - | closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone) | |
| 828 | + | closeRuntimeRestoreDoneLocked(d.inactive[record.Name].restoreDone) | |
| 829 | 829 | d.mu.Unlock() | |
| 830 | 830 | var protocolError *protoErr | |
| 831 | 831 | require.ErrorAs(t, <-result, &protocolError) | |
| @@ -837,7 +837,7 @@ func TestAttachBarrierSurvivesLiveRegistryPublication(t *testing.T) { | |||
| 837 | 837 | record := durableRecoveryRecord(0) | |
| 838 | 838 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| 839 | 839 | d.mu.Lock() | |
| 840 | - | done := d.stopped[record.Name].restoreDone | |
| 840 | + | done := d.inactive[record.Name].restoreDone | |
| 841 | 841 | d.mu.Unlock() | |
| 842 | 842 | sess := &session{sessionCore: sessionCore{name: record.Name, incarnation: record.IncarnationID}} | |
| 843 | 843 | ||
| @@ -894,7 +894,7 @@ func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) { | |||
| 894 | 894 | require.ErrorAs(t, <-attached, &protocolError) | |
| 895 | 895 | require.Equal(t, ports.ErrInternal, protocolError.code) | |
| 896 | 896 | <-restored | |
| 897 | - | entry := d.stopped[record.Name] | |
| 897 | + | entry := d.inactive[record.Name] | |
| 898 | 898 | require.Equal(t, ports.SessionBroken, entry.state) | |
| 899 | 899 | require.Equal(t, "restore interrupted", entry.record.DegradedReason) | |
| 900 | 900 | select { | |
| @@ -910,7 +910,7 @@ func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) { | |||
| 910 | 910 | record := durableRecoveryRecord(0) | |
| 911 | 911 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| 912 | 912 | d.mu.Lock() | |
| 913 | - | closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone) | |
| 913 | + | closeRuntimeRestoreDoneLocked(d.inactive[record.Name].restoreDone) | |
| 914 | 914 | d.mu.Unlock() | |
| 915 | 915 | ||
| 916 | 916 | result := make(chan error, 1) | |
| @@ -943,7 +943,7 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) { | |||
| 943 | 943 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| 944 | 944 | d.setStoppedRecovery(record, tt.state) | |
| 945 | 945 | d.mu.Lock() | |
| 946 | - | closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone) | |
| 946 | + | closeRuntimeRestoreDoneLocked(d.inactive[record.Name].restoreDone) | |
| 947 | 947 | d.mu.Unlock() | |
| 948 | 948 | ||
| 949 | 949 | var protocolError *protoErr | |
| @@ -975,7 +975,7 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) { | |||
| 975 | 975 | d.closeRestoreDone() | |
| 976 | 976 | ||
| 977 | 977 | d.mu.Lock() | |
| 978 | - | entry := d.stopped[record.Name] | |
| 978 | + | entry := d.inactive[record.Name] | |
| 979 | 979 | d.mu.Unlock() | |
| 980 | 980 | require.Equal(t, ports.SessionBroken, entry.state) | |
| 981 | 981 | select { | |
| @@ -993,6 +993,6 @@ func TestCatalogueRestoreDegradedVisible(t *testing.T) { | |||
| 993 | 993 | ||
| 994 | 994 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 995 | 995 | ||
| 996 | - | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) | |
| 996 | + | require.Equal(t, ports.SessionBroken, d.inactive[record.Name].state) | |
| 997 | 997 | require.Zero(t, repository.loads[record.Name]) | |
| 998 | 998 | } | |
M internal/usecase/daemon/handshake.go +1 -8
| @@ -153,13 +153,6 @@ func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr p | |||
| 153 | 153 | _ = tr.Close() | |
| 154 | 154 | } | |
| 155 | 155 | if ac.routeCreatedSession { | |
| 156 | - | sess.mu.Lock() | |
| 157 | - | empty := len(sess.attachments) == 0 | |
| 158 | - | sess.mu.Unlock() | |
| 159 | - | if empty { | |
| 160 | - | // killSession revalidates the attachment set under its publication | |
| 161 | - | // locks before removing the session. | |
| 162 | - | _ = d.killSession(sess, ports.ReasonSessionKilled, ac.routeSessionPurge) | |
| 163 | - | } | |
| 156 | + | _ = d.killSessionIfEmpty(sess, ports.ReasonSessionKilled, ac.routeSessionPurge) | |
| 164 | 157 | } | |
| 165 | 158 | } | |
M internal/usecase/daemon/handshake_test.go +3 -3
| @@ -240,7 +240,7 @@ func TestHandshakeTimeoutCancelsRouteRestoreWait(t *testing.T) { | |||
| 240 | 240 | d := newTestDaemon(t, nil, clock) | |
| 241 | 241 | restoreDone := make(chan struct{}) | |
| 242 | 242 | d.mu.Lock() | |
| 243 | - | d.stopped["restoring"] = stoppedSession{name: "restoring", restoreDone: restoreDone} | |
| 243 | + | d.inactive["restoring"] = inactiveSession{name: "restoring", restoreDone: restoreDone} | |
| 244 | 244 | d.mu.Unlock() | |
| 245 | 245 | ||
| 246 | 246 | tr := newHandshakeBlockingTransport(false) | |
| @@ -266,7 +266,7 @@ func TestHandshakeTimeoutRemovesRestoredEmptySession(t *testing.T) { | |||
| 266 | 266 | defer release() | |
| 267 | 267 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 268 | 268 | d.mu.Lock() | |
| 269 | - | d.stopped["restored"] = stoppedSession{name: "restored", cwd: "/tmp"} | |
| 269 | + | d.inactive["restored"] = inactiveSession{name: "restored", cwd: "/tmp", state: ports.SessionDown} | |
| 270 | 270 | d.mu.Unlock() | |
| 271 | 271 | ||
| 272 | 272 | clock := &signalClock{timers: make(chan *signalTimer, 4)} | |
| @@ -286,7 +286,7 @@ func TestHandshakeTimeoutRemovesRestoredEmptySession(t *testing.T) { | |||
| 286 | 286 | requireClosedHandshakeTransport(t, tr) | |
| 287 | 287 | d.mu.Lock() | |
| 288 | 288 | require.Empty(t, d.sessions) | |
| 289 | - | _, retained := d.stopped["restored"] | |
| 289 | + | _, retained := d.inactive["restored"] | |
| 290 | 290 | d.mu.Unlock() | |
| 291 | 291 | require.True(t, retained, "failed attach must retain the stopped session authority") | |
| 292 | 292 | } | |
A internal/usecase/daemon/inactive_session_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestInactiveSessionPredicates(t *testing.T) { | |
| 13 | + | pending := make(chan struct{}) | |
| 14 | + | settled := make(chan struct{}) | |
| 15 | + | close(settled) | |
| 16 | + | ||
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | entry inactiveSession | |
| 20 | + | visible bool | |
| 21 | + | broken bool | |
| 22 | + | canResume bool | |
| 23 | + | restorePending bool | |
| 24 | + | }{ | |
| 25 | + | {name: "healthy down", entry: inactiveSession{state: ports.SessionDown}, visible: true, canResume: true}, | |
| 26 | + | {name: "restore pending remains resumable after wait", entry: inactiveSession{state: ports.SessionDown, restoreDone: pending}, visible: true, canResume: true, restorePending: true}, | |
| 27 | + | {name: "settled restore", entry: inactiveSession{state: ports.SessionDown, restoreDone: settled}, visible: true, canResume: true}, | |
| 28 | + | {name: "broken", entry: inactiveSession{state: ports.SessionBroken}, visible: true, broken: true}, | |
| 29 | + | {name: "degraded", entry: inactiveSession{state: ports.SessionDown, record: domain.CatalogueRecord{DegradedReason: "checkpoint unavailable"}}, visible: true, broken: true}, | |
| 30 | + | {name: "purging", entry: inactiveSession{state: ports.SessionDown, purging: true}}, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | for _, test := range tests { | |
| 34 | + | t.Run(test.name, func(t *testing.T) { | |
| 35 | + | require.Equal(t, test.visible, test.entry.visible()) | |
| 36 | + | require.Equal(t, test.broken, test.entry.broken()) | |
| 37 | + | require.Equal(t, test.canResume, test.entry.canResume()) | |
| 38 | + | require.Equal(t, test.restorePending, test.entry.restorePending()) | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestInactiveSessionLifecycleMatch(t *testing.T) { | |
| 44 | + | entry := inactiveSession{name: "work", createdAt: 7, incarnation: domain.IncarnationID{1}} | |
| 45 | + | for _, test := range []struct { | |
| 46 | + | name string | |
| 47 | + | mutate func(*inactiveSession) | |
| 48 | + | want bool | |
| 49 | + | }{ | |
| 50 | + | {name: "same lifecycle", want: true}, | |
| 51 | + | {name: "different name", mutate: func(candidate *inactiveSession) { candidate.name = "other" }}, | |
| 52 | + | {name: "different creation time", mutate: func(candidate *inactiveSession) { candidate.createdAt++ }}, | |
| 53 | + | {name: "different incarnation", mutate: func(candidate *inactiveSession) { candidate.incarnation[0]++ }}, | |
| 54 | + | } { | |
| 55 | + | t.Run(test.name, func(t *testing.T) { | |
| 56 | + | candidate := entry | |
| 57 | + | if test.mutate != nil { | |
| 58 | + | test.mutate(&candidate) | |
| 59 | + | } | |
| 60 | + | require.Equal(t, test.want, entry.sameLifecycle(candidate)) | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | } | |
M internal/usecase/daemon/navigation_attachment_handoff_test.go +4 -4
| @@ -51,14 +51,14 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 51 | 51 | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error { | |
| 52 | 52 | d.mu.Lock() | |
| 53 | 53 | delete(d.sessions, target.id) | |
| 54 | - | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: ports.SessionDown} | |
| 54 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: ports.SessionDown} | |
| 55 | 55 | d.mu.Unlock() | |
| 56 | 56 | expectedCreatedAt := int64(9) | |
| 57 | 57 | return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session") | |
| 58 | 58 | }, | |
| 59 | 59 | check: func(t *testing.T, d *Daemon, _, _ *session) { | |
| 60 | 60 | d.mu.Lock() | |
| 61 | - | _, stopped := d.stopped["stopped"] | |
| 61 | + | _, stopped := d.inactive["stopped"] | |
| 62 | 62 | resumed := d.findByNameLocked("stopped") | |
| 63 | 63 | d.mu.Unlock() | |
| 64 | 64 | require.True(t, stopped) | |
| @@ -149,7 +149,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 149 | 149 | func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) { | |
| 150 | 150 | d, source, old, _ := newManualSessionWithPTYs(t, nil) | |
| 151 | 151 | d.mu.Lock() | |
| 152 | - | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, incarnation: domain.IncarnationID{3}, state: ports.SessionDown} | |
| 152 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 7, incarnation: domain.IncarnationID{3}, state: ports.SessionDown} | |
| 153 | 153 | d.mu.Unlock() | |
| 154 | 154 | ||
| 155 | 155 | rc := d.attachCoordinator(source, nil, old, true) | |
| @@ -189,7 +189,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 189 | 189 | require.NoError(t, <-done) | |
| 190 | 190 | ||
| 191 | 191 | d.mu.Lock() | |
| 192 | - | _, stillStopped := d.stopped["stopped"] | |
| 192 | + | _, stillStopped := d.inactive["stopped"] | |
| 193 | 193 | resumed := d.findByNameLocked("stopped") | |
| 194 | 194 | d.mu.Unlock() | |
| 195 | 195 | require.True(t, stillStopped) | |
M internal/usecase/daemon/navigation_overflow_test.go +2 -2
| @@ -119,7 +119,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||
| 119 | 119 | d.sessions[echo.id] = echo | |
| 120 | 120 | d.sessions[alpha.id] = alpha | |
| 121 | 121 | d.sessions[charlie.id] = charlie | |
| 122 | - | d.stopped["bravo"] = stoppedSession{name: "bravo", cwd: "/tmp/bravo"} | |
| 122 | + | d.inactive["bravo"] = inactiveSession{name: "bravo", cwd: "/tmp/bravo"} | |
| 123 | 123 | d.mu.Unlock() | |
| 124 | 124 | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}}) | |
| 125 | 125 | handler := daemonKeyHandler{d: d, ac: ac} | |
| @@ -150,7 +150,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||
| 150 | 150 | target.tabs[1].mu.Unlock() | |
| 151 | 151 | } | |
| 152 | 152 | d.mu.Lock() | |
| 153 | - | _, stopped := d.stopped["bravo"] | |
| 153 | + | _, stopped := d.inactive["bravo"] | |
| 154 | 154 | var bravoLive bool | |
| 155 | 155 | for _, candidate := range d.sessions { | |
| 156 | 156 | if candidate == nil { | |
M internal/usecase/daemon/palette.go +5 -4
| @@ -60,9 +60,11 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 60 | 60 | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot) []palette.Result { | |
| 61 | 61 | d.mu.Lock() | |
| 62 | 62 | sessions := d.sessionsSnapshotLocked() | |
| 63 | - | stopped := make([]stoppedSession, 0, len(d.stopped)) | |
| 64 | - | for _, entry := range d.stopped { | |
| 65 | - | if !entry.purging { | |
| 63 | + | stopped := make([]inactiveSession, 0, len(d.inactive)) | |
| 64 | + | localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(d.inactive)) | |
| 65 | + | for _, entry := range d.inactive { | |
| 66 | + | localLifecycles[entry.incarnation] = struct{}{} | |
| 67 | + | if entry.canResume() { | |
| 66 | 68 | stopped = append(stopped, entry) | |
| 67 | 69 | } | |
| 68 | 70 | } | |
| @@ -73,7 +75,6 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 73 | 75 | results = append(results, palette.NewCommandResult(cmd)) | |
| 74 | 76 | } | |
| 75 | 77 | active := make([]palette.Result, 0, len(sessions)) | |
| 76 | - | localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(stopped)) | |
| 77 | 78 | for _, candidate := range sessions { | |
| 78 | 79 | snap := candidate.snapshotView(viewOptions{}) | |
| 79 | 80 | if snap.name == "" || snap.ephemeral { | |
M internal/usecase/daemon/palette_test.go +13 -6
| @@ -228,8 +228,15 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | |||
| 228 | 228 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 229 | 229 | d.sessions["active"] = &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}} | |
| 230 | 230 | d.sessions["ephemeral"] = &session{sessionCore: sessionCore{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}} | |
| 231 | - | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12} | |
| 232 | - | d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true} | |
| 231 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", createdAt: 12, incarnation: domain.IncarnationID{3}, state: ports.SessionDown} | |
| 232 | + | d.inactive["purging"] = inactiveSession{name: "purging", createdAt: 13, incarnation: domain.IncarnationID{4}, state: ports.SessionDown, purging: true} | |
| 233 | + | d.inactive["broken"] = inactiveSession{name: "broken", createdAt: 14, incarnation: domain.IncarnationID{5}, state: ports.SessionBroken} | |
| 234 | + | d.inactive["degraded"] = inactiveSession{name: "degraded", createdAt: 15, incarnation: domain.IncarnationID{6}, state: ports.SessionDown, record: domain.CatalogueRecord{DegradedReason: "checkpoint unavailable"}} | |
| 235 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1, Entries: []ports.RecentRouteEntry{ | |
| 236 | + | testRouteEntry(1, 1, "purging", 4, ports.RouteKindLocal), | |
| 237 | + | testRouteEntry(2, 1, "broken", 5, ports.RouteKindLocal), | |
| 238 | + | testRouteEntry(3, 1, "degraded", 6, ports.RouteKindLocal), | |
| 239 | + | }}) | |
| 233 | 240 | ||
| 234 | 241 | d.enterPalette(current, ac) | |
| 235 | 242 | ac.overlays.paletteMu.Lock() | |
| @@ -314,8 +321,8 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te | |||
| 314 | 321 | p, release := newBlockingPTY(t) | |
| 315 | 322 | defer release() | |
| 316 | 323 | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |
| 317 | - | stopped := stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}} | |
| 318 | - | d.stopped[stopped.name] = stopped | |
| 324 | + | stopped := inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}, state: ports.SessionDown} | |
| 325 | + | d.inactive[stopped.name] = stopped | |
| 319 | 326 | ptys := portsmocks.NewMockPTYFactory(t) | |
| 320 | 327 | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once() | |
| 321 | 328 | d.ptys = ptys | |
| @@ -332,7 +339,7 @@ func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *te | |||
| 332 | 339 | ac.overlays.paletteMu.Lock() | |
| 333 | 340 | require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback) | |
| 334 | 341 | ac.overlays.paletteMu.Unlock() | |
| 335 | - | require.Equal(t, stopped, d.stopped[stopped.name], "failed resume must retain stopped lifecycle metadata") | |
| 342 | + | require.Equal(t, stopped, d.inactive[stopped.name], "failed resume must retain stopped lifecycle metadata") | |
| 336 | 343 | awaitFrame(t, sends, ports.MsgOutput) | |
| 337 | 344 | } | |
| 338 | 345 | ||
| @@ -343,7 +350,7 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) { | |||
| 343 | 350 | defer release2() | |
| 344 | 351 | d, current, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 345 | 352 | d.ptys = newFactory(t, p2) | |
| 346 | - | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42} | |
| 353 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 42, state: ports.SessionDown} | |
| 347 | 354 | ||
| 348 | 355 | d.handleInput(current, ac, []byte("\x1b ")) | |
| 349 | 356 | awaitFrame(t, sends, ports.MsgOutput) | |
M internal/usecase/daemon/picker.go +43 -94
| @@ -100,9 +100,9 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 100 | 100 | } | |
| 101 | 101 | sessions = append(sessions, entry) | |
| 102 | 102 | } | |
| 103 | - | stopped := make([]stoppedSession, 0, len(d.stopped)) | |
| 104 | - | for _, s := range d.stopped { | |
| 105 | - | if !s.purging { | |
| 103 | + | stopped := make([]inactiveSession, 0, len(d.inactive)) | |
| 104 | + | for _, s := range d.inactive { | |
| 105 | + | if s.visible() { | |
| 106 | 106 | stopped = append(stopped, s) | |
| 107 | 107 | } | |
| 108 | 108 | } | |
| @@ -179,15 +179,8 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 179 | 179 | for _, item := range live { | |
| 180 | 180 | views = append(views, item.view.pickerView()) | |
| 181 | 181 | } | |
| 182 | - | seenRemoteLifecycles := make(map[domain.SessionLifecycleID]struct{}, catalogRows) | |
| 183 | 182 | for _, host := range catalog { | |
| 184 | 183 | for _, session := range host.entry.Sessions { | |
| 185 | - | if session.LifecycleID != (domain.SessionLifecycleID{}) { | |
| 186 | - | if _, duplicate := seenRemoteLifecycles[session.LifecycleID]; duplicate { | |
| 187 | - | continue | |
| 188 | - | } | |
| 189 | - | seenRemoteLifecycles[session.LifecycleID] = struct{}{} | |
| 190 | - | } | |
| 191 | 184 | key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name} | |
| 192 | 185 | if key.Validate() != nil { | |
| 193 | 186 | continue | |
| @@ -886,15 +879,8 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||
| 886 | 879 | if token.sess == nil || token.ac == nil || token.effect == nil { | |
| 887 | 880 | return nil | |
| 888 | 881 | } | |
| 889 | - | if target.RemoteTarget != nil { | |
| 890 | - | var key domain.RemoteSessionKey | |
| 891 | - | if target.RemoteKey != nil { | |
| 892 | - | key = *target.RemoteKey | |
| 893 | - | } | |
| 894 | - | return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action) | |
| 895 | - | } | |
| 896 | - | if target.RemoteKey != nil { | |
| 897 | - | return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action) | |
| 882 | + | if target.RemoteTarget != nil || target.RemoteKey != nil { | |
| 883 | + | return d.sendRemoteAttachTargetForAttachment(token, target, guard, action) | |
| 898 | 884 | } | |
| 899 | 885 | return d.sendLocalAttachTargetForAttachment(token, target, guard, action) | |
| 900 | 886 | } | |
| @@ -910,7 +896,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||
| 910 | 896 | var exactTarget *ports.ExactSessionTarget | |
| 911 | 897 | var matches bool | |
| 912 | 898 | if target.Name != "" { | |
| 913 | - | var stopped stoppedSession | |
| 899 | + | var stopped inactiveSession | |
| 914 | 900 | var stoppedTarget bool | |
| 915 | 901 | targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target) | |
| 916 | 902 | if stoppedTarget && matches { | |
| @@ -971,36 +957,27 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||
| 971 | 957 | // endpoint to the thin client. The daemon owns no remote session shadow: after | |
| 972 | 958 | // the target is sent, the local attachment is detached and the client opens a | |
| 973 | 959 | // fresh connection to the owning daemon. | |
| 974 | - | func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error { | |
| 960 | + | func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, _ string) error { | |
| 975 | 961 | failUnavailable := func() error { | |
| 976 | 962 | if token.attachmentCurrent() { | |
| 977 | 963 | d.notifyRemotePickerUnavailable(token.sess, target) | |
| 978 | 964 | } | |
| 979 | 965 | return errAttachmentTransition | |
| 980 | 966 | } | |
| 981 | - | var handoff ports.AttachTarget | |
| 982 | - | if target.RemoteTarget != nil { | |
| 983 | - | remoteTarget := *target.RemoteTarget | |
| 984 | - | if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) { | |
| 985 | - | return failUnavailable() | |
| 986 | - | } | |
| 987 | - | if target.RemoteKey != nil { | |
| 988 | - | if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID { | |
| 989 | - | return failUnavailable() | |
| 990 | - | } | |
| 991 | - | } | |
| 992 | - | handoff = ports.AttachTarget{ | |
| 993 | - | Endpoint: remoteTarget.Endpoint, | |
| 994 | - | Session: remoteTarget.SessionName, | |
| 995 | - | Intent: ports.IntentAttach, | |
| 996 | - | RemoteTarget: &remoteTarget, | |
| 997 | - | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 998 | - | } | |
| 999 | - | } else { | |
| 1000 | - | if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) { | |
| 1001 | - | return failUnavailable() | |
| 1002 | - | } | |
| 1003 | - | handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned} | |
| 967 | + | if target.RemoteTarget == nil || target.RemoteKey == nil { | |
| 968 | + | return failUnavailable() | |
| 969 | + | } | |
| 970 | + | remoteTarget := *target.RemoteTarget | |
| 971 | + | key := *target.RemoteKey | |
| 972 | + | if err := remoteTarget.Validate(); err != nil || key.Validate() != nil || target.Session != key.ID() || key.Host != remoteTarget.Endpoint || key.Name != remoteTarget.SessionName || key.LifecycleID != remoteTarget.LifecycleID || !d.remotePickerTargetReadyTarget(remoteTarget) { | |
| 973 | + | return failUnavailable() | |
| 974 | + | } | |
| 975 | + | handoff := ports.AttachTarget{ | |
| 976 | + | Endpoint: remoteTarget.Endpoint, | |
| 977 | + | Session: remoteTarget.SessionName, | |
| 978 | + | Intent: ports.IntentAttach, | |
| 979 | + | RemoteTarget: &remoteTarget, | |
| 980 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 1004 | 981 | } | |
| 1005 | 982 | payload := ports.MarshalAttachTarget(handoff) | |
| 1006 | 983 | if payload == nil { | |
| @@ -1028,24 +1005,6 @@ func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCache | |||
| 1028 | 1005 | return entry, true | |
| 1029 | 1006 | } | |
| 1030 | 1007 | ||
| 1031 | - | func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool { | |
| 1032 | - | if d == nil { | |
| 1033 | - | return false | |
| 1034 | - | } | |
| 1035 | - | d.remoteCatalog.mu.Lock() | |
| 1036 | - | defer d.remoteCatalog.mu.Unlock() | |
| 1037 | - | entry, ok := d.remoteCatalogEntryLocked(key.Host) | |
| 1038 | - | if !ok { | |
| 1039 | - | return false | |
| 1040 | - | } | |
| 1041 | - | for _, session := range entry.Sessions { | |
| 1042 | - | if session.Name == key.Name { | |
| 1043 | - | return session.State == "up" | |
| 1044 | - | } | |
| 1045 | - | } | |
| 1046 | - | return false | |
| 1047 | - | } | |
| 1048 | - | ||
| 1049 | 1008 | func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool { | |
| 1050 | 1009 | if d == nil || target.Validate() != nil { | |
| 1051 | 1010 | return false | |
| @@ -1060,7 +1019,7 @@ func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget | |||
| 1060 | 1019 | if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID { | |
| 1061 | 1020 | continue | |
| 1062 | 1021 | } | |
| 1063 | - | if target.Stopped != remoteSessionStateStopped(session.State) || session.State == "broken" { | |
| 1022 | + | if target.Stopped != remoteSessionStateStopped(session.State) || session.State == ports.RemoteCatalogSessionBroken { | |
| 1064 | 1023 | return false | |
| 1065 | 1024 | } | |
| 1066 | 1025 | tabs := ports.CatalogTabs(session) | |
| @@ -1068,21 +1027,8 @@ func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget | |||
| 1068 | 1027 | for _, tab := range tabs { | |
| 1069 | 1028 | metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name}) | |
| 1070 | 1029 | } | |
| 1071 | - | if target.Stopped { | |
| 1072 | - | _, ok := target.StoppedTab.Resolve(metadata) | |
| 1073 | - | return ok | |
| 1074 | - | } | |
| 1075 | - | found := false | |
| 1076 | - | for _, tab := range tabs { | |
| 1077 | - | if tab.ID != string(target.LiveTabID) { | |
| 1078 | - | continue | |
| 1079 | - | } | |
| 1080 | - | if found { | |
| 1081 | - | return false | |
| 1082 | - | } | |
| 1083 | - | found = true | |
| 1084 | - | } | |
| 1085 | - | return found | |
| 1030 | + | _, ok := target.ResolveTab(metadata) | |
| 1031 | + | return ok | |
| 1086 | 1032 | } | |
| 1087 | 1033 | return false | |
| 1088 | 1034 | } | |
| @@ -1175,19 +1121,19 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC | |||
| 1175 | 1121 | // resolveNamedLifecycleTargetLocked chooses exactly one current representation | |
| 1176 | 1122 | // of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle | |
| 1177 | 1123 | // identity here closes the lookup-to-handoff window for palette targets. | |
| 1178 | - | func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) { | |
| 1124 | + | func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, inactiveSession, bool, bool) { | |
| 1179 | 1125 | if target.Name == "" { | |
| 1180 | - | return nil, stoppedSession{}, false, false | |
| 1126 | + | return nil, inactiveSession{}, false, false | |
| 1181 | 1127 | } | |
| 1182 | 1128 | if active := d.findByNameLocked(target.Name); active != nil { | |
| 1183 | 1129 | active.mu.Lock() | |
| 1184 | 1130 | matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation) | |
| 1185 | 1131 | active.mu.Unlock() | |
| 1186 | - | return active, stoppedSession{}, false, matches | |
| 1132 | + | return active, inactiveSession{}, false, matches | |
| 1187 | 1133 | } | |
| 1188 | - | stopped, ok := d.stopped[target.Name] | |
| 1189 | - | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) { | |
| 1190 | - | return nil, stoppedSession{}, false, false | |
| 1134 | + | stopped, ok := d.inactive[target.Name] | |
| 1135 | + | if !ok || !stopped.canResume() || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) { | |
| 1136 | + | return nil, inactiveSession{}, false, false | |
| 1191 | 1137 | } | |
| 1192 | 1138 | return nil, stopped, true, true | |
| 1193 | 1139 | } | |
| @@ -1282,10 +1228,13 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1282 | 1228 | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | |
| 1283 | 1229 | // the handoff while d.mu is held. Creation failure leaves the source client | |
| 1284 | 1230 | // and stopped record untouched. | |
| 1285 | - | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) { | |
| 1286 | - | if stopped.record.Name != "" && stopped.state == ports.SessionBroken { | |
| 1231 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped inactiveSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) { | |
| 1232 | + | if stopped.broken() { | |
| 1287 | 1233 | return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name} | |
| 1288 | 1234 | } | |
| 1235 | + | if !stopped.canResume() { | |
| 1236 | + | return nil, attachmentTransitionResult{}, false, errAttachmentTransition | |
| 1237 | + | } | |
| 1289 | 1238 | ||
| 1290 | 1239 | if sourceToken != nil { | |
| 1291 | 1240 | var targetSess *session | |
| @@ -1295,14 +1244,14 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1295 | 1244 | expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action, | |
| 1296 | 1245 | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | |
| 1297 | 1246 | createTargetLocked: func() (*session, error) { | |
| 1298 | - | current, ok := d.stopped[target.Name] | |
| 1299 | - | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) { | |
| 1247 | + | current, ok := d.inactive[target.Name] | |
| 1248 | + | if !ok || !current.canResume() || !current.sameLifecycle(stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) { | |
| 1300 | 1249 | return nil, errAttachmentTransition | |
| 1301 | 1250 | } | |
| 1302 | 1251 | from.mu.Lock() | |
| 1303 | 1252 | cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env) | |
| 1304 | 1253 | from.mu.Unlock() | |
| 1305 | - | created, createErr := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames) | |
| 1254 | + | created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames) | |
| 1306 | 1255 | targetSess = created | |
| 1307 | 1256 | return created, createErr | |
| 1308 | 1257 | }, | |
| @@ -1328,12 +1277,12 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1328 | 1277 | } | |
| 1329 | 1278 | env := copyEnvironment(from.env) | |
| 1330 | 1279 | from.mu.Unlock() | |
| 1331 | - | current, ok := d.stopped[target.Name] | |
| 1332 | - | if !ok || current.purging || !stoppedSessionMatchesLifecycle(current, stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) { | |
| 1280 | + | current, ok := d.inactive[target.Name] | |
| 1281 | + | if !ok || !current.canResume() || !current.sameLifecycle(stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) { | |
| 1333 | 1282 | return nil, attachmentTransitionResult{}, false, errAttachmentTransition | |
| 1334 | 1283 | } | |
| 1335 | 1284 | cwd := d.dirOrHome(current.cwd) | |
| 1336 | - | targetSess, err := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames) | |
| 1285 | + | targetSess, err := d.resumeInactiveSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames) | |
| 1337 | 1286 | if err != nil { | |
| 1338 | 1287 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 1339 | 1288 | return nil, attachmentTransitionResult{}, false, err | |
| @@ -1376,12 +1325,12 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 1376 | 1325 | // its stopped target and commits creation under one d.mu critical section. | |
| 1377 | 1326 | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | |
| 1378 | 1327 | d.mu.Lock() | |
| 1379 | - | stopped, ok := d.stopped[target.Name] | |
| 1328 | + | stopped, ok := d.inactive[target.Name] | |
| 1380 | 1329 | var ( | |
| 1381 | 1330 | transition attachmentTransitionResult | |
| 1382 | 1331 | switched bool | |
| 1383 | 1332 | ) | |
| 1384 | - | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) { | |
| 1333 | + | if ok && stopped.canResume() && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) { | |
| 1385 | 1334 | _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "") | |
| 1386 | 1335 | } | |
| 1387 | 1336 | d.mu.Unlock() | |
M internal/usecase/daemon/picker_test.go +16 -16
| @@ -136,8 +136,8 @@ func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | |||
| 136 | 136 | d.sessions[old.id] = old | |
| 137 | 137 | d.sessions[eph.id] = eph | |
| 138 | 138 | d.sessions[recent.id] = recent | |
| 139 | - | d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1} | |
| 140 | - | d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2} | |
| 139 | + | d.inactive["halted-old"] = inactiveSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1} | |
| 140 | + | d.inactive["halted-new"] = inactiveSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2} | |
| 141 | 141 | ||
| 142 | 142 | views, _ := d.pickerViews(recent, nil) | |
| 143 | 143 | ||
| @@ -177,7 +177,7 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) { | |||
| 177 | 177 | d.sessions[eph.id] = eph | |
| 178 | 178 | d.sessions[named.id] = named | |
| 179 | 179 | d.sessions[named2.id] = named2 | |
| 180 | - | d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9} | |
| 180 | + | d.inactive["halted"] = inactiveSession{name: "halted", createdAt: 9, lastUsedSeq: 9} | |
| 181 | 181 | ||
| 182 | 182 | views, _ := d.pickerViews(named, nil) | |
| 183 | 183 | ||
| @@ -217,7 +217,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | |||
| 217 | 217 | eph.mruAt.Store(5) | |
| 218 | 218 | d.mu.Lock() | |
| 219 | 219 | d.sessions[eph.id] = eph | |
| 220 | - | d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9} | |
| 220 | + | d.inactive["halted"] = inactiveSession{name: "halted", createdAt: 9, lastUsedSeq: 9} | |
| 221 | 221 | d.mu.Unlock() | |
| 222 | 222 | ||
| 223 | 223 | d.enterPicker(sess, ac) | |
| @@ -288,7 +288,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||
| 288 | 288 | defer cancel() | |
| 289 | 289 | active := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 23}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 290 | 290 | d.sessions[active.id] = active | |
| 291 | - | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24} | |
| 291 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", createdAt: 24} | |
| 292 | 292 | ||
| 293 | 293 | views, _ := d.pickerViews(active, nil) | |
| 294 | 294 | ||
| @@ -310,7 +310,7 @@ func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) { | |||
| 310 | 310 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}} | |
| 311 | 311 | d.sessions[source.id] = source | |
| 312 | 312 | d.sessions[destination.id] = destination | |
| 313 | - | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}} | |
| 313 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}} | |
| 314 | 314 | sourceLocator := moveSourceLocator{ | |
| 315 | 315 | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 316 | 316 | TabID: "source-tab", | |
| @@ -664,7 +664,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) { | |||
| 664 | 664 | record := durableRecoveryRecord(0) | |
| 665 | 665 | record.Name = "restoring" | |
| 666 | 666 | state, done := initialSessionState(record) | |
| 667 | - | d.stopped[record.Name] = stoppedSession{ | |
| 667 | + | d.inactive[record.Name] = inactiveSession{ | |
| 668 | 668 | name: record.Name, | |
| 669 | 669 | createdAt: record.CreatedAt, | |
| 670 | 670 | incarnation: record.IncarnationID, | |
| @@ -692,7 +692,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) { | |||
| 692 | 692 | defer cancel() | |
| 693 | 693 | target := &session{sessionCore: sessionCore{id: "restored", name: record.Name, createdAt: record.CreatedAt}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}} | |
| 694 | 694 | d.mu.Lock() | |
| 695 | - | delete(d.stopped, record.Name) | |
| 695 | + | delete(d.inactive, record.Name) | |
| 696 | 696 | d.sessions[target.id] = target | |
| 697 | 697 | closeRuntimeRestoreDoneLocked(done) | |
| 698 | 698 | d.mu.Unlock() | |
| @@ -731,7 +731,7 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) { | |||
| 731 | 731 | <-restored | |
| 732 | 732 | require.Same(t, from, ac.currentSession()) | |
| 733 | 733 | d.mu.Lock() | |
| 734 | - | entry := d.stopped[record.Name] | |
| 734 | + | entry := d.inactive[record.Name] | |
| 735 | 735 | d.mu.Unlock() | |
| 736 | 736 | require.Equal(t, ports.SessionBroken, entry.state) | |
| 737 | 737 | require.Equal(t, "restore interrupted", entry.record.DegradedReason) | |
| @@ -765,7 +765,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) { | |||
| 765 | 765 | } | |
| 766 | 766 | catalogue := persist.New(store) | |
| 767 | 767 | WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d) | |
| 768 | - | d.stopped[record.Name] = stoppedSession{ | |
| 768 | + | d.inactive[record.Name] = inactiveSession{ | |
| 769 | 769 | name: record.Name, | |
| 770 | 770 | createdAt: record.CreatedAt, | |
| 771 | 771 | incarnation: record.IncarnationID, | |
| @@ -791,7 +791,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) { | |||
| 791 | 791 | storeState.mu.Unlock() | |
| 792 | 792 | require.Zero(t, sets, "unsafe target must not mutate the catalogue") | |
| 793 | 793 | d.mu.Lock() | |
| 794 | - | entry := d.stopped[record.Name] | |
| 794 | + | entry := d.inactive[record.Name] | |
| 795 | 795 | d.mu.Unlock() | |
| 796 | 796 | require.Equal(t, record, entry.record) | |
| 797 | 797 | require.Equal(t, tt.runtimeState, entry.state) | |
| @@ -808,7 +808,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |||
| 808 | 808 | defer release3() | |
| 809 | 809 | d, from, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 810 | 810 | d.ptys = newFactorySeq(t, p2, p3) | |
| 811 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionDown} | |
| 811 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionDown} | |
| 812 | 812 | ||
| 813 | 813 | d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true}) | |
| 814 | 814 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -1406,7 +1406,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1406 | 1406 | return got != normalSize && got.Valid() | |
| 1407 | 1407 | })).Return(floating, nil).Once() | |
| 1408 | 1408 | d := newTestDaemon(t, f, stubClock{}) | |
| 1409 | - | d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1} | |
| 1409 | + | d.inactive["old"] = inactiveSession{name: "old", cwd: t.TempDir(), createdAt: 1, state: ports.SessionDown} | |
| 1410 | 1410 | tr := portsmocks.NewMockTransport(t) | |
| 1411 | 1411 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1412 | 1412 | tr.EXPECT().Close().Return(nil).Maybe() | |
| @@ -1448,7 +1448,7 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t | |||
| 1448 | 1448 | p, release := newBlockingPTY(t) | |
| 1449 | 1449 | defer release() | |
| 1450 | 1450 | d, from, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1451 | - | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7} | |
| 1451 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, state: ports.SessionDown} | |
| 1452 | 1452 | cause := errors.New("open failed") | |
| 1453 | 1453 | ptys := portsmocks.NewMockPTYFactory(t) | |
| 1454 | 1454 | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once() | |
| @@ -1659,7 +1659,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 1659 | 1659 | state.mu.Lock() | |
| 1660 | 1660 | state.deleteErr = func(string) error { return cause } | |
| 1661 | 1661 | state.mu.Unlock() | |
| 1662 | - | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, incarnation: record.IncarnationID, record: record} | |
| 1662 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, incarnation: record.IncarnationID, record: record} | |
| 1663 | 1663 | ||
| 1664 | 1664 | d.enterPicker(from, ac) | |
| 1665 | 1665 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -1677,7 +1677,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 1677 | 1677 | require.Equal(t, domain.NoticePersistDelete, toasts[0].Code) | |
| 1678 | 1678 | ||
| 1679 | 1679 | d.mu.Lock() | |
| 1680 | - | stopped, retained := d.stopped["stopped"] | |
| 1680 | + | stopped, retained := d.inactive["stopped"] | |
| 1681 | 1681 | d.mu.Unlock() | |
| 1682 | 1682 | require.True(t, retained, "failed deletion must retain the reserved name") | |
| 1683 | 1683 | require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore") | |
M internal/usecase/daemon/recovery_observability.go +5 -5
| @@ -26,10 +26,10 @@ func (d *Daemon) logStartupRecoveryCounts(restoring int) { | |||
| 26 | 26 | records, err = d.catalogue.Records() | |
| 27 | 27 | if err != nil { | |
| 28 | 28 | d.log.Error("daemon_startup_catalogue_read_failed", "err", err) | |
| 29 | - | records = d.stoppedRecords() | |
| 29 | + | records = d.inactiveRecords() | |
| 30 | 30 | } | |
| 31 | 31 | } else { | |
| 32 | - | records = d.stoppedRecords() | |
| 32 | + | records = d.inactiveRecords() | |
| 33 | 33 | } | |
| 34 | 34 | healthy, fresh, broken := 0, 0, 0 | |
| 35 | 35 | for _, record := range records { | |
| @@ -50,11 +50,11 @@ func (d *Daemon) logStartupRecoveryCounts(restoring int) { | |||
| 50 | 50 | ) | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | - | func (d *Daemon) stoppedRecords() []domain.CatalogueRecord { | |
| 53 | + | func (d *Daemon) inactiveRecords() []domain.CatalogueRecord { | |
| 54 | 54 | d.mu.Lock() | |
| 55 | 55 | defer d.mu.Unlock() | |
| 56 | - | records := make([]domain.CatalogueRecord, 0, len(d.stopped)) | |
| 57 | - | for _, entry := range d.stopped { | |
| 56 | + | records := make([]domain.CatalogueRecord, 0, len(d.inactive)) | |
| 57 | + | for _, entry := range d.inactive { | |
| 58 | 58 | records = append(records, entry.record) | |
| 59 | 59 | } | |
| 60 | 60 | return records | |
M internal/usecase/daemon/recovery_observability_test.go +1 -1
| @@ -57,7 +57,7 @@ func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) { | |||
| 57 | 57 | d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil))) | |
| 58 | 58 | d.persistEnabled = true | |
| 59 | 59 | d.catalogue = failingRecordsCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil), err: errors.New("read failed")} | |
| 60 | - | d.stopped[record.Name] = stoppedSession{record: record} | |
| 60 | + | d.inactive[record.Name] = inactiveSession{record: record} | |
| 61 | 61 | ||
| 62 | 62 | d.logStartupRecoveryCounts(0) | |
| 63 | 63 | ||
M internal/usecase/daemon/remote_attach.go +34 -28
| @@ -27,6 +27,10 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello | |||
| 27 | 27 | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target requires attach or resume"} | |
| 28 | 28 | } | |
| 29 | 29 | if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil { | |
| 30 | + | var protocol *protoErr | |
| 31 | + | if errors.As(err, &protocol) && protocol.code == ports.ErrInternal { | |
| 32 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is unavailable"} | |
| 33 | + | } | |
| 30 | 34 | return nil, nil, remoteTargetError(err) | |
| 31 | 35 | } | |
| 32 | 36 | ||
| @@ -52,16 +56,20 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello | |||
| 52 | 56 | return live, ac, err | |
| 53 | 57 | } | |
| 54 | 58 | ||
| 55 | - | stopped, ok := d.stopped[target.SessionName] | |
| 56 | - | if !ok || stopped.purging || stopped.incarnation != target.LifecycleID { | |
| 59 | + | if !target.Stopped { | |
| 60 | + | d.mu.Unlock() | |
| 61 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "live remote target has no active runtime"} | |
| 62 | + | } | |
| 63 | + | inactive, ok := d.inactive[target.SessionName] | |
| 64 | + | if !ok || inactive.incarnation != target.LifecycleID { | |
| 57 | 65 | d.mu.Unlock() | |
| 58 | 66 | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle no longer exists"} | |
| 59 | 67 | } | |
| 60 | - | if stopped.state == ports.SessionBroken || stopped.record.DegradedReason != "" { | |
| 68 | + | if !inactive.canResume() { | |
| 61 | 69 | d.mu.Unlock() | |
| 62 | - | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is broken"} | |
| 70 | + | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is unavailable"} | |
| 63 | 71 | } | |
| 64 | - | if _, ok := remoteTargetTabIndexStopped(stopped, target); !ok { | |
| 72 | + | if _, ok := remoteTargetTabIndexInactive(inactive, target); !ok { | |
| 65 | 73 | d.mu.Unlock() | |
| 66 | 74 | return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote stopped tab no longer exists"} | |
| 67 | 75 | } | |
| @@ -70,16 +78,30 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello | |||
| 70 | 78 | // client's Env/Cwd fields remain present for wire compatibility but are not | |
| 71 | 79 | // trusted for this branch. | |
| 72 | 80 | env := copyEnvironment(d.baseEnv) | |
| 73 | - | cwd := d.dirOrHome(stopped.cwd) | |
| 74 | - | sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, env, stopped.tabNames) | |
| 81 | + | cwd := d.dirOrHome(inactive.cwd) | |
| 82 | + | sess, err := d.resumeRemoteInactiveSessionLocked(target, cwd, h.Size, env, inactive) | |
| 75 | 83 | if err != nil { | |
| 76 | 84 | d.mu.Unlock() | |
| 85 | + | if errors.Is(err, errAttachmentTransition) || errors.Is(err, errSessionNameInUse) { | |
| 86 | + | return nil, nil, remoteTargetError(err) | |
| 87 | + | } | |
| 77 | 88 | return nil, nil, err | |
| 78 | 89 | } | |
| 79 | 90 | ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false) | |
| 80 | 91 | return sess, ac, err | |
| 81 | 92 | } | |
| 82 | 93 | ||
| 94 | + | func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTarget, cwd string, size domain.Size, env []string, expected inactiveSession) (*session, error) { | |
| 95 | + | validate := func(current inactiveSession, _ domain.CatalogueRecord, authoritativeExists bool) bool { | |
| 96 | + | if !target.Stopped || d.persistEnabled && !authoritativeExists { | |
| 97 | + | return false | |
| 98 | + | } | |
| 99 | + | _, ok := target.ResolveTab(stoppedTabMetadata(current)) | |
| 100 | + | return ok | |
| 101 | + | } | |
| 102 | + | return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, size, env, &expected, validate, expected.tabNames) | |
| 103 | + | } | |
| 104 | + | ||
| 83 | 105 | func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error { | |
| 84 | 106 | payload := ports.MarshalNavigationAction(action) | |
| 85 | 107 | if payload == nil { | |
| @@ -116,7 +138,7 @@ func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnect | |||
| 116 | 138 | func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) { | |
| 117 | 139 | ac, err := d.finishAttach(sess, tr, sz, h) | |
| 118 | 140 | if err != nil && routeCreated { | |
| 119 | - | if cleanupErr := d.killSession(sess, ports.ReasonSessionKilled, purge); cleanupErr != nil { | |
| 141 | + | if cleanupErr := d.killSessionIfEmpty(sess, ports.ReasonSessionKilled, purge); cleanupErr != nil { | |
| 120 | 142 | err = errors.Join(err, cleanupErr) | |
| 121 | 143 | } | |
| 122 | 144 | return nil, err | |
| @@ -156,7 +178,7 @@ func remoteTargetError(err error) error { | |||
| 156 | 178 | return &protoErr{ports.ErrNoSuchTarget, "remote target is unavailable"} | |
| 157 | 179 | } | |
| 158 | 180 | ||
| 159 | - | func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab { | |
| 181 | + | func stoppedTabMetadata(stopped inactiveSession) []domain.TabSelectorTab { | |
| 160 | 182 | records := stopped.tabRecords | |
| 161 | 183 | if len(records) == 0 { | |
| 162 | 184 | records = make([]domain.CatalogueTabRecord, len(stopped.tabNames)) | |
| @@ -171,8 +193,8 @@ func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab { | |||
| 171 | 193 | return metadata | |
| 172 | 194 | } | |
| 173 | 195 | ||
| 174 | - | func remoteTargetTabIndexStopped(stopped stoppedSession, target domain.RemoteSessionTarget) (int, bool) { | |
| 175 | - | return target.StoppedTab.Resolve(stoppedTabMetadata(stopped)) | |
| 196 | + | func remoteTargetTabIndexInactive(inactive inactiveSession, target domain.RemoteSessionTarget) (int, bool) { | |
| 197 | + | return target.ResolveTab(stoppedTabMetadata(inactive)) | |
| 176 | 198 | } | |
| 177 | 199 | ||
| 178 | 200 | // remoteTargetTabIndexLocked resolves the selector against the live session's | |
| @@ -194,21 +216,5 @@ func remoteTargetTabIndexLocked(sess *session, target domain.RemoteSessionTarget | |||
| 194 | 216 | metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name}) | |
| 195 | 217 | tab.mu.Unlock() | |
| 196 | 218 | } | |
| 197 | - | if target.Stopped { | |
| 198 | - | return target.StoppedTab.Resolve(metadata) | |
| 199 | - | } | |
| 200 | - | if target.LiveTabID == "" { | |
| 201 | - | return 0, false | |
| 202 | - | } | |
| 203 | - | found := -1 | |
| 204 | - | for i, tab := range metadata { | |
| 205 | - | if tab.ID != target.LiveTabID { | |
| 206 | - | continue | |
| 207 | - | } | |
| 208 | - | if found >= 0 { | |
| 209 | - | return 0, false | |
| 210 | - | } | |
| 211 | - | found = i | |
| 212 | - | } | |
| 213 | - | return found, found >= 0 | |
| 219 | + | return target.ResolveTab(metadata) | |
| 214 | 220 | } | |
M internal/usecase/daemon/remote_attach_test.go +52 -2
| @@ -58,7 +58,7 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | |||
| 58 | 58 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 59 | 59 | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 60 | 60 | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"} | |
| 61 | - | require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 61 | + | require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 62 | 62 | frame := receiveRemotePicker(t, sends, "rich attach target") | |
| 63 | 63 | got, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| 64 | 64 | require.NoError(t, err) | |
| @@ -67,6 +67,56 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | |||
| 67 | 67 | require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy) | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | + | func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) { | |
| 71 | + | for _, test := range []struct { | |
| 72 | + | name string | |
| 73 | + | mutate func(*picker.Target) | |
| 74 | + | }{ | |
| 75 | + | {name: "missing key", mutate: func(target *picker.Target) { target.RemoteKey = nil }}, | |
| 76 | + | {name: "endpoint mismatch", mutate: func(target *picker.Target) { | |
| 77 | + | key := *target.RemoteKey | |
| 78 | + | key.Host = "other" | |
| 79 | + | target.RemoteKey = &key | |
| 80 | + | target.Session = key.ID() | |
| 81 | + | }}, | |
| 82 | + | } { | |
| 83 | + | t.Run(test.name, func(t *testing.T) { | |
| 84 | + | d := newRemotePickerDaemon(nil) | |
| 85 | + | lifecycle := remoteLifecycleForTest() | |
| 86 | + | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |
| 87 | + | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{ | |
| 88 | + | LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp, | |
| 89 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}}, | |
| 90 | + | }}, | |
| 91 | + | }}) | |
| 92 | + | d.remoteCatalog.mu.Lock() | |
| 93 | + | d.remoteCatalog.status["arch"] = remoteHostFresh | |
| 94 | + | d.remoteCatalog.mu.Unlock() | |
| 95 | + | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 96 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 97 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 98 | + | require.True(t, admitted) | |
| 99 | + | token.effect = effect | |
| 100 | + | defer effect.End() | |
| 101 | + | ||
| 102 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 103 | + | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 104 | + | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"} | |
| 105 | + | test.mutate(&target) | |
| 106 | + | ||
| 107 | + | require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition) | |
| 108 | + | for { | |
| 109 | + | select { | |
| 110 | + | case frame := <-sends: | |
| 111 | + | require.NotEqual(t, ports.MsgAttachTarget, frame.Type) | |
| 112 | + | default: | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 70 | 120 | func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) { | |
| 71 | 121 | d := newRemotePickerDaemon(nil) | |
| 72 | 122 | lifecycle := remoteLifecycleForTest() | |
| @@ -89,7 +139,7 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) { | |||
| 89 | 139 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 90 | 140 | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 91 | 141 | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"} | |
| 92 | - | require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition) | |
| 142 | + | require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition) | |
| 93 | 143 | for { | |
| 94 | 144 | select { | |
| 95 | 145 | case frame := <-sends: | |
M internal/usecase/daemon/remote_picker.go +15 -21
| @@ -124,8 +124,8 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri | |||
| 124 | 124 | } | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | - | func remoteSessionStateStopped(state string) bool { | |
| 128 | - | return state == "down" || state == "stopped" | |
| 127 | + | func remoteSessionStateStopped(state ports.RemoteCatalogSessionState) bool { | |
| 128 | + | return state == ports.RemoteCatalogSessionDown | |
| 129 | 129 | } | |
| 130 | 130 | ||
| 131 | 131 | func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView { | |
| @@ -133,7 +133,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||
| 133 | 133 | key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host) | |
| 134 | 134 | availability := remotePickerAvailability(status) | |
| 135 | 135 | stopped := remoteSessionStateStopped(session.State) | |
| 136 | - | broken := session.State == "broken" | |
| 136 | + | broken := session.State == ports.RemoteCatalogSessionBroken | |
| 137 | 137 | tabs := ports.CatalogTabs(session) | |
| 138 | 138 | viewTabs := make([]picker.TabEntry, 0, len(tabs)) | |
| 139 | 139 | active := 0 | |
| @@ -153,7 +153,6 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||
| 153 | 153 | Attention: tab.Attention, | |
| 154 | 154 | }) | |
| 155 | 155 | } | |
| 156 | - | legacy := session.LifecycleID == (domain.SessionLifecycleID{}) | |
| 157 | 156 | target := domain.RemoteSessionTarget{ | |
| 158 | 157 | Endpoint: key.Host, | |
| 159 | 158 | DisplayOrigin: key.DisplayOrigin, | |
| @@ -178,23 +177,22 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||
| 178 | 177 | // malformed or a broken session cannot be activated. Cursor navigation | |
| 179 | 178 | // must remain possible, while sendRemoteAttachTargetForAttachment will | |
| 180 | 179 | // fail closed on the invalid target. | |
| 181 | - | var remoteTarget *domain.RemoteSessionTarget | |
| 182 | - | if !legacy { | |
| 183 | - | remoteTarget = &target | |
| 180 | + | remoteTarget := &target | |
| 181 | + | if stopped && len(viewTabs) == 0 { | |
| 182 | + | // A healthy durable record may intentionally have no retained tabs, | |
| 183 | + | // for example after a protocol reset. Mirror local stopped rows with | |
| 184 | + | // one default picker target; the owning daemon creates that sole tab. | |
| 185 | + | viewTabs = append(viewTabs, picker.TabEntry{}) | |
| 184 | 186 | } | |
| 185 | 187 | reason := remoteReasonForStatus(status) | |
| 186 | - | targetValid := remoteTarget != nil && remoteTarget.Validate() == nil | |
| 188 | + | targetValid := remoteTarget.Validate() == nil | |
| 187 | 189 | activation := picker.RemoteUnavailable | |
| 188 | 190 | if broken { | |
| 189 | 191 | reason = "session_broken" | |
| 190 | - | } else if !legacy && !targetValid { | |
| 192 | + | } else if !targetValid { | |
| 191 | 193 | reason = "identity_changed" | |
| 192 | 194 | } else if status == remoteHostFresh { | |
| 193 | - | if legacy { | |
| 194 | - | if !stopped { | |
| 195 | - | activation = picker.RemoteAttach | |
| 196 | - | } | |
| 197 | - | } else if stopped { | |
| 195 | + | if stopped { | |
| 198 | 196 | activation = picker.RemoteRestart | |
| 199 | 197 | } else { | |
| 200 | 198 | activation = picker.RemoteAttach | |
| @@ -206,18 +204,16 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||
| 206 | 204 | switch { | |
| 207 | 205 | case broken: | |
| 208 | 206 | detail = "session broken" | |
| 209 | - | case !legacy && !targetValid: | |
| 207 | + | case !targetValid: | |
| 210 | 208 | detail = "identity changed" | |
| 211 | - | case legacy && stopped: | |
| 212 | - | detail = "stopped — exact identity required" | |
| 213 | 209 | case activation == picker.RemoteRestart: | |
| 214 | 210 | detail = "stopped — Enter to restart" | |
| 215 | - | case session.State == "up": | |
| 211 | + | case session.State == ports.RemoteCatalogSessionUp: | |
| 216 | 212 | detail = "up" | |
| 217 | 213 | case stopped: | |
| 218 | 214 | detail = "stopped" | |
| 219 | 215 | default: | |
| 220 | - | detail = session.State | |
| 216 | + | detail = string(session.State) | |
| 221 | 217 | } | |
| 222 | 218 | } | |
| 223 | 219 | return picker.SessionView{ | |
| @@ -233,7 +229,6 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe | |||
| 233 | 229 | Tabs: viewTabs, | |
| 234 | 230 | Active: active, | |
| 235 | 231 | Stopped: stopped, | |
| 236 | - | ConnectOnly: legacy, | |
| 237 | 232 | CannotAcceptMoves: true, | |
| 238 | 233 | } | |
| 239 | 234 | } | |
| @@ -266,7 +261,6 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi | |||
| 266 | 261 | RemoteAvailability: remotePickerAvailability(status), | |
| 267 | 262 | RemoteDetail: remotePickerStatusDetail(status, time.Time{}), | |
| 268 | 263 | RemoteActivation: picker.RemoteUnavailable, | |
| 269 | - | ConnectOnly: true, | |
| 270 | 264 | CannotAcceptMoves: true, | |
| 271 | 265 | } | |
| 272 | 266 | } | |
M internal/usecase/daemon/remote_picker_test.go +129 -68
| @@ -59,30 +59,19 @@ func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon { | |||
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | 61 | func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) { | |
| 62 | - | for _, state := range []string{"down", "stopped"} { | |
| 63 | - | t.Run(state, func(t *testing.T) { | |
| 64 | - | lifecycle := remoteLifecycleForTest() | |
| 65 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle} | |
| 66 | - | session := ports.RemoteCatalogSession{ | |
| 67 | - | LifecycleID: lifecycle, Name: "work", State: state, | |
| 68 | - | Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | |
| 69 | - | } | |
| 70 | - | view := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0)) | |
| 71 | - | require.True(t, view.Stopped) | |
| 72 | - | require.NotNil(t, view.RemoteTarget) | |
| 73 | - | require.True(t, view.RemoteTarget.Stopped) | |
| 74 | - | require.Equal(t, picker.RemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target") | |
| 75 | - | require.Equal(t, "stopped — Enter to restart", view.RemoteDetail) | |
| 76 | - | ||
| 77 | - | legacy := session | |
| 78 | - | legacy.LifecycleID = domain.SessionLifecycleID{} | |
| 79 | - | legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0)) | |
| 80 | - | require.True(t, legacyView.Stopped) | |
| 81 | - | require.True(t, legacyView.ConnectOnly) | |
| 82 | - | require.Equal(t, picker.RemoteUnavailable, legacyView.RemoteActivation, "legacy stopped rows cannot be selected without lifecycle identity") | |
| 83 | - | require.Equal(t, "stopped — exact identity required", legacyView.RemoteDetail) | |
| 84 | - | }) | |
| 62 | + | lifecycle := remoteLifecycleForTest() | |
| 63 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle} | |
| 64 | + | session := ports.RemoteCatalogSession{ | |
| 65 | + | LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionDown, | |
| 66 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | |
| 85 | 67 | } | |
| 68 | + | view := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0)) | |
| 69 | + | require.True(t, view.Stopped) | |
| 70 | + | require.NotNil(t, view.RemoteTarget) | |
| 71 | + | require.True(t, view.RemoteTarget.Stopped) | |
| 72 | + | require.Equal(t, picker.RemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target") | |
| 73 | + | require.Equal(t, "stopped — Enter to restart", view.RemoteDetail) | |
| 74 | + | ||
| 86 | 75 | } | |
| 87 | 76 | ||
| 88 | 77 | func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) { | |
| @@ -106,7 +95,7 @@ func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) { | |||
| 106 | 95 | } | |
| 107 | 96 | } | |
| 108 | 97 | ||
| 109 | - | func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testing.T) { | |
| 98 | + | func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) { | |
| 110 | 99 | store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}} | |
| 111 | 100 | d := newRemotePickerDaemon(store) | |
| 112 | 101 | lifecycle := remoteLifecycleForTest() | |
| @@ -130,8 +119,9 @@ func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testi | |||
| 130 | 119 | targets = append(targets, view.RemoteTarget) | |
| 131 | 120 | } | |
| 132 | 121 | } | |
| 133 | - | require.Len(t, targets, 1) | |
| 134 | - | require.Equal(t, "remote", targets[0].Endpoint, "first pinned alias must remain the attach endpoint") | |
| 122 | + | require.Len(t, targets, 2) | |
| 123 | + | require.Equal(t, "remote", targets[0].Endpoint) | |
| 124 | + | require.Equal(t, "vev@remote", targets[1].Endpoint) | |
| 135 | 125 | } | |
| 136 | 126 | ||
| 137 | 127 | func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) { | |
| @@ -465,6 +455,14 @@ func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, | |||
| 465 | 455 | } | |
| 466 | 456 | } | |
| 467 | 457 | ||
| 458 | + | func remoteCatalogForTest(sessions ...ports.RemoteCatalogSession) ports.RemoteCatalog { | |
| 459 | + | return ports.RemoteCatalog{ | |
| 460 | + | ProtocolVersion: ports.ProtocolVersion, | |
| 461 | + | SchemaVersion: ports.RemoteCatalogSchemaVersion, | |
| 462 | + | Sessions: append([]ports.RemoteCatalogSession{}, sessions...), | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | ||
| 468 | 466 | func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) { | |
| 469 | 467 | t.Helper() | |
| 470 | 468 | catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)} | |
| @@ -523,7 +521,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T) | |||
| 523 | 521 | } | |
| 524 | 522 | for range 2 { | |
| 525 | 523 | request := receiveRemotePicker(t, catalog.requests, "catalog request") | |
| 526 | - | request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}} | |
| 524 | + | request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()} | |
| 527 | 525 | } | |
| 528 | 526 | for range 2 { | |
| 529 | 527 | receiveRemotePicker(t, cache.stores, "remote cache store") | |
| @@ -553,7 +551,7 @@ func TestRemoteRefreshLockProbesSerialized(t *testing.T) { | |||
| 553 | 551 | ||
| 554 | 552 | d.startRemotePickerRefresh(instance) | |
| 555 | 553 | request := receiveRemotePicker(t, catalog.requests, "catalog request") | |
| 556 | - | request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}} | |
| 554 | + | request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()} | |
| 557 | 555 | receiveRemotePicker(t, cache.stores, "remote cache store") | |
| 558 | 556 | requireNoRemoteLockViolations(t, catalog, cache) | |
| 559 | 557 | } | |
| @@ -596,7 +594,7 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes | |||
| 596 | 594 | require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"]) | |
| 597 | 595 | d.remoteCatalog.mu.Unlock() | |
| 598 | 596 | ||
| 599 | - | require.True(t, d.applyRemoteRefreshResult(7, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion}, nil)) | |
| 597 | + | require.True(t, d.applyRemoteRefreshResult(7, "arch", remoteCatalogForTest(), nil)) | |
| 600 | 598 | stored := receiveRemotePicker(t, cache.stores, "remote cache store") | |
| 601 | 599 | require.Equal(t, []ports.RemoteCatalogCacheEntry{ | |
| 602 | 600 | {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}}, | |
| @@ -619,9 +617,15 @@ func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) { | |||
| 619 | 617 | d.remoteCatalog.refresh = 9 | |
| 620 | 618 | d.remoteCatalog.mu.Unlock() | |
| 621 | 619 | ||
| 622 | - | require.False(t, d.applyRemoteRefreshResult(8, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "late", State: "up"}}}, nil)) | |
| 620 | + | require.False(t, d.applyRemoteRefreshResult(8, "arch", remoteCatalogForTest(ports.RemoteCatalogSession{ | |
| 621 | + | LifecycleID: remoteLifecycleForTest(), Name: "late", State: ports.RemoteCatalogSessionUp, | |
| 622 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}}, | |
| 623 | + | }), nil)) | |
| 623 | 624 | hosts.set() | |
| 624 | - | require.True(t, d.applyRemoteRefreshResult(9, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "removed", State: "up"}}}, nil)) | |
| 625 | + | require.True(t, d.applyRemoteRefreshResult(9, "arch", remoteCatalogForTest(ports.RemoteCatalogSession{ | |
| 626 | + | LifecycleID: remoteLifecycleForTest(), Name: "removed", State: ports.RemoteCatalogSessionUp, | |
| 627 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}}, | |
| 628 | + | }), nil)) | |
| 625 | 629 | require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store")) | |
| 626 | 630 | requireNoRemoteLockViolations(t, nil, cache) | |
| 627 | 631 | d.remoteCatalog.mu.Lock() | |
| @@ -669,9 +673,14 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) { | |||
| 669 | 673 | } | |
| 670 | 674 | ||
| 671 | 675 | func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |
| 676 | + | lifecycle := remoteLifecycleForTest() | |
| 677 | + | remoteSession := ports.RemoteCatalogSession{ | |
| 678 | + | LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp, | |
| 679 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}}, | |
| 680 | + | } | |
| 672 | 681 | d := newRemotePickerDaemon(nil) | |
| 673 | 682 | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |
| 674 | - | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}, | |
| 683 | + | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession}, | |
| 675 | 684 | }}) | |
| 676 | 685 | d.remoteCatalog.mu.Lock() | |
| 677 | 686 | d.remoteCatalog.status["arch"] = remoteHostFresh | |
| @@ -681,15 +690,16 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |||
| 681 | 690 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 682 | 691 | require.True(t, admitted) | |
| 683 | 692 | token.effect = effect | |
| 684 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 685 | - | target := picker.Target{Session: key.ID(), RemoteKey: &key} | |
| 686 | - | require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 693 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 694 | + | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 695 | + | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"} | |
| 696 | + | require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 687 | 697 | ||
| 688 | 698 | frame := receiveRemotePicker(t, sends, "attach target") | |
| 689 | 699 | require.Equal(t, ports.MsgAttachTarget, frame.Type) | |
| 690 | 700 | got, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| 691 | 701 | require.NoError(t, err) | |
| 692 | - | require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, got) | |
| 702 | + | require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach, RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, got) | |
| 693 | 703 | require.Nil(t, ac.currentAttachmentSession()) | |
| 694 | 704 | d.mu.Lock() | |
| 695 | 705 | require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow") | |
| @@ -736,7 +746,7 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||
| 736 | 746 | effect, admitted := localAttachment.beginAttachmentEffect(token) | |
| 737 | 747 | require.True(t, admitted) | |
| 738 | 748 | token.effect = effect | |
| 739 | - | require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, key, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 749 | + | require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 740 | 750 | ||
| 741 | 751 | frame := receiveRemotePicker(t, sends, "stopped remote target") | |
| 742 | 752 | handoff, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| @@ -749,8 +759,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||
| 749 | 759 | ||
| 750 | 760 | remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 751 | 761 | remote.mu.Lock() | |
| 752 | - | remote.stopped["work"] = stoppedSession{ | |
| 753 | - | name: "work", cwd: "/remote/work", incarnation: lifecycle, | |
| 762 | + | remote.inactive["work"] = inactiveSession{ | |
| 763 | + | name: "work", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown, | |
| 754 | 764 | tabNames: []string{"alpha", "beta"}, | |
| 755 | 765 | tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-a", Name: "alpha"}, {StableID: "tab-b", Name: "beta"}}, | |
| 756 | 766 | } | |
| @@ -767,6 +777,71 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||
| 767 | 777 | require.Equal(t, domain.TabStableID("tab-b"), attachment.viewSnapshot().tabID) | |
| 768 | 778 | } | |
| 769 | 779 | ||
| 780 | + | func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing.T) { | |
| 781 | + | lifecycle := remoteLifecycleForTest() | |
| 782 | + | remoteSession := ports.RemoteCatalogSession{ | |
| 783 | + | LifecycleID: lifecycle, | |
| 784 | + | Name: "work", | |
| 785 | + | State: "down", | |
| 786 | + | Tabs: []ports.RemoteCatalogTab{}, | |
| 787 | + | } | |
| 788 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 789 | + | remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0)) | |
| 790 | + | require.Equal(t, picker.RemoteRestart, remoteView.RemoteActivation) | |
| 791 | + | ||
| 792 | + | model := picker.New([]picker.SessionView{ | |
| 793 | + | {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}}, | |
| 794 | + | remoteView, | |
| 795 | + | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 796 | + | var selected picker.Target | |
| 797 | + | for range 3 { | |
| 798 | + | model.Down() | |
| 799 | + | candidate, ok := model.Selected() | |
| 800 | + | if ok && candidate.RemoteTarget != nil { | |
| 801 | + | selected = candidate | |
| 802 | + | break | |
| 803 | + | } | |
| 804 | + | } | |
| 805 | + | require.NotNil(t, selected.RemoteTarget) | |
| 806 | + | require.True(t, selected.RemoteTarget.Stopped) | |
| 807 | + | require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab) | |
| 808 | + | ||
| 809 | + | local := newRemotePickerDaemon(nil) | |
| 810 | + | local.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |
| 811 | + | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession}, | |
| 812 | + | }}) | |
| 813 | + | local.remoteCatalog.mu.Lock() | |
| 814 | + | local.remoteCatalog.status["arch"] = remoteHostFresh | |
| 815 | + | local.remoteCatalog.mu.Unlock() | |
| 816 | + | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | |
| 817 | + | token := localSession.attachmentToken(localAttachment, localAttachment.transport()) | |
| 818 | + | effect, admitted := localAttachment.beginAttachmentEffect(token) | |
| 819 | + | require.True(t, admitted) | |
| 820 | + | token.effect = effect | |
| 821 | + | require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 822 | + | ||
| 823 | + | frame := receiveRemotePicker(t, sends, "stopped remote target without tab metadata") | |
| 824 | + | handoff, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| 825 | + | require.NoError(t, err) | |
| 826 | + | require.Equal(t, selected.RemoteTarget, handoff.RemoteTarget) | |
| 827 | + | ||
| 828 | + | remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 829 | + | remote.mu.Lock() | |
| 830 | + | remote.inactive["work"] = inactiveSession{name: "work", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown} | |
| 831 | + | remote.mu.Unlock() | |
| 832 | + | transport, _ := newCapturingTransport(t) | |
| 833 | + | restored, attachment, err := remote.routeWithContext(context.Background(), ports.Hello{ | |
| 834 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: handoff.Session, | |
| 835 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: handoff.RemoteTarget, | |
| 836 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 837 | + | }, transport) | |
| 838 | + | require.NoError(t, err) | |
| 839 | + | t.Cleanup(func() { remote.clientGone(restored, attachment, transport, false) }) | |
| 840 | + | require.Equal(t, lifecycle, restored.incarnation) | |
| 841 | + | require.Len(t, restored.tabs, 1) | |
| 842 | + | require.Equal(t, restored.tabs[0].stableID, string(attachment.viewSnapshot().tabID)) | |
| 843 | + | } | |
| 844 | + | ||
| 770 | 845 | func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) { | |
| 771 | 846 | tests := []struct { | |
| 772 | 847 | name string | |
| @@ -795,9 +870,14 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) { | |||
| 795 | 870 | } | |
| 796 | 871 | ||
| 797 | 872 | func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) { | |
| 873 | + | lifecycle := remoteLifecycleForTest() | |
| 874 | + | remoteSession := ports.RemoteCatalogSession{ | |
| 875 | + | LifecycleID: lifecycle, Name: "work", State: ports.RemoteCatalogSessionUp, | |
| 876 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}}, | |
| 877 | + | } | |
| 798 | 878 | d := newRemotePickerDaemon(nil) | |
| 799 | 879 | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |
| 800 | - | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}, | |
| 880 | + | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{remoteSession}, | |
| 801 | 881 | }}) | |
| 802 | 882 | d.remoteCatalog.mu.Lock() | |
| 803 | 883 | d.remoteCatalog.status["arch"] = remoteHostFresh | |
| @@ -818,9 +898,10 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) { | |||
| 818 | 898 | require.True(t, admitted) | |
| 819 | 899 | token.effect = effect | |
| 820 | 900 | defer effect.End() | |
| 821 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 822 | - | target := picker.Target{Session: key.ID(), RemoteKey: &key} | |
| 823 | - | err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select") | |
| 901 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 902 | + | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 903 | + | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"} | |
| 904 | + | err := d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select") | |
| 824 | 905 | var userErr *domain.UserError | |
| 825 | 906 | require.ErrorAs(t, err, &userErr) | |
| 826 | 907 | require.Equal(t, "couldn't attach to remote session", userErr.Msg) | |
| @@ -877,7 +958,11 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) { | |||
| 877 | 958 | ||
| 878 | 959 | require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{ | |
| 879 | 960 | ProtocolVersion: ports.ProtocolVersion, | |
| 880 | - | Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up", Tabs: 2}}, | |
| 961 | + | SchemaVersion: ports.RemoteCatalogSchemaVersion, | |
| 962 | + | Sessions: []ports.RemoteCatalogSession{{ | |
| 963 | + | LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up", | |
| 964 | + | Tabs: []ports.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}}, | |
| 965 | + | }}, | |
| 881 | 966 | }, nil)) | |
| 882 | 967 | receiveRemotePicker(t, cache.stores, "remote cache store") | |
| 883 | 968 | requireNoRemoteLockViolations(t, catalog, cache) | |
| @@ -1186,27 +1271,3 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | |||
| 1186 | 1271 | require.Same(t, reopened, ac.overlays.picker, "a rebuild for the old picker must not publish into its replacement") | |
| 1187 | 1272 | ac.overlays.pickerMu.Unlock() | |
| 1188 | 1273 | } | |
| 1189 | - | ||
| 1190 | - | func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) { | |
| 1191 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 1192 | - | for _, tt := range []struct { | |
| 1193 | - | status remoteHostStatus | |
| 1194 | - | activation picker.RemoteActivation | |
| 1195 | - | }{ | |
| 1196 | - | {status: remoteHostCached, activation: picker.RemoteUnavailable}, | |
| 1197 | - | {status: remoteHostRefreshing, activation: picker.RemoteUnavailable}, | |
| 1198 | - | {status: remoteHostStale, activation: picker.RemoteUnavailable}, | |
| 1199 | - | {status: remoteHostMalformed, activation: picker.RemoteUnavailable}, | |
| 1200 | - | {status: remoteHostFresh, activation: picker.RemoteAttach}, | |
| 1201 | - | {status: remoteHostUnreachable, activation: picker.RemoteUnavailable}, | |
| 1202 | - | } { | |
| 1203 | - | view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, tt.status, time.Unix(1, 0)) | |
| 1204 | - | require.Equal(t, tt.activation, view.RemoteActivation, "status %d activation", tt.status) | |
| 1205 | - | _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() | |
| 1206 | - | require.Equal(t, tt.activation == picker.RemoteAttach, selectable, "status %d selection", tt.status) | |
| 1207 | - | } | |
| 1208 | - | mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{}) | |
| 1209 | - | require.Equal(t, picker.RemoteUnavailable, mismatch.RemoteActivation) | |
| 1210 | - | _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected() | |
| 1211 | - | require.False(t, selectable) | |
| 1212 | - | } | |
M internal/usecase/daemon/remote_route_test.go +75 -0
| @@ -50,6 +50,81 @@ func TestFinishRouteAttachRollsBackCreatedSession(t *testing.T) { | |||
| 50 | 50 | require.False(t, retained, "failed route attach must remove its newly created session") | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | + | func TestFinishRouteAttachPreservesConcurrentAttachment(t *testing.T) { | |
| 54 | + | pty, release := newBlockingPTY(t) | |
| 55 | + | defer release() | |
| 56 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 57 | + | sess, err := createSessionForTest(d, "work", false, "/tmp/work", defaultSize, terminalEnv{}, d.baseEnv) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | winner, winnerTransport := attachWhenRouteCleanupSnapshots(t, d, sess) | |
| 60 | + | ||
| 61 | + | missing := domain.RemoteSessionTarget{LifecycleID: sess.incarnation, SessionName: "work", LiveTabID: "missing-tab"} | |
| 62 | + | d.mu.Lock() | |
| 63 | + | _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, ports.Hello{ | |
| 64 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize, | |
| 65 | + | RemoteTarget: &missing, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 66 | + | }, true, true) | |
| 67 | + | ||
| 68 | + | var protocol *protoErr | |
| 69 | + | require.ErrorAs(t, err, &protocol) | |
| 70 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) | |
| 71 | + | require.Same(t, sess, winner().currentAttachmentSession()) | |
| 72 | + | d.mu.Lock() | |
| 73 | + | require.Same(t, sess, d.sessions[sess.id]) | |
| 74 | + | d.mu.Unlock() | |
| 75 | + | d.clientGone(sess, winner(), winnerTransport, false) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestFailedHandshakeCleanupPreservesConcurrentAttachment(t *testing.T) { | |
| 79 | + | pty, release := newBlockingPTY(t) | |
| 80 | + | defer release() | |
| 81 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 82 | + | sess, err := createSessionForTest(d, "work", false, "/tmp/work", defaultSize, terminalEnv{}, d.baseEnv) | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | initialTransport, _ := newCapturingTransport(t) | |
| 85 | + | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation, SessionName: "work", LiveTabID: domain.TabStableID(sess.tabs[0].stableID)} | |
| 86 | + | _, initial, err := d.routeWithContext(context.Background(), ports.Hello{ | |
| 87 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize, | |
| 88 | + | RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 89 | + | }, initialTransport) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | initial.routeCreatedSession = true | |
| 92 | + | initial.routeSessionPurge = true | |
| 93 | + | winner, winnerTransport := attachWhenRouteCleanupSnapshots(t, d, sess) | |
| 94 | + | ||
| 95 | + | d.failHandshakeAttachment(sess, initial, initialTransport, false) | |
| 96 | + | ||
| 97 | + | require.Same(t, sess, winner().currentAttachmentSession()) | |
| 98 | + | d.mu.Lock() | |
| 99 | + | require.Same(t, sess, d.sessions[sess.id]) | |
| 100 | + | d.mu.Unlock() | |
| 101 | + | d.clientGone(sess, winner(), winnerTransport, false) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func attachWhenRouteCleanupSnapshots(t *testing.T, d *Daemon, sess *session) (func() *attachedClient, ports.Transport) { | |
| 105 | + | t.Helper() | |
| 106 | + | winnerTransport, _ := newCapturingTransport(t) | |
| 107 | + | var winner *attachedClient | |
| 108 | + | d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) { | |
| 109 | + | d.afterAttachmentEffectParticipantsSnapshotted = nil | |
| 110 | + | target := domain.RemoteSessionTarget{ | |
| 111 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation, | |
| 112 | + | SessionName: sess.name, LiveTabID: domain.TabStableID(sess.tabs[0].stableID), | |
| 113 | + | } | |
| 114 | + | var err error | |
| 115 | + | _, winner, err = d.routeWithContext(context.Background(), ports.Hello{ | |
| 116 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name, Size: defaultSize, | |
| 117 | + | RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 118 | + | }, winnerTransport) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | } | |
| 121 | + | return func() *attachedClient { | |
| 122 | + | t.Helper() | |
| 123 | + | require.NotNil(t, winner) | |
| 124 | + | return winner | |
| 125 | + | }, winnerTransport | |
| 126 | + | } | |
| 127 | + | ||
| 53 | 128 | func TestRouteRemoteTargetRejectsSameNameReplacement(t *testing.T) { | |
| 54 | 129 | d := newTestDaemon(t, nil, stubClock{}) | |
| 55 | 130 | sess := addControlSession(d, "work", "tab-new", "pane-new") | |
M internal/usecase/daemon/remote_stopped_route_test.go +190 -4
| @@ -2,12 +2,16 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 8 | + | "github.com/stretchr/testify/mock" | |
| 7 | 9 | "github.com/stretchr/testify/require" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/bnema/vev/internal/domain" | |
| 10 | 12 | "github.com/bnema/vev/internal/ports" | |
| 13 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | + | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 11 | 15 | ) | |
| 12 | 16 | ||
| 13 | 17 | func TestDaemonOwnedNoExactTargetRejectsNewAndEphemeral(t *testing.T) { | |
| @@ -37,11 +41,11 @@ func TestDaemonOwnedNoExactTargetRejectsNewAndEphemeral(t *testing.T) { | |||
| 37 | 41 | } | |
| 38 | 42 | } | |
| 39 | 43 | ||
| 40 | - | func TestLegacyDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) { | |
| 44 | + | func TestDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) { | |
| 41 | 45 | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 42 | 46 | d.baseEnv = []string{"DAEMON=owned"} | |
| 43 | 47 | d.mu.Lock() | |
| 44 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/remote/work", tabNames: []string{"main"}, state: ports.SessionDown} | |
| 48 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: "/remote/work", tabNames: []string{"main"}, state: ports.SessionDown} | |
| 45 | 49 | d.mu.Unlock() | |
| 46 | 50 | ||
| 47 | 51 | hello := ports.Hello{ | |
| @@ -58,12 +62,194 @@ func TestLegacyDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) { | |||
| 58 | 62 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |
| 59 | 63 | } | |
| 60 | 64 | ||
| 65 | + | func TestRouteRemoteTargetRejectsLiveIntentForInactiveSession(t *testing.T) { | |
| 66 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 67 | + | lifecycle := remoteLifecycleForTest() | |
| 68 | + | d.mu.Lock() | |
| 69 | + | d.inactive["work"] = inactiveSession{ | |
| 70 | + | name: "work", incarnation: lifecycle, state: ports.SessionDown, | |
| 71 | + | tabNames: []string{"main"}, | |
| 72 | + | tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-1", Name: "main"}}, | |
| 73 | + | } | |
| 74 | + | d.mu.Unlock() | |
| 75 | + | ||
| 76 | + | target := domain.RemoteSessionTarget{ | |
| 77 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, | |
| 78 | + | SessionName: "work", LiveTabID: "tab-1", | |
| 79 | + | } | |
| 80 | + | _, _, err := d.routeWithContext(context.Background(), ports.Hello{ | |
| 81 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", | |
| 82 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, | |
| 83 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 84 | + | }, &closeTrackingTransport{}) | |
| 85 | + | ||
| 86 | + | var protocol *protoErr | |
| 87 | + | require.ErrorAs(t, err, &protocol) | |
| 88 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) | |
| 89 | + | d.mu.Lock() | |
| 90 | + | defer d.mu.Unlock() | |
| 91 | + | require.Empty(t, d.sessions) | |
| 92 | + | require.Contains(t, d.inactive, "work") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestRouteRemoteTargetMapsUnavailableInactiveStatesToNoSuchTarget(t *testing.T) { | |
| 96 | + | lifecycle := remoteLifecycleForTest() | |
| 97 | + | for _, test := range []struct { | |
| 98 | + | name string | |
| 99 | + | entry inactiveSession | |
| 100 | + | }{ | |
| 101 | + | {name: "broken", entry: inactiveSession{name: "work", incarnation: lifecycle, state: ports.SessionBroken}}, | |
| 102 | + | {name: "degraded", entry: inactiveSession{name: "work", incarnation: lifecycle, state: ports.SessionDown, record: domain.CatalogueRecord{Name: "work", DegradedReason: "checkpoint unavailable"}}}, | |
| 103 | + | {name: "purging", entry: inactiveSession{name: "work", incarnation: lifecycle, state: ports.SessionDown, purging: true}}, | |
| 104 | + | } { | |
| 105 | + | t.Run(test.name, func(t *testing.T) { | |
| 106 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 107 | + | d.inactive["work"] = test.entry | |
| 108 | + | target := domain.RemoteSessionTarget{ | |
| 109 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, | |
| 110 | + | SessionName: "work", Stopped: true, | |
| 111 | + | } | |
| 112 | + | _, _, err := d.routeWithContext(context.Background(), ports.Hello{ | |
| 113 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", | |
| 114 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, | |
| 115 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 116 | + | }, &closeTrackingTransport{}) | |
| 117 | + | ||
| 118 | + | var protocol *protoErr | |
| 119 | + | require.ErrorAs(t, err, &protocol) | |
| 120 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestRouteRemoteTargetMapsConcurrentInactiveResumeToNoSuchTarget(t *testing.T) { | |
| 126 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 127 | + | lifecycle := remoteLifecycleForTest() | |
| 128 | + | d.inactive["work"] = inactiveSession{name: "work", incarnation: lifecycle, state: ports.SessionDown} | |
| 129 | + | d.creating["work"] = struct{}{} | |
| 130 | + | target := domain.RemoteSessionTarget{ | |
| 131 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, | |
| 132 | + | SessionName: "work", Stopped: true, | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | _, _, err := d.routeWithContext(context.Background(), ports.Hello{ | |
| 136 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", | |
| 137 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, | |
| 138 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 139 | + | }, &closeTrackingTransport{}) | |
| 140 | + | ||
| 141 | + | var protocol *protoErr | |
| 142 | + | require.ErrorAs(t, err, &protocol) | |
| 143 | + | require.Equal(t, ports.ErrNoSuchTarget, protocol.code) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestRouteRemoteTargetPreservesInactiveResumeFailures(t *testing.T) { | |
| 147 | + | newTarget := func(record domain.CatalogueRecord) domain.RemoteSessionTarget { | |
| 148 | + | return domain.RemoteSessionTarget{ | |
| 149 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: record.IncarnationID, | |
| 150 | + | SessionName: record.Name, Stopped: true, | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | route := func(d *Daemon, target domain.RemoteSessionTarget) error { | |
| 154 | + | _, _, err := d.routeWithContext(context.Background(), ports.Hello{ | |
| 155 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: target.SessionName, | |
| 156 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, | |
| 157 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 158 | + | }, &closeTrackingTransport{}) | |
| 159 | + | return err | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | t.Run("PTY startup", func(t *testing.T) { | |
| 163 | + | cause := errors.New("PTY unavailable") | |
| 164 | + | factory := portsmocks.NewMockPTYFactory(t) | |
| 165 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once() | |
| 166 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 167 | + | record := durableRecoveryRecord(1) | |
| 168 | + | record.Committed = nil | |
| 169 | + | record.TabNames = nil | |
| 170 | + | record.TabRecords = nil | |
| 171 | + | d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil) | |
| 172 | + | ||
| 173 | + | err := route(d, newTarget(record)) | |
| 174 | + | require.ErrorIs(t, err, cause) | |
| 175 | + | var protocol *protoErr | |
| 176 | + | require.False(t, errors.As(err, &protocol)) | |
| 177 | + | }) | |
| 178 | + | ||
| 179 | + | t.Run("catalogue read", func(t *testing.T) { | |
| 180 | + | cause := errors.New("catalogue unavailable") | |
| 181 | + | record := durableRecoveryRecord(1) | |
| 182 | + | record.Committed = nil | |
| 183 | + | record.TabNames = nil | |
| 184 | + | record.TabRecords = nil | |
| 185 | + | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | |
| 186 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 187 | + | d.catalogue = recordErrorCatalogue{durableRecoveryCatalogue: catalogue, err: cause} | |
| 188 | + | d.persistEnabled = true | |
| 189 | + | d.recovery = recoveryusecase.NewCoordinator(d.catalogue, noOpSnapshotRepository{}, nil) | |
| 190 | + | d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil) | |
| 191 | + | ||
| 192 | + | err := route(d, newTarget(record)) | |
| 193 | + | require.ErrorIs(t, err, cause) | |
| 194 | + | var protocol *protoErr | |
| 195 | + | require.False(t, errors.As(err, &protocol)) | |
| 196 | + | }) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestRouteRemoteTargetResumesCanonicalPersistedTabMetadata(t *testing.T) { | |
| 200 | + | for _, test := range []struct { | |
| 201 | + | name string | |
| 202 | + | tabNames []string | |
| 203 | + | tabRecords []domain.CatalogueTabRecord | |
| 204 | + | selector domain.TabSelector | |
| 205 | + | }{ | |
| 206 | + | { | |
| 207 | + | name: "tab records without compatibility names", | |
| 208 | + | tabRecords: []domain.CatalogueTabRecord{{StableID: "tab-1"}}, | |
| 209 | + | selector: domain.NewStableTabSelector("tab-1"), | |
| 210 | + | }, | |
| 211 | + | { | |
| 212 | + | name: "tab names without stable records", | |
| 213 | + | tabNames: []string{"main"}, | |
| 214 | + | selector: domain.NewOrdinalTabSelector(0, "main", 1), | |
| 215 | + | }, | |
| 216 | + | } { | |
| 217 | + | t.Run(test.name, func(t *testing.T) { | |
| 218 | + | record := durableRecoveryRecord(1) | |
| 219 | + | record.Committed = nil | |
| 220 | + | record.TabNames = append([]string(nil), test.tabNames...) | |
| 221 | + | record.TabRecords = append([]domain.CatalogueTabRecord(nil), test.tabRecords...) | |
| 222 | + | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | |
| 223 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 224 | + | d.catalogue = catalogue | |
| 225 | + | d.persistEnabled = true | |
| 226 | + | d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil) | |
| 227 | + | d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionDown, nil) | |
| 228 | + | ||
| 229 | + | target := domain.RemoteSessionTarget{ | |
| 230 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: record.IncarnationID, | |
| 231 | + | SessionName: record.Name, Stopped: true, StoppedTab: test.selector, | |
| 232 | + | } | |
| 233 | + | transport, _ := newCapturingTransport(t) | |
| 234 | + | sess, attachment, err := d.routeWithContext(context.Background(), ports.Hello{ | |
| 235 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name, | |
| 236 | + | Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, | |
| 237 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 238 | + | }, transport) | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | t.Cleanup(func() { d.clientGone(sess, attachment, transport, false) }) | |
| 241 | + | require.Len(t, sess.tabs, 1) | |
| 242 | + | require.Equal(t, sess.tabs[0].stableID, string(attachment.viewSnapshot().tabID)) | |
| 243 | + | }) | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 61 | 247 | func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) { | |
| 62 | 248 | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 63 | 249 | lifecycle := remoteLifecycleForTest() | |
| 64 | 250 | d.mu.Lock() | |
| 65 | - | d.stopped["work"] = stoppedSession{ | |
| 66 | - | name: "work", cwd: "/remote/work", incarnation: lifecycle, | |
| 251 | + | d.inactive["work"] = inactiveSession{ | |
| 252 | + | name: "work", cwd: "/remote/work", incarnation: lifecycle, state: ports.SessionDown, | |
| 67 | 253 | tabNames: []string{"alpha", "beta"}, | |
| 68 | 254 | tabRecords: []domain.CatalogueTabRecord{ | |
| 69 | 255 | {StableID: "tab-a", Name: "alpha"}, | |
M internal/usecase/daemon/remote_stopped_selector_test.go +5 -5
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T) { | |
| 12 | 12 | lifecycle := remoteLifecycleForTest() | |
| 13 | - | stopped := stoppedSession{ | |
| 13 | + | stopped := inactiveSession{ | |
| 14 | 14 | name: "work", incarnation: lifecycle, | |
| 15 | 15 | tabNames: []string{"alpha", "beta"}, | |
| 16 | 16 | tabRecords: []domain.CatalogueTabRecord{ | |
| @@ -24,13 +24,13 @@ func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T) | |||
| 24 | 24 | SessionName: "work", Stopped: true, | |
| 25 | 25 | StoppedTab: domain.NewStableTabSelector("tab-b"), | |
| 26 | 26 | } | |
| 27 | - | index, ok := remoteTargetTabIndexStopped(stopped, stable) | |
| 27 | + | index, ok := remoteTargetTabIndexInactive(stopped, stable) | |
| 28 | 28 | require.True(t, ok) | |
| 29 | 29 | require.Equal(t, 1, index) | |
| 30 | 30 | ||
| 31 | 31 | ordinal := stable | |
| 32 | 32 | ordinal.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2) | |
| 33 | - | index, ok = remoteTargetTabIndexStopped(stopped, ordinal) | |
| 33 | + | index, ok = remoteTargetTabIndexInactive(stopped, ordinal) | |
| 34 | 34 | require.True(t, ok) | |
| 35 | 35 | require.Equal(t, 1, index) | |
| 36 | 36 | ||
| @@ -51,7 +51,7 @@ func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T) | |||
| 51 | 51 | return candidate | |
| 52 | 52 | }(), | |
| 53 | 53 | } { | |
| 54 | - | _, ok := remoteTargetTabIndexStopped(stopped, invalid) | |
| 54 | + | _, ok := remoteTargetTabIndexInactive(stopped, invalid) | |
| 55 | 55 | require.False(t, ok) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| @@ -60,7 +60,7 @@ func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T) | |||
| 60 | 60 | {StableID: "tab-b", Name: "beta"}, | |
| 61 | 61 | {StableID: "tab-a", Name: "alpha"}, | |
| 62 | 62 | } | |
| 63 | - | index, ok = remoteTargetTabIndexStopped(reordered, stable) | |
| 63 | + | index, ok = remoteTargetTabIndexInactive(reordered, stable) | |
| 64 | 64 | require.True(t, ok) | |
| 65 | 65 | require.Equal(t, 0, index, "stable selectors follow identity, not the old ordinal") | |
| 66 | 66 | } | |
M internal/usecase/daemon/session.go +69 -34
| @@ -186,29 +186,48 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 186 | 186 | return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...) | |
| 187 | 187 | } | |
| 188 | 188 | ||
| 189 | - | // createStoppedSessionLocked resumes exactly expectedStopped. It rechecks the | |
| 190 | - | // stopped lifecycle after catalogue I/O, which intentionally runs without d.mu. | |
| 191 | - | func (d *Daemon) createStoppedSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedStopped stoppedSession, restoredTabNames ...[]string) (*session, error) { | |
| 192 | - | return d.createSessionLockedWithModeAndStoppedFence(name, false, cwd, sz, env, &expectedStopped, restoredTabNames...) | |
| 189 | + | // resumeInactiveSessionLocked resumes exactly expectedInactive. It rechecks | |
| 190 | + | // lifecycle and resumability after catalogue I/O, which intentionally runs | |
| 191 | + | // without d.mu. | |
| 192 | + | func (d *Daemon) resumeInactiveSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedInactive inactiveSession, restoredTabNames ...[]string) (*session, error) { | |
| 193 | + | return d.createSessionLockedWithModeAndInactiveFence(name, false, cwd, sz, env, &expectedInactive, nil, restoredTabNames...) | |
| 193 | 194 | } | |
| 194 | 195 | ||
| 195 | - | func stoppedSessionMatchesLifecycle(current, expected stoppedSession) bool { | |
| 196 | - | return current.name == expected.name && current.createdAt == expected.createdAt && current.incarnation == expected.incarnation | |
| 196 | + | func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) { | |
| 197 | + | return d.createSessionLockedWithModeAndInactiveFence(name, ephemeral, cwd, sz, env, nil, nil, restoredTabNames...) | |
| 197 | 198 | } | |
| 198 | 199 | ||
| 199 | - | func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) { | |
| 200 | - | return d.createSessionLockedWithModeAndStoppedFence(name, ephemeral, cwd, sz, env, nil, restoredTabNames...) | |
| 200 | + | type inactiveResumeValidator func(inactiveSession, domain.CatalogueRecord, bool) bool | |
| 201 | + | ||
| 202 | + | func (s inactiveSession) matchesRecord(record domain.CatalogueRecord) bool { | |
| 203 | + | canonical := inactiveSessionFromRecord(record, s.state, s.restoreDone) | |
| 204 | + | return s.sameLifecycle(canonical) && s.cwd == canonical.cwd && | |
| 205 | + | slices.Equal(s.tabNames, canonical.tabNames) && slices.Equal(s.tabRecords, canonical.tabRecords) && | |
| 206 | + | s.record.DegradedReason == canonical.record.DegradedReason | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func inactiveResumeFenceAllows(current inactiveSession, exists bool, expected *inactiveSession, authoritative domain.CatalogueRecord, authoritativeExists bool, validate inactiveResumeValidator) bool { | |
| 210 | + | if expected == nil { | |
| 211 | + | return true | |
| 212 | + | } | |
| 213 | + | if !exists || !current.sameLifecycle(*expected) || !current.canResume() { | |
| 214 | + | return false | |
| 215 | + | } | |
| 216 | + | if authoritativeExists && !current.matchesRecord(authoritative) { | |
| 217 | + | return false | |
| 218 | + | } | |
| 219 | + | return validate == nil || validate(current, authoritative, authoritativeExists) | |
| 201 | 220 | } | |
| 202 | 221 | ||
| 203 | - | func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedStopped *stoppedSession, restoredTabNames ...[]string) (*session, error) { | |
| 222 | + | func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedInactive *inactiveSession, validateInactive inactiveResumeValidator, restoredTabNames ...[]string) (*session, error) { | |
| 204 | 223 | env = copyEnvironment(env) | |
| 205 | 224 | if _, reserved := d.creating[name]; reserved { | |
| 206 | 225 | return nil, errSessionNameInUse | |
| 207 | 226 | } | |
| 208 | 227 | d.creating[name] = struct{}{} | |
| 209 | 228 | defer delete(d.creating, name) | |
| 210 | - | stopped, resuming := d.stopped[name] | |
| 211 | - | if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) { | |
| 229 | + | stopped, resuming := d.inactive[name] | |
| 230 | + | if !inactiveResumeFenceAllows(stopped, resuming, expectedInactive, domain.CatalogueRecord{}, false, nil) { | |
| 212 | 231 | return nil, errAttachmentTransition | |
| 213 | 232 | } | |
| 214 | 233 | var authoritative domain.CatalogueRecord | |
| @@ -224,10 +243,12 @@ func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemer | |||
| 224 | 243 | if d.closing { | |
| 225 | 244 | return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 226 | 245 | } | |
| 227 | - | stopped, resuming = d.stopped[name] | |
| 228 | - | if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) { | |
| 246 | + | stopped, resuming = d.inactive[name] | |
| 247 | + | if !inactiveResumeFenceAllows(stopped, resuming, expectedInactive, authoritative, authoritativeExists, validateInactive) { | |
| 229 | 248 | return nil, errAttachmentTransition | |
| 230 | 249 | } | |
| 250 | + | } else if validateInactive != nil && !validateInactive(stopped, domain.CatalogueRecord{}, false) { | |
| 251 | + | return nil, errAttachmentTransition | |
| 231 | 252 | } | |
| 232 | 253 | var createdAt int64 | |
| 233 | 254 | var incarnation domain.IncarnationID | |
| @@ -409,7 +430,7 @@ func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemer | |||
| 409 | 430 | if !ephemeral { | |
| 410 | 431 | // Keep stopped lifecycle authority intact until runtime publication and | |
| 411 | 432 | // its durable side effects have both succeeded. | |
| 412 | - | delete(d.stopped, name) | |
| 433 | + | delete(d.inactive, name) | |
| 413 | 434 | } | |
| 414 | 435 | d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral) | |
| 415 | 436 | for i, tb := range tabs { | |
| @@ -934,7 +955,7 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 934 | 955 | return errSessionNameInUse | |
| 935 | 956 | } | |
| 936 | 957 | sess.mu.Lock() | |
| 937 | - | if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name { | |
| 958 | + | if stopped, ok := d.inactive[name]; ok && stopped.name != sess.name { | |
| 938 | 959 | sess.mu.Unlock() | |
| 939 | 960 | d.mu.Unlock() | |
| 940 | 961 | return errSessionNameInUse | |
| @@ -992,8 +1013,8 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 992 | 1013 | } | |
| 993 | 1014 | } | |
| 994 | 1015 | ||
| 995 | - | delete(d.stopped, oldName) | |
| 996 | - | delete(d.stopped, name) | |
| 1016 | + | delete(d.inactive, oldName) | |
| 1017 | + | delete(d.inactive, name) | |
| 997 | 1018 | sess.name = name | |
| 998 | 1019 | sess.createdAt = createdAt | |
| 999 | 1020 | sess.incarnation = record.IncarnationID | |
| @@ -1238,13 +1259,13 @@ func (d *Daemon) retryStoppedPurge(name string) error { | |||
| 1238 | 1259 | ||
| 1239 | 1260 | func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error { | |
| 1240 | 1261 | d.mu.Lock() | |
| 1241 | - | stopped, ok := d.stopped[name] | |
| 1262 | + | stopped, ok := d.inactive[name] | |
| 1242 | 1263 | if !ok { | |
| 1243 | 1264 | d.mu.Unlock() | |
| 1244 | 1265 | return nil | |
| 1245 | 1266 | } | |
| 1246 | 1267 | stopped.purging = true | |
| 1247 | - | d.stopped[name] = stopped | |
| 1268 | + | d.inactive[name] = stopped | |
| 1248 | 1269 | ||
| 1249 | 1270 | if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil { | |
| 1250 | 1271 | d.mu.Unlock() | |
| @@ -1254,8 +1275,8 @@ func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) erro | |||
| 1254 | 1275 | d.mu.Unlock() | |
| 1255 | 1276 | return err | |
| 1256 | 1277 | } | |
| 1257 | - | if current, ok := d.stopped[name]; ok && current.purging { | |
| 1258 | - | delete(d.stopped, name) | |
| 1278 | + | if current, ok := d.inactive[name]; ok && current.purging { | |
| 1279 | + | delete(d.inactive, name) | |
| 1259 | 1280 | } | |
| 1260 | 1281 | d.mu.Unlock() | |
| 1261 | 1282 | return nil | |
| @@ -1288,7 +1309,7 @@ func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge boo | |||
| 1288 | 1309 | // architecture locks, but releases every lock before any attachment gate is frozen or | |
| 1289 | 1310 | // drained. The initiator is part of the same deduplicated global gate set even | |
| 1290 | 1311 | // when it belongs to a different source session. | |
| 1291 | - | func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) { | |
| 1312 | + | func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission, requireEmpty bool) (sessionKillParticipants, bool) { | |
| 1292 | 1313 | snapshot := sessionKillParticipants{target: target} | |
| 1293 | 1314 | if admission != nil { | |
| 1294 | 1315 | token := admission.token | |
| @@ -1313,6 +1334,12 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1313 | 1334 | source = target | |
| 1314 | 1335 | } | |
| 1315 | 1336 | unlockSessions := lockAttachmentSessions(source, target) | |
| 1337 | + | if requireEmpty && len(target.attachments) != 0 { | |
| 1338 | + | unlockSessions() | |
| 1339 | + | d.notices.routingMu.Unlock() | |
| 1340 | + | d.mu.Unlock() | |
| 1341 | + | return sessionKillParticipants{}, false | |
| 1342 | + | } | |
| 1316 | 1343 | snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments)) | |
| 1317 | 1344 | snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments)) | |
| 1318 | 1345 | for ac := range target.attachments { | |
| @@ -1503,13 +1530,24 @@ func (s *session) finishTeardown() { | |||
| 1503 | 1530 | // only immutable capture state; it never observes closed worker channels or | |
| 1504 | 1531 | // session-owned cache that teardown has released. | |
| 1505 | 1532 | func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission) error { | |
| 1533 | + | return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, purge, deadline, admission, false) | |
| 1534 | + | } | |
| 1535 | + | ||
| 1536 | + | // killSessionIfEmpty tears down sess only while its exact registry entry has no | |
| 1537 | + | // attachments. Membership is captured under the normal architecture locks and | |
| 1538 | + | // fenced again at publication, so an attachment winning either side survives. | |
| 1539 | + | func (d *Daemon) killSessionIfEmpty(sess *session, reason uint8, purge bool) error { | |
| 1540 | + | return d.killSessionWithSnapshotDeadlineAndCondition(sess, reason, purge, nil, nil, true) | |
| 1541 | + | } | |
| 1542 | + | ||
| 1543 | + | func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission, requireEmpty bool) error { | |
| 1506 | 1544 | if sess == nil { | |
| 1507 | 1545 | return nil | |
| 1508 | 1546 | } | |
| 1509 | 1547 | // Discover every attachment whose membership can be invalidated before freezing | |
| 1510 | 1548 | // any one of them. The common gate helper deduplicates and freezes the whole | |
| 1511 | 1549 | // immutable identity set in the same global order used by transitions. | |
| 1512 | - | participants, ok := d.snapshotSessionKillParticipants(sess, admission) | |
| 1550 | + | participants, ok := d.snapshotSessionKillParticipants(sess, admission, requireEmpty) | |
| 1513 | 1551 | if !ok { | |
| 1514 | 1552 | return nil | |
| 1515 | 1553 | } | |
| @@ -1693,18 +1731,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1693 | 1731 | } | |
| 1694 | 1732 | clear(sess.attachments) | |
| 1695 | 1733 | stoppedName := sess.name | |
| 1696 | - | stoppedCwd := sess.cwd | |
| 1697 | - | createdAt := sess.createdAt | |
| 1698 | 1734 | stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))) | |
| 1699 | - | tabNames := stoppedRecord.TabNames | |
| 1700 | - | tabRecords := stoppedRecord.TabRecords | |
| 1701 | 1735 | ephemeral := sess.ephemeral | |
| 1702 | 1736 | unlockCoordinators() | |
| 1703 | 1737 | unlockSessions() | |
| 1704 | 1738 | d.notices.routingMu.Unlock() | |
| 1705 | 1739 | if !ephemeral { | |
| 1706 | - | stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, tabRecords: tabRecords, record: stoppedRecord, state: ports.SessionDown, purging: purge} | |
| 1707 | - | d.stopped[stoppedName] = stopped | |
| 1740 | + | stopped := inactiveSessionFromRecord(stoppedRecord, ports.SessionDown, nil) | |
| 1741 | + | stopped.purging = purge | |
| 1742 | + | d.inactive[stoppedName] = stopped | |
| 1708 | 1743 | } | |
| 1709 | 1744 | empty := len(d.sessions) == 0 | |
| 1710 | 1745 | if empty { | |
| @@ -1763,8 +1798,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1763 | 1798 | if err != nil { | |
| 1764 | 1799 | purgeErr = errors.Join(purgeErr, err) | |
| 1765 | 1800 | d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName) | |
| 1766 | - | } else if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging { | |
| 1767 | - | delete(d.stopped, stoppedName) | |
| 1801 | + | } else if stopped, ok := d.inactive[stoppedName]; ok && stopped.purging { | |
| 1802 | + | delete(d.inactive, stoppedName) | |
| 1768 | 1803 | } | |
| 1769 | 1804 | d.mu.Unlock() | |
| 1770 | 1805 | } | |
| @@ -1783,13 +1818,13 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1783 | 1818 | // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds | |
| 1784 | 1819 | // d.mu. | |
| 1785 | 1820 | func (d *Daemon) allocEphemeralNameLocked() string { | |
| 1786 | - | used := make(map[string]struct{}, len(d.sessions)+len(d.stopped)) | |
| 1821 | + | used := make(map[string]struct{}, len(d.sessions)+len(d.inactive)) | |
| 1787 | 1822 | for _, s := range d.sessions { | |
| 1788 | 1823 | if s != nil { | |
| 1789 | 1824 | used[s.name] = struct{}{} | |
| 1790 | 1825 | } | |
| 1791 | 1826 | } | |
| 1792 | - | for name := range d.stopped { | |
| 1827 | + | for name := range d.inactive { | |
| 1793 | 1828 | used[name] = struct{}{} | |
| 1794 | 1829 | } | |
| 1795 | 1830 | for i := 0; ; i++ { | |
| @@ -1813,7 +1848,7 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool { | |||
| 1813 | 1848 | if d.findByNameLocked(name) != nil { | |
| 1814 | 1849 | return true | |
| 1815 | 1850 | } | |
| 1816 | - | if _, ok := d.stopped[name]; ok { | |
| 1851 | + | if _, ok := d.inactive[name]; ok { | |
| 1817 | 1852 | return true | |
| 1818 | 1853 | } | |
| 1819 | 1854 | _, reserved := d.creating[name] | |
M internal/usecase/daemon/session_purge_control_test.go +1 -1
| @@ -23,7 +23,7 @@ func TestStoppedPurgeMetadataFailureFencesCatalogue(t *testing.T) { | |||
| 23 | 23 | state.mu.Lock() | |
| 24 | 24 | state.deleteErr = func(string) error { return metadataErr } | |
| 25 | 25 | state.mu.Unlock() | |
| 26 | - | d.stopped["work"] = stoppedSession{name: "work", incarnation: record.IncarnationID, record: record} | |
| 26 | + | d.inactive["work"] = inactiveSession{name: "work", incarnation: record.IncarnationID, record: record} | |
| 27 | 27 | ||
| 28 | 28 | require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr) | |
| 29 | 29 | require.Empty(t, repository.calls, "snapshot deletion must wait for catalogue removal") | |
M internal/usecase/daemon/session_test.go +23 -20
| @@ -772,7 +772,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) { | |||
| 772 | 772 | ||
| 773 | 773 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store)) | |
| 774 | 774 | d.mu.Lock() | |
| 775 | - | stopped := d.stopped["work"] | |
| 775 | + | stopped := d.inactive["work"] | |
| 776 | 776 | d.mu.Unlock() | |
| 777 | 777 | require.Equal(t, "work", stopped.name) | |
| 778 | 778 | require.Equal(t, "/tmp/work", stopped.cwd) | |
| @@ -795,7 +795,7 @@ func TestDaemonNewWithoutPersistenceLogsNoLoadWarning(t *testing.T) { | |||
| 795 | 795 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil))) | |
| 796 | 796 | require.NotNil(t, d) | |
| 797 | 797 | require.False(t, d.persistEnabled) | |
| 798 | - | require.Empty(t, d.stopped) | |
| 798 | + | require.Empty(t, d.inactive) | |
| 799 | 799 | require.NotContains(t, logBuf.String(), "loading persisted sessions failed") | |
| 800 | 800 | } | |
| 801 | 801 | ||
| @@ -868,7 +868,7 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) { | |||
| 868 | 868 | store, state := newMockStore(t) | |
| 869 | 869 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 870 | 870 | WithStore(t, store)(d) | |
| 871 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42} | |
| 871 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42} | |
| 872 | 872 | d.mruSeq.Store(42) | |
| 873 | 873 | ||
| 874 | 874 | sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv) | |
| @@ -1015,7 +1015,10 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) { | |||
| 1015 | 1015 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 1016 | 1016 | WithStore(t, store)(d) | |
| 1017 | 1017 | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}})) | |
| 1018 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}} | |
| 1018 | + | record, ok, err := d.catalogueRecord("work") | |
| 1019 | + | require.NoError(t, err) | |
| 1020 | + | require.True(t, ok) | |
| 1021 | + | d.inactive["work"] = inactiveSessionFromRecord(record, ports.SessionDown, nil) | |
| 1019 | 1022 | tr := portsmocks.NewMockTransport(t) | |
| 1020 | 1023 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1021 | 1024 | tr.EXPECT().Close().Return(nil).Maybe() | |
| @@ -1035,7 +1038,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |||
| 1035 | 1038 | store, state := newMockStore(t) | |
| 1036 | 1039 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 1037 | 1040 | WithStore(t, store)(d) | |
| 1038 | - | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | |
| 1041 | + | d.inactive["taken"] = inactiveSession{name: "taken", cwd: "/tmp", createdAt: 1} | |
| 1039 | 1042 | ||
| 1040 | 1043 | sess, err := createSessionForTest(d, "0", true, "/tmp/e", sz, terminalEnv{}, d.baseEnv) | |
| 1041 | 1044 | require.NoError(t, err) | |
| @@ -1189,7 +1192,7 @@ func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) { | |||
| 1189 | 1192 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 1190 | 1193 | WithStore(t, store)(d) | |
| 1191 | 1194 | cwd := t.TempDir() | |
| 1192 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: cwd, createdAt: 1} | |
| 1195 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: cwd, createdAt: 1, state: ports.SessionDown} | |
| 1193 | 1196 | tr := portsmocks.NewMockTransport(t) | |
| 1194 | 1197 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1195 | 1198 | tr.EXPECT().Close().Return(nil).Maybe() | |
| @@ -1200,7 +1203,7 @@ func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) { | |||
| 1200 | 1203 | require.Equal(t, "work", sess.name) | |
| 1201 | 1204 | require.Equal(t, cwd, sess.cwd) | |
| 1202 | 1205 | d.mu.Lock() | |
| 1203 | - | _, stillStopped := d.stopped["work"] | |
| 1206 | + | _, stillStopped := d.inactive["work"] | |
| 1204 | 1207 | d.mu.Unlock() | |
| 1205 | 1208 | require.False(t, stillStopped) | |
| 1206 | 1209 | } | |
| @@ -1212,7 +1215,7 @@ func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) { | |||
| 1212 | 1215 | store, _ := newMockStore(t) | |
| 1213 | 1216 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 1214 | 1217 | WithStore(t, store)(d) | |
| 1215 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1} | |
| 1218 | + | d.inactive["work"] = inactiveSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1, state: ports.SessionDown} | |
| 1216 | 1219 | tr := portsmocks.NewMockTransport(t) | |
| 1217 | 1220 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1218 | 1221 | tr.EXPECT().Close().Return(nil).Maybe() | |
| @@ -1227,11 +1230,11 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) { | |||
| 1227 | 1230 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 1228 | 1231 | WithStore(t, store)(d) | |
| 1229 | 1232 | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) | |
| 1230 | - | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1} | |
| 1233 | + | d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp", createdAt: 1} | |
| 1231 | 1234 | require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true})) | |
| 1232 | 1235 | require.False(t, state.has("old")) | |
| 1233 | 1236 | d.mu.Lock() | |
| 1234 | - | _, ok := d.stopped["old"] | |
| 1237 | + | _, ok := d.inactive["old"] | |
| 1235 | 1238 | d.mu.Unlock() | |
| 1236 | 1239 | require.False(t, ok) | |
| 1237 | 1240 | } | |
| @@ -1293,7 +1296,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) { | |||
| 1293 | 1296 | ||
| 1294 | 1297 | func TestIntentNewStoppedNameRejected(t *testing.T) { | |
| 1295 | 1298 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 1296 | - | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | |
| 1299 | + | d.inactive["taken"] = inactiveSession{name: "taken", cwd: "/tmp", createdAt: 1} | |
| 1297 | 1300 | tr := portsmocks.NewMockTransport(t) | |
| 1298 | 1301 | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr) | |
| 1299 | 1302 | require.ErrorContains(t, err, "name already in use") | |
| @@ -1363,7 +1366,7 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | |||
| 1363 | 1366 | _ = d.killSession(natural, ports.ReasonSessionKilled, false) | |
| 1364 | 1367 | require.True(t, state.has("natural")) | |
| 1365 | 1368 | d.mu.Lock() | |
| 1366 | - | stopped := d.stopped["natural"] | |
| 1369 | + | stopped := d.inactive["natural"] | |
| 1367 | 1370 | d.mu.Unlock() | |
| 1368 | 1371 | require.Equal(t, "/tmp/latest", stopped.cwd) | |
| 1369 | 1372 | ||
| @@ -1688,7 +1691,7 @@ func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T) | |||
| 1688 | 1691 | d := newTestDaemon(t, nil, stubClock{}) | |
| 1689 | 1692 | d.lastAllocatedCreatedAt = math.MaxInt64 | |
| 1690 | 1693 | d.nextID = 17 | |
| 1691 | - | d.stopped["retained"] = stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9} | |
| 1694 | + | d.inactive["retained"] = inactiveSession{name: "retained", cwd: "/tmp", createdAt: 9} | |
| 1692 | 1695 | ||
| 1693 | 1696 | sess, err := createSessionForTest(d, "new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |
| 1694 | 1697 | ||
| @@ -1697,13 +1700,13 @@ func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T) | |||
| 1697 | 1700 | require.Empty(t, d.sessions) | |
| 1698 | 1701 | require.Equal(t, uint64(17), d.nextID) | |
| 1699 | 1702 | require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt) | |
| 1700 | - | require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"]) | |
| 1703 | + | require.Equal(t, inactiveSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.inactive["retained"]) | |
| 1701 | 1704 | ||
| 1702 | 1705 | _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil) | |
| 1703 | 1706 | require.ErrorContains(t, err, "lifecycle identities exhausted") | |
| 1704 | 1707 | require.Empty(t, d.sessions) | |
| 1705 | 1708 | require.Equal(t, uint64(17), d.nextID) | |
| 1706 | - | require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"]) | |
| 1709 | + | require.Equal(t, inactiveSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.inactive["retained"]) | |
| 1707 | 1710 | } | |
| 1708 | 1711 | ||
| 1709 | 1712 | func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) { | |
| @@ -1716,7 +1719,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) { | |||
| 1716 | 1719 | ||
| 1717 | 1720 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records)) | |
| 1718 | 1721 | ||
| 1719 | - | alpha := d.stopped["alpha"] | |
| 1722 | + | alpha := d.inactive["alpha"] | |
| 1720 | 1723 | require.Equal(t, records[0], alpha.record) | |
| 1721 | 1724 | require.Equal(t, ports.SessionDown, alpha.state) | |
| 1722 | 1725 | require.NotNil(t, alpha.restoreDone) | |
| @@ -1725,7 +1728,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) { | |||
| 1725 | 1728 | t.Fatal("healthy catalogue record must begin in restoring state") | |
| 1726 | 1729 | default: | |
| 1727 | 1730 | } | |
| 1728 | - | work := d.stopped["work"] | |
| 1731 | + | work := d.inactive["work"] | |
| 1729 | 1732 | require.Equal(t, records[1], work.record) | |
| 1730 | 1733 | require.Equal(t, ports.SessionBroken, work.state) | |
| 1731 | 1734 | require.NotNil(t, work.restoreDone) | |
| @@ -1765,7 +1768,7 @@ func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testin | |||
| 1765 | 1768 | store, state := newMockStore(t) | |
| 1766 | 1769 | WithStore(t, store)(d) | |
| 1767 | 1770 | d.ptys = newFactory(t, targetPTY) | |
| 1768 | - | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 77} | |
| 1771 | + | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 77, state: ports.SessionDown} | |
| 1769 | 1772 | ||
| 1770 | 1773 | require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true})) | |
| 1771 | 1774 | resumed := ac.currentSession() | |
| @@ -1793,7 +1796,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||
| 1793 | 1796 | d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY) | |
| 1794 | 1797 | d.ptys = newFactory(t, targetPTY) | |
| 1795 | 1798 | expected := int64(31) | |
| 1796 | - | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected} | |
| 1799 | + | d.inactive["target"] = inactiveSession{name: "target", cwd: "/tmp", createdAt: expected, state: ports.SessionDown} | |
| 1797 | 1800 | ||
| 1798 | 1801 | require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected})) | |
| 1799 | 1802 | require.Equal(t, "target", ac.currentSession().name) | |
| @@ -1815,7 +1818,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||
| 1815 | 1818 | fromPTY, releaseFrom := newBlockingPTY(t) | |
| 1816 | 1819 | defer releaseFrom() | |
| 1817 | 1820 | d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY) | |
| 1818 | - | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52} | |
| 1821 | + | d.inactive["target"] = inactiveSession{name: "target", cwd: "/tmp", createdAt: 52, state: ports.SessionDown} | |
| 1819 | 1822 | expected := int64(51) | |
| 1820 | 1823 | ||
| 1821 | 1824 | require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | |
M internal/usecase/daemon/snapshot_restore.go +7 -7
| @@ -16,7 +16,7 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | func (d *Daemon) closeRestoreDone() { | |
| 18 | 18 | d.mu.Lock() | |
| 19 | - | for name, entry := range d.stopped { | |
| 19 | + | for name, entry := range d.inactive { | |
| 20 | 20 | if entry.restoreDone == nil { | |
| 21 | 21 | continue | |
| 22 | 22 | } | |
| @@ -29,7 +29,7 @@ func (d *Daemon) closeRestoreDone() { | |||
| 29 | 29 | if entry.record.DegradedReason == "" { | |
| 30 | 30 | entry.record.DegradedReason = "restore unavailable" | |
| 31 | 31 | } | |
| 32 | - | d.stopped[name] = entry | |
| 32 | + | d.inactive[name] = entry | |
| 33 | 33 | closeRuntimeRestoreDoneLocked(entry.restoreDone) | |
| 34 | 34 | } | |
| 35 | 35 | d.mu.Unlock() | |
| @@ -201,7 +201,7 @@ func closeRestoredTabs(tabs []*tab) { | |||
| 201 | 201 | func (d *Daemon) restoredSessionTabNames(name string) []string { | |
| 202 | 202 | d.mu.Lock() | |
| 203 | 203 | defer d.mu.Unlock() | |
| 204 | - | return append([]string(nil), d.stopped[name].tabNames...) | |
| 204 | + | return append([]string(nil), d.inactive[name].tabNames...) | |
| 205 | 205 | } | |
| 206 | 206 | ||
| 207 | 207 | func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, allowlist map[string]struct{}) (*tab, error) { | |
| @@ -303,7 +303,7 @@ func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context | |||
| 303 | 303 | sess.cwd = snap.Tabs[0].Panes[0].Cwd | |
| 304 | 304 | } | |
| 305 | 305 | d.mu.Lock() | |
| 306 | - | if stopped, ok := d.stopped[snap.Name]; ok { | |
| 306 | + | if stopped, ok := d.inactive[snap.Name]; ok { | |
| 307 | 307 | sess.mruAt.Store(stopped.lastUsedSeq) | |
| 308 | 308 | } | |
| 309 | 309 | d.mu.Unlock() | |
| @@ -315,7 +315,7 @@ func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context | |||
| 315 | 315 | func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) { | |
| 316 | 316 | if sess.incarnation == (domain.IncarnationID{}) { | |
| 317 | 317 | d.mu.Lock() | |
| 318 | - | stopped := d.stopped[sess.name] | |
| 318 | + | stopped := d.inactive[sess.name] | |
| 319 | 319 | d.mu.Unlock() | |
| 320 | 320 | sess.incarnation = stopped.incarnation | |
| 321 | 321 | if sess.incarnation == (domain.IncarnationID{}) { | |
| @@ -347,7 +347,7 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se | |||
| 347 | 347 | } | |
| 348 | 348 | sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 349 | 349 | d.nextID++ | |
| 350 | - | stopped := d.stopped[sess.name] | |
| 350 | + | stopped := d.inactive[sess.name] | |
| 351 | 351 | sess.mu.Lock() | |
| 352 | 352 | sess.restoreDone = stopped.restoreDone | |
| 353 | 353 | sess.mu.Unlock() | |
| @@ -357,7 +357,7 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se | |||
| 357 | 357 | // The stopped entry is the durable authority and restore barrier until the | |
| 358 | 358 | // runtime identity has actually been published. A registry rejection must | |
| 359 | 359 | // leave that exact entry available for attach/recovery retries. | |
| 360 | - | delete(d.stopped, sess.name) | |
| 360 | + | delete(d.inactive, sess.name) | |
| 361 | 361 | sess.snapDirty.Store(false) | |
| 362 | 362 | return true, nil | |
| 363 | 363 | } | |
M internal/usecase/daemon/snapshot_restore_incremental.go +10 -10
| @@ -68,7 +68,7 @@ func (d *Daemon) restoreCatalogue(ctx context.Context, records []domain.Catalogu | |||
| 68 | 68 | wg.Wait() | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | - | func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.SessionState, done chan struct{}) stoppedSession { | |
| 71 | + | func inactiveSessionFromRecord(record domain.CatalogueRecord, state ports.SessionState, done chan struct{}) inactiveSession { | |
| 72 | 72 | tabRecords := append([]domain.CatalogueTabRecord(nil), record.TabRecords...) | |
| 73 | 73 | tabNames := append([]string(nil), record.TabNames...) | |
| 74 | 74 | if len(tabRecords) != 0 { | |
| @@ -85,7 +85,7 @@ func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.Session | |||
| 85 | 85 | tabRecords[i] = domain.CatalogueTabRecord{Name: name} | |
| 86 | 86 | } | |
| 87 | 87 | } | |
| 88 | - | return stoppedSession{ | |
| 88 | + | return inactiveSession{ | |
| 89 | 89 | name: record.Name, | |
| 90 | 90 | cwd: record.Cwd, | |
| 91 | 91 | createdAt: record.CreatedAt, | |
| @@ -102,11 +102,11 @@ func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.Session | |||
| 102 | 102 | func (d *Daemon) ensureCatalogueRegistryEntry(record domain.CatalogueRecord) chan struct{} { | |
| 103 | 103 | d.mu.Lock() | |
| 104 | 104 | defer d.mu.Unlock() | |
| 105 | - | if entry, ok := d.stopped[record.Name]; ok { | |
| 105 | + | if entry, ok := d.inactive[record.Name]; ok { | |
| 106 | 106 | return entry.restoreDone | |
| 107 | 107 | } | |
| 108 | 108 | state, done := initialSessionState(record) | |
| 109 | - | d.stopped[record.Name] = stoppedSessionFromRecord(record, state, done) | |
| 109 | + | d.inactive[record.Name] = inactiveSessionFromRecord(record, state, done) | |
| 110 | 110 | return done | |
| 111 | 111 | } | |
| 112 | 112 | ||
| @@ -126,11 +126,11 @@ func closeRuntimeRestoreDoneLocked(done chan struct{}) { | |||
| 126 | 126 | func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state ports.SessionState) { | |
| 127 | 127 | d.mu.Lock() | |
| 128 | 128 | defer d.mu.Unlock() | |
| 129 | - | entry, ok := d.stopped[record.Name] | |
| 129 | + | entry, ok := d.inactive[record.Name] | |
| 130 | 130 | if !ok { | |
| 131 | 131 | return | |
| 132 | 132 | } | |
| 133 | - | d.stopped[record.Name] = stoppedSessionFromRecord(record, state, entry.restoreDone) | |
| 133 | + | d.inactive[record.Name] = inactiveSessionFromRecord(record, state, entry.restoreDone) | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | 136 | func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr error, done chan struct{}) { | |
| @@ -155,8 +155,8 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e | |||
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | 157 | d.mu.Lock() | |
| 158 | - | if entry, ok := d.stopped[record.Name]; ok && degraded { | |
| 159 | - | d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionBroken, entry.restoreDone) | |
| 158 | + | if entry, ok := d.inactive[record.Name]; ok && degraded { | |
| 159 | + | d.inactive[record.Name] = inactiveSessionFromRecord(record, ports.SessionBroken, entry.restoreDone) | |
| 160 | 160 | } | |
| 161 | 161 | closeRuntimeRestoreDoneLocked(done) | |
| 162 | 162 | d.mu.Unlock() | |
| @@ -251,8 +251,8 @@ func (d *Daemon) resetIncompatibleCheckpoint(ctx context.Context, record domain. | |||
| 251 | 251 | } | |
| 252 | 252 | ||
| 253 | 253 | d.mu.Lock() | |
| 254 | - | if entry, ok := d.stopped[record.Name]; ok { | |
| 255 | - | d.stopped[record.Name] = stoppedSessionFromRecord(fresh, ports.SessionDown, entry.restoreDone) | |
| 254 | + | if entry, ok := d.inactive[record.Name]; ok { | |
| 255 | + | d.inactive[record.Name] = inactiveSessionFromRecord(fresh, ports.SessionDown, entry.restoreDone) | |
| 256 | 256 | } | |
| 257 | 257 | d.mu.Unlock() | |
| 258 | 258 | ||
M internal/usecase/daemon/snapshot_resume_publication_test.go +1 -1
| @@ -174,7 +174,7 @@ func TestStoppedNamedSessionResumePublishesNextCheckpointInSameDaemon(t *testing | |||
| 174 | 174 | ||
| 175 | 175 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 176 | 176 | d.mu.Lock() | |
| 177 | - | stopped, retained := d.stopped[sess.name] | |
| 177 | + | stopped, retained := d.inactive[sess.name] | |
| 178 | 178 | d.mu.Unlock() | |
| 179 | 179 | require.True(t, retained, "non-purged named session must remain stopped") | |
| 180 | 180 | require.False(t, stopped.purging) | |
M internal/usecase/picker/model.go +54 -50
| @@ -57,7 +57,6 @@ type SessionView struct { | |||
| 57 | 57 | RemoteDetail string | |
| 58 | 58 | RemoteReason string | |
| 59 | 59 | RemoteActivation RemoteActivation | |
| 60 | - | ConnectOnly bool | |
| 61 | 60 | // CannotAcceptMoves reports whether this session cannot receive a moved tab | |
| 62 | 61 | // or pane. False for ordinary local (and stopped) sessions; true for | |
| 63 | 62 | // restricted remote rows. | |
| @@ -248,7 +247,11 @@ func New(sessions []SessionView, config SelectionConfig) *Model { | |||
| 248 | 247 | // rowsForSession is the sole owner of mode-specific destination eligibility. | |
| 249 | 248 | // The daemon supplies canonical lifecycle/tab snapshots without prefiltering. | |
| 250 | 249 | func rowsForSession(session SessionView, config SelectionConfig) []row { | |
| 251 | - | if config.Mode != SelectNavigationTab && session.Stopped { | |
| 250 | + | stopped := session.Stopped | |
| 251 | + | if session.RemoteTarget != nil { | |
| 252 | + | stopped = session.RemoteTarget.Stopped | |
| 253 | + | } | |
| 254 | + | if config.Mode != SelectNavigationTab && stopped { | |
| 252 | 255 | return nil | |
| 253 | 256 | } | |
| 254 | 257 | if config.Mode == SelectMoveTabSession && sourceMatchesSession(config.Source, session) { | |
| @@ -259,7 +262,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 259 | 262 | } | |
| 260 | 263 | ||
| 261 | 264 | targetName := session.TargetName | |
| 262 | - | if config.Mode == SelectNavigationTab && !session.Stopped && session.ExpectedCreatedAt == nil { | |
| 265 | + | if config.Mode == SelectNavigationTab && !stopped && session.ExpectedCreatedAt == nil { | |
| 263 | 266 | targetName = "" | |
| 264 | 267 | } else if targetName == "" { | |
| 265 | 268 | targetName = session.Name | |
| @@ -267,7 +270,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 267 | 270 | expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt) | |
| 268 | 271 | common := row{ | |
| 269 | 272 | session: session.ID, incarnation: session.Incarnation, targetName: targetName, | |
| 270 | - | stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt, | |
| 273 | + | stopped: stopped, expectedCreatedAt: expectedCreatedAt, | |
| 271 | 274 | hasExpectedCreatedAt: hasExpectedCreatedAt, | |
| 272 | 275 | } | |
| 273 | 276 | if session.RemoteKey != nil { | |
| @@ -299,11 +302,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 299 | 302 | header.selectable = false | |
| 300 | 303 | header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0 | |
| 301 | 304 | } else { | |
| 302 | - | header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteActivatable(session) | |
| 303 | - | header.focusable = header.selectable | |
| 304 | - | if !common.hasRemoteKey && !common.hasRemoteTarget { | |
| 305 | - | header.focusable = config.Mode == SelectNavigationTab | |
| 306 | - | } | |
| 305 | + | header.selectable = false | |
| 306 | + | header.focusable = config.Mode == SelectNavigationTab && session.RemoteHost != "" | |
| 307 | 307 | } | |
| 308 | 308 | header.unavailableReason = session.RemoteReason | |
| 309 | 309 | header.dim = session.RemoteActivation == RemoteUnavailable | |
| @@ -312,53 +312,50 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 312 | 312 | header.selectable, header.focusable, header.dim = false, false, true | |
| 313 | 313 | } | |
| 314 | 314 | rows := []row{header} | |
| 315 | - | if !session.ConnectOnly || common.hasRemoteTarget { | |
| 316 | - | for i, tab := range session.Tabs { | |
| 317 | - | if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) { | |
| 318 | - | continue | |
| 319 | - | } | |
| 320 | - | tabRow := common | |
| 321 | - | tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention | |
| 322 | - | tabRow.tabID, tabRow.tabIndex = tab.TabID, i | |
| 323 | - | tabRow.selectable = tabRow.kind.selectable(config.Mode) | |
| 324 | - | tabRow.focusable = tabRow.selectable | |
| 325 | - | if common.remote { | |
| 326 | - | tabRow.unavailableReason = session.RemoteReason | |
| 327 | - | if common.hasRemoteTarget { | |
| 328 | - | remoteTarget := common.remoteTarget | |
| 329 | - | if session.Stopped { | |
| 330 | - | remoteTarget.LiveTabID = "" | |
| 331 | - | if tab.TabID != "" { | |
| 332 | - | remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID) | |
| 333 | - | } else { | |
| 334 | - | tabCount := min(len(session.Tabs), math.MaxUint16) | |
| 335 | - | remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount)) | |
| 336 | - | } | |
| 337 | - | } else { | |
| 338 | - | remoteTarget.StoppedTab = domain.TabSelector{} | |
| 339 | - | remoteTarget.LiveTabID = tab.TabID | |
| 315 | + | for i, tab := range session.Tabs { | |
| 316 | + | if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) { | |
| 317 | + | continue | |
| 318 | + | } | |
| 319 | + | tabRow := common | |
| 320 | + | remoteTargetResolvable := true | |
| 321 | + | tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention | |
| 322 | + | tabRow.tabID, tabRow.tabIndex = tab.TabID, i | |
| 323 | + | tabRow.selectable = tabRow.kind.selectable(config.Mode) | |
| 324 | + | tabRow.focusable = tabRow.selectable | |
| 325 | + | if common.remote { | |
| 326 | + | tabRow.unavailableReason = session.RemoteReason | |
| 327 | + | if common.hasRemoteTarget { | |
| 328 | + | remoteTarget := common.remoteTarget | |
| 329 | + | if remoteTarget.Stopped { | |
| 330 | + | remoteTarget.LiveTabID = "" | |
| 331 | + | if tab.TabID != "" { | |
| 332 | + | remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID) | |
| 333 | + | } else if remoteTarget.StoppedTab != (domain.TabSelector{}) { | |
| 334 | + | remoteTarget.StoppedTab, remoteTargetResolvable = remoteStoppedOrdinalSelector(i, tab.RawName, len(session.Tabs)) | |
| 340 | 335 | } | |
| 341 | - | // Keep the selected tab's structured identity even when it is | |
| 342 | - | // invalid. Focus remains possible for diagnostics, while both | |
| 343 | - | // the readiness gate and daemon wire validation reject it. | |
| 344 | - | tabRow.remoteTarget = remoteTarget | |
| 345 | - | tabRow.hasRemoteTarget = true | |
| 346 | - | } | |
| 347 | - | if !tabRow.hasRemoteTarget { | |
| 348 | - | legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteActivatable(session) | |
| 349 | - | tabRow.focusable = legacyRemote | |
| 350 | - | tabRow.selectable = legacyRemote | |
| 351 | 336 | } else { | |
| 352 | - | tabRow.focusable = true | |
| 353 | - | tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session) | |
| 337 | + | remoteTarget.StoppedTab = domain.TabSelector{} | |
| 338 | + | remoteTarget.LiveTabID = tab.TabID | |
| 354 | 339 | } | |
| 355 | - | tabRow.dim = session.RemoteActivation == RemoteUnavailable | |
| 340 | + | // Keep the selected tab's structured identity even when it is | |
| 341 | + | // invalid. Focus remains possible for diagnostics, while both | |
| 342 | + | // the readiness gate and daemon wire validation reject it. | |
| 343 | + | tabRow.remoteTarget = remoteTarget | |
| 344 | + | tabRow.hasRemoteTarget = true | |
| 356 | 345 | } | |
| 357 | - | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | |
| 358 | - | tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true | |
| 346 | + | if !tabRow.hasRemoteTarget { | |
| 347 | + | tabRow.focusable = false | |
| 348 | + | tabRow.selectable = false | |
| 349 | + | } else { | |
| 350 | + | tabRow.focusable = true | |
| 351 | + | tabRow.selectable = remoteTargetResolvable && tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session) | |
| 359 | 352 | } | |
| 360 | - | rows = append(rows, tabRow) | |
| 353 | + | tabRow.dim = session.RemoteActivation == RemoteUnavailable | |
| 354 | + | } | |
| 355 | + | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | |
| 356 | + | tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true | |
| 361 | 357 | } | |
| 358 | + | rows = append(rows, tabRow) | |
| 362 | 359 | } | |
| 363 | 360 | if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves { | |
| 364 | 361 | return nil | |
| @@ -366,6 +363,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 366 | 363 | return rows | |
| 367 | 364 | } | |
| 368 | 365 | ||
| 366 | + | func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) { | |
| 367 | + | if tabCount > math.MaxUint16 || index < 0 || index >= tabCount { | |
| 368 | + | return domain.TabSelector{}, false | |
| 369 | + | } | |
| 370 | + | return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true | |
| 371 | + | } | |
| 372 | + | ||
| 369 | 373 | func remoteActivatable(session SessionView) bool { | |
| 370 | 374 | return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart | |
| 371 | 375 | } | |
M internal/usecase/picker/model_test.go +52 -94
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package picker | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 4 | 5 | "reflect" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| @@ -740,107 +741,64 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) { | |||
| 740 | 741 | } | |
| 741 | 742 | } | |
| 742 | 743 | ||
| 743 | - | func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) { | |
| 744 | - | key := domain.RemoteSessionKey{Host: "build@mule", Name: "work"} | |
| 745 | - | availability := []RemoteAvailability{RemoteCached, RemoteFresh, RemoteStale, RemoteVersionMismatch} | |
| 746 | - | sessions := make([]SessionView, 0, len(availability)) | |
| 747 | - | for _, state := range availability { | |
| 748 | - | sessions = append(sessions, SessionView{ | |
| 749 | - | ID: key.ID(), | |
| 750 | - | Name: "presentation text is not routing data", | |
| 751 | - | Tabs: []TabEntry{{Name: "relayed metadata is not a picker target"}}, | |
| 752 | - | RemoteKey: &key, | |
| 753 | - | RemoteAvailability: state, | |
| 754 | - | RemoteDetail: "remote state detail", | |
| 755 | - | RemoteActivation: RemoteUnavailable, | |
| 756 | - | ConnectOnly: true, | |
| 757 | - | }) | |
| 758 | - | } | |
| 744 | + | func TestStructuredRemoteTargetOwnsStoppedState(t *testing.T) { | |
| 745 | + | for _, test := range []struct { | |
| 746 | + | name string | |
| 747 | + | viewStopped bool | |
| 748 | + | targetStopped bool | |
| 749 | + | }{ | |
| 750 | + | {name: "down target overrides live presentation flag", targetStopped: true}, | |
| 751 | + | {name: "live target overrides stopped presentation flag", viewStopped: true}, | |
| 752 | + | } { | |
| 753 | + | t.Run(test.name, func(t *testing.T) { | |
| 754 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: domain.SessionLifecycleID{1}, DisplayOrigin: "arch"} | |
| 755 | + | remoteTarget := domain.RemoteSessionTarget{ | |
| 756 | + | Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: key.LifecycleID, | |
| 757 | + | SessionName: "work", LiveTabID: "tab-1", Stopped: test.targetStopped, | |
| 758 | + | } | |
| 759 | + | activation := RemoteAttach | |
| 760 | + | if test.targetStopped { | |
| 761 | + | remoteTarget.LiveTabID = "" | |
| 762 | + | remoteTarget.StoppedTab = domain.NewStableTabSelector("tab-1") | |
| 763 | + | activation = RemoteRestart | |
| 764 | + | } | |
| 765 | + | model := New([]SessionView{{ | |
| 766 | + | ID: key.ID(), Name: key.Display(), Tabs: []TabEntry{{TabID: "tab-1", Name: "main"}}, | |
| 767 | + | RemoteKey: &key, RemoteTarget: &remoteTarget, RemoteActivation: activation, | |
| 768 | + | Stopped: test.viewStopped, | |
| 769 | + | }}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 759 | 770 | ||
| 760 | - | model := New(sessions, SelectionConfig{Mode: SelectNavigationTab}) | |
| 761 | - | require.Len(t, model.rows, len(availability), "connect-only remote rows do not synthesize tab destinations") | |
| 762 | - | for i, pickerRow := range model.rows { | |
| 763 | - | require.Equal(t, rowSession, pickerRow.kind) | |
| 764 | - | require.Equal(t, key, pickerRow.remoteKey) | |
| 765 | - | require.True(t, pickerRow.hasRemoteKey) | |
| 766 | - | require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i]) | |
| 767 | - | require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i]) | |
| 768 | - | require.Equal(t, "@mule (remote state detail)", pickerRow.detail) | |
| 769 | - | } | |
| 770 | - | _, ok := model.Selected() | |
| 771 | - | require.False(t, ok) | |
| 772 | - | frame := model.Render(domain.Size{Cols: 80, Rows: len(availability)}, Preview{}) | |
| 773 | - | for y := range availability { | |
| 774 | - | require.NotZero(t, frame.At(0, y).Style.Attrs&renderer.AttrDim) | |
| 771 | + | selected, ok := model.Selected() | |
| 772 | + | require.True(t, ok) | |
| 773 | + | require.Equal(t, test.targetStopped, selected.Stopped) | |
| 774 | + | require.NotNil(t, selected.RemoteTarget) | |
| 775 | + | require.Equal(t, test.targetStopped, selected.RemoteTarget.Stopped) | |
| 776 | + | }) | |
| 775 | 777 | } | |
| 776 | 778 | } | |
| 777 | 779 | ||
| 778 | - | func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *testing.T) { | |
| 779 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 780 | - | cached := New([]SessionView{{ | |
| 781 | - | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached, | |
| 782 | - | RemoteActivation: RemoteUnavailable, ConnectOnly: true, | |
| 783 | - | }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}}) | |
| 784 | - | ||
| 785 | - | cursor, ok := cached.Cursor() | |
| 786 | - | require.True(t, ok) | |
| 787 | - | require.Equal(t, key.ID(), cursor.Session) | |
| 788 | - | require.Equal(t, &key, cursor.RemoteKey) | |
| 789 | - | _, selectable := cached.Selected() | |
| 790 | - | require.False(t, selectable, "cursor identity must not bypass the phase 5 activation gate") | |
| 791 | - | ||
| 792 | - | live := New([]SessionView{{ | |
| 793 | - | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh, | |
| 794 | - | RemoteActivation: RemoteUnavailable, ConnectOnly: true, | |
| 795 | - | }}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{ | |
| 796 | - | Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey, | |
| 797 | - | }}) | |
| 798 | - | ||
| 799 | - | upgraded, ok := live.Cursor() | |
| 800 | - | require.True(t, ok) | |
| 801 | - | require.Equal(t, cursor, upgraded) | |
| 802 | - | _, selectable = live.Selected() | |
| 803 | - | require.False(t, selectable, "cache-to-live cursor preservation must not enable attach") | |
| 804 | - | } | |
| 805 | - | ||
| 806 | - | func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) { | |
| 807 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 808 | - | model := New([]SessionView{{ | |
| 809 | - | ID: key.ID(), | |
| 810 | - | Name: key.Display(), | |
| 811 | - | Tabs: []TabEntry{{TabID: "remote-tab", Name: "metadata"}}, | |
| 812 | - | RemoteKey: &key, | |
| 813 | - | RemoteAvailability: RemoteFresh, | |
| 814 | - | RemoteActivation: RemoteUnavailable, | |
| 815 | - | }}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 816 | - | ||
| 817 | - | require.Len(t, model.rows, 2) | |
| 818 | - | for _, pickerRow := range model.rows { | |
| 819 | - | require.False(t, pickerRow.selectable) | |
| 820 | - | require.True(t, pickerRow.dim) | |
| 780 | + | func TestRemoteStoppedOrdinalSelectorBounds(t *testing.T) { | |
| 781 | + | for _, test := range []struct { | |
| 782 | + | name string | |
| 783 | + | index int | |
| 784 | + | tabCount int | |
| 785 | + | want domain.TabSelector | |
| 786 | + | ok bool | |
| 787 | + | }{ | |
| 788 | + | {name: "largest representable", index: math.MaxUint16 - 1, tabCount: math.MaxUint16, want: domain.NewOrdinalTabSelector(math.MaxUint16-1, "tab", math.MaxUint16), ok: true}, | |
| 789 | + | {name: "unrepresentable count", index: math.MaxUint16, tabCount: math.MaxUint16 + 1}, | |
| 790 | + | {name: "negative index", index: -1, tabCount: 1}, | |
| 791 | + | {name: "index equals count", index: 1, tabCount: 1}, | |
| 792 | + | {name: "empty count", index: 0, tabCount: 0}, | |
| 793 | + | } { | |
| 794 | + | t.Run(test.name, func(t *testing.T) { | |
| 795 | + | selector, ok := remoteStoppedOrdinalSelector(test.index, "tab", test.tabCount) | |
| 796 | + | require.Equal(t, test.ok, ok) | |
| 797 | + | require.Equal(t, test.want, selector) | |
| 798 | + | }) | |
| 821 | 799 | } | |
| 822 | 800 | } | |
| 823 | 801 | ||
| 824 | - | func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) { | |
| 825 | - | key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"} | |
| 826 | - | model := New([]SessionView{{ | |
| 827 | - | ID: key.ID(), | |
| 828 | - | Name: "not a parseable remote display label", | |
| 829 | - | RemoteKey: &key, | |
| 830 | - | RemoteAvailability: RemoteFresh, | |
| 831 | - | RemoteActivation: RemoteAttach, | |
| 832 | - | ConnectOnly: true, | |
| 833 | - | }}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 834 | - | wantKey := key | |
| 835 | - | key.Host = "mutated-after-model-construction" | |
| 836 | - | ||
| 837 | - | got, ok := model.Selected() | |
| 838 | - | require.True(t, ok) | |
| 839 | - | require.Equal(t, wantKey.ID(), got.Session) | |
| 840 | - | require.Equal(t, &wantKey, got.RemoteKey) | |
| 841 | - | require.Equal(t, "", got.Name, "connect-only remote routing never derives a name from display text") | |
| 842 | - | } | |
| 843 | - | ||
| 844 | 802 | func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) { | |
| 845 | 803 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 846 | 804 | local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}} | |
M scripts/remote-picker-harness/main.go +2 -2
| @@ -930,11 +930,11 @@ func targetEndpoint(endpoint string) string { | |||
| 930 | 930 | return endpoint | |
| 931 | 931 | } | |
| 932 | 932 | ||
| 933 | - | func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name, state string) (ports.RemoteCatalogSession, error) { | |
| 933 | + | func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state ports.RemoteCatalogSessionState) (ports.RemoteCatalogSession, error) { | |
| 934 | 934 | return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0) | |
| 935 | 935 | } | |
| 936 | 936 | ||
| 937 | - | func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name, state string, tabs int) (ports.RemoteCatalogSession, error) { | |
| 937 | + | func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string, state ports.RemoteCatalogSessionState, tabs int) (ports.RemoteCatalogSession, error) { | |
| 938 | 938 | deadline, cancel := context.WithTimeout(ctx, 10*time.Second) | |
| 939 | 939 | defer cancel() | |
| 940 | 940 | for { | |