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