detached f571e9b

Merge pull request #210 from bnema/fix/hybrid-remote-stopped-resurrection

fix(remote): resurrect inactive sessions across hosts
commit
f571e9bb840fca6d635487d9d2aaca1afd75636c
author
bnema · · 2026-08-24 17:22 UTC
committer
GitHub · · 2026-08-24 17:22 UTC
merge of
6305842 + 5664af3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
45 files · +1530 -906
M .gitignore +1 -0
@@ -2,6 +2,7 @@
2 *.test2 *.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 states5 ## 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 commands9 ## 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 ordered64+Remote catalogues are bounded and versioned. The current schema is mandatory
65-tab IDs, names, state, active tab, attachment state, and MRU sequence. Stopped65+and contains lifecycle IDs, ordered typed tab records, state, active tab,
66-tab metadata is retained across daemon restart; legacy count-only catalogues are66+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 without69 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, nil111 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 catalogEnvelope148+ 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{}, errCatalogRequired152 return ports.RemoteCatalog{}, errCatalogRequired
129 }153 }
130- if err := rejectTrailingJSON(dec); err != nil {154+ if version.SchemaVersion == nil {
131- return ports.RemoteCatalog{}, err155+ 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.Sessions176+ 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{}, err210 return ports.RemoteCatalog{}, err
170 }211 }
171 return catalog, nil212 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.RawMessage227 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 while238 // 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 = 220+ 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.LastUsedSeq126 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.ActiveTabID129 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.State155 state := session.State
161 ephemeral := session.Ephemeral156 ephemeral := session.Ephemeral
162 attached := session.Attached157 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.LastUsedSeq163 lastUsed := session.LastUsedSeq
166 row.LastUsedSeq = &lastUsed164 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.ActiveTabID167 active := session.ActiveTabID
174 row.ActiveTabID = &active168 row.ActiveTabID = &active
175 }169 }
176- if tabs := ports.CatalogTabs(session); tabs != nil {
177- copyTabs := append([]ports.RemoteCatalogTab(nil), tabs...)
178- row.TabList = &copyTabs
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 := session224 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 string123 name string
114 raw []byte124 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.RemoteCatalogVersionMismatchError118 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 = ctx293 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 cmd299 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.SessionUp209 info.State = ports.SessionUp
210- case "down":210+ case ports.RemoteCatalogSessionDown:
211 info.State = ports.SessionDown211 info.State = ports.SessionDown
212- case "broken":212+ case ports.RemoteCatalogSessionBroken:
213 info.State = ports.SessionBroken213 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.SessionState265 wantState ports.SessionState
266 wantTabs uint16266 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 remote111 // RemoteSessionTarget is the structured identity used by picker-based remote
112 // access. Endpoint and DisplayOrigin are intentionally separate values: the112 // 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 string117 Endpoint string
116 DisplayOrigin string118 DisplayOrigin string
@@ -137,8 +139,11 @@ func (t RemoteSessionTarget) Validate() error {
137 return err139 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 nil148 return nil
144 }149 }
@@ -151,6 +156,23 @@ func (t RemoteSessionTarget) Validate() error {
151 return nil156 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 imposing176 // 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 string54 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. A64 // 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 = 266+ RemoteCatalogSchemaVersion uint16 = 3
68 67
69 RemoteCatalogMaxHosts = 6468 RemoteCatalogMaxHosts = 64
70 RemoteCatalogMaxSessions = 25669 RemoteCatalogMaxSessions = 256
@@ -75,10 +74,9 @@ const (
75 RemoteCatalogMaxCatalogBytes = 1 << 2074 RemoteCatalogMaxCatalogBytes = 1 << 20
76 )75 )
77 76
78-// RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for77+// RemoteCatalogCacheEntry is one immutable exact-schema host snapshot persisted
79-// immediate remote discovery at daemon startup. The legacy-shaped Sessions78+// for immediate remote discovery at daemon startup. Cache writers strip dynamic
80-// field is retained as a source-compatible container, but cache writers strip79+// Detail and Attention fields before persistence.
81-// dynamic Detail and Attention fields before persistence.
82 type RemoteCatalogCacheEntry struct {80 type RemoteCatalogCacheEntry struct {
83 Host string81 Host string
84 FetchedAt time.Time82 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. State103+// 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 count106+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 0152 return 0
@@ -219,11 +158,11 @@ func SaturateUint16(n int) uint16 {
219 }158 }
220 159
221 // RemoteCatalog is the independently versioned JSON envelope returned by160 // RemoteCatalog is the independently versioned JSON envelope returned by
222-// remote-catalog --json. ProtocolVersion remains for legacy peers; new peers161+// 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 or192+// ValidateRemoteCatalog applies the exact current schema and all bounds before
254-// rendered. It is deliberately strict for the current schema; zero schema is193+// 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 := 0206 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.Name235+ 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 nil290 return nil
352 }291 }
353 292
354-// ValidateRemoteCatalogCacheEntries validates the bounded cache DTO before it293+// ValidateRemoteCatalogCacheEntries validates the bounded exact-schema cache
355-// is loaded into picker state or written to disk. A cache may contain legacy294+// 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 := false311+ 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 true328 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 = catalogue226 d.catalogue = catalogue
226 d.persistEnabled = true227 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 = catalogue276 d.catalogue = catalogue
276 d.persistEnabled = true277 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.CreatedAt294 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)].ID736 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 "", err747 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, nil796 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.tabRecords800 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 = false724 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 = true735 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] = base827+ 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.RemoteCatalog835+ 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.Mutex72 mu sync.Mutex
73 sessions map[domain.SessionID]*session73 sessions map[domain.SessionID]*session
74- stopped map[string]stoppedSession74+ 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 uint6477 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-screen351+// 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 string356 name string
355 cwd string357 cwd string
356 createdAt int64358 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.CreatedAt690 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 continue1063 continue
1034 }1064 }
1035 state := ports.SessionDown1065 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.SessionBroken1067 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-second1098 // 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 stoppedSession1361+ stopped inactiveSession
1334 ok bool1362 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.restoreDone1366 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 nil1372 return nil
1345 }1373 }
1346- done = stopped.restoreDone1374+ 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 nil1391 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 nil1395 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 nil1414 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 nil1420 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 := false1568 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.Env1576 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 error1580 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, err1584 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].restoreDone369+ 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 = true797 entry.purging = true
798 entry.state = ports.SessionDown798 entry.state = ports.SessionDown
799- d.stopped[record.Name] = entry799+ 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 *protoErr830 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].restoreDone840+ 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 <-restored896 <-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 *protoErr949 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] = echo119 d.sessions[echo.id] = echo
120 d.sessions[alpha.id] = alpha120 d.sessions[alpha.id] = alpha
121 d.sessions[charlie.id] = charlie121 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 bool154 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] = stopped325+ 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 = ptys328 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 continue186 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 nil880 return nil
888 }881 }
889- if target.RemoteTarget != nil {882+ if target.RemoteTarget != nil || target.RemoteKey != nil {
890- var key domain.RemoteSessionKey883+ 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.ExactSessionTarget896 var exactTarget *ports.ExactSessionTarget
911 var matches bool897 var matches bool
912 if target.Name != "" {898 if target.Name != "" {
913- var stopped stoppedSession899+ var stopped inactiveSession
914 var stoppedTarget bool900 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: after957 // 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 a958 // 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 errAttachmentTransition965 return errAttachmentTransition
980 }966 }
981- var handoff ports.AttachTarget967+ if target.RemoteTarget == nil || target.RemoteKey == nil {
982- if target.RemoteTarget != nil {968+ return failUnavailable()
983- remoteTarget := *target.RemoteTarget969+ }
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, true1005 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 false1010 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 continue1020 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 false1023 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 false1033 return false
1088 }1034 }
@@ -1175,19 +1121,19 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
1175 // resolveNamedLifecycleTargetLocked chooses exactly one current representation1121 // resolveNamedLifecycleTargetLocked chooses exactly one current representation
1176 // of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle1122 // 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, false1126+ 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, matches1132+ 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, false1136+ return nil, inactiveSession{}, false, false
1191 }1137 }
1192 return nil, stopped, true, true1138 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 commits1228 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
1283 // the handoff while d.mu is held. Creation failure leaves the source client1229 // 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 *session1240 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, errAttachmentTransition1249 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 = created1255 targetSess = created
1307 return created, createErr1256 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, errAttachmentTransition1282 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, err1288 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 attachmentTransitionResult1330 transition attachmentTransitionResult
1382 switched bool1331 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] = old136 d.sessions[old.id] = old
137 d.sessions[eph.id] = eph137 d.sessions[eph.id] = eph
138 d.sessions[recent.id] = recent138 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] = eph177 d.sessions[eph.id] = eph
178 d.sessions[named.id] = named178 d.sessions[named.id] = named
179 d.sessions[named2.id] = named2179 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] = eph219 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] = active290 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] = source311 d.sessions[source.id] = source
312 d.sessions[destination.id] = destination312 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] = target696 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 <-restored731 <-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, 034 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 records60 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 = true58 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, err56 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 not78 // 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, err88 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, err91 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, err144 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.tabRecords182 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 metadata193 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's200 // 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 := 0139 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 navigation177 // malformed or a broken session cannot be activated. Cursor navigation
179 // must remain possible, while sendRemoteAttachTargetForAttachment will178 // must remain possible, while sendRemoteAttachTargetForAttachment will
180 // fail closed on the invalid target.179 // fail closed on the invalid target.
181- var remoteTarget *domain.RemoteSessionTarget180+ remoteTarget := &target
182- if !legacy {181+ if stopped && len(viewTabs) == 0 {
183- remoteTarget = &target182+ // 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() == nil188+ targetValid := remoteTarget.Validate() == nil
187 activation := picker.RemoteUnavailable189 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.RemoteRestart196 activation = picker.RemoteRestart
199 } else {197 } else {
200 activation = picker.RemoteAttach198 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.State216+ 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 = 9617 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"] = remoteHostFresh686 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 = effect692 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 = effect748 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 string847 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"] = remoteHostFresh883 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 = effect899 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.UserError905 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 := stable31 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 candidate51 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 the189+// 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.incarnation197+ 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, errSessionNameInUse225 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, errAttachmentTransition231 return nil, errAttachmentTransition
213 }232 }
214 var authoritative domain.CatalogueRecord233 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, errAttachmentTransition248 return nil, errAttachmentTransition
230 }249 }
250+ } else if validateInactive != nil && !validateInactive(stopped, domain.CatalogueRecord{}, false) {
251+ return nil, errAttachmentTransition
231 }252 }
232 var createdAt int64253 var createdAt int64
233 var incarnation domain.IncarnationID254 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 and431 // 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 errSessionNameInUse955 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 errSessionNameInUse961 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 = name1018 sess.name = name
998 sess.createdAt = createdAt1019 sess.createdAt = createdAt
999 sess.incarnation = record.IncarnationID1020 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 nil1265 return nil
1245 }1266 }
1246 stopped.purging = true1267 stopped.purging = true
1247- d.stopped[name] = stopped1268+ 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 err1276 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 nil1282 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 or1309 // 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 even1310 // 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.token1315 token := admission.token
@@ -1313,6 +1334,12 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1313 source = target1334 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 or1530 // 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 nil1545 return nil
1508 }1546 }
1509 // Discover every attachment whose membership can be invalidated before freezing1547 // Discover every attachment whose membership can be invalidated before freezing
1510 // any one of them. The common gate helper deduplicates and freezes the whole1548 // 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 nil1552 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.name1733 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.ephemeral1735 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] = stopped1741+ stopped.purging = purge
1742+ d.inactive[stoppedName] = stopped
1708 }1743 }
1709 empty := len(d.sessions) == 01744 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 holds1818 // 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 true1849 return true
1815 }1850 }
1816- if _, ok := d.stopped[name]; ok {1851+ if _, ok := d.inactive[name]; ok {
1817 return true1852 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.MaxInt641692 d.lastAllocatedCreatedAt = math.MaxInt64
1690 d.nextID = 171693 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 continue21 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] = entry32+ 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].Cwd303 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.incarnation320 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.restoreDone352 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 the357 // The stopped entry is the durable authority and restore barrier until the
358 // runtime identity has actually been published. A registry rejection must358 // 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, nil362 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.restoreDone106 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 done110 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 return131 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 string57 RemoteDetail string
58 RemoteReason string58 RemoteReason string
59 RemoteActivation RemoteActivation59 RemoteActivation RemoteActivation
60- ConnectOnly bool
61 // CannotAcceptMoves reports whether this session cannot receive a moved tab60 // CannotAcceptMoves reports whether this session cannot receive a moved tab
62 // or pane. False for ordinary local (and stopped) sessions; true for61 // 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 nil255 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.TargetName264 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.Name268 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 = false302 header.selectable = false
300 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0303 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.selectable306+ 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.RemoteReason308 header.unavailableReason = session.RemoteReason
309 header.dim = session.RemoteActivation == RemoteUnavailable309 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, true312 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- continue318+ }
319- }319+ tabRow := common
320- tabRow := common320+ remoteTargetResolvable := true
321- tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention321+ tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
322- tabRow.tabID, tabRow.tabIndex = tab.TabID, i322+ 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.selectable324+ tabRow.focusable = tabRow.selectable
325- if common.remote {325+ if common.remote {
326- tabRow.unavailableReason = session.RemoteReason326+ tabRow.unavailableReason = session.RemoteReason
327- if common.hasRemoteTarget {327+ if common.hasRemoteTarget {
328- remoteTarget := common.remoteTarget328+ 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 = true337+ 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 == RemoteUnavailable340+ // 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, true347+ 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 nil361 return nil
@@ -366,6 +363,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
366 return rows363 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 == RemoteRestart374 return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart
371 }375 }
M internal/usecase/picker/model_test.go +52 -94
@@ -1,6 +1,7 @@
1 package picker1 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 endpoint930 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close