detached 6707942

Merge pull request #186 from bnema/feat/unified-remote-picker-ownership

feat(daemon): retain unified remote picker ownership
commit
670794247b365c1ca7f77773adafd4665761943c
author
bnema · · 2026-08-07 12:48 UTC
committer
GitHub · · 2026-08-07 12:48 UTC
merge of
c1cc94c + 08ce113 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
101 files · +10190 -1077
A docs/ansi-content-replay-phase0.md +11 -0
@@ -0,0 +1,11 @@
1+# Phase 0: ANSI content replay gate
2+
3+The test-only prototype in `internal/usecase/daemon/ansi_content_replay_phase0_test.go` records the current feasibility boundary for remote content:
4+
5+- ANSI produced by `pkg/renderer` is replayed into a private `pkg/vt.Screen`.
6+- The screen has nil response, bell, notification, progress, and clipboard callbacks, so replay cannot emit remote host-side effects.
7+- Full and incremental output, styles, wide-cell continuation, cursor state, and split CSI/OSC writes are captured as cells and cursor metadata.
8+- The captured grid can be handed to a local renderer for composition; the remote ANSI stream is not passed to that renderer or a transport.
9+- A test-only ordered-frame model requires metadata before the first full content frame, contiguous state-bearing deltas, and base-zero full resets. Gaps and malformed resets are rejected.
10+
11+This is feasibility evidence only. It does not change the production renderer, protocol, layout, or attach flow. The remaining gate is to specify and integrate a production remote metadata/content ownership contract around this boundary; no compatibility path is implied by Phase 0.
M internal/adapters/remote/catalog.go +1 -1
@@ -185,7 +185,7 @@ func rejectTrailingJSON(dec *json.Decoder) error {
185185
186186 func validCatalogState(state string) bool {
187187 switch state {
188- case "running", "stopped", "broken":
188+ case "up", "down", "broken":
189189 return true
190190 default:
191191 return false
M internal/adapters/remote/catalog_cache_test.go +5 -5
@@ -30,8 +30,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
3030 Host: "zebra",
3131 FetchedAt: fetchedAt,
3232 Sessions: []ports.RemoteCatalogSession{
33- {Name: "work", State: "running", Tabs: 2},
34- {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true},
33+ {Name: "work", State: "up", Tabs: 2},
34+ {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},
3535 },
3636 },
3737 {
@@ -72,8 +72,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
7272 Host: "zebra",
7373 FetchedAt: fetchedAt,
7474 Sessions: []ports.RemoteCatalogSession{
75- {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true},
76- {Name: "work", State: "running", Tabs: 2},
75+ {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},
76+ {Name: "work", State: "up", Tabs: 2},
7777 },
7878 },
7979 }
@@ -122,7 +122,7 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
122122 {name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},
123123 {name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},
124124 {name: "duplicate hosts", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},
125- {name: "duplicate sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"running","ephemeral":false,"tabs":1,"attached":false},{"name":"work","state":"running","ephemeral":false,"tabs":1,"attached":false}]}]}`)},
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}]}]}`)},
126126 {name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},
127127 }
128128 for _, tc := range cases {
M internal/adapters/remote/catalog_test.go +2 -2
@@ -83,7 +83,7 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
8383 valid := ports.RemoteCatalog{
8484 ProtocolVersion: ports.ProtocolVersion,
8585 Sessions: []ports.RemoteCatalogSession{
86- {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},
86+ {Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true},
8787 },
8888 }
8989 validJSON := mustCatalogJSON(t, valid)
@@ -148,7 +148,7 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
148148 },
149149 {
150150 name: "invalid session name",
151- stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "bad name", State: "running"}}}),
151+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "bad name", State: "up"}}}),
152152 wantErr: errCatalogSession,
153153 },
154154 {
M internal/app/remote_hosts.go +4 -4
@@ -205,10 +205,10 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [
205205 Attached: session.Attached,
206206 }
207207 switch session.State {
208- case "running":
209- info.State = ports.SessionRunning
210- case "stopped":
211- info.State = ports.SessionStopped
208+ case "up":
209+ info.State = ports.SessionUp
210+ case "down":
211+ info.State = ports.SessionDown
212212 case "broken":
213213 info.State = ports.SessionBroken
214214 default:
M internal/app/remote_test.go +9 -9
@@ -171,7 +171,7 @@ func TestRemoteHostListingUsesUnifiedStore(t *testing.T) {
171171 store := portsmocks.NewMockRemoteHostStore(t)
172172 store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()
173173 catalog := portsmocks.NewMockRemoteCatalogClient(t)
174- catalog.EXPECT().List(mock.Anything, "arch").Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "build", State: "running"}}}, nil).Once()
174+ catalog.EXPECT().List(mock.Anything, "arch").Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "build", State: "up"}}}, nil).Once()
175175 var out bytes.Buffer
176176 err := runRemoteList(context.Background(), command{listHost: "arch"}, remoteHostDeps{
177177 store: store,
@@ -194,7 +194,7 @@ func TestListAllSessionsInvariants(t *testing.T) {
194194 var out bytes.Buffer
195195 err := listAllSessions(context.Background(), remoteHostDeps{
196196 localList: func(context.Context) ([]ports.SessionInfo, error) {
197- return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, localErr
197+ return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, localErr
198198 },
199199 stdout: &out,
200200 }, nil)
@@ -210,13 +210,13 @@ func TestListAllSessionsInvariants(t *testing.T) {
210210 catalog := portsmocks.NewMockRemoteCatalogClient(t)
211211 catalog.EXPECT().List(mock.Anything, "arch").RunAndReturn(func(context.Context, string) (ports.RemoteCatalog, error) {
212212 cancel()
213- return ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "running"}}}, nil
213+ return ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil
214214 }).Once()
215215 var out bytes.Buffer
216216 err := listAllSessions(ctx, remoteHostDeps{
217217 catalog: catalog,
218218 localList: func(context.Context) ([]ports.SessionInfo, error) {
219- return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, nil
219+ return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, nil
220220 },
221221 stdout: &out,
222222 }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
@@ -233,15 +233,15 @@ func TestListAllSessionsInvariants(t *testing.T) {
233233 var calls []string
234234 catalog.EXPECT().List(mock.Anything, "arch").Run(func(_ context.Context, target string) {
235235 calls = append(calls, target)
236- }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "running"}}}, nil).Once()
236+ }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil).Once()
237237 catalog.EXPECT().List(mock.Anything, "mule").Run(func(_ context.Context, target string) {
238238 calls = append(calls, target)
239- }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "ops", State: "running"}}}, nil).Once()
239+ }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "ops", State: "up"}}}, nil).Once()
240240 var out bytes.Buffer
241241 err := listAllSessions(context.Background(), remoteHostDeps{
242242 catalog: catalog,
243243 localList: func(context.Context) ([]ports.SessionInfo, error) {
244- return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, nil
244+ return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, nil
245245 },
246246 stdout: &out,
247247 }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
@@ -265,8 +265,8 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
265265 wantState ports.SessionState
266266 wantTabs uint16
267267 }{
268- {name: "running", session: ports.RemoteCatalogSession{Name: "dev", State: "running", Tabs: 2}, wantState: ports.SessionRunning, wantTabs: 2},
269- {name: "stopped", session: ports.RemoteCatalogSession{Name: "dev", State: "stopped", Tabs: 2}, wantState: ports.SessionStopped, wantTabs: 2},
268+ {name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: 2}, wantState: ports.SessionUp, wantTabs: 2},
269+ {name: "down", session: ports.RemoteCatalogSession{Name: "dev", State: "down", Tabs: 2}, wantState: ports.SessionDown, wantTabs: 2},
270270 {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
271271 {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
272272 }
M internal/app/run.go +4 -4
@@ -1434,7 +1434,7 @@ func listLocalSessions(ctx context.Context) (_ []ports.SessionInfo, retErr error
14341434 }
14351435 infos := make([]ports.SessionInfo, 0, len(records))
14361436 for _, r := range records {
1437- state := ports.SessionStopped
1437+ state := ports.SessionDown
14381438 if r.DegradedReason != "" {
14391439 state = ports.SessionBroken
14401440 }
@@ -1481,12 +1481,12 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
14811481 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
14821482 _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED")
14831483 for _, s := range sessions {
1484- state := "running"
1484+ state := "up"
14851485 tabs := fmt.Sprintf("%d", s.Tabs)
14861486 attached := "no"
14871487 switch s.State {
1488- case ports.SessionStopped:
1489- state = "stopped"
1488+ case ports.SessionDown:
1489+ state = "down"
14901490 tabs = "-"
14911491 case ports.SessionBroken:
14921492 state = "broken"
M internal/app/run_test.go +14 -14
@@ -575,7 +575,7 @@ func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
575575 t.Run(tt.name, func(t *testing.T) {
576576 cmd, err := parseArgs([]string{"attach", tt.arg})
577577 require.NoError(t, err)
578- connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{
578+ connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.AttachRequest{
579579 Intent: cmd.intent,
580580 SessionName: cmd.name,
581581 })
@@ -588,26 +588,26 @@ func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
588588 func TestListShowsBroken(t *testing.T) {
589589 var out bytes.Buffer
590590 printSessions(&out, []ports.SessionInfo{
591- {Name: "fresh", State: ports.SessionStopped},
592- {Name: "loading", State: ports.SessionStopped},
591+ {Name: "fresh", State: ports.SessionDown},
592+ {Name: "loading", State: ports.SessionDown},
593593 {Name: "broken", State: ports.SessionBroken},
594594 })
595595 require.Contains(t, out.String(), "fresh")
596- require.Contains(t, out.String(), "stopped")
596+ require.Contains(t, out.String(), "down")
597597 require.Contains(t, out.String(), "loading")
598598 require.Contains(t, out.String(), "broken")
599599 require.NotContains(t, out.String(), "restoring")
600600 require.NotContains(t, out.String(), "degraded")
601601 }
602602
603-func TestPrintSessionsShowsStoppedState(t *testing.T) {
603+func TestPrintSessionsShowsDownState(t *testing.T) {
604604 var out bytes.Buffer
605605 printSessions(&out, []ports.SessionInfo{
606- {Name: "main", State: ports.SessionRunning, Tabs: 2, Attached: true},
607- {Name: "old", State: ports.SessionStopped},
606+ {Name: "main", State: ports.SessionUp, Tabs: 2, Attached: true},
607+ {Name: "old", State: ports.SessionDown},
608608 })
609609 got := out.String()
610- for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {
610+ for _, want := range []string{"NAME", "STATE", "main", "up", "2", "yes", "old", "down", "-"} {
611611 if !strings.Contains(got, want) {
612612 t.Fatalf("printSessions output %q missing %q", got, want)
613613 }
@@ -617,12 +617,12 @@ func TestPrintSessionsShowsStoppedState(t *testing.T) {
617617 func TestPrintSessionsMarksEphemeral(t *testing.T) {
618618 var buf bytes.Buffer
619619 printSessions(&buf, []ports.SessionInfo{
620- {Name: "0", State: ports.SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
621- {Name: "work", State: ports.SessionRunning, Tabs: 2, Attached: true},
622- {Name: "old", State: ports.SessionStopped},
620+ {Name: "0", State: ports.SessionUp, Ephemeral: true, Tabs: 1, Attached: false},
621+ {Name: "work", State: ports.SessionUp, Tabs: 2, Attached: true},
622+ {Name: "old", State: ports.SessionDown},
623623 })
624624 out := buf.String()
625- for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {
625+ for _, want := range []string{"0", "temporary", "work", "up", "old", "down"} {
626626 require.Contains(t, out, want)
627627 }
628628 }
@@ -634,7 +634,7 @@ func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
634634 return persist.New(store)
635635 }
636636
637-func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
637+func TestRunListReadsDownSessionsWithoutDaemon(t *testing.T) {
638638 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
639639 t.Setenv("XDG_STATE_HOME", stateRoot)
640640 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
@@ -653,7 +653,7 @@ func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
653653 t.Fatalf("runList error = %v", err)
654654 }
655655 })
656- for _, want := range []string{"stored", "stopped", "-"} {
656+ for _, want := range []string{"stored", "down", "-"} {
657657 if !strings.Contains(got, want) {
658658 t.Fatalf("runList output %q missing %q", got, want)
659659 }
M internal/domain/lifecycle.go +3 -3
@@ -138,15 +138,15 @@ func (t RemoteSessionTarget) Validate() error {
138138 }
139139 if t.Stopped {
140140 if t.LiveTabID != "" || t.StoppedTab.Validate() != nil {
141- return errors.New("stopped remote target requires an ordinal or stable tab selector")
141+ return errors.New("down remote target requires an ordinal or stable tab selector")
142142 }
143143 return nil
144144 }
145145 if err := ValidateTabStableID(t.LiveTabID); err != nil {
146- return errors.New("running remote target requires a stable tab ID")
146+ return errors.New("up remote target requires a stable tab ID")
147147 }
148148 if t.StoppedTab != (TabSelector{}) {
149- return errors.New("running remote target cannot carry a stopped selector")
149+ return errors.New("up remote target cannot carry a down selector")
150150 }
151151 return nil
152152 }
M internal/ports/frame.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 24
15+const ProtocolVersion uint16 = 25
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 24 {
7- t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion)
6+ if ProtocolVersion != 25 {
7+ t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)
88 }
99 }
1010
M internal/ports/remote.go +9 -9
@@ -64,7 +64,7 @@ type RemotePreviewClient interface {
6464 const (
6565 // RemoteCatalogSchemaVersion is independent from the IPC protocol. A
6666 // catalogue can be rejected without changing the attachment wire layout.
67- RemoteCatalogSchemaVersion uint16 = 1
67+ RemoteCatalogSchemaVersion uint16 = 2
6868
6969 RemoteCatalogMaxHosts = 64
7070 RemoteCatalogMaxSessions = 256
@@ -103,7 +103,7 @@ type RemoteCatalogTab struct {
103103 }
104104
105105 // RemoteCatalogSession is one session in the remote discovery catalog. State
106-// is an explicit string contract (running|stopped|broken), not SessionState.
106+// is an explicit string contract (up|down|broken), not SessionState.
107107 //
108108 // Tabs is interface-typed only to keep old v0.x callers that supplied a count
109109 // source-compatible while the versioned schema carries []RemoteCatalogTab.
@@ -301,7 +301,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
301301 if session.ActiveTabID != "" {
302302 return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
303303 }
304- if session.State == "running" && session.Tabs == nil {
304+ if session.State == "up" && session.Tabs == nil {
305305 return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
306306 }
307307 }
@@ -320,8 +320,8 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
320320 if tab.Index != uint16(i) {
321321 return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
322322 }
323- if session.State == "running" && tab.ID == "" {
324- return fmt.Errorf("%w: running tab has zero ID", ErrInvalidRemoteCatalog)
323+ if session.State == "up" && tab.ID == "" {
324+ return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)
325325 }
326326 if tab.ID != "" {
327327 if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil {
@@ -335,8 +335,8 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
335335 bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
336336 }
337337 if session.ActiveTabID != "" {
338- if session.State != "running" {
339- return fmt.Errorf("%w: stopped or broken session has an active tab", ErrInvalidRemoteCatalog)
338+ if session.State != "up" {
339+ return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)
340340 }
341341 if _, ok := tabIDs[session.ActiveTabID]; !ok {
342342 return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
@@ -404,7 +404,7 @@ func validRemoteCatalogText(value string) bool {
404404
405405 func validRemoteCatalogState(state string) bool {
406406 switch state {
407- case "running", "stopped", "broken":
407+ case "up", "down", "broken":
408408 return true
409409 default:
410410 return false
@@ -413,7 +413,7 @@ func validRemoteCatalogState(state string) bool {
413413
414414 func validRemoteCatalogReason(reason string) bool {
415415 switch reason {
416- case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_stopped", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
416+ case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_down", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
417417 return true
418418 default:
419419 return false
M internal/ports/remote_preview_hardening_test.go +1 -1
@@ -194,7 +194,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
194194 want := []byte{
195195 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
196196 0x00, 0x04, 'w', 'o', 'r', 'k', IntentAttach,
197- 0x01, 0x01,
197+ 0x01,
198198 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
199199 0x00, 0x09, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
200200 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
A internal/ports/remote_state_test.go +27 -0
@@ -0,0 +1,27 @@
1+package ports
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestRemoteCatalogStateContract(t *testing.T) {
10+ require.Equal(t, uint16(2), RemoteCatalogSchemaVersion)
11+
12+ for _, state := range []string{"up", "down", "broken"} {
13+ t.Run(state, func(t *testing.T) {
14+ err := ValidateRemoteCatalog(RemoteCatalog{
15+ ProtocolVersion: ProtocolVersion,
16+ Sessions: []RemoteCatalogSession{{Name: "work", State: state, Tabs: 1}},
17+ })
18+ require.NoError(t, err)
19+ })
20+ }
21+
22+ err := ValidateRemoteCatalog(RemoteCatalog{
23+ ProtocolVersion: ProtocolVersion,
24+ Sessions: []RemoteCatalogSession{{Name: "work", State: "running", Tabs: 1}},
25+ })
26+ require.ErrorIs(t, err, ErrRemoteCatalogUnknownState)
27+}
M internal/ports/remote_target_wire_test.go +50 -30
@@ -2,6 +2,7 @@ package ports
22
33 import (
44 "bytes"
5+ "reflect"
56 "testing"
67
78 "github.com/bnema/vev/internal/domain"
@@ -79,15 +80,54 @@ func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) {
7980 }
8081 }
8182
83+func TestProxiedHelloGoldenAndStrict(t *testing.T) {
84+ target := testRemoteTarget(false)
85+ msg := Hello{
86+ Version: ProtocolVersion, Intent: IntentAttach, RenderMode: RenderModeProxiedContent,
87+ ClientID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, ResumeToken: 0x0102030405060708,
88+ Name: target.SessionName, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 2,
89+ RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
90+ }
91+ want := []byte{
92+ 0, 25, IntentAttach, byte(RenderModeProxiedContent),
93+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
94+ 1, 2, 3, 4, 5, 6, 7, 8,
95+ 0, 4, 'w', 'o', 'r', 'k', 0, 80, 0, 24, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0,
96+ 1,
97+ 0, 15, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
98+ 0, 9, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
99+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
100+ 0, 4, 'w', 'o', 'r', 'k', 0, 0, 5, 't', 'a', 'b', '-', '3', 0, byte(EnvironmentPolicyDaemonOwned),
101+ }
102+ payload := MarshalHello(msg)
103+ if !bytes.Equal(payload, want) {
104+ t.Fatalf("proxied Hello bytes = %x, want %x", payload, want)
105+ }
106+ decoded, err := UnmarshalHello(payload)
107+ if err != nil || !reflect.DeepEqual(decoded, msg) || decoded.RemoteTarget == msg.RemoteTarget {
108+ t.Fatalf("proxied Hello = %#v, error %v", decoded, err)
109+ }
110+ assertAllPrefixesFail(t, payload, UnmarshalHello)
111+ assertTrailingGarbageFails(t, payload, UnmarshalHello)
112+
113+ if MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, RenderMode: RenderModeProxiedContent, Size: domain.Size{Cols: 80, Rows: 24}}) != nil {
114+ t.Fatal("proxied Hello without exact target marshaled")
115+ }
116+ malformed := append([]byte(nil), payload...)
117+ malformed[3] = 2
118+ if _, err := UnmarshalHello(malformed); err == nil {
119+ t.Fatal("unknown render mode decoded")
120+ }
121+}
122+
82123 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
83124 target := testRemoteTarget(false)
84125 tests := []struct {
85- name string
86- validate func() error
87- marshalBad func() []byte
88- marshalGood func() []byte
89- marshalLegacy func() []byte
90- unmarshal func([]byte) error
126+ name string
127+ validate func() error
128+ marshalBad func() []byte
129+ marshalGood func() []byte
130+ unmarshal func([]byte) error
91131 }{
92132 {
93133 name: "hello",
@@ -112,12 +152,6 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
112152 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
113153 })
114154 },
115- marshalLegacy: func() []byte {
116- return MarshalHello(Hello{
117- Version: ProtocolVersion, Intent: IntentAttach,
118- Name: target.SessionName, Size: domain.Size{Cols: 80, Rows: 24},
119- })
120- },
121155 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
122156 },
123157 {
@@ -140,11 +174,6 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
140174 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
141175 })
142176 },
143- marshalLegacy: func() []byte {
144- return MarshalAttachTarget(AttachTarget{
145- Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach,
146- })
147- },
148177 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
149178 },
150179 }
@@ -161,16 +190,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
161190 if payload == nil {
162191 t.Fatal("failed to marshal daemon-owned control payload")
163192 }
164- legacyLen := len(tt.marshalLegacy())
165- if err := tt.unmarshal(payload[:legacyLen]); err != nil {
166- t.Fatalf("complete legacy prefix failed to decode: %v", err)
167- }
168- for i := 0; i < len(payload); i++ {
169- if i == legacyLen {
170- // The complete legacy prefix is a valid count-only payload by
171- // design; every other rich-target prefix is malformed.
172- continue
173- }
193+ for i := range len(payload) {
174194 if err := tt.unmarshal(payload[:i]); err == nil {
175195 t.Fatalf("prefix %d unexpectedly decoded", i)
176196 }
@@ -187,10 +207,10 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
187207 }
188208 }
189209
190-func TestLegacyTargetWireBytesRemainUnchanged(t *testing.T) {
191- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach}
210+func TestTargetWireIncludesRequiredV25Section(t *testing.T) {
211+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}
192212 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
193213 if !bytes.Equal(got, want) {
194- t.Fatalf("legacy target bytes = %x, want %x", got, want)
214+ t.Fatalf("target bytes = %x, want %x", got, want)
195215 }
196216 }
M internal/ports/wire.go +127 -73
@@ -80,7 +80,18 @@ func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
8080 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
8181 }
8282
83-const remoteTargetWireVersion uint8 = 1
83+// RenderMode determines whether the daemon emits a complete terminal view or
84+// only the private remote content surface requested by another daemon.
85+type RenderMode uint8
86+
87+const (
88+ RenderModeFullTerminal RenderMode = iota
89+ RenderModeProxiedContent
90+)
91+
92+func validRenderMode(mode RenderMode) bool {
93+ return mode == RenderModeFullTerminal || mode == RenderModeProxiedContent
94+}
8495
8596 // Capability bits advertised in Welcome.
8697 const (
@@ -116,6 +127,7 @@ const (
116127 type Hello struct {
117128 Version uint16
118129 Intent uint8
130+ RenderMode RenderMode
119131 ClientID [16]byte
120132 ResumeToken uint64
121133 Name string
@@ -224,6 +236,7 @@ type Welcome struct {
224236 SessionName string
225237 Ephemeral bool
226238 ResumeToken uint64
239+ RenderMode RenderMode
227240 Capabilities uint32
228241 }
229242
@@ -275,8 +288,8 @@ type Kill struct {
275288 type SessionState uint8
276289
277290 const (
278- SessionRunning SessionState = iota
279- SessionStopped
291+ SessionUp SessionState = iota
292+ SessionDown
280293 SessionBroken
281294 )
282295
@@ -299,16 +312,19 @@ type Sessions struct {
299312 type OutputResetRequest struct{}
300313
301314 // SessionTabMeta describes one tab in a remote session's metadata snapshot.
315+// ID is stable for the lifetime of its remote tab and ordered only for display.
302316 type SessionTabMeta struct {
303- Index uint16
317+ ID domain.TabStableID
304318 Name string
305319 Attention bool
306320 }
307321
308-// SessionMeta is authoritative tab metadata for a remote session.
322+// SessionMeta is an authoritative, revisioned snapshot of remote tab metadata.
309323 type SessionMeta struct {
324+ LifecycleID domain.SessionLifecycleID
325+ Revision uint64
310326 SessionName string
311- Active uint16
327+ ActiveTabID domain.TabStableID
312328 Tabs []SessionTabMeta
313329 }
314330
@@ -368,8 +384,10 @@ func (w *payloadWriter) putLongBytes(b []byte) {
368384 w.b = append(w.b, b...)
369385 }
370386
371-func marshalRemoteTargetExtension(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {
372- w.putUint8(remoteTargetWireVersion)
387+// marshalRemoteTargetSection writes the required v25 target/policy section.
388+// A target may be absent for direct local attaches, but its presence marker and
389+// environment policy are never optional: v25 has no extension or legacy tail.
390+func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {
373391 if target == nil {
374392 w.putBool(false)
375393 } else {
@@ -393,14 +411,7 @@ func marshalRemoteTargetExtension(w *payloadWriter, target *domain.RemoteSession
393411 w.putUint8(uint8(policy))
394412 }
395413
396-func skipRemoteTargetExtension(r *payloadReader) error {
397- version, err := r.getUint8()
398- if err != nil {
399- return err
400- }
401- if version != remoteTargetWireVersion {
402- return errInvalidEnum
403- }
414+func skipRemoteTargetSection(r *payloadReader) error {
404415 present, err := r.getBool()
405416 if err != nil {
406417 return err
@@ -518,14 +529,7 @@ func unmarshalRemoteTarget(r *payloadReader) (*domain.RemoteSessionTarget, error
518529 return target, nil
519530 }
520531
521-func unmarshalRemoteTargetExtension(r *payloadReader) (*domain.RemoteSessionTarget, EnvironmentPolicy, error) {
522- version, err := r.getUint8()
523- if err != nil {
524- return nil, 0, err
525- }
526- if version != remoteTargetWireVersion {
527- return nil, 0, errInvalidEnum
528- }
532+func unmarshalRemoteTargetSection(r *payloadReader) (*domain.RemoteSessionTarget, EnvironmentPolicy, error) {
529533 target, err := unmarshalRemoteTarget(r)
530534 if err != nil {
531535 return nil, 0, err
@@ -534,6 +538,9 @@ func unmarshalRemoteTargetExtension(r *payloadReader) (*domain.RemoteSessionTarg
534538 if err != nil {
535539 return nil, 0, err
536540 }
541+ if !validEnvironmentPolicy(EnvironmentPolicy(policy)) {
542+ return nil, 0, errInvalidEnum
543+ }
537544 return target, EnvironmentPolicy(policy), nil
538545 }
539546
@@ -692,6 +699,9 @@ func ValidateHello(h Hello) error {
692699 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
693700 return ErrInvalidHello
694701 }
702+ if !validRenderMode(h.RenderMode) {
703+ return ErrInvalidHello
704+ }
695705 if err := ValidateSize(h.Size); err != nil {
696706 return fmt.Errorf("%w: size", ErrInvalidHello)
697707 }
@@ -707,7 +717,7 @@ func ValidateHello(h Hello) error {
707717 }
708718 }
709719 if h.RemoteTarget == nil {
710- if h.EnvironmentPolicy != EnvironmentPolicyClientOwned {
720+ if h.EnvironmentPolicy != EnvironmentPolicyClientOwned || h.RenderMode == RenderModeProxiedContent {
711721 return ErrInvalidHello
712722 }
713723 return nil
@@ -804,6 +814,7 @@ func MarshalHello(h Hello) []byte {
804814 w := payloadWriter{}
805815 w.putUint16(h.Version)
806816 w.putUint8(h.Intent)
817+ w.putUint8(uint8(h.RenderMode))
807818 w.putBytes(h.ClientID[:])
808819 w.putUint64(h.ResumeToken)
809820 w.putString(h.Name)
@@ -817,9 +828,7 @@ func MarshalHello(h Hello) []byte {
817828 for _, entry := range h.Env {
818829 w.putLongString(entry)
819830 }
820- if h.RemoteTarget != nil || h.EnvironmentPolicy != EnvironmentPolicyClientOwned {
821- marshalRemoteTargetExtension(&w, h.RemoteTarget, h.EnvironmentPolicy)
822- }
831+ marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
823832 return w.b
824833 }
825834
@@ -835,6 +844,13 @@ func preflightHello(b []byte) error {
835844 if _, err := r.getUint8(); err != nil {
836845 return err
837846 }
847+ mode, err := r.getUint8()
848+ if err != nil {
849+ return err
850+ }
851+ if !validRenderMode(RenderMode(mode)) {
852+ return errInvalidEnum
853+ }
838854 if len(r.b) < 16 {
839855 return errShortPayload
840856 }
@@ -875,10 +891,8 @@ func preflightHello(b []byte) error {
875891 return err
876892 }
877893 }
878- if len(r.b) != 0 {
879- if err := skipRemoteTargetExtension(&r); err != nil {
880- return err
881- }
894+ if err := skipRemoteTargetSection(&r); err != nil {
895+ return err
882896 }
883897 return r.done()
884898 }
@@ -901,6 +915,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
901915 if h.Intent, err = r.getUint8(); err != nil {
902916 return Hello{}, err
903917 }
918+ mode, err := r.getUint8()
919+ if err != nil {
920+ return Hello{}, err
921+ }
922+ h.RenderMode = RenderMode(mode)
904923 clientID, err := r.getBytes(len(h.ClientID))
905924 if err != nil {
906925 return Hello{}, err
@@ -952,10 +971,8 @@ func UnmarshalHello(b []byte) (Hello, error) {
952971 h.Env = append(h.Env, entry)
953972 }
954973 }
955- if len(r.b) != 0 {
956- if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil {
957- return Hello{}, err
958- }
974+ if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
975+ return Hello{}, err
959976 }
960977 if err := r.done(); err != nil {
961978 return Hello{}, err
@@ -1241,6 +1258,9 @@ func UnmarshalAck(b []byte) (Ack, error) {
12411258
12421259 // MarshalWelcome encodes m into a Welcome message payload.
12431260 func MarshalWelcome(m Welcome) []byte {
1261+ if !validRenderMode(m.RenderMode) {
1262+ return nil
1263+ }
12441264 w := payloadWriter{}
12451265 w.putString(m.SessionID)
12461266 w.putString(m.SessionName)
@@ -1250,6 +1270,7 @@ func MarshalWelcome(m Welcome) []byte {
12501270 w.putUint8(0)
12511271 }
12521272 w.putUint64(m.ResumeToken)
1273+ w.putUint8(uint8(m.RenderMode))
12531274 w.putUint32(m.Capabilities)
12541275 return w.b
12551276 }
@@ -1274,6 +1295,14 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
12741295 if m.ResumeToken, err = r.getUint64(); err != nil {
12751296 return Welcome{}, err
12761297 }
1298+ mode, err := r.getUint8()
1299+ if err != nil {
1300+ return Welcome{}, err
1301+ }
1302+ m.RenderMode = RenderMode(mode)
1303+ if !validRenderMode(m.RenderMode) {
1304+ return Welcome{}, errInvalidEnum
1305+ }
12771306 if m.Capabilities, err = r.getUint32(); err != nil {
12781307 return Welcome{}, err
12791308 }
@@ -1400,9 +1429,7 @@ func MarshalAttachTarget(m AttachTarget) []byte {
14001429 w.putString(m.Endpoint)
14011430 w.putString(m.Session)
14021431 w.putUint8(m.Intent)
1403- if m.RemoteTarget != nil || m.EnvironmentPolicy != EnvironmentPolicyClientOwned {
1404- marshalRemoteTargetExtension(&w, m.RemoteTarget, m.EnvironmentPolicy)
1405- }
1432+ marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)
14061433 return w.b
14071434 }
14081435
@@ -1424,10 +1451,8 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
14241451 if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) {
14251452 return AttachTarget{}, ErrInvalidAttachTarget
14261453 }
1427- if len(probe.b) != 0 {
1428- if err := skipRemoteTargetExtension(&probe); err != nil {
1429- return AttachTarget{}, ErrInvalidAttachTarget
1430- }
1454+ if err := skipRemoteTargetSection(&probe); err != nil {
1455+ return AttachTarget{}, ErrInvalidAttachTarget
14311456 }
14321457 if err := probe.done(); err != nil {
14331458 return AttachTarget{}, ErrInvalidAttachTarget
@@ -1444,10 +1469,8 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
14441469 if m.Intent, err = r.getUint8(); err != nil {
14451470 return AttachTarget{}, err
14461471 }
1447- if len(r.b) != 0 {
1448- if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil {
1449- return AttachTarget{}, err
1450- }
1472+ if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
1473+ return AttachTarget{}, err
14511474 }
14521475 if err := r.done(); err != nil {
14531476 return AttachTarget{}, err
@@ -1509,66 +1532,94 @@ func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) {
15091532 return OutputResetRequest{}, nil
15101533 }
15111534
1512-// MarshalSessionMeta encodes m when it satisfies the protocol's authoritative
1513-// ordered-tab constraints.
1514-func MarshalSessionMeta(m SessionMeta) ([]byte, error) {
1515- if len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 || int(m.Active) >= len(m.Tabs) {
1516- return nil, ErrInvalidSessionMeta
1535+// ValidateSessionMeta validates a revisioned authoritative remote metadata
1536+// snapshot. Ordered tabs remain display order, while IDs are the only identity.
1537+func ValidateSessionMeta(m SessionMeta) error {
1538+ if m.LifecycleID == (domain.SessionLifecycleID{}) || m.Revision == 0 ||
1539+ m.SessionName == "" || len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 {
1540+ return ErrInvalidSessionMeta
1541+ }
1542+ if len(m.SessionName) > math.MaxUint16 || len(m.ActiveTabID) > math.MaxUint16 {
1543+ return ErrSessionMetaStringTooLong
15171544 }
1518- if len(m.SessionName) > math.MaxUint16 {
1519- return nil, ErrSessionMetaStringTooLong
1545+ if err := domain.ValidateTabStableID(m.ActiveTabID); err != nil {
1546+ return ErrInvalidSessionMeta
15201547 }
1521- for i, tab := range m.Tabs {
1522- if tab.Index != uint16(i) {
1523- return nil, ErrInvalidSessionMeta
1548+ activeFound := false
1549+ seen := make(map[domain.TabStableID]struct{}, len(m.Tabs))
1550+ for _, tab := range m.Tabs {
1551+ if len(tab.ID) > math.MaxUint16 || len(tab.Name) > math.MaxUint16 {
1552+ return ErrSessionMetaStringTooLong
1553+ }
1554+ if err := domain.ValidateTabStableID(tab.ID); err != nil {
1555+ return ErrInvalidSessionMeta
15241556 }
1525- if len(tab.Name) > math.MaxUint16 {
1526- return nil, ErrSessionMetaStringTooLong
1557+ if _, duplicate := seen[tab.ID]; duplicate {
1558+ return ErrInvalidSessionMeta
15271559 }
1560+ seen[tab.ID] = struct{}{}
1561+ activeFound = activeFound || tab.ID == m.ActiveTabID
15281562 }
1563+ if !activeFound {
1564+ return ErrInvalidSessionMeta
1565+ }
1566+ return nil
1567+}
15291568
1569+// MarshalSessionMeta encodes a strict v25 revisioned metadata snapshot.
1570+func MarshalSessionMeta(m SessionMeta) ([]byte, error) {
1571+ if err := ValidateSessionMeta(m); err != nil {
1572+ return nil, err
1573+ }
15301574 w := payloadWriter{}
1575+ w.putBytes(m.LifecycleID[:])
1576+ w.putUint64(m.Revision)
15311577 w.putString(m.SessionName)
1532- w.putUint16(m.Active)
1578+ w.putString(string(m.ActiveTabID))
15331579 w.putUint16(uint16(len(m.Tabs)))
15341580 for _, tab := range m.Tabs {
1535- w.putUint16(tab.Index)
1581+ w.putString(string(tab.ID))
15361582 w.putString(tab.Name)
15371583 w.putBool(tab.Attention)
15381584 }
15391585 return w.b, nil
15401586 }
15411587
1542-// UnmarshalSessionMeta decodes a strict authoritative metadata snapshot.
1588+// UnmarshalSessionMeta decodes a strict v25 authoritative metadata snapshot.
15431589 func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
15441590 r := payloadReader{b: b}
15451591 var m SessionMeta
1546- var err error
1592+ lifecycle, err := r.getBytes(len(m.LifecycleID))
1593+ if err != nil {
1594+ return SessionMeta{}, err
1595+ }
1596+ copy(m.LifecycleID[:], lifecycle)
1597+ if m.Revision, err = r.getUint64(); err != nil {
1598+ return SessionMeta{}, err
1599+ }
15471600 if m.SessionName, err = r.getString(); err != nil {
15481601 return SessionMeta{}, err
15491602 }
1550- if m.Active, err = r.getUint16(); err != nil {
1603+ activeTabID, err := r.getString()
1604+ if err != nil {
15511605 return SessionMeta{}, err
15521606 }
1607+ m.ActiveTabID = domain.TabStableID(activeTabID)
15531608 count, err := r.getUint16()
15541609 if err != nil {
15551610 return SessionMeta{}, err
15561611 }
1557- if count == 0 || int(m.Active) >= int(count) {
1558- return SessionMeta{}, ErrInvalidSessionMeta
1559- }
1560- if uint64(count) > uint64(len(r.b)/5) {
1612+ if count == 0 || uint64(count) > uint64(len(r.b)/5) {
15611613 return SessionMeta{}, errShortPayload
15621614 }
15631615 m.Tabs = make([]SessionTabMeta, 0, int(count))
1564- for i := range int(count) {
1616+ for range int(count) {
15651617 var tab SessionTabMeta
1566- if tab.Index, err = r.getUint16(); err != nil {
1618+ id, err := r.getString()
1619+ if err != nil {
15671620 return SessionMeta{}, err
15681621 }
1569- if tab.Index != uint16(i) {
1570- return SessionMeta{}, ErrInvalidSessionMeta
1571- }
1622+ tab.ID = domain.TabStableID(id)
15721623 if tab.Name, err = r.getString(); err != nil {
15731624 return SessionMeta{}, err
15741625 }
@@ -1580,6 +1631,9 @@ func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
15801631 if err := r.done(); err != nil {
15811632 return SessionMeta{}, err
15821633 }
1634+ if err := ValidateSessionMeta(m); err != nil {
1635+ return SessionMeta{}, err
1636+ }
15831637 return m, nil
15841638 }
15851639
M internal/ports/wire_final_test.go +9 -9
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 24 {
15- t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion)
14+ if ProtocolVersion != 25 {
15+ t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 24, 2}, make([]byte, 16+8)...)
30- want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
29+ want := append([]byte{0, 25, 2, 0}, make([]byte, 16+8)...)
30+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
3333 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -178,7 +178,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
178178
179179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
180180 msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}
181- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2}
181+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0}
182182 got := MarshalAttachTarget(msg)
183183 if !bytes.Equal(got, want) {
184184 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -197,7 +197,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
197197 }
198198
199199 func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
200- meta, err := MarshalSessionMeta(SessionMeta{Tabs: []SessionTabMeta{{Name: "tab"}}})
200+ meta, err := MarshalSessionMeta(SessionMeta{LifecycleID: domain.SessionLifecycleID{1}, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "tab"}}})
201201 if err != nil {
202202 t.Fatal(err)
203203 }
@@ -210,7 +210,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
210210 name: "hello boolean",
211211 payload: func() []byte {
212212 b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
213- b[len(b)-6] = 2
213+ b[len(b)-8] = 2
214214 return b
215215 }(),
216216 decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },
@@ -237,7 +237,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
237237 {
238238 name: "sessions ephemeral boolean",
239239 payload: func() []byte {
240- b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})
240+ b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionUp}}})
241241 b[6] = 2
242242 return b
243243 }(),
@@ -246,7 +246,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
246246 {
247247 name: "sessions attached boolean",
248248 payload: func() []byte {
249- b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})
249+ b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionUp}}})
250250 b[9] = 2
251251 return b
252252 }(),
M internal/ports/wire_test.go +60 -31
@@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
7373 TrueColor: true,
7474 MaxOutputInFlight: 8,
7575 },
76- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00},
76+ want: []byte{0x00, 0x01, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
7777 },
7878 {
7979 name: "empty strings",
@@ -86,7 +86,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
8686 Cwd: "",
8787 TrueColor: false,
8888 },
89- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
89+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
9090 },
9191 }
9292
@@ -124,7 +124,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
124124 }
125125 got := MarshalHello(msg)
126126 want := []byte{
127- 0x00, 0x18, 0x00, // version, intent
127+ 0x00, 0x19, 0x00, 0x00, // version, intent, render mode
128128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
129129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
130130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -136,6 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136136 0x00, 0x00, 0x00, 0x02, // uint32 environment entry count
137137 0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',
138138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
139+ 0x00, 0x00, // no remote target, client-owned environment
139140 }
140141 require.Equal(t, want, got)
141142 decoded, err := UnmarshalHello(want)
@@ -164,7 +165,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
164165 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
165166 withCount := func(count byte) []byte {
166167 payload := append([]byte(nil), base...)
167- payload[len(payload)-1] = count
168+ payload[len(payload)-3] = count
168169 return payload
169170 }
170171 tests := []struct {
@@ -341,7 +342,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
341342 }
342343 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
343344 require.Equal(t, want, MarshalTheme(cleared))
344- require.Equal(t, uint16(24), ProtocolVersion)
345+ require.Equal(t, uint16(25), ProtocolVersion)
345346 }
346347
347348 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -421,12 +422,12 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
421422 {
422423 name: "ephemeral",
423424 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
424- want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05},
425+ want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x00, 0x05},
425426 },
426427 {
427428 name: "non-ephemeral empty name",
428429 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
429- want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
430+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
430431 },
431432 }
432433
@@ -451,6 +452,13 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
451452 assertTrailingGarbageFails(t, full, UnmarshalWelcome)
452453 }
453454
455+func TestMarshalWelcomeRejectsInvalidRenderMode(t *testing.T) {
456+ got := MarshalWelcome(Welcome{SessionID: "sess-1", RenderMode: RenderMode(99)})
457+ if got != nil {
458+ t.Fatalf("MarshalWelcome() = %#v, want nil for invalid render mode", got)
459+ }
460+}
461+
454462 func TestCommandErrorCodes(t *testing.T) {
455463 require.Equal(t, uint16(6), ErrUnknownCommand)
456464 require.Equal(t, uint16(7), ErrNotScriptable)
@@ -469,7 +477,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
469477 name: "minimal",
470478 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
471479 want: []byte{
472- 0x00, 0x18,
480+ 0x00, 0x19,
473481 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
474482 0x00, // attached
475483 0x00, // self
@@ -492,7 +500,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
492500 JSON: true,
493501 },
494502 want: []byte{
495- 0x00, 0x18,
503+ 0x00, 0x19,
496504 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
497505 0x00, // attached
498506 0x01, // self
@@ -768,8 +776,8 @@ func TestSessionInfoRecoveryState(t *testing.T) {
768776 state SessionState
769777 want []byte
770778 }{
771- {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
772- {name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
779+ {name: "up", state: SessionUp, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
780+ {name: "down", state: SessionDown, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
773781 {name: "broken", state: SessionBroken, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}},
774782 }
775783 for _, tt := range tests {
@@ -805,8 +813,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
805813 {
806814 name: "two",
807815 msg: Sessions{Sessions: []SessionInfo{
808- {SessionID: "0", Name: "0", State: SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
809- {SessionID: "work", Name: "proj", State: SessionStopped, Ephemeral: false, Tabs: 5, Attached: true},
816+ {SessionID: "0", Name: "0", State: SessionUp, Ephemeral: true, Tabs: 1, Attached: false},
817+ {SessionID: "work", Name: "proj", State: SessionDown, Ephemeral: false, Tabs: 5, Attached: true},
810818 }},
811819 want: []byte{
812820 0x00, 0x02,
@@ -890,9 +898,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
890898 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
891899 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
892900 // The empty Hello has a fixed 1x1 size before the negotiated output window.
893- want := append(make([]byte, 38), window, 0, 0, 0, 0)
901+ want := append(make([]byte, 39), window, 0, 0, 0, 0, 0, 0)
894902 want[1] = 14
895- want[30], want[32] = 1, 1
903+ want[31], want[33] = 1, 1
896904 got := MarshalHello(hello)
897905 requireBytesEqual(t, want, got)
898906 back, err := UnmarshalHello(got)
@@ -920,7 +928,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
920928 name: "attached",
921929 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
922930 want: []byte{
923- 0x00, 0x18, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
931+ 0x00, 0x19, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
924932 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
925933 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
926934 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -930,7 +938,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
930938 name: "control",
931939 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
932940 want: []byte{
933- 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
941+ 0x00, 0x19, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
934942 0x00, 0x02, 'l', 's', 0x00, 0x00,
935943 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
936944 },
@@ -989,6 +997,7 @@ func TestOutputResetRequestStrict(t *testing.T) {
989997 }
990998
991999 func TestSessionMetaGoldenAndStrict(t *testing.T) {
1000+ lifecycle := domain.SessionLifecycleID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
9921001 tests := []struct {
9931002 name string
9941003 msg SessionMeta
@@ -996,13 +1005,29 @@ func TestSessionMetaGoldenAndStrict(t *testing.T) {
9961005 }{
9971006 {
9981007 name: "one unicode tab",
999- msg: SessionMeta{SessionName: "démo", Active: 0, Tabs: []SessionTabMeta{{Index: 0, Name: "é", Attention: true}}},
1000- want: []byte{0x00, 0x05, 'd', 0xc3, 0xa9, 'm', 'o', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0xc3, 0xa9, 0x01},
1008+ msg: SessionMeta{LifecycleID: lifecycle, Revision: 2, SessionName: "démo", ActiveTabID: "tab-é", Tabs: []SessionTabMeta{{ID: "tab-é", Name: "é", Attention: true}}},
1009+ want: []byte{
1010+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
1011+ 0, 0, 0, 0, 0, 0, 0, 2,
1012+ 0, 5, 'd', 0xc3, 0xa9, 'm', 'o',
1013+ 0, 6, 't', 'a', 'b', '-', 0xc3, 0xa9,
1014+ 0, 1,
1015+ 0, 6, 't', 'a', 'b', '-', 0xc3, 0xa9,
1016+ 0, 2, 0xc3, 0xa9, 1,
1017+ },
10011018 },
10021019 {
10031020 name: "multiple tabs",
1004- msg: SessionMeta{SessionName: "work", Active: 1, Tabs: []SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build", Attention: true}}},
1005- want: []byte{0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x05, 's', 'h', 'e', 'l', 'l', 0x00, 0x00, 0x01, 0x00, 0x05, 'b', 'u', 'i', 'l', 'd', 0x01},
1021+ msg: SessionMeta{LifecycleID: lifecycle, Revision: 7, SessionName: "work", ActiveTabID: "tab-build", Tabs: []SessionTabMeta{{ID: "tab-shell", Name: "shell"}, {ID: "tab-build", Name: "build", Attention: true}}},
1022+ want: []byte{
1023+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
1024+ 0, 0, 0, 0, 0, 0, 0, 7,
1025+ 0, 4, 'w', 'o', 'r', 'k',
1026+ 0, 9, 't', 'a', 'b', '-', 'b', 'u', 'i', 'l', 'd',
1027+ 0, 2,
1028+ 0, 9, 't', 'a', 'b', '-', 's', 'h', 'e', 'l', 'l', 0, 5, 's', 'h', 'e', 'l', 'l', 0,
1029+ 0, 9, 't', 'a', 'b', '-', 'b', 'u', 'i', 'l', 'd', 0, 5, 'b', 'u', 'i', 'l', 'd', 1,
1030+ },
10061031 },
10071032 }
10081033 for _, tt := range tests {
@@ -1019,36 +1044,40 @@ func TestSessionMetaGoldenAndStrict(t *testing.T) {
10191044 }
10201045
10211046 maxString := string(bytes.Repeat([]byte{'x'}, math.MaxUint16))
1022- payload, err := MarshalSessionMeta(SessionMeta{SessionName: maxString, Tabs: []SessionTabMeta{{Name: maxString}}})
1047+ payload, err := MarshalSessionMeta(SessionMeta{LifecycleID: lifecycle, Revision: 1, SessionName: maxString, ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: maxString}}})
10231048 require.NoError(t, err)
10241049 back, err := UnmarshalSessionMeta(payload)
10251050 require.NoError(t, err)
10261051 require.Equal(t, maxString, back.SessionName)
10271052 require.Equal(t, maxString, back.Tabs[0].Name)
10281053
1054+ validMeta := func() SessionMeta {
1055+ return SessionMeta{LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}}
1056+ }
10291057 invalid := []SessionMeta{
10301058 {},
1031- {Tabs: []SessionTabMeta{{Name: "one"}}, Active: 1},
1032- {Tabs: []SessionTabMeta{{Index: 1, Name: "one"}}},
1033- {Tabs: []SessionTabMeta{{Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}, Active: 0},
1034- {SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), Tabs: []SessionTabMeta{{Name: "one"}}},
1059+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1060+ {LifecycleID: lifecycle, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1061+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "missing", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1062+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}, {ID: "tab", Name: "two"}}},
1063+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}},
1064+ {LifecycleID: lifecycle, Revision: 1, SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
10351065 }
10361066 for _, msg := range invalid {
10371067 _, err := MarshalSessionMeta(msg)
10381068 require.Error(t, err)
10391069 }
10401070
1041- valid, err := MarshalSessionMeta(tests[1].msg)
1071+ valid, err := MarshalSessionMeta(validMeta())
10421072 require.NoError(t, err)
10431073 for _, tt := range []struct {
10441074 name string
10451075 mutate func([]byte)
10461076 wantError error
10471077 }{
1048- {name: "active outside tab count", mutate: func(b []byte) { b[6] = 0; b[7] = 2 }, wantError: ErrInvalidSessionMeta},
1049- // The first tab index is the uint16 at bytes 10–11, after the session
1050- // name, active index, and tab count.
1051- {name: "first tab index out of order", mutate: func(b []byte) { b[10] = 0; b[11] = 1 }, wantError: ErrInvalidSessionMeta},
1078+ {name: "zero lifecycle", mutate: func(b []byte) { clear(b[:16]) }, wantError: ErrInvalidSessionMeta},
1079+ {name: "zero revision", mutate: func(b []byte) { clear(b[16:24]) }, wantError: ErrInvalidSessionMeta},
1080+ {name: "invalid active stable ID", mutate: func(b []byte) { b[32] = ' ' }, wantError: ErrInvalidSessionMeta},
10521081 {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }},
10531082 } {
10541083 t.Run(tt.name, func(t *testing.T) {
M internal/usecase/client/client.go +42 -5
@@ -40,6 +40,10 @@ func (e *ProtocolError) Error() string {
4040 return fmt.Sprintf("vev: %s", e.Text)
4141 }
4242
43+// errRenderModeMismatch is a permanent attach failure: retrying the same
44+// request cannot make a daemon accept a different render contract.
45+var errRenderModeMismatch = errors.New("vev: daemon accepted render mode")
46+
4347 // DetachedError reports that the daemon detached this client for a reason
4448 // other than an ordinary user-initiated detach.
4549 type DetachedError struct {
@@ -171,6 +175,7 @@ type Dependencies struct {
171175 type AttachRequest struct {
172176 Intent uint8
173177 SessionName string
178+ RenderMode ports.RenderMode
174179 Remote bool
175180 RemoteTarget *domain.RemoteSessionTarget
176181 EnvironmentPolicy ports.EnvironmentPolicy
@@ -227,7 +232,32 @@ func (r *Runner) terminalInput() *terminalInputPump {
227232 }
228233
229234 func validateAttachRequest(request AttachRequest) error {
235+ switch request.RenderMode {
236+ case ports.RenderModeFullTerminal, ports.RenderModeProxiedContent:
237+ default:
238+ return fmt.Errorf("vev: invalid render mode %d", request.RenderMode)
239+ }
240+ switch request.Intent {
241+ case ports.IntentEphemeral:
242+ case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
243+ if request.SessionName == "" {
244+ return domain.ErrInvalidSessionName
245+ }
246+ default:
247+ return fmt.Errorf("vev: invalid session intent %d", request.Intent)
248+ }
249+ if request.SessionName != "" {
250+ if err := domain.ValidateSessionName(request.SessionName); err != nil {
251+ return err
252+ }
253+ }
230254 if request.RemoteTarget == nil {
255+ if request.RenderMode == ports.RenderModeProxiedContent {
256+ return errors.New("vev: proxied attach request requires remote target")
257+ }
258+ if request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned {
259+ return errors.New("vev: attach request without remote target requires client-owned environment")
260+ }
231261 return nil
232262 }
233263 if err := request.RemoteTarget.Validate(); err != nil {
@@ -239,6 +269,9 @@ func validateAttachRequest(request AttachRequest) error {
239269 if request.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
240270 return errors.New("vev: remote attach target requires daemon-owned environment")
241271 }
272+ if request.Intent != ports.IntentAttach && request.Intent != ports.IntentResume {
273+ return errors.New("vev: remote attach target requires attach or resume intent")
274+ }
242275 return nil
243276 }
244277
@@ -246,6 +279,7 @@ func validateAttachRequest(request AttachRequest) error {
246279 // above attach attempts so raw mode remains active while a live client process
247280 // redials a lost link.
248281 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
282+ request = cloneAttachRequest(request)
249283 if err := validateAttachRequest(request); err != nil {
250284 return err
251285 }
@@ -378,10 +412,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
378412 return errors.New("vev: dialer returned nil transport")
379413 }
380414 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
381- connection, err := NewSessionConnection(transport, SessionTarget{
382- Intent: attemptRequest.Intent,
383- SessionName: attemptRequest.SessionName,
384- })
415+ connection, err := NewSessionConnection(transport, attemptRequest)
385416 if err != nil {
386417 stopHandshakeTransport()
387418 finishHandshake()
@@ -430,6 +461,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
430461 if handoffErr != nil {
431462 return handoffErr
432463 }
464+ nextRequest = cloneAttachRequest(nextRequest)
433465 if err := validateAttachRequest(nextRequest); err != nil {
434466 return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)
435467 }
@@ -439,6 +471,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
439471 dialer = nextDialer
440472 attemptRequest = nextRequest
441473 remote = attemptRequest.Remote || r.remote
474+ reconnect.remote = remote
442475 resumeToken = 0
443476 backoff = defaultReconnectBackoff.initial
444477 continue
@@ -679,7 +712,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
679712 enterRaw := a.enterRaw
680713 reconnect := a.reconnect
681714 linkEvents := a.linkEvents
682- remote := a.remote || a.request.Remote
715+ remote := a.remote || request.Remote
683716 clipboard := a.runner.clipboard
684717 log := a.runner.logger
685718 observer := a.runner.runtimeObserver
@@ -737,6 +770,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
737770 ClientID: clientID,
738771 ResumeToken: resumeToken,
739772 Name: name,
773+ RenderMode: request.RenderMode,
740774 Size: size,
741775 TermEnv: termEnv,
742776 Cwd: cwd,
@@ -771,6 +805,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
771805 if derr != nil {
772806 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
773807 }
808+ if welcome.RenderMode != request.RenderMode {
809+ return result(false, fmt.Errorf("%w %d, want %d", errRenderModeMismatch, welcome.RenderMode, request.RenderMode))
810+ }
774811 resumeToken = welcome.ResumeToken
775812 name = welcome.SessionName
776813 ms.welcomed = true
M internal/usecase/client/client_test.go +235 -23
@@ -41,6 +41,44 @@ func (r *realTimer) C() <-chan time.Time { return r.t.C }
4141 func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
4242 func (r *realTimer) Stop() bool { return r.t.Stop() }
4343
44+const runnerInitialBackoff = 100 * time.Millisecond
45+
46+type runnerTestClock struct {
47+ handshake chan *runnerTestTimer
48+ palette chan *runnerTestTimer
49+ reconnect chan *runnerTestTimer
50+}
51+
52+type runnerTestTimer struct {
53+ ch chan time.Time
54+ duration time.Duration
55+}
56+
57+func newRunnerTestClock() *runnerTestClock {
58+ return &runnerTestClock{
59+ handshake: make(chan *runnerTestTimer, 4),
60+ palette: make(chan *runnerTestTimer, 8),
61+ reconnect: make(chan *runnerTestTimer, 16),
62+ }
63+}
64+
65+func (*runnerTestClock) Now() time.Time { return time.Time{} }
66+func (c *runnerTestClock) NewTimer(duration time.Duration) ports.Timer {
67+ timer := &runnerTestTimer{ch: make(chan time.Time, 1), duration: duration}
68+ if duration == ports.HandshakeTimeout {
69+ c.handshake <- timer
70+ } else if duration == 200*time.Millisecond {
71+ c.palette <- timer
72+ } else {
73+ c.reconnect <- timer
74+ }
75+ return timer
76+}
77+func (t *runnerTestTimer) C() <-chan time.Time { return t.ch }
78+func (*runnerTestTimer) Reset(time.Duration) bool { return false }
79+func (*runnerTestTimer) Stop() bool { return true }
80+func (t *runnerTestTimer) fire() { t.ch <- time.Time{} }
81+
4482 // recvItem is one scripted Recv result.
4583 type recvItem struct {
4684 f ports.Frame
@@ -789,11 +827,15 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
789827 type recordingTransport struct {
790828 recvs []recvItem
791829 sends []ports.Frame
830+ onSend func(ports.Frame)
792831 closed atomic.Int32
793832 }
794833
795834 func (t *recordingTransport) Send(f ports.Frame) error {
796835 t.sends = append(t.sends, f)
836+ if t.onSend != nil {
837+ t.onSend(f)
838+ }
797839 return nil
798840 }
799841
@@ -846,33 +888,203 @@ func welcomeFrame(token uint64) ports.Frame {
846888 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
847889 }
848890
849-func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
891+func TestRunReconnectsPreservesRichRemoteTarget(t *testing.T) {
892+ lifecycle := domain.SessionLifecycleID{}
893+ lifecycle[0] = 1
894+ otherLifecycle := domain.SessionLifecycleID{}
895+ otherLifecycle[0] = 2
896+ tests := []struct {
897+ name string
898+ target domain.RemoteSessionTarget
899+ }{
900+ {
901+ name: "live tab",
902+ target: domain.RemoteSessionTarget{
903+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
904+ SessionName: "main", LiveTabID: "tab-live",
905+ },
906+ },
907+ {
908+ name: "stopped tab",
909+ target: domain.RemoteSessionTarget{
910+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: otherLifecycle,
911+ SessionName: "main", Stopped: true,
912+ StoppedTab: domain.NewOrdinalTabSelector(1, "shell", 2),
913+ },
914+ },
915+ }
916+
917+ for _, tt := range tests {
918+ t.Run(tt.name, func(t *testing.T) {
919+ term := newRunTerminal()
920+ defer term.in.unblock()
921+ wantTarget := tt.target
922+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
923+ tr1.onSend = func(frame ports.Frame) {
924+ if frame.Type != ports.MsgHello {
925+ return
926+ }
927+ tt.target.Endpoint = "mutated.invalid"
928+ tt.target.LifecycleID = domain.SessionLifecycleID{}
929+ tt.target.LiveTabID = "mutated-tab"
930+ }
931+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
932+ tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
933+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
934+ request := client.AttachRequest{
935+ Intent: ports.IntentAttach,
936+ SessionName: "main",
937+ Remote: true,
938+ RemoteTarget: &tt.target,
939+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
940+ }
941+
942+ clock := newRunnerTestClock()
943+ done := make(chan error, 1)
944+ go func() {
945+ done <- runTestClient(context.Background(), testDependencies(d, term, clock, nil, nil), request)
946+ }()
947+ handshakeTimer := <-clock.handshake
948+ require.Equal(t, testPreWelcomeTimeout, handshakeTimer.duration)
949+ firstReconnect := <-clock.reconnect
950+ require.Equal(t, runnerInitialBackoff, firstReconnect.duration)
951+ firstReconnect.fire()
952+ handshakeTimer = <-clock.handshake
953+ require.Equal(t, testPreWelcomeTimeout, handshakeTimer.duration)
954+ secondReconnect := <-clock.reconnect
955+ require.Equal(t, runnerInitialBackoff, secondReconnect.duration)
956+ secondReconnect.fire()
957+ handshakeTimer = <-clock.handshake
958+ require.Equal(t, testPreWelcomeTimeout, handshakeTimer.duration)
959+ require.NoError(t, <-done)
960+ require.Equal(t, int32(3), d.calls.Load())
961+ require.Equal(t, int32(1), term.rawCount.Load())
962+ require.Equal(t, int32(1), term.restoreCount.Load())
963+
964+ h1 := helloFromSend(t, tr1)
965+ h2 := helloFromSend(t, tr2)
966+ h3 := helloFromSend(t, tr3)
967+ require.Equal(t, ports.IntentAttach, h1.Intent)
968+ require.Zero(t, h1.ResumeToken)
969+ require.Equal(t, ports.IntentResume, h2.Intent)
970+ require.Equal(t, uint64(11), h2.ResumeToken)
971+ require.Equal(t, ports.IntentResume, h3.Intent)
972+ require.Equal(t, uint64(22), h3.ResumeToken)
973+ for _, hello := range []ports.Hello{h1, h2, h3} {
974+ require.Equal(t, &wantTarget, hello.RemoteTarget)
975+ require.Equal(t, ports.EnvironmentPolicyDaemonOwned, hello.EnvironmentPolicy)
976+ }
977+ require.Equal(t, h1.ClientID, h2.ClientID)
978+ require.Equal(t, h1.ClientID, h3.ClientID)
979+ require.Contains(t, term.out.String(), "reconnecting through SSH")
980+ })
981+ }
982+}
983+
984+func TestRunSendsRequestedProxiedRenderMode(t *testing.T) {
985+ lifecycle := domain.SessionLifecycleID{1}
986+ target := domain.RemoteSessionTarget{
987+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
988+ SessionName: "main", LiveTabID: "tab-live",
989+ }
850990 term := newRunTerminal()
851991 defer term.in.unblock()
852- tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
853- tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
854- tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
855- d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
856-
857- err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
992+ tr := &recordingTransport{recvs: []recvItem{
993+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
994+ SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
995+ }))},
996+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
997+ }}
998+ deps := testDependencies(&sequenceDialer{trs: []ports.Transport{tr}}, term, realClock{}, nil, nil)
999+ err := runTestClient(context.Background(), deps, client.AttachRequest{
1000+ Intent: ports.IntentAttach, SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
1001+ RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1002+ })
8581003 require.NoError(t, err)
859- require.Equal(t, int32(3), d.calls.Load())
860- require.Equal(t, int32(1), term.rawCount.Load())
861- require.Equal(t, int32(1), term.restoreCount.Load())
1004+ require.Equal(t, ports.RenderModeProxiedContent, helloFromSend(t, tr).RenderMode)
1005+}
8621006
863- h1 := helloFromSend(t, tr1)
864- h2 := helloFromSend(t, tr2)
865- h3 := helloFromSend(t, tr3)
866- require.Equal(t, ports.IntentAttach, h1.Intent)
867- require.Zero(t, h1.ResumeToken)
868- require.Equal(t, ports.IntentResume, h2.Intent)
869- require.Equal(t, uint64(11), h2.ResumeToken)
870- require.Equal(t, ports.IntentResume, h3.Intent)
871- require.Equal(t, uint64(22), h3.ResumeToken)
872- require.Equal(t, h1.ClientID, h2.ClientID)
873- require.Equal(t, h1.ClientID, h3.ClientID)
874- require.Contains(t, term.out.String(), "reconnecting…")
875- require.Contains(t, term.out.String(), "\x1b[2K")
1007+func TestRunRejectsMismatchedProxiedWelcomeBeforeEnteringRawMode(t *testing.T) {
1008+ lifecycle := domain.SessionLifecycleID{1}
1009+ target := domain.RemoteSessionTarget{
1010+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
1011+ SessionName: "main", LiveTabID: "tab-live",
1012+ }
1013+ term := newRunTerminal()
1014+ defer term.in.unblock()
1015+ tr := &recordingTransport{recvs: []recvItem{{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
1016+ SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeFullTerminal,
1017+ }))}}}
1018+
1019+ err := runTestClient(context.Background(), testDependencies(&sequenceDialer{trs: []ports.Transport{tr}}, term, realClock{}, nil, nil), client.AttachRequest{
1020+ Intent: ports.IntentAttach, SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
1021+ RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1022+ })
1023+ require.ErrorContains(t, err, "daemon accepted render mode")
1024+ require.Zero(t, term.rawCount.Load())
1025+}
1026+
1027+func TestRunnerRejectsInvalidAttachRequestBeforeHello(t *testing.T) {
1028+ lifecycle := domain.SessionLifecycleID{}
1029+ lifecycle[0] = 1
1030+ validTarget := domain.RemoteSessionTarget{
1031+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
1032+ SessionName: "main", LiveTabID: "tab-live",
1033+ }
1034+ tests := []struct {
1035+ name string
1036+ request client.AttachRequest
1037+ wantErr string
1038+ }{
1039+ {
1040+ name: "invalid render mode",
1041+ request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", RenderMode: ports.RenderMode(99)},
1042+ wantErr: "vev: invalid render mode",
1043+ },
1044+ {
1045+ name: "invalid session intent",
1046+ request: client.AttachRequest{Intent: 99, SessionName: "main"},
1047+ wantErr: "vev: invalid session intent",
1048+ },
1049+ {
1050+ name: "missing daemon-owned policy",
1051+ request: client.AttachRequest{
1052+ Intent: ports.IntentAttach,
1053+ SessionName: "main",
1054+ RemoteTarget: &validTarget,
1055+ },
1056+ wantErr: "remote attach target requires daemon-owned environment",
1057+ },
1058+ {
1059+ name: "target session mismatch",
1060+ request: client.AttachRequest{
1061+ Intent: ports.IntentAttach,
1062+ SessionName: "other",
1063+ RemoteTarget: &validTarget,
1064+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1065+ },
1066+ wantErr: "remote attach target session mismatch",
1067+ },
1068+ {
1069+ name: "malformed target",
1070+ request: client.AttachRequest{
1071+ Intent: ports.IntentAttach,
1072+ SessionName: "main",
1073+ RemoteTarget: &domain.RemoteSessionTarget{SessionName: "main", LiveTabID: "tab-live"},
1074+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1075+ },
1076+ wantErr: "invalid remote attach target",
1077+ },
1078+ }
1079+
1080+ for _, tt := range tests {
1081+ t.Run(tt.name, func(t *testing.T) {
1082+ dialer := &sequenceDialer{}
1083+ err := client.NewRunner(client.Dependencies{Dialer: dialer}).Run(context.Background(), tt.request)
1084+ require.ErrorContains(t, err, tt.wantErr)
1085+ require.Zero(t, dialer.calls.Load(), "invalid request must be rejected before dialing and Hello")
1086+ })
1087+ }
8761088 }
8771089
8781090 func TestAttachRememberRemoteHost(t *testing.T) {
M internal/usecase/client/palette_attach_generation_test.go +1 -1
@@ -35,7 +35,7 @@ type attachPaletteTimer struct {
3535 func newAttachPaletteClock() *attachPaletteClock {
3636 return &attachPaletteClock{
3737 timers: make(chan *attachPaletteTimer, 16),
38- handshakeTimers: make(chan *attachPaletteTimer, 1),
38+ handshakeTimers: make(chan *attachPaletteTimer, 4),
3939 }
4040 }
4141
M internal/usecase/client/reconnect.go +3 -0
@@ -56,6 +56,9 @@ func shouldReconnect(err error) bool {
5656 if _, ok := errors.AsType[*ProtocolError](err); ok {
5757 return false
5858 }
59+ if errors.Is(err, errRenderModeMismatch) {
60+ return false
61+ }
5962 _, ok := errors.AsType[*DetachedError](err)
6063 return !ok
6164 }
M internal/usecase/client/session_connection.go +17 -32
@@ -4,52 +4,38 @@ import (
44 "errors"
55 "fmt"
66
7- "github.com/bnema/vev/internal/domain"
87 "github.com/bnema/vev/internal/ports"
98 )
109
1110 var errNilSessionTransport = errors.New("client: nil session transport")
1211
13-// SessionTarget is the validated daemon-facing session selection. Endpoint
14-// details are resolved before a transport is opened and never enter this type.
15-type SessionTarget struct {
16- Intent uint8
17- SessionName string
18-}
19-
20-func (t SessionTarget) validate() error {
21- switch t.Intent {
22- case ports.IntentEphemeral:
23- if t.SessionName == "" {
24- return nil
25- }
26- case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
27- if t.SessionName == "" {
28- return domain.ErrInvalidSessionName
29- }
30- default:
31- return fmt.Errorf("invalid session intent %d", t.Intent)
12+func cloneAttachRequest(request AttachRequest) AttachRequest {
13+ if request.RemoteTarget != nil {
14+ target := *request.RemoteTarget
15+ request.RemoteTarget = &target
3216 }
33- return domain.ValidateSessionName(t.SessionName)
17+ return request
3418 }
3519
36-// SessionConnection owns one already-open transport and its daemon-facing
37-// session target. Local sockets and remote carriages use this same owner.
20+// SessionConnection owns one already-open transport and the complete
21+// validated daemon-facing attach request. Local sockets and remote carriages
22+// use this same owner.
3823 type SessionConnection struct {
3924 transport ports.Transport
40- target SessionTarget
25+ request AttachRequest
4126 }
4227
4328 // NewSessionConnection creates a connection for an already-selected transport.
44-// Target validation happens before the connection is used for a Hello.
45-func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) {
29+// Request validation happens before the connection is used for a Hello.
30+func NewSessionConnection(transport ports.Transport, request AttachRequest) (*SessionConnection, error) {
4631 if transport == nil {
4732 return nil, errNilSessionTransport
4833 }
49- if err := target.validate(); err != nil {
50- return nil, fmt.Errorf("invalid session target: %w", err)
34+ request = cloneAttachRequest(request)
35+ if err := validateAttachRequest(request); err != nil {
36+ return nil, fmt.Errorf("invalid attach request: %w", err)
5137 }
52- return &SessionConnection{transport: transport, target: target}, nil
38+ return &SessionConnection{transport: transport, request: request}, nil
5339 }
5440
5541 // Transport returns the owned carriage for this connection.
@@ -60,13 +46,12 @@ func (c *SessionConnection) Transport() ports.Transport {
6046 return c.transport
6147 }
6248
63-// AttachRequest returns the common request shape used after transport
64-// selection. It contains no remote/proxy semantic.
49+// AttachRequest returns the complete request retained for this connection.
6550 func (c *SessionConnection) AttachRequest() AttachRequest {
6651 if c == nil {
6752 return AttachRequest{}
6853 }
69- return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName}
54+ return cloneAttachRequest(c.request)
7055 }
7156
7257 // Close releases the owned transport.
M internal/usecase/client/session_connection_test.go +47 -16
@@ -3,46 +3,77 @@ package client_test
33 import (
44 "testing"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/ports"
78 portsmocks "github.com/bnema/vev/internal/ports/mocks"
89 "github.com/bnema/vev/internal/usecase/client"
910 "github.com/stretchr/testify/require"
1011 )
1112
12-func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) {
13- target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}
13+func TestSessionConnectionOwnsCompleteAttachRequestForLocalAndRemoteTransports(t *testing.T) {
14+ var lifecycle domain.SessionLifecycleID
15+ lifecycle[0] = 1
16+ target := &domain.RemoteSessionTarget{
17+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
18+ SessionName: "work", LiveTabID: "tab-1",
19+ }
20+ wantTarget := *target
21+ request := client.AttachRequest{
22+ Intent: ports.IntentAttach,
23+ SessionName: "work",
24+ RenderMode: ports.RenderModeProxiedContent,
25+ Remote: true,
26+ RemoteTarget: target,
27+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
28+ }
29+ wantRequest := request
30+ wantRequest.RemoteTarget = &wantTarget
1431 local := portsmocks.NewMockTransport(t)
1532 remote := portsmocks.NewMockTransport(t)
1633
17- localConnection, err := client.NewSessionConnection(local, target)
34+ localConnection, err := client.NewSessionConnection(local, request)
1835 require.NoError(t, err)
19- remoteConnection, err := client.NewSessionConnection(remote, target)
36+ remoteConnection, err := client.NewSessionConnection(remote, request)
2037 require.NoError(t, err)
2138
39+ target.LiveTabID = "mutated-tab"
40+ target.LifecycleID = domain.SessionLifecycleID{}
41+ returned := localConnection.AttachRequest()
42+ returned.RemoteTarget.LiveTabID = "mutated-returned-tab"
43+
2244 require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest())
23- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, localConnection.AttachRequest())
45+ require.Equal(t, wantRequest, localConnection.AttachRequest())
2446 require.Same(t, local, localConnection.Transport())
2547 require.Same(t, remote, remoteConnection.Transport())
2648 }
2749
28-func TestSessionTargetValidation(t *testing.T) {
50+func TestSessionConnectionAttachRequestValidation(t *testing.T) {
51+ lifecycle := domain.SessionLifecycleID{1}
52+ remoteTarget := &domain.RemoteSessionTarget{
53+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
54+ SessionName: "work", LiveTabID: "tab-1",
55+ }
2956 tests := []struct {
3057 name string
31- target client.SessionTarget
58+ request client.AttachRequest
3259 wantErr bool
3360 }{
34- {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}},
35- {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}},
36- {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}},
37- {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}},
38- {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true},
39- {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
40- {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "work"}, wantErr: true},
61+ {name: "ephemeral", request: client.AttachRequest{Intent: ports.IntentEphemeral}},
62+ {name: "new", request: client.AttachRequest{Intent: ports.IntentNew, SessionName: "work"}},
63+ {name: "attach", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
64+ {name: "resume", request: client.AttachRequest{Intent: ports.IntentResume, SessionName: "work"}},
65+ {name: "missing named session", request: client.AttachRequest{Intent: ports.IntentAttach}, wantErr: true},
66+ {name: "unsafe session", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
67+ {name: "unknown intent", request: client.AttachRequest{Intent: 99, SessionName: "work"}, wantErr: true},
68+ {name: "invalid render mode", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", RenderMode: ports.RenderMode(99)}, wantErr: true},
69+ {name: "proxied without remote target", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", RenderMode: ports.RenderModeProxiedContent}, wantErr: true},
70+ {name: "remote target with new intent", request: client.AttachRequest{Intent: ports.IntentNew, SessionName: "work", RemoteTarget: remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, wantErr: true},
71+ {name: "daemon-owned without remote target", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, wantErr: true},
4172 }
4273 for _, tt := range tests {
4374 t.Run(tt.name, func(t *testing.T) {
4475 transport := portsmocks.NewMockTransport(t)
45- _, err := client.NewSessionConnection(transport, tt.target)
76+ _, err := client.NewSessionConnection(transport, tt.request)
4677 if tt.wantErr {
4778 require.Error(t, err)
4879 return
@@ -53,6 +84,6 @@ func TestSessionTargetValidation(t *testing.T) {
5384 }
5485
5586 func TestSessionConnectionRequiresTransport(t *testing.T) {
56- _, err := client.NewSessionConnection(nil, client.SessionTarget{Intent: ports.IntentEphemeral})
87+ _, err := client.NewSessionConnection(nil, client.AttachRequest{Intent: ports.IntentEphemeral})
5788 require.Error(t, err)
5889 }
A internal/usecase/daemon/ansi_content_replay_phase0_test.go +438 -0
@@ -0,0 +1,438 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "testing"
6+ "unicode/utf8"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/bnema/vev/pkg/vt"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// phase0ReplayCapture is deliberately test-only. It models the proposed
14+// remote-content boundary: ANSI is consumed by a private VT screen, then the
15+// owned visible grid and cursor are captured for local composition.
16+type phase0ReplayCapture struct {
17+ frame renderer.Frame
18+ cursor vt.CursorSnapshot
19+}
20+
21+func capturePhase0Replay(screen *vt.Screen) phase0ReplayCapture {
22+ snapshot := screen.Snapshot()
23+ frame := renderer.NewFrame(snapshot.Columns(), snapshot.Rows())
24+ for y := range snapshot.Rows() {
25+ copy(frame.Row(y), snapshot.Row(y))
26+ }
27+ return phase0ReplayCapture{frame: frame, cursor: snapshot.Cursor()}
28+}
29+
30+func composePhase0Replay(capture phase0ReplayCapture) renderer.Frame {
31+ // Composition receives cells, not the remote ANSI stream. The private remote
32+ // surface occupies only the content rows, leaving locally owned chrome above
33+ // and below it.
34+ content := capture.frame
35+ composed := renderer.NewFrame(content.Width, content.Height+2)
36+ writePhase0Chrome(&composed, 0, "LOCAL TOP")
37+ for y := range content.Height {
38+ copy(composed.Row(y+1), content.Row(y))
39+ }
40+ writePhase0Chrome(&composed, composed.Height-1, "LOCAL BOTTOM")
41+ return composed
42+}
43+
44+func writePhase0Chrome(frame *renderer.Frame, row int, label string) {
45+ for column, r := range label {
46+ if column >= frame.Width {
47+ return
48+ }
49+ frame.Set(column, row, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
50+ }
51+}
52+
53+// replayPhase0ANSI intentionally splits every byte of CSI/OSC controls across
54+// separate writes while keeping UTF-8 runes together. vt.Screen buffers split
55+// escape sequences, but deliberately does not buffer incomplete UTF-8 runes.
56+func replayPhase0ANSI(screen *vt.Screen, data []byte) {
57+ for len(data) > 0 {
58+ if data[0] != 0x1b {
59+ _, size := utf8.DecodeRune(data)
60+ if size == 0 {
61+ size = 1
62+ }
63+ screen.Write(data[:size])
64+ data = data[size:]
65+ continue
66+ }
67+
68+ screen.Write(data[:1])
69+ data = data[1:]
70+ if len(data) == 0 {
71+ return
72+ }
73+ kind := data[0]
74+ screen.Write(data[:1])
75+ data = data[1:]
76+
77+ switch kind {
78+ case '[':
79+ for len(data) > 0 {
80+ b := data[0]
81+ screen.Write(data[:1])
82+ data = data[1:]
83+ if b >= '@' && b <= '~' {
84+ break
85+ }
86+ }
87+ case ']':
88+ for len(data) > 0 {
89+ b := data[0]
90+ screen.Write(data[:1])
91+ data = data[1:]
92+ if b == 0x07 {
93+ break
94+ }
95+ if b == 0x1b && len(data) > 0 && data[0] == '\\' {
96+ screen.Write(data[:1])
97+ data = data[1:]
98+ break
99+ }
100+ }
101+ case 'P', '_', '^', 'X':
102+ for len(data) > 0 {
103+ b := data[0]
104+ screen.Write(data[:1])
105+ data = data[1:]
106+ if b == 0x1b && len(data) > 0 && data[0] == '\\' {
107+ screen.Write(data[:1])
108+ data = data[1:]
109+ break
110+ }
111+ }
112+ }
113+ }
114+}
115+
116+func phase0CursorTail(row, col, style int, visible bool) []byte {
117+ tail := []byte(fmt.Sprintf("\x1b[%d;%dH\x1b[%d q", row+1, col+1, style))
118+ if visible {
119+ return append(tail, "\x1b[?25h"...)
120+ }
121+ return append(tail, "\x1b[?25l"...)
122+}
123+
124+func phase0StyledFrame() renderer.Frame {
125+ styled := renderer.Style{
126+ Bold: true,
127+ Italic: true,
128+ Attrs: renderer.AttrUnderline,
129+ UnderlineStyle: renderer.UnderlineCurly,
130+ Foreground: -1,
131+ Background: -1,
132+ HasForegroundRGB: true,
133+ ForegroundRGB: renderer.RGB{R: 12, G: 34, B: 56},
134+ HasBackgroundRGB: true,
135+ BackgroundRGB: renderer.RGB{R: 200, G: 100, B: 50},
136+ }
137+ indexed := renderer.Style{Foreground: 82, Background: 17}
138+
139+ frame := renderer.NewFrame(10, 2)
140+ frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: styled})
141+ frame.Set(1, 0, renderer.Cell{Rune: '界', Style: styled})
142+ frame.Set(2, 0, renderer.Cell{Continuation: true, Style: styled})
143+ frame.Set(3, 0, renderer.Cell{Rune: 'A', Style: indexed})
144+ frame.Set(0, 1, renderer.Cell{Rune: 'q', Style: renderer.DefaultStyle()})
145+ return frame
146+}
147+
148+func requirePhase0Frame(t *testing.T, got, want renderer.Frame) {
149+ t.Helper()
150+ require.Equal(t, want.Width, got.Width)
151+ require.Equal(t, want.Height, got.Height)
152+ for y := range want.Height {
153+ for x := range want.Width {
154+ require.Equal(t, want.At(x, y), got.At(x, y), "cell(%d,%d)", x, y)
155+ }
156+ }
157+}
158+
159+func requirePhase0ComposedContent(t *testing.T, composed, content renderer.Frame) {
160+ t.Helper()
161+ require.Equal(t, content.Width, composed.Width)
162+ require.Equal(t, content.Height+2, composed.Height)
163+ for y := range content.Height {
164+ for x := range content.Width {
165+ require.Equal(t, content.At(x, y), composed.At(x, y+1), "content cell(%d,%d)", x, y)
166+ }
167+ }
168+ for column, r := range "LOCAL TOP" {
169+ if column >= composed.Width {
170+ break
171+ }
172+ require.Equal(t, r, composed.At(column, 0).Rune, "top chrome column %d", column)
173+ }
174+ for column, r := range "LOCAL BOTTOM" {
175+ if column >= composed.Width {
176+ break
177+ }
178+ require.Equal(t, r, composed.At(column, composed.Height-1).Rune, "bottom chrome column %d", column)
179+ }
180+}
181+
182+func TestPhase0RemoteANSIReplayFullAndIncremental(t *testing.T) {
183+ initial := phase0StyledFrame()
184+ updated := initial.Clone()
185+ updatedStyle := renderer.Style{
186+ Bold: true,
187+ Attrs: renderer.AttrUnderline,
188+ UnderlineStyle: renderer.UnderlineDouble,
189+ Foreground: -1,
190+ Background: -1,
191+ HasForegroundRGB: true,
192+ ForegroundRGB: renderer.RGB{R: 90, G: 80, B: 70},
193+ }
194+ updated.Set(0, 0, renderer.Cell{Rune: 'Z', Style: updatedStyle})
195+ updated.Set(1, 0, renderer.Cell{Rune: '好', Style: updatedStyle})
196+ updated.Set(2, 0, renderer.Cell{Continuation: true, Style: updatedStyle})
197+
198+ remoteRenderer := renderer.New(renderer.Capabilities{SynchronizedOutput: true})
199+ privateScreen := vt.NewScreen(initial.Width, initial.Height)
200+ // These callbacks are the security boundary under test: remote content is
201+ // replayed into a private screen that cannot answer, notify, ring, or write
202+ // the host clipboard.
203+ privateScreen.OnResponse = nil
204+ privateScreen.OnBell = nil
205+ privateScreen.OnNotify = nil
206+ privateScreen.OnProgress = nil
207+ privateScreen.OnClipboard = nil
208+
209+ full, err := remoteRenderer.Draw(initial, []renderer.Damage{renderer.FullRedraw()})
210+ require.NoError(t, err)
211+ require.Contains(t, string(full), renderer.SyncStartCSI)
212+ require.Contains(t, string(full), renderer.SyncEndCSI)
213+ full = append(full, phase0CursorTail(0, 3, 2, true)...)
214+ replayPhase0ANSI(privateScreen, full)
215+
216+ fullCapture := capturePhase0Replay(privateScreen)
217+ requirePhase0Frame(t, fullCapture.frame, initial)
218+ fullComposition := composePhase0Replay(fullCapture)
219+ requirePhase0ComposedContent(t, fullComposition, initial)
220+ require.Equal(t, vt.CursorSnapshot{Row: 0, Col: 3, Visible: true, Style: 2, StyleSet: true}, fullCapture.cursor)
221+ require.False(t, privateScreen.Snapshot().Modes().SynchronizedUpdate)
222+
223+ // A composed frame is rendered by the local renderer. The remote byte
224+ // stream is not passed to this renderer or to a transport.
225+ localRenderer := renderer.New(renderer.Capabilities{})
226+ localBytes, err := localRenderer.Draw(fullComposition, []renderer.Damage{renderer.FullRedraw()})
227+ require.NoError(t, err)
228+ localScreen := vt.NewScreen(fullComposition.Width, fullComposition.Height)
229+ replayPhase0ANSI(localScreen, localBytes)
230+ requirePhase0Frame(t, localScreen.Frame, fullComposition)
231+
232+ incremental, err := remoteRenderer.Draw(updated, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 3, Height: 1}})
233+ require.NoError(t, err)
234+ require.Contains(t, string(incremental), renderer.SyncStartCSI)
235+ require.Contains(t, string(incremental), renderer.SyncEndCSI)
236+ incremental = append(incremental, phase0CursorTail(1, 5, 3, false)...)
237+ replayPhase0ANSI(privateScreen, incremental)
238+
239+ incrementalCapture := capturePhase0Replay(privateScreen)
240+ requirePhase0Frame(t, incrementalCapture.frame, updated)
241+ requirePhase0ComposedContent(t, composePhase0Replay(incrementalCapture), updated)
242+ require.Equal(t, vt.CursorSnapshot{Row: 1, Col: 5, Visible: false, Style: 3, StyleSet: true}, incrementalCapture.cursor)
243+ require.False(t, privateScreen.Snapshot().Modes().SynchronizedUpdate)
244+
245+ noOp, err := remoteRenderer.Draw(updated, nil)
246+ require.NoError(t, err)
247+ require.Empty(t, noOp, "the remote renderer shadow must advance only once per emitted full/incremental frame")
248+}
249+
250+func TestPhase0PrivateReplaySuppressesHostSideEffects(t *testing.T) {
251+ screen := vt.NewScreen(16, 2)
252+ screen.OnResponse = nil
253+ screen.OnBell = nil
254+ screen.OnNotify = nil
255+ screen.OnProgress = nil
256+ screen.OnClipboard = nil
257+ screen.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true)
258+
259+ data := []byte("safe")
260+ data = append(data, "\x1b]52;c;Y2xpcA==\x07"...)
261+ data = append(data, "\x1b[5n\x1b[c\x1b[6n\x1b]10;?\x07"...)
262+ data = append(data, "\x1b]9;remote notification\x07"...)
263+ data = append(data, "\x1b]777;notify;title;body\x07"...)
264+ data = append(data, "\x1b]0;remote-title\x07"...)
265+ data = append(data, "\x1b]2;remote-title-2\x1b\\"...)
266+ data = append(data, "\x1b]9;4;2;0\x07\x1b]9;4;0\x07\a"...)
267+ data = append(data, "\x1bP1;2|dcs-payload\x1b\\"...)
268+ data = append(data, "\x1b_Ga=T,f=100;image-payload\x1b\\"...)
269+ replayPhase0ANSI(screen, data)
270+
271+ expected := renderer.NewFrame(16, 2)
272+ expected.Set(0, 0, renderer.Cell{Rune: 's', Style: renderer.DefaultStyle()})
273+ expected.Set(1, 0, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
274+ expected.Set(2, 0, renderer.Cell{Rune: 'f', Style: renderer.DefaultStyle()})
275+ expected.Set(3, 0, renderer.Cell{Rune: 'e', Style: renderer.DefaultStyle()})
276+ capture := capturePhase0Replay(screen)
277+ requirePhase0Frame(t, capture.frame, expected)
278+ composition := composePhase0Replay(capture)
279+ requirePhase0ComposedContent(t, composition, expected)
280+ require.Equal(t, 0, capture.cursor.Row)
281+ require.Equal(t, 4, capture.cursor.Col)
282+ // The private remote VT retains its title as terminal state, but the
283+ // composed local output below contains no title-setting control sequence.
284+ require.Equal(t, "remote-title-2", screen.TerminalTitle())
285+
286+ // A control sequence is consumed before composition and cannot appear in
287+ // the locally rendered result.
288+ localBytes, err := renderer.New(renderer.Capabilities{}).Draw(composition, []renderer.Damage{renderer.FullRedraw()})
289+ require.NoError(t, err)
290+ require.NotContains(t, string(localBytes), "\x1b]")
291+ require.NotContains(t, string(localBytes), "52;")
292+}
293+
294+type phase0OrderedFrame struct {
295+ metadata bool
296+ stateBearing bool
297+ epoch uint64
298+ base uint64
299+ next uint64
300+ full bool
301+}
302+
303+type phase0OrderedFrameContract struct {
304+ metadataSeen bool
305+ contentSeen bool
306+ epoch uint64
307+ next uint64
308+}
309+
310+func (c *phase0OrderedFrameContract) accept(frame phase0OrderedFrame) error {
311+ if frame.metadata {
312+ if frame.stateBearing || frame.full {
313+ return fmt.Errorf("metadata frame carries output state")
314+ }
315+ if c.metadataSeen || c.contentSeen {
316+ return fmt.Errorf("metadata must be unique and precede content")
317+ }
318+ c.metadataSeen = true
319+ return nil
320+ }
321+ if !frame.stateBearing {
322+ // Side-effect-like output does not participate in the content chain.
323+ return nil
324+ }
325+ if !c.metadataSeen {
326+ return fmt.Errorf("metadata must precede first full content")
327+ }
328+ if frame.epoch == 0 || frame.next == 0 {
329+ return fmt.Errorf("state-bearing output has invalid state numbers")
330+ }
331+ if !c.contentSeen {
332+ if !frame.full || frame.base != 0 {
333+ return fmt.Errorf("first content must be a full reset from base zero")
334+ }
335+ c.contentSeen = true
336+ c.epoch, c.next = frame.epoch, frame.next
337+ return nil
338+ }
339+ if frame.full {
340+ if frame.base != 0 {
341+ return fmt.Errorf("full content must reset from base zero")
342+ }
343+ if frame.epoch <= c.epoch {
344+ return fmt.Errorf("full content reset epoch %d is not newer than %d", frame.epoch, c.epoch)
345+ }
346+ c.epoch, c.next = frame.epoch, frame.next
347+ return nil
348+ }
349+ if frame.epoch != c.epoch {
350+ return fmt.Errorf("epoch changed without a full reset")
351+ }
352+ if frame.base != c.next {
353+ return fmt.Errorf("state-bearing output gap: base=%d want=%d", frame.base, c.next)
354+ }
355+ if frame.next != frame.base+1 {
356+ return fmt.Errorf("state-bearing output must advance by one")
357+ }
358+ c.next = frame.next
359+ return nil
360+}
361+
362+func acceptPhase0Frames(frames []phase0OrderedFrame) error {
363+ var contract phase0OrderedFrameContract
364+ for i, frame := range frames {
365+ if err := contract.accept(frame); err != nil {
366+ return fmt.Errorf("frame %d: %w", i, err)
367+ }
368+ }
369+ return nil
370+}
371+
372+func TestPhase0OrderedFrameContract(t *testing.T) {
373+ metadata := phase0OrderedFrame{metadata: true}
374+ full := phase0OrderedFrame{stateBearing: true, epoch: 1, base: 0, next: 1, full: true}
375+
376+ t.Run("metadata full contiguous deltas and full reset are accepted", func(t *testing.T) {
377+ require.NoError(t, acceptPhase0Frames([]phase0OrderedFrame{
378+ {stateBearing: false},
379+ metadata,
380+ full,
381+ {stateBearing: false},
382+ {stateBearing: true, epoch: 1, base: 1, next: 2},
383+ {stateBearing: true, epoch: 2, base: 0, next: 1, full: true},
384+ {stateBearing: true, epoch: 2, base: 1, next: 2},
385+ }))
386+ })
387+
388+ tests := []struct {
389+ name string
390+ frames []phase0OrderedFrame
391+ }{
392+ {
393+ name: "first full content before metadata",
394+ frames: []phase0OrderedFrame{full},
395+ },
396+ {
397+ name: "incremental content before first full",
398+ frames: []phase0OrderedFrame{metadata, {stateBearing: true, epoch: 1, base: 0, next: 1}},
399+ },
400+ {
401+ name: "state-bearing gap",
402+ frames: []phase0OrderedFrame{
403+ metadata, full,
404+ {stateBearing: true, epoch: 1, base: 3, next: 4},
405+ },
406+ },
407+ {
408+ name: "epoch change without full reset",
409+ frames: []phase0OrderedFrame{
410+ metadata, full,
411+ {stateBearing: true, epoch: 2, base: 1, next: 2},
412+ },
413+ },
414+ {
415+ name: "full content with nonzero base",
416+ frames: []phase0OrderedFrame{
417+ metadata, full,
418+ {stateBearing: true, epoch: 2, base: 1, next: 2, full: true},
419+ },
420+ },
421+ {
422+ name: "stale full reset",
423+ frames: []phase0OrderedFrame{
424+ metadata, full,
425+ {stateBearing: true, epoch: 1, base: 0, next: 1, full: true},
426+ },
427+ },
428+ {
429+ name: "duplicate metadata after content",
430+ frames: []phase0OrderedFrame{metadata, full, metadata},
431+ },
432+ }
433+ for _, tt := range tests {
434+ t.Run(tt.name, func(t *testing.T) {
435+ require.Error(t, acceptPhase0Frames(tt.frames))
436+ })
437+ }
438+}
M internal/usecase/daemon/attachment_connection.go +71 -32
@@ -3,11 +3,11 @@ package daemon
33 import "github.com/bnema/vev/internal/ports"
44
55 // attachmentConnectionToken is the exact capability for one registered
6-// attachment connection incarnation. It is invalid as soon as any of the
7-// session, attachment membership, connection generation, transport link, or
6+// attachment connection incarnation. It is invalid as soon as the owner,
7+// attachment membership, connection generation, transport link, or
88 // coordinator lease changes.
99 type attachmentConnectionToken struct {
10- sess *session
10+ owner attachmentOwner
1111 ac *attachedClient
1212 generation uint64
1313 transport transportSnapshot
@@ -18,11 +18,19 @@ type attachmentConnectionToken struct {
1818 rebase bool
1919 }
2020
21+// attachmentToken retains the local-session constructor for the existing
22+// local route. Remote-view token construction is added with remote attachment
23+// membership in Phase 4; it must not manufacture a local-session capability.
2124 func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
22- if entry == nil || entry.core() == nil || ac == nil || tr == nil {
25+ return attachmentOwnerToken(entry, ac, tr)
26+}
27+
28+func attachmentOwnerToken(owner attachmentOwner, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
29+ owner = normalizeAttachmentOwner(owner)
30+ if owner == nil || ac == nil || tr == nil {
2331 return attachmentConnectionToken{}
2432 }
25- // Capture one concrete link incarnation before taking the session lock. The
33+ // Capture one concrete link incarnation before taking the owner lock. The
2634 // same snapshot is revalidated below; do not take a second snapshot and
2735 // accidentally bind a replacement link.
2836 transport := ac.transportSnapshot()
@@ -32,23 +40,38 @@ func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) att
3240 if ac.beforeAttachmentTokenValidation != nil {
3341 ac.beforeAttachmentTokenValidation()
3442 }
35- core := entry.core()
36- core.mu.Lock()
37- if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) {
43+
44+ token := attachmentConnectionToken{owner: owner, ac: ac, transport: transport}
45+ switch entry := owner.(type) {
46+ case *session:
47+ if entry.core() == nil {
48+ return attachmentConnectionToken{}
49+ }
50+ core := entry.core()
51+ core.mu.Lock()
52+ if !attachmentRegisteredLocked(entry, ac) ||
53+ !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) ||
54+ !ac.transportSnapshotCurrent(transport) {
55+ core.mu.Unlock()
56+ return attachmentConnectionToken{}
57+ }
58+ token.generation = ac.connectionGeneration.Load()
59+ if rc := core.coordinator.Load(); rc != nil {
60+ token.lease = rc.attachmentLease(ac)
61+ }
3862 core.mu.Unlock()
63+ case *remoteView:
64+ entry.mu.Lock()
65+ _, registered := entry.attachments[ac]
66+ if !registered || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) || !ac.transportSnapshotCurrent(transport) {
67+ entry.mu.Unlock()
68+ return attachmentConnectionToken{}
69+ }
70+ token.generation = ac.connectionGeneration.Load()
71+ entry.mu.Unlock()
72+ default:
3973 return attachmentConnectionToken{}
4074 }
41- generation := ac.connectionGeneration.Load()
42- token := attachmentConnectionToken{
43- sess: entry,
44- ac: ac,
45- generation: generation,
46- transport: transport,
47- }
48- if rc := core.coordinator.Load(); rc != nil {
49- token.lease = rc.attachmentLease(ac)
50- }
51- core.mu.Unlock()
5275 if !token.current() {
5376 return attachmentConnectionToken{}
5477 }
@@ -60,27 +83,39 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
6083 return attachmentToken(s, ac, tr)
6184 }
6285
63-// current validates every immutable identity captured by the token. Membership
64-// is the sole authority; there is no owner, replacement, or compatibility role.
86+// localSession narrows a token only for explicitly local operations. It is
87+// intentionally nil for a remote owner, so local PTY/persistence paths fail
88+// closed rather than treating remote metadata as a local session.
89+func (t attachmentConnectionToken) localSession() *session {
90+ return localSession(t.owner)
91+}
92+
93+// current validates every immutable identity captured by the token. The owner
94+// binding is the sole mutable authority; the local session is only a derived
95+// capability for local attachment membership and coordinator validation.
6596 func (t attachmentConnectionToken) current() bool {
66- return t.sess != nil && t.ac != nil &&
97+ return t.owner != nil && t.ac != nil &&
6798 t.ac.connectionGeneration.Load() == t.generation &&
68- t.ac.currentAttachmentSession() == t.sess &&
99+ sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) &&
69100 t.ac.transportSnapshotCurrent(t.transport) &&
70- attachmentRegistered(t.sess, t.ac)
101+ attachmentOwnerRegistered(t.owner, t.ac)
71102 }
72103
73104 // attachmentCurrent additionally validates the optional coordinator lease used
74105 // by render and resize effects. Attachments without that optional lease still
75106 // own their independent connection lifecycle.
76107 func (t attachmentConnectionToken) attachmentCurrent() bool {
77- if !t.current() || t.ac.currentAttachmentSession() != t.sess {
108+ if !t.current() {
78109 return false
79110 }
80111 if t.lease == nil {
81112 return true
82113 }
83- rc := t.sess.core().coordinator.Load()
114+ entry := t.localSession()
115+ if entry == nil {
116+ return false
117+ }
118+ rc := entry.core().coordinator.Load()
84119 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
85120 }
86121
@@ -108,23 +143,27 @@ func (t *attachmentConnectionToken) endAttachmentEffect() {
108143 t.effect = nil
109144 }
110145
111-// attachmentEffectCurrentSessionLocked is the same check at a session-owned
112-// mutation boundary where sess.core().mu is already held.
146+// attachmentEffectCurrentSessionLocked is the same check at a local
147+// session-owned mutation boundary where sess.core().mu is already held.
113148 func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {
149+ entry := t.localSession()
150+ if entry == nil {
151+ return false
152+ }
114153 if t.effect != nil && !t.effect.ended.Load() {
115154 return true
116155 }
117- if t.sess == nil || t.ac == nil ||
156+ if t.ac == nil ||
118157 t.ac.connectionGeneration.Load() != t.generation ||
119158 !t.ac.transportSnapshotCurrent(t.transport) ||
120- t.ac.currentAttachmentSession() != t.sess ||
121- !attachmentRegisteredLocked(t.sess, t.ac) {
159+ !sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) ||
160+ !attachmentRegisteredLocked(entry, t.ac) {
122161 return false
123162 }
124163 if t.lease == nil {
125164 return true
126165 }
127- rc := t.sess.core().coordinator.Load()
166+ rc := entry.core().coordinator.Load()
128167 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
129168 }
130169
A internal/usecase/daemon/attachment_connection_test.go +35 -0
@@ -0,0 +1,35 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestAttachmentOwnerTokenRejectsTypedNilRemoteView(t *testing.T) {
10+ var view *remoteView
11+ ac := &attachedClient{tr: &closeTrackingTransport{}}
12+
13+ token := attachmentOwnerToken(view, ac, ac.transport())
14+
15+ require.Nil(t, token.owner)
16+ require.Nil(t, token.ac)
17+}
18+
19+func TestTypedNilRemoteOwnerCannotEnterBackNavigation(t *testing.T) {
20+ var view *remoteView
21+ ac := &attachedClient{}
22+
23+ require.NotPanics(t, func() {
24+ require.NoError(t, (&Daemon{}).backSessionForAttachment(attachmentConnectionToken{owner: view, ac: ac}))
25+ })
26+}
27+
28+func TestRecordPreviousOwnerIgnoresTypedNilRemoteView(t *testing.T) {
29+ var view *remoteView
30+ ac := &attachedClient{}
31+
32+ ac.recordPreviousOwner(view)
33+
34+ require.Nil(t, ac.previousOwner.Get())
35+}
M internal/usecase/daemon/attachment_effect_gate.go +15 -7
@@ -18,7 +18,7 @@ const (
1818 // effect admission. It deliberately contains no independently mutable state:
1919 // a ticket is admitted only when every field matches the frame's token.
2020 type attachmentCapability struct {
21- sess *session
21+ owner attachmentOwner
2222 generation uint64
2323 transport transportSnapshot
2424 lease *attachmentLease
@@ -26,7 +26,7 @@ type attachmentCapability struct {
2626
2727 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
2828 return attachmentCapability{
29- sess: token.sess,
29+ owner: token.owner,
3030 generation: token.generation,
3131 transport: token.transport,
3232 lease: token.lease,
@@ -34,7 +34,7 @@ func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
3434 }
3535
3636 func (c attachmentCapability) matches(token attachmentConnectionToken) bool {
37- return token.ac != nil && c.sess == token.sess &&
37+ return token.ac != nil && sameAttachmentOwner(c.owner, token.owner) &&
3838 c.generation == token.generation && c.transport.transport == token.transport.transport &&
3939 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
4040 }
@@ -219,11 +219,19 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken)
219219 // after Welcome has completed so a replacement blocked behind that send can
220220 // publish before readiness or first paint.
221221 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
222- if ac == nil || sess == nil || tr == nil {
222+ return ac.beginCurrentAttachmentOwnerEffect(sess, tr)
223+}
224+
225+// beginCurrentAttachmentOwnerEffect waits out a transition that already froze
226+// ac, then admits the capability derived from one exact attachment owner.
227+// Handshakes use it after Welcome so a resumed remote view follows the same
228+// generation-fenced client lifecycle as a local session.
229+func (ac *attachedClient) beginCurrentAttachmentOwnerEffect(owner attachmentOwner, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
230+ if ac == nil || normalizeAttachmentOwner(owner) == nil || tr == nil {
223231 return attachmentConnectionToken{}, nil, false
224232 }
225233 for {
226- token := sess.attachmentToken(ac, tr)
234+ token := attachmentOwnerToken(owner, ac, tr)
227235 if token.ac == nil {
228236 return token, nil, false
229237 }
@@ -271,13 +279,13 @@ func (ac *attachedClient) publishAttachmentCapability(token attachmentConnection
271279 // bootstrapAttachmentCapability supports direct/headless session construction. It
272280 // never changes an existing or frozen production publication.
273281 func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {
274- if ac == nil || token.sess == nil || token.ac != ac {
282+ if ac == nil || token.owner == nil || token.ac != ac {
275283 return
276284 }
277285 g := &ac.attachmentEffects
278286 g.mu.Lock()
279287 g.initLocked()
280- if g.phase == attachmentEffectsStable && g.capability.sess == nil {
288+ if g.phase == attachmentEffectsStable && g.capability.owner == nil {
281289 g.capability = capabilityFromToken(token)
282290 g.failedTransport = transportSnapshot{}
283291 }
A internal/usecase/daemon/attachment_handshake.go +113 -0
@@ -0,0 +1,113 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// completeAttachmentHandshake publishes one already-routed attachment. Its
10+// owner is deliberately generic so a resumed remote view receives the same
11+// local-client Welcome/output lifecycle without being manufactured as a local
12+// session.
13+func (d *Daemon) completeAttachmentHandshake(handshakeCtx context.Context, stopHandshakeTransport, finishHandshake func(), tr ports.Transport, owner attachmentOwner, ac *attachedClient) {
14+ failAttachment := func() {
15+ d.failHandshakeAttachmentOwner(owner, ac, tr)
16+ }
17+ if ac == nil || normalizeAttachmentOwner(owner) == nil || handshakeCtx.Err() != nil {
18+ failAttachment()
19+ return
20+ }
21+
22+ expected := ac.transportSnapshot()
23+ welcomeToken := attachmentOwnerToken(owner, ac, tr)
24+ welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
25+ if expected.transport != tr || !admitted || welcomeToken.ac == nil {
26+ if admitted {
27+ welcomeTicket.End()
28+ }
29+ if !d.abortResumeClaim(ac) {
30+ failAttachment()
31+ }
32+ return
33+ }
34+ if handshakeCtx.Err() != nil {
35+ welcomeTicket.End()
36+ failAttachment()
37+ return
38+ }
39+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
40+ return ac.sendExpectedTransportForAttachment(expected, frameWelcomeForAttachment(owner, ac), welcomeTicket)
41+ }); err != nil {
42+ welcomeTicket.End()
43+ failAttachment()
44+ return
45+ }
46+ // Release Welcome's effect before discovering post-handshake authority so a
47+ // replacement blocked behind the send can publish its generation and lease.
48+ welcomeTicket.End()
49+
50+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentOwnerEffect(owner, tr)
51+ if !admitted || postWelcomeToken.ac == nil {
52+ if postWelcomeTicket != nil {
53+ postWelcomeTicket.End()
54+ }
55+ failAttachment()
56+ return
57+ }
58+ sess := localSession(owner)
59+ var lease *attachmentLease
60+ var rc *renderCoordinator
61+ if sess != nil {
62+ rc = sess.renderCoordinator()
63+ if rc != nil {
64+ lease = rc.attachmentLease(ac)
65+ }
66+ }
67+ if sess != nil && postWelcomeToken.ac.renderMode == ports.RenderModeProxiedContent {
68+ meta, metaErr := frameSessionMeta(sess, postWelcomeToken.ac, 1)
69+ if metaErr != nil {
70+ postWelcomeTicket.End()
71+ failAttachment()
72+ return
73+ }
74+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
75+ return postWelcomeToken.ac.sendExpectedTransportForAttachment(expected, meta, postWelcomeTicket)
76+ }); err != nil {
77+ postWelcomeTicket.End()
78+ failAttachment()
79+ return
80+ }
81+ postWelcomeToken.ac.sendMu.Lock()
82+ postWelcomeToken.ac.proxiedMetaRevision = 1
83+ postWelcomeToken.ac.sendMu.Unlock()
84+ }
85+ if lease != nil && (rc == nil || !rc.markAttachmentReady(lease)) {
86+ postWelcomeTicket.End()
87+ failAttachment()
88+ return
89+ }
90+ postWelcomeTicket.End()
91+
92+ paintToken := attachmentOwnerToken(owner, ac, tr)
93+ painted := make(chan bool, 1)
94+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
95+ painted <- d.firstPaintForTransition(paintToken)
96+ return nil
97+ }); err != nil {
98+ failAttachment()
99+ return
100+ }
101+ if !<-painted || handshakeCtx.Err() != nil {
102+ failAttachment()
103+ return
104+ }
105+ if !d.commitResumeClaim(ac) {
106+ failAttachment()
107+ return
108+ }
109+ stopHandshakeTransport()
110+ finishHandshake()
111+ d.runConnLoop(ac)
112+ _ = tr.Close()
113+}
A internal/usecase/daemon/attachment_owner.go +281 -0
@@ -0,0 +1,281 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/vt"
10+)
11+
12+// attachmentOwner is the sole mutable owner binding for an attached client.
13+// It is deliberately sealed to this package: only a local session or a
14+// daemon-owned remote view may own an attachment. Local-only operations must
15+// obtain a *session through localSession instead of assuming every owner has
16+// PTYs, persistence, or a tab tree.
17+type attachmentOwner interface {
18+ attachmentOwner()
19+}
20+
21+func (*session) attachmentOwner() {}
22+
23+// remoteViewID is allocated by the local daemon. It is distinct from the
24+// remote lifecycle identity: a remote lifecycle validates the remote endpoint,
25+// while this ID identifies the exact local object that owns attachments and
26+// asynchronous lifecycle work.
27+type remoteViewID uint64
28+
29+// remoteViewKey identifies the one reusable local view for an exact remote
30+// session lifecycle. Display data and tab selection are intentionally absent:
31+// both are mutable presentation state, not remote-link identity.
32+type remoteViewKey struct {
33+ endpoint string
34+ lifecycleID domain.SessionLifecycleID
35+ sessionName string
36+}
37+
38+type remoteViewLinkState uint8
39+
40+const (
41+ remoteViewLinkHealthy remoteViewLinkState = iota
42+ remoteViewLinkReconnecting
43+ remoteViewLinkUnavailable
44+)
45+
46+func remoteViewKeyForTarget(target domain.RemoteSessionTarget) (remoteViewKey, error) {
47+ if err := target.Validate(); err != nil {
48+ return remoteViewKey{}, err
49+ }
50+ return remoteViewKey{
51+ endpoint: target.Endpoint,
52+ lifecycleID: target.LifecycleID,
53+ sessionName: target.SessionName,
54+ }, nil
55+}
56+
57+// remoteView is intentionally attachment-facing only. Its private VT and
58+// presentation metadata are the remote content boundary: neither carries a
59+// local PTY, persistence, tab tree, or render coordinator. The remote-link
60+// lifecycle owns writes to them in Phase 4; local composition only snapshots
61+// them while holding this mutex.
62+type remoteView struct {
63+ id remoteViewID
64+ key remoteViewKey
65+
66+ mu sync.Mutex
67+ // tabActivationMu serializes command/metadata activation sequences without
68+ // retaining mu while waiting for remote transport or metadata frames.
69+ tabActivationMu sync.Mutex
70+ closed bool
71+ attachments map[*attachedClient]struct{}
72+ screen *vt.Screen
73+ metadata ports.SessionMeta
74+ displayOrigin string
75+ // link and linkGeneration fence every remote transport callback. They are
76+ // owned by the view rather than the local-session registry, and neither
77+ // is ever used while view.mu is held for transport I/O.
78+ link *remoteLink
79+ linkGeneration uint64
80+ // metadataChanged is replaced and the prior channel is closed after every
81+ // exact metadata or link-generation change. Waiters use it only after
82+ // releasing view.mu, so it never carries mutable metadata itself.
83+ metadataChanged chan struct{}
84+ linkState remoteViewLinkState
85+ // reconnectTarget is the currently active running tab route used only for
86+ // a later link replacement. It is guarded by mu with link state.
87+ reconnectTarget domain.RemoteSessionTarget
88+ // reconnectGeneration fences a single automatic reconnect attempt against
89+ // its failed link generation and any later terminal/replacement lifecycle.
90+ reconnectGeneration uint64
91+ output remoteOutputState
92+ resetRequested bool
93+ // warm and warmGeneration fence the five-minute retention timer for an
94+ // unattached view. The timer is always stopped after view.mu is released.
95+ warm *remoteViewWarm
96+ warmGeneration uint64
97+}
98+
99+func (*remoteView) attachmentOwner() {}
100+
101+func (v *remoteView) registerAttachmentLocked(ac *attachedClient) bool {
102+ if v == nil || v.closed || ac == nil {
103+ return false
104+ }
105+ if v.attachments == nil {
106+ v.attachments = make(map[*attachedClient]struct{})
107+ }
108+ if _, exists := v.attachments[ac]; exists {
109+ return false
110+ }
111+ v.attachments[ac] = struct{}{}
112+ return true
113+}
114+
115+func (v *remoteView) unregisterAttachmentLocked(ac *attachedClient) bool {
116+ if v == nil || ac == nil {
117+ return false
118+ }
119+ if _, exists := v.attachments[ac]; !exists {
120+ return false
121+ }
122+ delete(v.attachments, ac)
123+ return true
124+}
125+
126+func (v *remoteView) attachmentRegistered(ac *attachedClient) bool {
127+ if v == nil || ac == nil {
128+ return false
129+ }
130+ v.mu.Lock()
131+ defer v.mu.Unlock()
132+ if v.closed {
133+ return false
134+ }
135+ _, registered := v.attachments[ac]
136+ return registered
137+}
138+
139+// close retires attachment membership without transport I/O and returns the
140+// exact active attachments for retirement after the caller releases view.mu.
141+// The daemon first marks shutdown under its registry lock, then uses that
142+// snapshot to interrupt each blocked local-client transport.
143+func (v *remoteView) close() []*attachedClient {
144+ if v == nil {
145+ return nil
146+ }
147+ v.mu.Lock()
148+ attachments := make([]*attachedClient, 0, len(v.attachments))
149+ for ac := range v.attachments {
150+ attachments = append(attachments, ac)
151+ }
152+ v.closed = true
153+ clear(v.attachments)
154+ v.mu.Unlock()
155+ return attachments
156+}
157+
158+// retireShutdownRemoteAttachment closes one exact local-client transport after
159+// a remote view is unpublished. It never performs I/O while holding daemon or
160+// remote-view locks; a future remote link is separately retired in Phase 4.
161+func (d *Daemon) retireShutdownRemoteAttachment(view *remoteView, ac *attachedClient, reason uint8) {
162+ if d == nil || view == nil || ac == nil {
163+ return
164+ }
165+ if !sameAttachmentOwner(ac.currentAttachmentOwner(), view) {
166+ return
167+ }
168+ ac.connectionGeneration.Add(1)
169+ ac.setAttachmentOwner(nil)
170+ ac.clearPreviousSession()
171+ transport := ac.transport()
172+ d.boundedSend(ac, frameDetached(reason))
173+ _ = ac.closeCapturedTransport(ac.revokeTransport(transport))
174+}
175+
176+func attachmentOwnerRegistered(owner attachmentOwner, ac *attachedClient) bool {
177+ switch owner := owner.(type) {
178+ case *session:
179+ return attachmentRegistered(owner, ac)
180+ case *remoteView:
181+ return owner.attachmentRegistered(ac)
182+ default:
183+ return false
184+ }
185+}
186+
187+func normalizeAttachmentOwner(owner attachmentOwner) attachmentOwner {
188+ switch typed := owner.(type) {
189+ case *session:
190+ if typed == nil {
191+ return nil
192+ }
193+ case *remoteView:
194+ if typed == nil {
195+ return nil
196+ }
197+ }
198+ return owner
199+}
200+
201+func localSession(owner attachmentOwner) *session {
202+ sess, _ := normalizeAttachmentOwner(owner).(*session)
203+ return sess
204+}
205+
206+func sameAttachmentOwner(a, b attachmentOwner) bool {
207+ a, b = normalizeAttachmentOwner(a), normalizeAttachmentOwner(b)
208+ return a != nil && a == b
209+}
210+
211+func attachmentOwnerName(owner attachmentOwner) string {
212+ switch owner := normalizeAttachmentOwner(owner).(type) {
213+ case *session:
214+ owner.mu.Lock()
215+ defer owner.mu.Unlock()
216+ return owner.name
217+ case *remoteView:
218+ return owner.key.sessionName
219+ default:
220+ return ""
221+ }
222+}
223+
224+// attachmentOwnerRegisteredByDaemonLocked verifies the owner has not been
225+// removed or replaced in its concrete registry. Caller holds d.mu.
226+func (d *Daemon) attachmentOwnerRegisteredByDaemonLocked(owner attachmentOwner) bool {
227+ switch owner := normalizeAttachmentOwner(owner).(type) {
228+ case *session:
229+ return owner.core() != nil && d.sessions[owner.core().id] == owner
230+ case *remoteView:
231+ return d.remoteViews[owner.id] == owner && d.remoteViewsByKey[owner.key] == owner.id
232+ default:
233+ return false
234+ }
235+}
236+
237+func (d *Daemon) registerRemoteViewLocked(view *remoteView) error {
238+ if d == nil || view == nil || view.key.endpoint == "" ||
239+ view.key.lifecycleID == (domain.SessionLifecycleID{}) || view.key.sessionName == "" {
240+ return errors.New("invalid remote view")
241+ }
242+ if d.remoteViews == nil {
243+ d.remoteViews = make(map[remoteViewID]*remoteView)
244+ }
245+ if d.remoteViewsByKey == nil {
246+ d.remoteViewsByKey = make(map[remoteViewKey]remoteViewID)
247+ }
248+ if existingID, exists := d.remoteViewsByKey[view.key]; exists {
249+ if d.remoteViews[existingID] == view {
250+ return nil
251+ }
252+ return errors.New("remote view key is already registered")
253+ }
254+ if view.id == 0 {
255+ d.nextRemoteViewID++
256+ view.id = d.nextRemoteViewID
257+ } else if d.remoteViews[view.id] != nil {
258+ return errors.New("remote view ID is already registered")
259+ } else if view.id > d.nextRemoteViewID {
260+ d.nextRemoteViewID = view.id
261+ }
262+ d.remoteViews[view.id] = view
263+ d.remoteViewsByKey[view.key] = view.id
264+ return nil
265+}
266+
267+func (d *Daemon) remoteViewByKeyLocked(key remoteViewKey) *remoteView {
268+ if d == nil {
269+ return nil
270+ }
271+ return d.remoteViews[d.remoteViewsByKey[key]]
272+}
273+
274+func (d *Daemon) unregisterRemoteViewLocked(view *remoteView) bool {
275+ if d == nil || view == nil || d.remoteViews[view.id] != view || d.remoteViewsByKey[view.key] != view.id {
276+ return false
277+ }
278+ delete(d.remoteViews, view.id)
279+ delete(d.remoteViewsByKey, view.key)
280+ return true
281+}
A internal/usecase/daemon/attachment_owner_test.go +564 -0
@@ -0,0 +1,564 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/pkg/vt"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type parkedRemoteResumeTransport struct {
16+ closeOnce sync.Once
17+ closed chan struct{}
18+ sends chan ports.Frame
19+}
20+
21+func newParkedRemoteResumeTransport() *parkedRemoteResumeTransport {
22+ return &parkedRemoteResumeTransport{closed: make(chan struct{}), sends: make(chan ports.Frame, 4)}
23+}
24+
25+func (t *parkedRemoteResumeTransport) Send(frame ports.Frame) error {
26+ t.sends <- frame
27+ return nil
28+}
29+
30+func (t *parkedRemoteResumeTransport) Recv() (ports.Frame, error) {
31+ <-t.closed
32+ return ports.Frame{}, io.EOF
33+}
34+
35+func (t *parkedRemoteResumeTransport) Close() error {
36+ t.closeOnce.Do(func() { close(t.closed) })
37+ return nil
38+}
39+
40+type failingRemoteTransport struct {
41+ closeTrackingTransport
42+}
43+
44+func (*failingRemoteTransport) Send(ports.Frame) error {
45+ return errors.New("send failed")
46+}
47+
48+func TestAttachedClientOwnerBindingNarrowsLocalOnlyBehavior(t *testing.T) {
49+ local := &session{sessionCore: sessionCore{id: "local"}}
50+ remote := &remoteView{id: 7}
51+ ac := &attachedClient{}
52+
53+ ac.setSession(local)
54+ require.Same(t, local, ac.currentAttachmentOwner())
55+ require.Same(t, local, ac.currentAttachmentSession())
56+
57+ ac.setAttachmentOwner(remote)
58+ require.Same(t, remote, ac.currentAttachmentOwner())
59+ require.Nil(t, ac.currentAttachmentSession(), "remote owners must not reach local-only paths")
60+}
61+
62+func TestAttachedClientOwnerBindingNormalizesTypedNil(t *testing.T) {
63+ ac := &attachedClient{}
64+
65+ ac.setSession(nil)
66+ require.Nil(t, ac.currentAttachmentOwner())
67+
68+ var remote *remoteView
69+ ac.setAttachmentOwner(remote)
70+ require.Nil(t, ac.currentAttachmentOwner())
71+}
72+
73+func TestAttachmentTokenRejectsAnOwnerReboundToRemoteView(t *testing.T) {
74+ transport := &closeTrackingTransport{}
75+ local := &session{sessionCore: sessionCore{id: "local"}}
76+ ac := &attachedClient{tr: transport}
77+ ac.setSession(local)
78+ local.mu.Lock()
79+ require.True(t, local.registerAttachmentLocked(ac))
80+ local.mu.Unlock()
81+
82+ token := local.attachmentToken(ac, transport)
83+ require.True(t, token.current())
84+
85+ ac.setAttachmentOwner(&remoteView{id: 1})
86+ require.False(t, token.current(), "a local token must not survive an exact owner rebind")
87+}
88+
89+func TestRemoteAttachmentTokenUsesExactRemoteOwnerMembership(t *testing.T) {
90+ transport := &closeTrackingTransport{}
91+ view := &remoteView{id: 1}
92+ ac := &attachedClient{tr: transport}
93+ ac.setAttachmentOwner(view)
94+ view.mu.Lock()
95+ require.True(t, view.registerAttachmentLocked(ac))
96+ view.mu.Unlock()
97+
98+ token := attachmentOwnerToken(view, ac, transport)
99+ require.True(t, token.current())
100+ require.True(t, token.attachmentCurrent(), "remote tokens do not acquire a local render lease")
101+
102+ view.mu.Lock()
103+ require.True(t, view.unregisterAttachmentLocked(ac))
104+ view.mu.Unlock()
105+ require.False(t, token.current(), "remote membership is part of exact token identity")
106+}
107+
108+func TestTransitionToRemoteViewPublishesOneAuthoritativeOwner(t *testing.T) {
109+ d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
110+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
111+ d.mu.Lock()
112+ require.NoError(t, d.registerRemoteViewLocked(view))
113+ d.mu.Unlock()
114+
115+ before := source.attachmentToken(ac, ac.transport())
116+ require.True(t, before.current())
117+ published, err := d.transitionToRemoteView(before, view)
118+ require.NoError(t, err)
119+
120+ require.False(t, before.current())
121+ require.True(t, published.current())
122+ require.Same(t, view, ac.currentAttachmentOwner())
123+ require.Empty(t, source.snapshotAttachments())
124+ require.True(t, view.attachmentRegistered(ac))
125+ require.Same(t, source, ac.previousOwner.Get())
126+}
127+
128+func TestRemoteViewFirstPaintComposesPrivateContentUnderLocalChrome(t *testing.T) {
129+ d, source, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
130+ view := &remoteView{
131+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
132+ screen: vt.NewScreen(80, 22),
133+ }
134+ view.screen.Write([]byte("remote content"))
135+ d.mu.Lock()
136+ require.NoError(t, d.registerRemoteViewLocked(view))
137+ d.mu.Unlock()
138+
139+ token, err := d.transitionToRemoteView(source.attachmentToken(ac, ac.transport()), view)
140+ require.NoError(t, err)
141+ require.True(t, d.firstPaintForTransition(token))
142+
143+ output := mustApplyOutput(t, vt.NewScreen(80, 24), awaitOutputFrameWithoutSleep(t, sends))
144+ require.True(t, output.Full)
145+ terminal := vt.NewScreen(80, 24)
146+ terminal.Write(output.Data)
147+ require.Contains(t, rowText(terminal.Snapshot().Row(0)), "remote")
148+ require.Contains(t, rowText(terminal.Snapshot().Row(1)), "remote content")
149+}
150+
151+func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) {
152+ d := newTestDaemon(t, nil, stubClock{})
153+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
154+ d.mu.Lock()
155+ require.NoError(t, d.registerRemoteViewLocked(view))
156+ d.mu.Unlock()
157+
158+ transport := &closeTrackingTransport{}
159+ ac := &attachedClient{tr: transport}
160+ ac.setAttachmentOwner(view)
161+ view.mu.Lock()
162+ require.True(t, view.registerAttachmentLocked(ac))
163+ view.mu.Unlock()
164+ token := attachmentOwnerToken(view, ac, transport)
165+ require.True(t, token.current())
166+
167+ d.shutdownAll(ports.ReasonServerShutdown)
168+
169+ d.mu.Lock()
170+ require.Empty(t, d.remoteViews)
171+ require.Empty(t, d.remoteViewsByKey)
172+ d.mu.Unlock()
173+ require.False(t, token.current())
174+ require.Nil(t, ac.currentAttachmentOwner())
175+ require.True(t, transport.Closed())
176+}
177+
178+func TestRemoteClientGoneParksExactOwnerBinding(t *testing.T) {
179+ d := newTestDaemon(t, nil, stubClock{})
180+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
181+ d.mu.Lock()
182+ require.NoError(t, d.registerRemoteViewLocked(view))
183+ d.mu.Unlock()
184+
185+ transport := &closeTrackingTransport{}
186+ ac := &attachedClient{tr: transport, resumeCapable: true}
187+ ac.setAttachmentOwner(view)
188+ view.mu.Lock()
189+ require.True(t, view.registerAttachmentLocked(ac))
190+ view.mu.Unlock()
191+ token := attachmentOwnerToken(view, ac, transport)
192+
193+ require.True(t, d.clientGoneRemote(view, token, false))
194+ require.Nil(t, ac.currentAttachmentOwner())
195+ require.False(t, view.attachmentRegistered(ac))
196+ d.mu.Lock()
197+ parked := d.parked[ac.resumeToken]
198+ d.mu.Unlock()
199+ require.NotNil(t, parked)
200+ require.Same(t, view, parked.owner)
201+}
202+
203+func TestRemoteClientResumeRebindsParkedAttachmentToExactView(t *testing.T) {
204+ d := newTestDaemon(t, nil, stubClock{})
205+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
206+ d.mu.Lock()
207+ require.NoError(t, d.registerRemoteViewLocked(view))
208+ d.mu.Unlock()
209+
210+ oldTransport := &closeTrackingTransport{}
211+ ac := &attachedClient{
212+ tr: oldTransport,
213+ output: newOutputStateStream(),
214+ size: domain.Size{Cols: 80, Rows: 24},
215+ resumeCapable: true,
216+ clientID: [16]byte{1},
217+ }
218+ ac.setAttachmentOwner(view)
219+ view.mu.Lock()
220+ require.True(t, view.registerAttachmentLocked(ac))
221+ view.mu.Unlock()
222+ oldToken := attachmentOwnerToken(view, ac, oldTransport)
223+ require.True(t, d.clientGoneRemote(view, oldToken, false))
224+ parkedToken := ac.resumeToken
225+
226+ newTransport := &closeTrackingTransport{}
227+ resumedView, resumed, handled, err := d.resumeParkedRemoteView(ports.Hello{
228+ Version: ports.ProtocolVersion,
229+ Intent: ports.IntentResume,
230+ ResumeToken: parkedToken,
231+ ClientID: ac.clientID,
232+ Size: domain.Size{Cols: 100, Rows: 30},
233+ }, newTransport)
234+ require.NoError(t, err)
235+ require.True(t, handled)
236+ require.Same(t, view, resumedView)
237+ require.Same(t, ac, resumed)
238+ require.Same(t, view, ac.currentAttachmentOwner())
239+ require.True(t, view.attachmentRegistered(ac))
240+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, ac.sizeSnapshot())
241+ require.NotEqual(t, parkedToken, ac.resumeToken)
242+ require.True(t, d.commitResumeClaim(ac))
243+ d.mu.Lock()
244+ require.NotContains(t, d.parked, parkedToken)
245+ require.Empty(t, d.sessions)
246+ d.mu.Unlock()
247+}
248+
249+func TestRemoteClientLiveResumeParksOldTransportAndRebindsExactView(t *testing.T) {
250+ d := newTestDaemon(t, nil, stubClock{})
251+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
252+ d.mu.Lock()
253+ require.NoError(t, d.registerRemoteViewLocked(view))
254+ d.mu.Unlock()
255+
256+ oldTransport := &closeTrackingTransport{}
257+ ac := &attachedClient{
258+ tr: oldTransport,
259+ output: newOutputStateStream(),
260+ size: domain.Size{Cols: 80, Rows: 24},
261+ resumeCapable: true,
262+ clientID: [16]byte{1},
263+ }
264+ d.mu.Lock()
265+ ac.resumeToken = d.nextResumeTokenLocked()
266+ d.mu.Unlock()
267+ ac.setAttachmentOwner(view)
268+ view.mu.Lock()
269+ require.True(t, view.registerAttachmentLocked(ac))
270+ view.mu.Unlock()
271+ oldToken := ac.resumeToken
272+
273+ newTransport := &closeTrackingTransport{}
274+ resumedView, resumed, handled, err := d.resumeLiveRemoteView(ports.Hello{
275+ Version: ports.ProtocolVersion,
276+ Intent: ports.IntentResume,
277+ ResumeToken: oldToken,
278+ ClientID: ac.clientID,
279+ Size: domain.Size{Cols: 100, Rows: 30},
280+ }, newTransport)
281+ require.NoError(t, err)
282+ require.True(t, handled)
283+ require.Same(t, view, resumedView)
284+ require.Same(t, ac, resumed)
285+ require.True(t, oldTransport.Closed())
286+ require.Same(t, view, ac.currentAttachmentOwner())
287+ require.True(t, d.commitResumeClaim(ac))
288+}
289+
290+func TestRemoteClientResumeWaitsForInFlightRemoteParking(t *testing.T) {
291+ d := newTestDaemon(t, nil, stubClock{})
292+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
293+ d.mu.Lock()
294+ require.NoError(t, d.registerRemoteViewLocked(view))
295+ d.mu.Unlock()
296+
297+ oldTransport := &closeTrackingTransport{}
298+ ac := &attachedClient{
299+ tr: oldTransport,
300+ output: newOutputStateStream(),
301+ size: domain.Size{Cols: 80, Rows: 24},
302+ resumeCapable: true,
303+ clientID: [16]byte{1},
304+ }
305+ ac.setAttachmentOwner(view)
306+ view.mu.Lock()
307+ require.True(t, view.registerAttachmentLocked(ac))
308+ view.mu.Unlock()
309+ resumeToken := d.markParkingInFlightOwner(view, ac)
310+ d.clearParkingInFlight(resumeToken, ac)
311+
312+ detachStarted := make(chan struct{})
313+ releaseDetach := make(chan struct{})
314+ d.beforeClientGoneDetach = func() {
315+ close(detachStarted)
316+ <-releaseDetach
317+ }
318+ waitArmed := make(chan struct{})
319+ d.afterParkingWaitArmed = func() { close(waitArmed) }
320+ gone := make(chan bool, 1)
321+ go func() {
322+ gone <- d.clientGoneRemote(view, attachmentOwnerToken(view, ac, oldTransport), false)
323+ }()
324+ awaitTestCompletion(t, detachStarted, "remote detach did not publish parking")
325+
326+ type result struct {
327+ view *remoteView
328+ ac *attachedClient
329+ handled bool
330+ err error
331+ }
332+ resumed := make(chan result, 1)
333+ go func() {
334+ resumedView, resumedAC, handled, err := d.resumeParkedRemoteView(ports.Hello{
335+ Version: ports.ProtocolVersion,
336+ Intent: ports.IntentResume,
337+ ResumeToken: resumeToken,
338+ ClientID: ac.clientID,
339+ Size: domain.Size{Cols: 80, Rows: 24},
340+ }, &closeTrackingTransport{})
341+ resumed <- result{view: resumedView, ac: resumedAC, handled: handled, err: err}
342+ }()
343+ awaitTestCompletion(t, waitArmed, "remote resume did not wait for in-flight parking")
344+ close(releaseDetach)
345+ require.True(t, awaitTestValue(t, gone, "remote detach did not finish"))
346+ got := awaitTestValue(t, resumed, "remote resume did not finish")
347+ require.NoError(t, got.err)
348+ require.True(t, got.handled)
349+ require.Same(t, view, got.view)
350+ require.Same(t, ac, got.ac)
351+ require.True(t, d.commitResumeClaim(ac))
352+}
353+
354+func TestRemoteClientResumeHandshakeRestoresLocalComposition(t *testing.T) {
355+ d := newTestDaemon(t, nil, stubClock{})
356+ view := &remoteView{
357+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
358+ screen: vt.NewScreen(80, 22),
359+ }
360+ view.screen.Write([]byte("remote content"))
361+ d.mu.Lock()
362+ require.NoError(t, d.registerRemoteViewLocked(view))
363+ d.mu.Unlock()
364+
365+ oldTransport := &closeTrackingTransport{}
366+ ac := &attachedClient{
367+ tr: oldTransport,
368+ output: newOutputStateStream(),
369+ size: domain.Size{Cols: 80, Rows: 24},
370+ resumeCapable: true,
371+ clientID: [16]byte{1},
372+ }
373+ ac.initOverlays()
374+ ac.setAttachmentOwner(view)
375+ view.mu.Lock()
376+ require.True(t, view.registerAttachmentLocked(ac))
377+ view.mu.Unlock()
378+ oldToken := attachmentOwnerToken(view, ac, oldTransport)
379+ require.True(t, d.clientGoneRemote(view, oldToken, false))
380+ parkedToken := ac.resumeToken
381+
382+ transport := newParkedRemoteResumeTransport()
383+ done := make(chan struct{})
384+ go func() {
385+ d.handleHello(transport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
386+ Version: ports.ProtocolVersion,
387+ Intent: ports.IntentResume,
388+ ResumeToken: parkedToken,
389+ ClientID: ac.clientID,
390+ Size: domain.Size{Cols: 100, Rows: 30},
391+ })})
392+ close(done)
393+ }()
394+
395+ welcomeFrame := awaitFrame(t, transport.sends, ports.MsgWelcome)
396+ welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
397+ require.NoError(t, err)
398+ require.Equal(t, "remote", welcome.SessionName)
399+ require.NotEqual(t, parkedToken, welcome.ResumeToken)
400+ outputFrame := awaitFrame(t, transport.sends, ports.MsgOutput)
401+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
402+ require.NoError(t, err)
403+ require.True(t, output.Full)
404+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, output.Size)
405+ require.Same(t, view, ac.currentAttachmentOwner())
406+ require.True(t, view.attachmentRegistered(ac))
407+
408+ require.NoError(t, transport.Close())
409+ awaitTestCompletion(t, done, "remote resume handshake did not finish")
410+ d.mu.Lock()
411+ parked := d.parked[welcome.ResumeToken]
412+ d.mu.Unlock()
413+ require.NotNil(t, parked)
414+ require.Same(t, view, parked.owner)
415+}
416+
417+func TestRemoteResumeHandshakeFailureRestoresParkedOwner(t *testing.T) {
418+ d := newTestDaemon(t, nil, stubClock{})
419+ view := &remoteView{
420+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
421+ screen: vt.NewScreen(80, 22),
422+ }
423+ view.screen.Write([]byte("retained content"))
424+ d.mu.Lock()
425+ require.NoError(t, d.registerRemoteViewLocked(view))
426+ d.mu.Unlock()
427+
428+ oldTransport := &closeTrackingTransport{}
429+ ac := &attachedClient{
430+ tr: oldTransport,
431+ output: newOutputStateStream(),
432+ size: domain.Size{Cols: 80, Rows: 24},
433+ resumeCapable: true,
434+ clientID: [16]byte{1},
435+ }
436+ ac.setAttachmentOwner(view)
437+ view.mu.Lock()
438+ require.True(t, view.registerAttachmentLocked(ac))
439+ view.mu.Unlock()
440+ require.True(t, d.clientGoneRemote(view, attachmentOwnerToken(view, ac, oldTransport), false))
441+ parkedToken := ac.resumeToken
442+
443+ failed := &resumeWelcomeFailureTransport{}
444+ d.handleHello(failed, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
445+ Version: ports.ProtocolVersion,
446+ Intent: ports.IntentResume,
447+ ResumeToken: parkedToken,
448+ ClientID: ac.clientID,
449+ Size: domain.Size{Cols: 80, Rows: 24},
450+ })})
451+
452+ require.Nil(t, ac.currentAttachmentOwner())
453+ require.False(t, view.attachmentRegistered(ac))
454+ view.mu.Lock()
455+ require.Equal(t, 80, view.screen.Frame.Width)
456+ require.Equal(t, 22, view.screen.Frame.Height)
457+ view.mu.Unlock()
458+ require.Equal(t, parkedToken, ac.resumeToken)
459+ d.mu.Lock()
460+ parked := d.parked[parkedToken]
461+ d.mu.Unlock()
462+ require.NotNil(t, parked)
463+ require.False(t, parked.claimed)
464+ require.Same(t, view, parked.owner)
465+}
466+
467+func TestRemotePaintSendFailureParksExactOwnerBinding(t *testing.T) {
468+ d := newTestDaemon(t, nil, stubClock{})
469+ view := &remoteView{
470+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
471+ screen: vt.NewScreen(80, 22),
472+ }
473+ view.screen.Write([]byte("remote content"))
474+ d.mu.Lock()
475+ require.NoError(t, d.registerRemoteViewLocked(view))
476+ d.mu.Unlock()
477+
478+ transport := &failingRemoteTransport{}
479+ ac := &attachedClient{
480+ tr: transport,
481+ output: newOutputStateStream(),
482+ size: domain.Size{Cols: 80, Rows: 24},
483+ resumeCapable: true,
484+ }
485+ ac.setAttachmentOwner(view)
486+ view.mu.Lock()
487+ require.True(t, view.registerAttachmentLocked(ac))
488+ view.mu.Unlock()
489+ token := attachmentOwnerToken(view, ac, transport)
490+ cleanupDone := make(chan struct{})
491+ d.afterAttachmentSendErrorCleanup = func() { close(cleanupDone) }
492+
493+ d.paintRemoteView(view, ac, true, token)
494+ awaitTestCompletion(t, cleanupDone, "remote send-error cleanup did not finish")
495+ require.Nil(t, ac.currentAttachmentOwner())
496+ require.False(t, view.attachmentRegistered(ac))
497+ d.mu.Lock()
498+ parked := d.parked[ac.resumeToken]
499+ d.mu.Unlock()
500+ require.NotNil(t, parked)
501+ require.Same(t, view, parked.owner)
502+}
503+
504+func TestRemoteParkingRetainsStableOwnerBinding(t *testing.T) {
505+ d := newTestDaemon(t, nil, stubClock{})
506+ view := &remoteView{
507+ id: 1,
508+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
509+ }
510+ d.mu.Lock()
511+ require.NoError(t, d.registerRemoteViewLocked(view))
512+ d.mu.Unlock()
513+
514+ ac := &attachedClient{tr: &closeTrackingTransport{}, resumeCapable: true}
515+ ac.setAttachmentOwner(view)
516+ view.mu.Lock()
517+ require.True(t, view.registerAttachmentLocked(ac))
518+ view.mu.Unlock()
519+
520+ token := d.markParkingInFlightOwner(view, ac)
521+ require.NotZero(t, token)
522+ view.mu.Lock()
523+ require.True(t, view.unregisterAttachmentLocked(ac))
524+ view.mu.Unlock()
525+ ac.setAttachmentOwner(nil)
526+ require.True(t, d.parkAttachmentOwner(view, ac))
527+
528+ d.mu.Lock()
529+ parked := d.parked[token]
530+ d.mu.Unlock()
531+ require.NotNil(t, parked)
532+ require.Same(t, view, parked.owner)
533+}
534+
535+func TestRemoteViewRegistryUsesDaemonLocalIDAndExactRemoteLifecycleKey(t *testing.T) {
536+ target := domain.RemoteSessionTarget{
537+ Endpoint: "user@host",
538+ DisplayOrigin: "host",
539+ LifecycleID: domain.SessionLifecycleID{1},
540+ SessionName: "remote",
541+ LiveTabID: "tab-1",
542+ }
543+ key, err := remoteViewKeyForTarget(target)
544+ require.NoError(t, err)
545+
546+ d := &Daemon{
547+ remoteViews: make(map[remoteViewID]*remoteView),
548+ remoteViewsByKey: make(map[remoteViewKey]remoteViewID),
549+ }
550+ first := &remoteView{key: key}
551+ second := &remoteView{key: key}
552+
553+ d.mu.Lock()
554+ require.NoError(t, d.registerRemoteViewLocked(first))
555+ require.NotZero(t, first.id)
556+ require.Same(t, first, d.remoteViewByKeyLocked(key))
557+ require.Error(t, d.registerRemoteViewLocked(second), "a lifecycle key cannot alias another local view")
558+ require.Zero(t, second.id, "a rejected registration must not consume a local view ID")
559+ require.Len(t, d.remoteViews, 1)
560+ require.Len(t, d.remoteViewsByKey, 1)
561+ require.True(t, d.unregisterRemoteViewLocked(first))
562+ require.Nil(t, d.remoteViewByKeyLocked(key))
563+ d.mu.Unlock()
564+}
M internal/usecase/daemon/attachment_transition.go +3 -3
@@ -45,7 +45,7 @@ type attachmentTransitionRequest struct {
4545 }
4646
4747 func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {
48- return token.sess == source && token.ac == req.next &&
48+ return sameAttachmentOwner(token.owner, source) && token.ac == req.next &&
4949 token.generation == req.next.connectionGeneration.Load() &&
5050 token.transport.transport == req.expectedTransport.transport &&
5151 token.transport.incarnation == req.expectedTransport.incarnation
@@ -55,9 +55,9 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source
5555 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection
5656 // handoff check for client-originated navigation and lifecycle mutations.
5757 func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
58- if token.sess != source || token.ac != req.next ||
58+ if !sameAttachmentOwner(token.owner, source) || token.ac != req.next ||
5959 token.generation != req.next.connectionGeneration.Load() ||
60- token.sess == nil || token.ac.currentAttachmentSession() != source ||
60+ token.localSession() == nil || !sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) ||
6161 !attachmentRegisteredLocked(source, req.next) ||
6262 !req.next.transportSnapshotCurrent(token.transport) {
6363 return false
M internal/usecase/daemon/attachment_transition_publish.go +9 -8
@@ -29,25 +29,26 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
2929 }
3030
3131 func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {
32- if token.sess == nil || token.ac == nil {
32+ sess := token.localSession()
33+ if sess == nil || token.ac == nil {
3334 return false
3435 }
3536 d.mu.Lock()
3637 defer d.mu.Unlock()
37- if d.closing || token.sess.core() == nil || d.sessions[token.sess.core().id] != token.sess {
38+ if d.closing || sess.core() == nil || d.sessions[sess.core().id] != sess {
3839 return false
3940 }
4041 d.notices.routingMu.Lock()
4142 defer d.notices.routingMu.Unlock()
42- token.sess.core().mu.Lock()
43- defer token.sess.core().mu.Unlock()
44- coordinator := token.sess.core().coordinator.Load()
43+ sess.core().mu.Lock()
44+ defer sess.core().mu.Unlock()
45+ coordinator := sess.core().coordinator.Load()
4546 if coordinator != nil {
4647 coordinator.mu.Lock()
4748 defer coordinator.mu.Unlock()
4849 }
49- req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}
50- return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
50+ req := attachmentTransitionRequest{source: sess, next: token.ac, expectedTransport: token.transport}
51+ return transitionSourceTokenCurrentLocked(token, sess, coordinator, req)
5152 }
5253
5354 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
@@ -215,7 +216,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
215216 }
216217 }
217218 result.published = attachmentConnectionToken{
218- sess: req.target, ac: req.next,
219+ owner: req.target, ac: req.next,
219220 generation: publication.nextGeneration,
220221 transport: req.expectedTransport, lease: lease,
221222 rebase: publication.source != req.target,
M internal/usecase/daemon/client.go +156 -37
@@ -42,6 +42,14 @@ type attachedClient struct {
4242 // remoteOrigin is attachment-specific presentation metadata supplied by a
4343 // validated picker handoff; it never participates in routing.
4444 remoteOrigin string
45+ // renderMode is fixed by the accepted Hello and controls the remote daemon's
46+ // publication contract for this transport. It is immutable for one
47+ // attachment lifetime and is not local session ownership state.
48+ renderMode ports.RenderMode
49+ // proxiedMetaRevision and proxiedOutputStarted are serialized by sendMu.
50+ // They order metadata snapshots ahead of every post-handshake content output.
51+ proxiedMetaRevision uint64
52+ proxiedOutputStarted bool
4553 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
4654 // the linearization gate for every attachment-bound observable operation. A role
4755 // transition freezes and drains this gate before changing either generation
@@ -85,18 +93,29 @@ type attachedClient struct {
8593 // tabs and panes without changing shared session ownership.
8694 viewMu sync.Mutex
8795 view attachmentView
88- sess Guarded[*session]
96+ owner Guarded[attachmentOwner]
8997 mouseScan mouse.Scanner
9098 themeMu sync.Mutex
9199 clientTheme themeui.Theme
92100 appliedTheme appliedTheme
93101 lastCursor cursorOut
94102 renderStages renderStageHooks // optional render and handoff observability hooks
95- // previousSession is guarded independently. It is retained through temporary
96- // setSession(nil) hand-offs and cleared only on terminal teardown.
97- previousSession Guarded[*session]
98- linkMu sync.Mutex
99- sendMu sync.Mutex
103+ // previousOwner is guarded independently. It retains navigation history
104+ // through temporary owner hand-offs and is cleared only on terminal teardown.
105+ previousOwner Guarded[attachmentOwner]
106+ // remoteRecent is attachment-local, display-only MRU state. Remote views do
107+ // not enter the local session registry, so these entries must not be usable
108+ // by navigation, palette, or persistence paths.
109+ remoteRecent Guarded[[]remoteRecentSession]
110+ linkMu sync.Mutex
111+ sendMu sync.Mutex
112+ // remotePaintMu coalesces asynchronous remote-view repaints to one worker
113+ // per attachment. The worker always consumes the latest reset request.
114+ remotePaintMu sync.Mutex
115+ remotePaintView *remoteView
116+ remotePaintPending bool
117+ remotePaintRunning bool
118+ remotePaintReset bool
100119 // routeCreatedSession marks a session created by this attachment's route.
101120 // A handshake that never commits Welcome must tear down that exact empty
102121 // session, while an attachment routed to an existing session must not.
@@ -209,18 +228,50 @@ func (ac *attachedClient) clearGeometryClaim() {
209228 ac.sizeMu.Unlock()
210229 }
211230
212-func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }
231+// currentAttachmentOwner returns the one authoritative binding for this
232+// attachment. Callers that need local-only behavior must narrow it with
233+// localSession rather than retaining a second session owner.
234+func (ac *attachedClient) currentAttachmentOwner() attachmentOwner {
235+ if ac == nil {
236+ return nil
237+ }
238+ return ac.owner.Get()
239+}
213240
214-// currentSession narrows the attachment to its owning local session.
241+func (ac *attachedClient) currentAttachmentSession() *session {
242+ return localSession(ac.currentAttachmentOwner())
243+}
244+
245+// currentSession is retained for local-session call sites. It deliberately
246+// returns nil for a remote view so local PTY/persistence paths fail closed.
215247 func (ac *attachedClient) currentSession() *session {
216248 return ac.currentAttachmentSession()
217249 }
218250
219-func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
251+// navigationSession returns the local session that owns local navigation UI.
252+// A remote view has no local tabs or PTYs, but the attachment retains its
253+// immediate local predecessor so picker and palette shell state stay local.
254+func (ac *attachedClient) navigationSession() *session {
255+ if ac == nil {
256+ return nil
257+ }
258+ if current := ac.currentAttachmentSession(); current != nil {
259+ return current
260+ }
261+ return localSession(ac.previousOwner.Get())
262+}
263+
264+func (ac *attachedClient) setAttachmentOwner(owner attachmentOwner) {
265+ if ac != nil {
266+ ac.owner.Set(normalizeAttachmentOwner(owner))
267+ }
268+}
269+
270+func (ac *attachedClient) setSession(sess *session) { ac.setAttachmentOwner(sess) }
220271
221272 func (ac *attachedClient) clearPreviousSession() {
222273 if ac != nil {
223- ac.previousSession.Set(nil)
274+ ac.previousOwner.Set(nil)
224275 }
225276 }
226277
@@ -569,6 +620,7 @@ type attachClientOptions struct {
569620 resumeCapable bool
570621 maxOutputInFlight uint8
571622 remoteOrigin string
623+ renderMode ports.RenderMode
572624 }
573625
574626 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -615,6 +667,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
615667 view: attachmentView{windowRows: sz.Rows, windowSet: true},
616668 clientID: opts.clientID,
617669 remoteOrigin: opts.remoteOrigin,
670+ renderMode: opts.renderMode,
618671 resumeCapable: opts.resumeCapable,
619672 resumeToken: resumeToken,
620673 }
@@ -759,10 +812,23 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
759812 token.ac.captureFrames = nil
760813 token.ac.sendMu.Unlock()
761814 }
762- if token.sess == nil {
815+ if view, remote := token.owner.(*remoteView); remote {
816+ return d.paintRemoteView(view, token.ac, true, token) == paintEmitted
817+ }
818+ sess := token.localSession()
819+ if sess == nil {
763820 return false
764821 }
765- d.firstPaintWithLease(token.sess, token.ac, token.lease, true)
822+ if token.ac.renderMode == ports.RenderModeProxiedContent {
823+ // The proxied Hello size is already content geometry. Reconcile the shared
824+ // PTY/layout claim before the first authoritative content reset and wake
825+ // every existing attachment if that claim changed their geometry. Welcome
826+ // and the first SessionMeta were committed before this post-transition
827+ // path, so the proxied attachment cannot receive content ahead of metadata.
828+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "client.go")
829+ return d.paintProxiedContent(sess, token.ac, true, token.lease) == paintEmitted
830+ }
831+ d.firstPaintWithLease(sess, token.ac, token.lease, true)
766832 return true
767833 }
768834
@@ -833,15 +899,23 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
833899 return false
834900 }
835901 clientTheme := themeFromMessage(msg)
902+ if view, remote := token.owner.(*remoteView); remote {
903+ token.ac.setClientTheme(clientTheme)
904+ token.ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
905+ if !token.attachmentEffectCurrent() {
906+ return false
907+ }
908+ return d.paintRemoteView(view, token.ac, true, token) == paintEmitted
909+ }
836910 token.ac.setClientTheme(clientTheme)
837- sess := token.sess
911+ sess := token.localSession()
838912 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
839913 return false
840914 }
841915 if !token.attachmentEffectCurrent() {
842916 return false
843917 }
844- if rc := token.sess.core().coordinator.Load(); rc != nil {
918+ if rc := sess.renderCoordinator(); rc != nil {
845919 return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
846920 }
847921 return false
@@ -880,6 +954,23 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
880954 }
881955 ac.sendMu.Unlock()
882956
957+ if view, remote := token.owner.(*remoteView); remote {
958+ if sameSize || !token.attachmentEffectCurrent() || !view.resizeScreen(ac.sizeSnapshot()) {
959+ return false
960+ }
961+ resizePayload, err := ports.MarshalResize(ports.Resize{Size: contentSize(ac.sizeSnapshot())})
962+ if err != nil {
963+ return false
964+ }
965+ if err := d.sendRemoteViewFrame(view, ports.Frame{Type: ports.MsgResize, Payload: resizePayload}); err != nil {
966+ // A disconnected remote link must not suppress the attachment-local
967+ // rebase: retained content remains locally renderable while Phase 4
968+ // reconnect policy restores the remote transport.
969+ d.log.Warn("forwarding remote view resize failed", "endpoint", view.key.endpoint, "session", view.key.sessionName, "err", err)
970+ }
971+ return d.paintRemoteView(view, ac, true, token) == paintEmitted
972+ }
973+
883974 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {
884975 // The picker preview geometry is attachment-local. A resize must
885976 // invalidate any in-flight remote request and fetch the selected row
@@ -887,7 +978,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
887978 d.registerPreviewForSelection(ac)
888979 }
889980
890- sess := token.sess
981+ sess := token.localSession()
891982 if sess == nil {
892983 return false
893984 }
@@ -945,11 +1036,21 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
9451036 d.notices.routingMu.Unlock()
9461037 return
9471038 }
948- if token.sess == nil {
1039+ sess := token.localSession()
1040+ if sess == nil {
1041+ view, remote := token.owner.(*remoteView)
1042+ if !remote {
1043+ d.notices.routingMu.Unlock()
1044+ return
1045+ }
1046+ repaint := d.mutateRemoteClientNotice(token.ac, notice)
9491047 d.notices.routingMu.Unlock()
1048+ if repaint && token.attachmentEffectCurrent() {
1049+ d.scheduleRemoteViewPaint(view, token.ac, false)
1050+ }
9501051 return
9511052 }
952- repaint := d.mutateClientNotice(token.sess, token.ac, notice)
1053+ repaint := d.mutateClientNotice(sess, token.ac, notice)
9531054 d.notices.routingMu.Unlock()
9541055 if repaint && token.attachmentEffectCurrent() {
9551056 d.repaintForNotice(token.ac)
@@ -976,34 +1077,52 @@ func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, not
9761077 }
9771078
9781079 func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {
979- switch notice.Action {
980- case ports.ClientNoticeClipboardFallback:
981- return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
982- case ports.ClientNoticeClipboardTooLarge:
983- return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
984- case ports.ClientNoticeLinkDegraded:
985- return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
986- case ports.ClientNoticeLinkConnected:
1080+ if notice.Action == ports.ClientNoticeLinkConnected {
9871081 return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
988- default:
1082+ }
1083+ n, ok := d.clientNoticeNotification(notice)
1084+ if !ok {
9891085 return false
9901086 }
1087+ n.SessionID = sess.id
1088+ return d.publishToast(ac, d.notices.record(n))
9911089 }
9921090
993-// recordClientNotice mutates notice history and the selected attachment while
994-// routingMu keeps another connection from changing that selection. Rendering is left
995-// to the caller after it releases routingMu.
996-func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
997- n := d.notices.record(domain.Notification{
998- Code: code,
999- Severity: sev,
1000- Message: message,
1001- Time: d.clock.Now(),
1002- SessionID: sess.id,
1003- })
1091+// mutateRemoteClientNotice keeps client-originated feedback local to the
1092+// attachment. A remoteView has no local session scope or notice history to
1093+// mutate; only its visible toast stack is part of the local composition.
1094+func (d *Daemon) mutateRemoteClientNotice(ac *attachedClient, notice ports.ClientNotice) bool {
1095+ if ac == nil {
1096+ return false
1097+ }
1098+ if notice.Action == ports.ClientNoticeLinkConnected {
1099+ return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, "")
1100+ }
1101+ n, ok := d.clientNoticeNotification(notice)
1102+ if !ok {
1103+ return false
1104+ }
1105+ n.Count = 1
10041106 return d.publishToast(ac, n)
10051107 }
10061108
1109+// clientNoticeNotification maps the closed client notice vocabulary to daemon
1110+// notification values. Caller-specific session scope and LinkConnected handling
1111+// remain at the two mutation sites; rendering is left to the caller after it
1112+// releases routingMu.
1113+func (d *Daemon) clientNoticeNotification(notice ports.ClientNotice) (domain.Notification, bool) {
1114+ switch notice.Action {
1115+ case ports.ClientNoticeClipboardFallback:
1116+ return domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", Time: d.clock.Now()}, true
1117+ case ports.ClientNoticeClipboardTooLarge:
1118+ return domain.Notification{Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", Time: d.clock.Now()}, true
1119+ case ports.ClientNoticeLinkDegraded:
1120+ return domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", Time: d.clock.Now()}, true
1121+ default:
1122+ return domain.Notification{}, false
1123+ }
1124+}
1125+
10071126 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
10081127 // returned value reports whether the synchronous request was accepted.
10091128 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
M internal/usecase/daemon/client_frame_routing.go +45 -17
@@ -13,24 +13,24 @@ const connectionSnapshotAttempts = 8
1313 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
1414 // handoff lands between those reads. The attachment gate still performs the final
1515 // mutation admission after this function returns.
16-func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentConnectionToken, bool) {
16+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentOwner, attachmentConnectionToken, bool) {
1717 for range connectionSnapshotAttempts {
1818 if !ac.currentTransportIs(tr) {
1919 break
2020 }
21- sess := ac.currentAttachmentSession()
22- if sess == nil {
21+ owner := ac.currentAttachmentOwner()
22+ if owner == nil {
2323 return nil, attachmentConnectionToken{}, false
2424 }
25- if d.afterConnectionSessionSnapshot != nil {
25+ if sess := localSession(owner); sess != nil && d.afterConnectionSessionSnapshot != nil {
2626 d.afterConnectionSessionSnapshot(sess)
2727 }
28- token := attachmentToken(sess, ac, tr)
29- if token.ac == nil || ac.currentAttachmentSession() != sess {
28+ token := attachmentOwnerToken(owner, ac, tr)
29+ if token.ac == nil || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) {
3030 runtime.Gosched()
3131 continue
3232 }
33- return sess, token, true
33+ return owner, token, true
3434 }
3535 return nil, attachmentConnectionToken{}, false
3636 }
@@ -44,19 +44,23 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
4444 return
4545 }
4646 for {
47- if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil {
47+ if !ac.currentTransportIs(tr) || ac.currentAttachmentOwner() == nil {
4848 return
4949 }
5050 f, err := tr.Recv()
5151 if err != nil {
5252 for range connectionSnapshotAttempts {
53- sess, _, ok := d.currentAttachmentConnection(ac, tr)
53+ owner, token, ok := d.currentAttachmentConnection(ac, tr)
5454 if !ok {
5555 return
5656 }
57- d.clientGone(sess, ac, tr, false)
58- current := ac.currentAttachmentSession()
59- if current == sess || !ac.currentTransportIs(tr) || current == nil {
57+ if sess := localSession(owner); sess != nil {
58+ d.clientGone(sess, ac, tr, false)
59+ } else if view, remote := owner.(*remoteView); remote {
60+ d.clientGoneRemote(view, token, false)
61+ }
62+ current := ac.currentAttachmentOwner()
63+ if sameAttachmentOwner(current, owner) || !ac.currentTransportIs(tr) || current == nil {
6064 return
6165 }
6266 }
@@ -171,12 +175,17 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
171175 result.Text = "attached commands cannot target themselves"
172176 return result
173177 }
178+ if _, remote := token.owner.(*remoteView); remote {
179+ result.Code = ports.ErrNoSuchTarget
180+ result.Text = "attached session is no longer active"
181+ return result
182+ }
174183 if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {
175184 result.Code = ports.ErrInvalidCommandArgs
176185 result.Text = "attached commands cannot override their active session target"
177186 return result
178187 }
179- sess := token.sess
188+ sess := token.localSession()
180189 if sess == nil || !token.attachmentEffectCurrent() {
181190 result.Code = ports.ErrNoSuchTarget
182191 result.Text = "attached session is no longer active"
@@ -234,7 +243,11 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
234243 ac.pipelineCache = composeCacheInput{}
235244 ac.pipelineScratch = composeCacheInput{}
236245 ac.sendMu.Unlock()
237- go d.paint(token.sess, ac, true, token.lease)
246+ if view, remote := token.owner.(*remoteView); remote {
247+ d.scheduleRemoteViewPaint(view, ac, true)
248+ } else if sess := token.localSession(); sess != nil {
249+ go d.paint(sess, ac, true, token.lease)
250+ }
238251 return true
239252 }
240253
@@ -244,10 +257,25 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
244257 return false
245258 }
246259 ac.sendMu.Lock()
247- ac.output.ack(epoch, state)
260+ if ac.output == nil {
261+ ac.sendMu.Unlock()
262+ return false
263+ }
264+ advanced := ac.output.ack(epoch, state)
248265 ac.sendMu.Unlock()
249- if rc := token.sess.core().coordinator.Load(); rc != nil {
250- rc.notifyAckForLease(token.lease)
266+ if !advanced {
267+ return true
268+ }
269+ if sess := token.localSession(); sess != nil {
270+ if rc := sess.renderCoordinator(); rc != nil {
271+ rc.notifyAckForLease(token.lease)
272+ }
273+ } else if view, remote := token.owner.(*remoteView); remote {
274+ // Remote views do not have a session render coordinator to consume the
275+ // newly available output-window capacity. Recompose from the retained
276+ // private VT so an update previously blocked by the local client window
277+ // is not stranded until another remote frame arrives.
278+ d.scheduleRemoteViewPaint(view, ac, false)
251279 }
252280 return true
253281 }
M internal/usecase/daemon/client_transport_errors.go +102 -2
@@ -37,7 +37,10 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
3737 }
3838 token.effect.bindActionEnd(d, "detach")
3939 token.effect.End()
40- sess := token.sess
40+ if view, remote := token.owner.(*remoteView); remote {
41+ return d.clientGoneRemote(view, token, explicit)
42+ }
43+ sess := token.localSession()
4144 if sess == nil {
4245 return false
4346 }
@@ -53,6 +56,94 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
5356 return true
5457 }
5558
59+// clientGoneRemote retires only the current local-client binding to a remote
60+// view. The remote link itself remains a Phase 4 lifecycle concern; a failed
61+// local transport parks the stable view owner without inventing a local PTY.
62+func (d *Daemon) clientGoneRemote(view *remoteView, token attachmentConnectionToken, explicit bool) bool {
63+ if d == nil || view == nil || token.ac == nil || !sameAttachmentOwner(token.owner, view) {
64+ return false
65+ }
66+ ac := token.ac
67+ var parkingToken uint64
68+ if !explicit {
69+ parkingToken = d.markParkingInFlightOwner(view, ac)
70+ }
71+ if d.beforeClientGoneDetach != nil {
72+ d.beforeClientGoneDetach()
73+ }
74+ if !d.detachRemoteAttachmentCurrent(view, token) {
75+ d.clearParkingInFlightIfAbandonedOwner(view, ac, parkingToken)
76+ return false
77+ }
78+ return d.finishRemoteClientGone(view, ac, token.transport.transport, explicit)
79+}
80+
81+// finishRemoteClientGone completes remote local-client teardown only after the
82+// exact owner binding has been unpublished. It intentionally leaves remote-link
83+// lifecycle ownership to Phase 4.
84+func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, transport ports.Transport, explicit bool) bool {
85+ if d == nil || view == nil || ac == nil {
86+ return false
87+ }
88+ d.unregisterPreview(ac)
89+ if !explicit && d.parkAttachmentOwner(view, ac) {
90+ _ = ac.closeCapturedTransport(transport)
91+ d.parkRemoteViewWarm(view)
92+ return true
93+ }
94+ d.clearParkingInFlight(ac.resumeToken, ac)
95+ d.closePicker(ac)
96+ ac.clearPreviousSession()
97+ if explicit {
98+ d.boundedSend(ac, frameDetached(ports.ReasonDetach))
99+ }
100+ _ = ac.closeCapturedTransport(transport)
101+ d.parkRemoteViewWarm(view)
102+ return true
103+}
104+
105+// detachRemoteAttachmentCurrent invalidates exactly the frozen remote-view
106+// membership. It performs no transport I/O while architecture locks are held.
107+func (d *Daemon) detachRemoteAttachmentCurrent(view *remoteView, token attachmentConnectionToken) bool {
108+ return d.detachRemoteAttachmentCurrentUntil(view, token, nil)
109+}
110+
111+// detachRemoteAttachmentCurrentUntil is the deadline-bounded counterpart used
112+// by asynchronous failed-send cleanup. A timed-out cleanup leaves ownership
113+// intact for a later terminal sweep rather than violating effect-gate ordering.
114+func (d *Daemon) detachRemoteAttachmentCurrentUntil(view *remoteView, token attachmentConnectionToken, done func() <-chan struct{}) bool {
115+ if d == nil || view == nil || token.ac == nil || token.transport.transport == nil {
116+ return false
117+ }
118+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
119+ defer frozen.unfreeze()
120+ if !frozen.acquired || !frozen.drained {
121+ return false
122+ }
123+ d.mu.Lock()
124+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
125+ d.mu.Unlock()
126+ return false
127+ }
128+ view.mu.Lock()
129+ current := sameAttachmentOwner(token.owner, view) &&
130+ token.generation == token.ac.connectionGeneration.Load() &&
131+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), view) &&
132+ token.ac.transportSnapshotCurrent(token.transport)
133+ if _, registered := view.attachments[token.ac]; !registered {
134+ current = false
135+ }
136+ if current {
137+ view.unregisterAttachmentLocked(token.ac)
138+ token.ac.connectionGeneration.Add(1)
139+ token.ac.setAttachmentOwner(nil)
140+ token.ac.invalidateFrozenAttachmentCapability()
141+ }
142+ view.mu.Unlock()
143+ d.mu.Unlock()
144+ return current
145+}
146+
56147 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
57148 if sess == nil || ac == nil {
58149 return
@@ -127,7 +218,16 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
127218 if failed == nil || failed != token.transport.transport {
128219 return
129220 }
130- sess := token.sess
221+ if view, remote := token.owner.(*remoteView); remote {
222+ parkingToken := d.markParkingInFlightOwner(view, token.ac)
223+ if d.detachRemoteAttachmentCurrentUntil(view, token, done) {
224+ d.finishRemoteClientGone(view, token.ac, failed, false)
225+ return
226+ }
227+ d.clearParkingInFlightIfAbandonedOwner(view, token.ac, parkingToken)
228+ return
229+ }
230+ sess := token.localSession()
131231 if sess == nil {
132232 return
133233 }
M internal/usecase/daemon/clipboard.go +12 -4
@@ -27,14 +27,14 @@ var (
2727 )
2828
2929 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30- if !token.attachmentEffectCurrent() {
30+ if token.localSession() == nil || !token.attachmentEffectCurrent() || token.ac.renderMode == ports.RenderModeProxiedContent {
3131 return
3232 }
3333 d.handleImagePushForAttachment(token, ip)
3434 }
3535
3636 func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37- if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
37+ if token.localSession() == nil || len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
3838 return
3939 }
4040 path, err := d.writeClipboardImageForAttachment(token, ip)
@@ -85,11 +85,14 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
8585 }
8686
8787 func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
88+ if token.localSession() == nil || !token.attachmentEffectCurrent() {
89+ return "", errAttachmentTransition
90+ }
8891 path, err := d.createClipboardImage(ip)
8992 if err != nil {
9093 return "", err
9194 }
92- sess := token.sess
95+ sess := token.localSession()
9396 if sess == nil {
9497 _ = os.Remove(path)
9598 return "", errAttachmentTransition
@@ -135,7 +138,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
135138 }
136139
137140 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138- sess := token.sess
141+ sess := token.localSession()
139142 if sess == nil || !token.attachmentEffectCurrent() {
140143 return
141144 }
@@ -236,6 +239,11 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
236239
237240 items := make([]clipboardForward, 0, len(attachments))
238241 for _, ac := range attachments {
242+ // A remote daemon's proxied content stream must never carry OSC52 or
243+ // another host-facing side effect to the local link.
244+ if ac.renderMode == ports.RenderModeProxiedContent {
245+ continue
246+ }
239247 transport := ac.transport()
240248 token := sess.attachmentToken(ac, transport)
241249 if token.attachmentCurrent() {
M internal/usecase/daemon/coderabbit_regression_test.go +35 -4
@@ -8,6 +8,7 @@ import (
88 "sync"
99 "syscall"
1010 "testing"
11+ "time"
1112
1213 "github.com/stretchr/testify/require"
1314
@@ -120,7 +121,7 @@ func TestRestoreFailureCannotOverwriteDiscardReplacement(t *testing.T) {
120121 entry := d.stopped[record.Name]
121122 d.mu.Unlock()
122123 require.Equal(t, persisted, entry.record)
123- require.Equal(t, ports.SessionStopped, entry.state)
124+ require.Equal(t, ports.SessionDown, entry.state)
124125 }
125126
126127 func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) {
@@ -138,7 +139,7 @@ func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) {
138139 record.Committed = &ref
139140 return record
140141 },
141- state: ports.SessionStopped,
142+ state: ports.SessionDown,
142143 },
143144 {
144145 name: "newer incarnation",
@@ -146,7 +147,7 @@ func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) {
146147 record.IncarnationID = domain.IncarnationID{9}
147148 return record
148149 },
149- state: ports.SessionStopped,
150+ state: ports.SessionDown,
150151 },
151152 {
152153 name: "degraded authority",
@@ -223,7 +224,7 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
223224 d := newTestDaemon(t, nil, stubClock{})
224225 d.catalogue = catalogue
225226 d.persistEnabled = true
226- d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionStopped, make(chan struct{}))
227+ d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionDown, make(chan struct{}))
227228
228229 result := make(chan error, 1)
229230 go func() {
@@ -243,6 +244,36 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
243244 require.Empty(t, d.sessions)
244245 }
245246
247+func TestShutdownDeadlineDoesNotWaitForUncooperativeRemoteConstruction(t *testing.T) {
248+ d := newTestDaemon(t, nil, stubClock{})
249+ construction := &remoteViewConstruction{done: make(chan struct{})}
250+ d.mu.Lock()
251+ d.remoteViewConstructions[remoteViewKey{}] = construction
252+ d.mu.Unlock()
253+ deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
254+ close(deadline.done)
255+
256+ result := make(chan bool, 1)
257+ go func() { result <- d.shutdownAllWithSnapshotDeadline(ports.ReasonServerShutdown, deadline) }()
258+ require.True(t, awaitTestValue(t, result, "shutdown blocked on canceled remote construction"))
259+}
260+
261+func TestShutdownWithoutDeadlineBoundsUncooperativeRemoteConstruction(t *testing.T) {
262+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
263+ d := newTestDaemon(t, nil, clock)
264+ construction := &remoteViewConstruction{done: make(chan struct{})}
265+ d.mu.Lock()
266+ d.remoteViewConstructions[remoteViewKey{}] = construction
267+ d.mu.Unlock()
268+
269+ result := make(chan bool, 1)
270+ go func() { result <- d.shutdownAll(ports.ReasonServerShutdown) }()
271+ timer := awaitTestValue(t, clock.timers, "remote construction shutdown timer")
272+ require.Equal(t, remoteConstructionShutdownGrace, timer.duration)
273+ timer.ch <- time.Time{}
274+ require.True(t, awaitTestValue(t, result, "shutdown blocked on uncooperative remote construction"))
275+}
276+
246277 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {
247278 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
248279 _, cancel := snapshotStopContext(deadline)
M internal/usecase/daemon/command_tracker.go +22 -0
@@ -135,6 +135,28 @@ func (t *CommandRequestTracker) Fail(requestID, generation uint64, err error) {
135135 t.finish(requestID, generation, CommandRequestOutcome{Err: err})
136136 }
137137
138+// FailGeneration completes every pending request for one retired transport
139+// generation. It releases the tracker lock before publishing outcomes so a
140+// caller can immediately wait or begin work on a replacement generation.
141+func (t *CommandRequestTracker) FailGeneration(generation uint64, err error) {
142+ if t == nil {
143+ return
144+ }
145+ t.mu.Lock()
146+ pending := make([]commandRequestPending, 0)
147+ for requestID, request := range t.pending {
148+ if request.generation != generation {
149+ continue
150+ }
151+ delete(t.pending, requestID)
152+ pending = append(pending, request)
153+ }
154+ t.mu.Unlock()
155+ for _, request := range pending {
156+ request.outcome <- CommandRequestOutcome{Err: err}
157+ }
158+}
159+
138160 func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {
139161 if t == nil {
140162 return
M internal/usecase/daemon/control.go +4 -4
@@ -643,7 +643,7 @@ func (e controlExec) SessionRecovery(action string) (string, error) {
643643 return "", err
644644 }
645645 if ok {
646- e.d.setStoppedRecovery(record, ports.SessionStopped)
646+ e.d.setStoppedRecovery(record, ports.SessionDown)
647647 }
648648 return "", nil
649649 }
@@ -736,7 +736,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
736736 row := ports.RemoteCatalogSession{
737737 LifecycleID: snap.incarnation,
738738 Name: snap.name,
739- State: "running",
739+ State: "up",
740740 Ephemeral: snap.ephemeral,
741741 Tabs: tabs,
742742 Attached: snap.attached,
@@ -752,7 +752,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
752752 if err != nil {
753753 return "", err
754754 }
755- state := "stopped"
755+ state := "down"
756756 reason := ""
757757 if entry.state == ports.SessionBroken || entry.record.DegradedReason != "" {
758758 state = "broken"
@@ -791,7 +791,7 @@ func (e controlExec) remoteCatalogLegacy(sessions []*session) (string, error) {
791791 snap := sess.snapshotView(viewOptions{})
792792 rows = append(rows, ports.RemoteCatalogSession{
793793 Name: snap.name,
794- State: "running",
794+ State: "up",
795795 Ephemeral: snap.ephemeral,
796796 Tabs: ports.SaturateUint16(snap.tabCount),
797797 Attached: snap.attached,
M internal/usecase/daemon/control_test.go +5 -5
@@ -735,7 +735,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
735735 build.mruAt.Store(1)
736736
737737 d.mu.Lock()
738- d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionStopped}
738+ d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionDown}
739739 d.mu.Unlock()
740740
741741 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
@@ -765,12 +765,12 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
765765 require.True(t, hasProtocol && hasSessions)
766766
767767 require.Equal(t, []ports.RemoteCatalogSession{
768- {Name: "build", State: "running", Ephemeral: true, Tabs: 1, Attached: false},
769- {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},
768+ {Name: "build", State: "up", Ephemeral: true, Tabs: 1, Attached: false},
769+ {Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true},
770770 }, catalog.Sessions)
771771
772772 for _, session := range catalog.Sessions {
773- require.Equal(t, "running", session.State)
773+ require.Equal(t, "up", session.State)
774774 require.NotEqual(t, "old", session.Name)
775775 }
776776
@@ -797,7 +797,7 @@ func TestRemoteCatalogTabCountSaturates(t *testing.T) {
797797 var catalog ports.RemoteCatalog
798798 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
799799 require.Equal(t, []ports.RemoteCatalogSession{{
800- Name: "work", State: "running", Ephemeral: true, Tabs: int(^uint16(0)), Attached: false,
800+ Name: "work", State: "up", Ephemeral: true, Tabs: int(^uint16(0)), Attached: false,
801801 }}, catalog.Sessions)
802802 }
803803
M internal/usecase/daemon/daemon.go +143 -110
@@ -45,6 +45,10 @@ const (
4545 // the in-flight send. Teardown is never gated on a client draining its socket.
4646 const detachNotifyTimeout = time.Second
4747
48+// remoteConstructionShutdownGrace bounds a non-cooperative remote dialer when
49+// durable snapshots have not installed a shared shutdown deadline.
50+const remoteConstructionShutdownGrace = ports.HandshakeTimeout
51+
4852 // maxUnackedOutputStates caps how many output states may be in flight (sent but
4953 // not yet acked by the client) before paint defers rather than composing
5054 // another diff. It bounds the daemon's paint rate to the client's ack rate, so
@@ -72,6 +76,16 @@ type Daemon struct {
7276 mu sync.Mutex
7377 sessions map[domain.SessionID]*session
7478 stopped map[string]stoppedSession
79+ // remoteViews holds non-persistent, daemon-local attachment owners. The
80+ // companion key index provides warm-link reuse without ever widening
81+ // sessions beyond concrete local *session values. Both are guarded by mu.
82+ remoteViews map[remoteViewID]*remoteView
83+ remoteViewsByKey map[remoteViewKey]remoteViewID
84+ // remoteViewConstructions reserves a remote key while its dial and
85+ // handshake run outside d.mu. A waiter never dials and can cancel without
86+ // disturbing the elected constructor.
87+ remoteViewConstructions map[remoteViewKey]*remoteViewConstruction
88+ nextRemoteViewID remoteViewID
7589 // creating reserves names while durable creation I/O runs without mu.
7690 creating map[string]struct{}
7791 nextID uint64
@@ -322,7 +336,7 @@ type Daemon struct {
322336 }
323337
324338 type parkedAttachment struct {
325- sess *session
339+ owner attachmentOwner
326340 ac *attachedClient
327341 pickerGeneration uint64
328342 timer ports.Timer
@@ -335,7 +349,7 @@ type parkedAttachment struct {
335349 // credential. done closes when the token is published into parked or parking
336350 // is abandoned.
337351 type parkingAttachment struct {
338- sess *session
352+ owner attachmentOwner
339353 ac *attachedClient
340354 done chan struct{}
341355 doneOnce sync.Once
@@ -594,28 +608,31 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
594608 }
595609 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
596610 d := &Daemon{
597- sessions: make(map[domain.SessionID]*session),
598- stopped: make(map[string]stoppedSession),
599- creating: make(map[string]struct{}),
600- parked: make(map[uint64]*parkedAttachment),
601- parking: make(map[uint64]*parkingAttachment),
602- paneProcessCtx: paneProcessCtx,
603- paneProcessCancel: paneProcessCancel,
604- ptys: ptys,
605- clock: clock,
606- log: log,
607- baseEnv: os.Environ(),
608- shell: shell,
609- dirOrHome: dirOrHome,
610- done: make(chan struct{}),
611- restoreDone: make(chan struct{}),
612- animWake: make(chan struct{}, 1),
613- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
614- snapshotAdmitted: make(map[*snapshotCapture]struct{}),
615- snapshotWake: make(chan struct{}, 1),
616- notices: newNoticeCenter(),
617- remoteCatalog: newRemoteCatalogState(),
618- resumeParkGrace: defaultResumeParkGrace,
611+ sessions: make(map[domain.SessionID]*session),
612+ stopped: make(map[string]stoppedSession),
613+ remoteViews: make(map[remoteViewID]*remoteView),
614+ remoteViewsByKey: make(map[remoteViewKey]remoteViewID),
615+ remoteViewConstructions: make(map[remoteViewKey]*remoteViewConstruction),
616+ creating: make(map[string]struct{}),
617+ parked: make(map[uint64]*parkedAttachment),
618+ parking: make(map[uint64]*parkingAttachment),
619+ paneProcessCtx: paneProcessCtx,
620+ paneProcessCancel: paneProcessCancel,
621+ ptys: ptys,
622+ clock: clock,
623+ log: log,
624+ baseEnv: os.Environ(),
625+ shell: shell,
626+ dirOrHome: dirOrHome,
627+ done: make(chan struct{}),
628+ restoreDone: make(chan struct{}),
629+ animWake: make(chan struct{}, 1),
630+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
631+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
632+ snapshotWake: make(chan struct{}, 1),
633+ notices: newNoticeCenter(),
634+ remoteCatalog: newRemoteCatalogState(),
635+ resumeParkGrace: defaultResumeParkGrace,
619636 barScripts: &barScriptState{
620637 cfg: barConfigFromDomain(domain.Defaults().Bar),
621638 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -658,7 +675,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
658675 state, done := initialSessionState(r)
659676 d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done)
660677 } else {
661- d.stopped[r.Name] = stoppedSessionFromRecord(r, ports.SessionStopped, nil)
678+ d.stopped[r.Name] = stoppedSessionFromRecord(r, ports.SessionDown, nil)
662679 }
663680 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
664681 maxCreatedAt = r.CreatedAt
@@ -796,14 +813,93 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
796813 d.closing = true
797814 d.purgeAllParkingLocked()
798815 parkedRetirements := d.purgeAllParkedLocked()
816+ remoteViews := make([]*remoteView, 0, len(d.remoteViews))
817+ for _, view := range d.remoteViews {
818+ remoteViews = append(remoteViews, view)
819+ }
820+ constructions := make([]*remoteViewConstruction, 0, len(d.remoteViewConstructions))
821+ for _, construction := range d.remoteViewConstructions {
822+ constructions = append(constructions, construction)
823+ }
824+ clear(d.remoteViews)
825+ clear(d.remoteViewsByKey)
826+ clear(d.remoteViewConstructions)
799827 snapshot := d.sessionsSnapshotLocked()
800828 empty := len(snapshot) == 0
801829 d.mu.Unlock()
830+ // A remote view is not a daemon-liveness root. Retire each exact local
831+ // attachment after releasing d.mu so a token cannot remain valid and a
832+ // blocked local-client Recv/Send cannot outlive daemon shutdown. Candidate
833+ // construction uses the same exact-close rule, even before publication.
834+ for _, construction := range constructions {
835+ if construction != nil && construction.cancel != nil {
836+ construction.cancel()
837+ }
838+ }
839+ // Candidate construction owns dial and handshake I/O outside daemon locks.
840+ // A shutdown deadline bounds a non-cooperative dialer; a late constructor
841+ // observes d.closing and retires its unpublished candidate independently.
842+ var constructionTimer ports.Timer
843+ var constructionTimeout <-chan time.Time
844+ graceExpired := false
845+ if deadline == nil && len(constructions) != 0 {
846+ clock := d.clock
847+ if clock == nil {
848+ clock = systemClock{}
849+ }
850+ constructionTimer = clock.NewTimer(remoteConstructionShutdownGrace)
851+ if constructionTimer == nil || constructionTimer.C() == nil {
852+ constructionTimer = systemClock{}.NewTimer(remoteConstructionShutdownGrace)
853+ }
854+ constructionTimeout = constructionTimer.C()
855+ defer constructionTimer.Stop()
856+ }
857+ for _, construction := range constructions {
858+ if construction == nil || construction.done == nil {
859+ continue
860+ }
861+ if deadline == nil {
862+ if graceExpired {
863+ continue
864+ }
865+ select {
866+ case <-construction.done:
867+ case <-constructionTimeout:
868+ checkpointIncomplete = true
869+ graceExpired = true
870+ }
871+ continue
872+ }
873+ select {
874+ case <-construction.done:
875+ case <-deadline.Done():
876+ checkpointIncomplete = true
877+ }
878+ }
879+ remoteWarms := make([]*remoteViewWarm, 0, len(remoteViews))
880+ remoteLinks := make([]*remoteLink, 0, len(remoteViews))
881+ for _, view := range remoteViews {
882+ for _, ac := range view.close() {
883+ d.retireShutdownRemoteAttachment(view, ac, reason)
884+ }
885+ if warm := d.stopRemoteViewWarm(view); warm != nil {
886+ remoteWarms = append(remoteWarms, warm)
887+ }
888+ if link := d.stopRemoteViewLink(view); link != nil {
889+ remoteLinks = append(remoteLinks, link)
890+ }
891+ }
892+ for _, warm := range remoteWarms {
893+ warm.stop()
894+ }
895+ for _, link := range remoteLinks {
896+ joinRemoteLink(link)
897+ }
802898 d.finishParkedAttachmentRetirements(parkedRetirements)
803899 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
804900 if empty {
805901 d.doneOnce.Do(func() { close(d.done) })
806- return false
902+ return checkpointIncomplete
807903 }
808904 for _, s := range snapshot {
809905 // Cancellation and PTY closure must not wait behind a teardown owner that
@@ -1023,7 +1119,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
10231119 info := ports.SessionInfo{
10241120 SessionID: string(s.id),
10251121 Name: s.name,
1026- State: ports.SessionRunning,
1122+ State: ports.SessionUp,
10271123 Ephemeral: s.ephemeral,
10281124 Tabs: uint16(len(s.tabs)),
10291125 Attached: len(s.attachments) != 0,
@@ -1036,7 +1132,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
10361132 if _, live := liveNames[name]; live {
10371133 continue
10381134 }
1039- state := ports.SessionStopped
1135+ state := ports.SessionDown
10401136 if stopped.purging || stopped.state == ports.SessionBroken {
10411137 // Purge is the dominant externally visible state: restoration must
10421138 // never make a deletion-reserved record appear attachable.
@@ -1135,8 +1231,24 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11351231 return
11361232 }
11371233
1138- sess, ac, rerr := d.routeWithContext(handshakeCtx, h, tr)
1234+ view, ac, remoteResume, rerr := d.resumeParkedRemoteView(h, tr)
1235+ if !remoteResume {
1236+ view, ac, remoteResume, rerr = d.resumeLiveRemoteView(h, tr)
1237+ }
1238+ var owner attachmentOwner = view
1239+ if remoteResume && rerr == nil && h.RemoteTarget != nil && !remoteViewMatchesTarget(view, *h.RemoteTarget) {
1240+ d.abortResumeClaim(ac)
1241+ rerr = &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
1242+ }
1243+ if !remoteResume {
1244+ var sess *session
1245+ sess, ac, rerr = d.routeWithContext(handshakeCtx, h, tr)
1246+ owner = sess
1247+ }
11391248 if rerr != nil {
1249+ if errors.Is(rerr, errResumeTokenLifecycleRace) {
1250+ rerr = &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1251+ }
11401252 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
11411253 if pe, ok := errors.AsType[*protoErr](rerr); ok {
11421254 _ = sendHandshake(frameError(pe.code, pe.text))
@@ -1146,87 +1258,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11461258 _ = tr.Close()
11471259 return
11481260 }
1149-
1150- failAttachment := func() {
1151- d.failHandshakeAttachment(sess, ac, tr)
1152- }
1153- if err := handshakeCtx.Err(); err != nil {
1154- failAttachment()
1155- return
1156- }
1157- rc := sess.renderCoordinator()
1158- lease := (*attachmentLease)(nil)
1159- if rc != nil {
1160- lease = rc.attachmentLease(ac)
1161- }
1162- expected := ac.transportSnapshot()
1163- welcomeToken := sess.attachmentToken(ac, tr)
1164- welcomeToken.lease = lease
1165- welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1166- if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1167- if admitted {
1168- welcomeTicket.End()
1169- }
1170- if !d.abortResumeClaim(ac) {
1171- d.clientGone(sess, ac, tr, false)
1172- }
1173- return
1174- }
1175- if err := handshakeCtx.Err(); err != nil {
1176- welcomeTicket.End()
1177- failAttachment()
1178- return
1179- }
1180- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1181- return ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket)
1182- }); err != nil {
1183- welcomeTicket.End()
1184- failAttachment()
1185- return
1186- }
1187- // Release Welcome's effect before discovering post-handshake authority so a
1188- // replacement blocked behind the send can publish its generation and lease.
1189- welcomeTicket.End()
1190- postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)
1191- if !admitted {
1192- failAttachment()
1193- return
1194- }
1195- postWelcomeLease := postWelcomeToken.lease
1196- if postWelcomeToken.ac == nil {
1197- postWelcomeTicket.End()
1198- failAttachment()
1199- return
1200- }
1201- if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
1202- postWelcomeTicket.End()
1203- // The attachment was detached while Welcome was in flight; never let
1204- // this stale handshake emit an Output frame.
1205- failAttachment()
1206- return
1207- }
1208- postWelcomeTicket.End()
1209- paintToken := sess.attachmentToken(ac, tr)
1210- painted := make(chan bool, 1)
1211- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1212- painted <- d.firstPaintForTransition(paintToken)
1213- return nil
1214- }); err != nil {
1215- failAttachment()
1216- return
1217- }
1218- if !<-painted || handshakeCtx.Err() != nil {
1219- failAttachment()
1220- return
1221- }
1222- if !d.commitResumeClaim(ac) {
1223- failAttachment()
1224- return
1225- }
1226- stopHandshakeTransport()
1227- finishHandshake()
1228- d.runConnLoop(ac)
1229- _ = tr.Close()
1261+ d.completeAttachmentHandshake(handshakeCtx, stopHandshakeTransport, finishHandshake, tr, owner, ac)
12301262 }
12311263
12321264 // protoErr is a session-level rejection carrying a wire ErrorMsg code.
@@ -1269,6 +1301,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
12691301 clientID: h.ClientID,
12701302 resumeCapable: true,
12711303 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1304+ renderMode: h.RenderMode,
12721305 }
12731306 if h.RemoteTarget != nil {
12741307 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
M internal/usecase/daemon/durable_recovery_test.go +13 -13
@@ -304,7 +304,7 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
304304 healthyEntry := d.stopped[healthy.Name]
305305 brokenEntry := d.stopped[broken.Name]
306306 d.mu.Unlock()
307- require.Equal(t, ports.SessionStopped, healthyEntry.state)
307+ require.Equal(t, ports.SessionDown, healthyEntry.state)
308308 require.Equal(t, ports.SessionBroken, brokenEntry.state)
309309 for name, done := range map[string]<-chan struct{}{healthy.Name: healthyEntry.restoreDone, broken.Name: brokenEntry.restoreDone} {
310310 select {
@@ -344,14 +344,14 @@ func TestCatalogueRestoreResetsOnlyIncompatibleCheckpoint(t *testing.T) {
344344 require.Empty(t, fresh.DegradedReason)
345345 require.Empty(t, fresh.TabNames)
346346 require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted)
347- require.Equal(t, ports.SessionStopped, d.stopped[incompatible.Name].state)
347+ require.Equal(t, ports.SessionDown, d.stopped[incompatible.Name].state)
348348 require.Equal(t, fresh, d.stopped[incompatible.Name].record)
349349
350350 restoredSibling, ok, err := catalogue.Record(healthy.Name)
351351 require.NoError(t, err)
352352 require.True(t, ok)
353353 require.Equal(t, healthy, restoredSibling)
354- require.Equal(t, ports.SessionStopped, d.stopped[healthy.Name].state)
354+ require.Equal(t, ports.SessionDown, d.stopped[healthy.Name].state)
355355 require.Equal(t, 1, repository.loads[healthy.Name])
356356 }
357357
@@ -381,7 +381,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te
381381 d.mu.Lock()
382382 entry := d.stopped[record.Name]
383383 d.mu.Unlock()
384- require.Equal(t, ports.SessionStopped, entry.state)
384+ require.Equal(t, ports.SessionDown, entry.state)
385385 require.Equal(t, fresh, entry.record)
386386 require.Equal(t, restoreDone, entry.restoreDone)
387387 select {
@@ -394,7 +394,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te
394394 d.mu.Lock()
395395 entry = d.stopped[record.Name]
396396 d.mu.Unlock()
397- require.Equal(t, ports.SessionStopped, entry.state)
397+ require.Equal(t, ports.SessionDown, entry.state)
398398 require.Equal(t, fresh, entry.record)
399399 select {
400400 case <-entry.restoreDone:
@@ -472,7 +472,7 @@ func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
472472
473473 require.Len(t, d.stopped, count)
474474 for _, record := range records {
475- require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state)
475+ require.Equal(t, ports.SessionDown, d.stopped[record.Name].state)
476476 require.Equal(t, 1, repository.loads[record.Name])
477477 }
478478 }
@@ -486,7 +486,7 @@ func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) {
486486 // discovery API and therefore performs exactly one direct catalogue lookup.
487487 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
488488
489- require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state)
489+ require.Equal(t, ports.SessionDown, d.stopped[record.Name].state)
490490 require.Equal(t, 1, repository.loads[record.Name])
491491 }
492492
@@ -709,7 +709,7 @@ func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T
709709 name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt,
710710 incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq,
711711 tabNames: append([]string(nil), record.TabNames...), record: record,
712- state: ports.SessionStopped, restoreDone: make(chan struct{}),
712+ state: ports.SessionDown, restoreDone: make(chan struct{}),
713713 }
714714 }
715715 beforeStopped, hadStopped := d.stopped[tc.sessName]
@@ -769,7 +769,7 @@ func TestSessionRecoveryCommand(t *testing.T) {
769769
770770 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")
771771 require.NoError(t, err)
772- require.Equal(t, ports.SessionStopped, d.stopped[degraded.Name].state)
772+ require.Equal(t, ports.SessionDown, d.stopped[degraded.Name].state)
773773 require.Equal(t, fresh, d.stopped[degraded.Name].record)
774774 }
775775
@@ -783,8 +783,8 @@ func TestListShowsDegraded(t *testing.T) {
783783
784784 listed := listSessions(t, d)
785785 require.Equal(t, []ports.SessionInfo{
786- {Name: fresh.Name, State: ports.SessionStopped},
787- {Name: restoring.Name, State: ports.SessionStopped},
786+ {Name: fresh.Name, State: ports.SessionDown},
787+ {Name: restoring.Name, State: ports.SessionDown},
788788 {Name: degraded.Name, State: ports.SessionBroken},
789789 }, listed.Sessions)
790790 }
@@ -795,7 +795,7 @@ func TestListPurgingDominatesRestoringState(t *testing.T) {
795795 d.mu.Lock()
796796 entry := d.stopped[record.Name]
797797 entry.purging = true
798- entry.state = ports.SessionStopped
798+ entry.state = ports.SessionDown
799799 d.stopped[record.Name] = entry
800800 d.mu.Unlock()
801801
@@ -935,7 +935,7 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
935935 state ports.SessionState
936936 message string
937937 }{
938- {name: "healthy record without runtime", state: ports.SessionStopped, message: "session was not restored into this daemon: " + record.Name},
938+ {name: "healthy record without runtime", state: ports.SessionDown, message: "session was not restored into this daemon: " + record.Name},
939939 {name: "broken durable state", state: ports.SessionBroken, message: "session durable state is broken: " + record.Name},
940940 }
941941 for _, tt := range tests {
M internal/usecase/daemon/handshake.go +13 -3
@@ -115,7 +115,13 @@ func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fal
115115 // by route. A fresh route-owned session is purged only after its attachment is
116116 // removed; existing sessions and their unrelated attachments are preserved.
117117 func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {
118- if sess == nil || ac == nil {
118+ d.failHandshakeAttachmentOwner(sess, ac, tr)
119+}
120+
121+// failHandshakeAttachmentOwner synchronously retires an admitted local-client
122+// handshake without assuming that its attachment owner owns a local session.
123+func (d *Daemon) failHandshakeAttachmentOwner(owner attachmentOwner, ac *attachedClient, tr ports.Transport) {
124+ if normalizeAttachmentOwner(owner) == nil || ac == nil {
119125 if tr != nil {
120126 _ = tr.Close()
121127 }
@@ -130,11 +136,15 @@ func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr p
130136 }
131137 return
132138 }
133- d.clientGone(sess, ac, tr, true)
139+ if view, remote := owner.(*remoteView); remote {
140+ d.clientGoneRemote(view, attachmentOwnerToken(view, ac, tr), true)
141+ } else if sess := localSession(owner); sess != nil {
142+ d.clientGone(sess, ac, tr, true)
143+ }
134144 if tr != nil {
135145 _ = tr.Close()
136146 }
137- if ac.routeCreatedSession {
147+ if sess := localSession(owner); sess != nil && ac.routeCreatedSession {
138148 sess.mu.Lock()
139149 empty := len(sess.attachments) == 0
140150 sess.mu.Unlock()
M internal/usecase/daemon/input.go +83 -4
@@ -7,6 +7,7 @@ import (
77 "strconv"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/usecase/keys"
1112 "github.com/bnema/vev/internal/usecase/layout"
1213 "github.com/bnema/vev/internal/usecase/mouse"
@@ -20,13 +21,90 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
2021 d.handleInput(sess, ac, data)
2122 }
2223
23-func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
24+func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, inputSeq uint64, data []byte) {
2425 if !token.attachmentEffectCurrent() {
2526 return
2627 }
28+ if view, remote := token.owner.(*remoteView); remote {
29+ d.handleRemoteViewInputForAttachment(view, token, inputSeq, data)
30+ return
31+ }
32+ if token.ac.renderMode == ports.RenderModeProxiedContent {
33+ d.handleProxiedInputForAttachment(token, data)
34+ return
35+ }
2736 d.handleInputForAttachment(token, data)
2837 }
2938
39+// handleRemoteViewInputForAttachment keeps picker and palette routing local
40+// while forwarding ordinary terminal bytes unchanged to the exact remote link.
41+func (d *Daemon) handleRemoteViewInputForAttachment(view *remoteView, token attachmentConnectionToken, inputSeq uint64, data []byte) {
42+ if view == nil || token.ac == nil || !token.attachmentEffectCurrent() {
43+ return
44+ }
45+ ac := token.ac
46+ ac.initOverlays()
47+ if ac.overlays.HandleInput(d, data, token.effect) {
48+ return
49+ }
50+ if ac.keys == nil {
51+ d.handleRemoteViewInput(view, token, inputSeq, data)
52+ return
53+ }
54+ ac.keys.RouteWithHandler(data, remoteViewKeyHandler{d: d, ac: ac, view: view, token: token, inputSeq: inputSeq})
55+}
56+
57+type remoteViewKeyHandler struct {
58+ d *Daemon
59+ ac *attachedClient
60+ view *remoteView
61+ token attachmentConnectionToken
62+ inputSeq uint64
63+}
64+
65+func (h remoteViewKeyHandler) Forward(data []byte) {
66+ h.d.handleRemoteViewInput(h.view, h.token, h.inputSeq, data)
67+}
68+
69+func (h remoteViewKeyHandler) Action(action keys.Action, raw []byte) {
70+ if !h.token.attachmentEffectCurrent() {
71+ return
72+ }
73+ if action != keys.ActionOpenPalette {
74+ h.Forward(raw)
75+ return
76+ }
77+ if source := h.ac.navigationSession(); source != nil {
78+ h.d.enterPalette(source, h.ac)
79+ }
80+}
81+
82+// handleProxiedInputForAttachment preserves remote terminal bytes exactly.
83+// Local key routing, overlays, mouse interpretation, and copy-mode state are
84+// deliberately unavailable on the remote daemon's content-only surface.
85+func (d *Daemon) handleProxiedInputForAttachment(token attachmentConnectionToken, data []byte) {
86+ sess := token.localSession()
87+ if len(data) == 0 || !token.attachmentEffectCurrent() || sess == nil {
88+ return
89+ }
90+ _ = sess.runMutation(func() error {
91+ if !token.attachmentEffectCurrent() {
92+ return nil
93+ }
94+ tab := sess.tabForAttachment(token.ac)
95+ if tab == nil {
96+ return nil
97+ }
98+ tab.mu.Lock()
99+ pane := tab.terminalTargetLocked()
100+ tab.mu.Unlock()
101+ if pane != nil {
102+ d.writeToPane(sess, pane, data)
103+ }
104+ return nil
105+ })
106+}
107+
30108 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
31109 ac.initOverlays()
32110 ac.mouseScan.Scan(data,
@@ -383,7 +461,7 @@ type daemonKeyHandler struct {
383461 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384462 if h.connectionToken.ac != nil {
385463 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386- return h.connectionToken.sess, effect, false
464+ return h.connectionToken.localSession(), effect, false
387465 }
388466 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
389467 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -397,11 +475,12 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
397475 token.effect = effect
398476 h.d.afterAttachmentEffectAdmitted(token)
399477 }
400- if h.connectionToken.sess == nil {
478+ sess := h.connectionToken.localSession()
479+ if sess == nil {
401480 effect.End()
402481 return nil, nil, false
403482 }
404- return h.connectionToken.sess, effect, true
483+ return sess, effect, true
405484 }
406485 sess := h.ac.currentSession()
407486 if sess == nil {
M internal/usecase/daemon/input_test.go +15 -15
@@ -799,8 +799,8 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
799799 prepare func()
800800 }{
801801 {name: "no target", prepare: func() {}},
802- {name: "stale target", prepare: func() { ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
803- {name: "same session", prepare: func() { ac.previousSession.Set(current) }},
802+ {name: "stale target", prepare: func() { ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
803+ {name: "same session", prepare: func() { ac.previousOwner.Set(current) }},
804804 } {
805805 t.Run(tc.name, func(t *testing.T) {
806806 ac.clearPreviousSession()
@@ -808,7 +808,7 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
808808 runPaletteCommand(t, d, current, ac, "BSK")
809809 require.Same(t, current, ac.currentSession())
810810 if tc.name != "no target" {
811- require.Nil(t, ac.previousSession.Get(), "invalid target must not remain toggleable")
811+ require.Nil(t, ac.previousOwner.Get(), "invalid target must not remain toggleable")
812812 }
813813 })
814814 }
@@ -818,7 +818,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
818818 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
819819 defer releaseAll(releases)
820820 target := d.sessions[domain.SessionID("recent")]
821- ac.previousSession.Set(target)
821+ ac.previousOwner.Set(target)
822822
823823 // A displaced attachment makes the previously valid handoff stale by the
824824 // time switchToTarget commits it.
@@ -829,7 +829,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
829829 d.backSession(current, ac)
830830
831831 require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")
832- require.Same(t, target, ac.previousSession.Get(), "a failed handoff remains retryable")
832+ require.Same(t, target, ac.previousOwner.Get(), "a failed handoff remains retryable")
833833 history := d.notices.history()
834834 require.Len(t, history, 1, "the switch failure must be reported exactly once")
835835 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
@@ -839,14 +839,14 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
839839 ac := &attachedClient{}
840840 stale := &session{sessionCore: sessionCore{id: "stale"}}
841841 updated := &session{sessionCore: sessionCore{id: "updated"}}
842- ac.previousSession.Set(stale)
842+ ac.previousOwner.Set(stale)
843843
844844 // Model a completed hand-off between observing a stale target and clearing
845845 // it. The conditional clear must not erase the newer toggle destination.
846- ac.previousSession.Set(updated)
847- ac.clearPreviousSessionIf(stale)
846+ ac.previousOwner.Set(updated)
847+ ac.clearPreviousOwnerIf(stale)
848848
849- require.Same(t, updated, ac.previousSession.Get())
849+ require.Same(t, updated, ac.previousOwner.Get())
850850 }
851851
852852 // backSession's invalid-target fallback is a render producer: it must publish
@@ -861,10 +861,10 @@ func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
861861 }{
862862 {name: "no previous target", prepare: func(*session, *attachedClient) {}},
863863 {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
864- ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}})
864+ ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}})
865865 }},
866866 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
867- ac.previousSession.Set(current)
867+ ac.previousOwner.Set(current)
868868 }},
869869 } {
870870 t.Run(tc.name, func(t *testing.T) {
@@ -940,10 +940,10 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
940940 }
941941 require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
942942 if tc.wantPrev == "" {
943- require.Nil(t, ac.previousSession.Get())
943+ require.Nil(t, ac.previousOwner.Get())
944944 return
945945 }
946- require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().core().id)
946+ require.Equal(t, domain.SessionID(tc.wantPrev), localSession(ac.previousOwner.Get()).core().id)
947947 })
948948 }
949949 }
@@ -963,10 +963,10 @@ func TestTerminalTeardownClearsPreviousSession(t *testing.T) {
963963 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
964964 defer releaseAll(releases)
965965
966- ac.previousSession.Set(d.sessions[domain.SessionID("recent")])
966+ ac.previousOwner.Set(d.sessions[domain.SessionID("recent")])
967967 tc.teardown(d, current, ac)
968968
969- require.Nil(t, ac.previousSession.Get())
969+ require.Nil(t, ac.previousOwner.Get())
970970 })
971971 }
972972 }
M internal/usecase/daemon/move_attachments.go +2 -2
@@ -1,8 +1,8 @@
11 package daemon
22
33 func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {
4- return token.sess == sess && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5- token.ac.currentAttachmentSession() == sess && token.ac.transportSnapshotCurrent(token.transport) &&
4+ return sameAttachmentOwner(token.owner, sess) && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), sess) && token.ac.transportSnapshotCurrent(token.transport) &&
66 attachmentRegisteredLocked(sess, token.ac)
77 }
88
M internal/usecase/daemon/move_picker.go +5 -2
@@ -161,13 +161,16 @@ type pickerRefreshOptions struct {
161161 }
162162
163163 func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
164- sess := ac.currentSession()
164+ // A remote view retains its local navigation predecessor for picker state.
165+ // Rebuild against that session so remote catalog completions update the
166+ // picker while its attachment remains remotely owned.
167+ sess := ac.navigationSession()
165168 if sess == nil {
166169 return
167170 }
168171 rt := ac.overlays
169172 rt.pickerMu.Lock()
170- if rt.picker == nil {
173+ if rt.picker == nil || rt.pickerRemoteSelection != nil {
171174 rt.pickerMu.Unlock()
172175 return
173176 }
M internal/usecase/daemon/navigation_attachment_handoff_test.go +2 -2
@@ -56,7 +56,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
5656 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
5757 d.mu.Lock()
5858 delete(d.sessions, target.id)
59- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped}
59+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionDown}
6060 d.mu.Unlock()
6161 expectedCreatedAt := int64(9)
6262 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
@@ -149,7 +149,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
149149 func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {
150150 d, source, old, _ := newManualSessionWithPTYs(t, nil)
151151 d.mu.Lock()
152- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}
152+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionDown}
153153 d.mu.Unlock()
154154
155155 rc := d.attachCoordinator(source, nil, old, true)
M internal/usecase/daemon/notify.go +30 -0
@@ -370,6 +370,29 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
370370 d.notify(nil, sev, code, msg, cause)
371371 }
372372
373+// notifyAttachment publishes a notice to one exact attachment. Remote views do
374+// not belong to the local-session attachment registry, so a remote selection
375+// failure must not fall back to daemon-global delivery.
376+func (d *Daemon) notifyAttachment(ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
377+ if d == nil || ac == nil {
378+ return
379+ }
380+ n := d.notices.record(domain.Notification{
381+ Code: code,
382+ Severity: sev,
383+ Message: msg,
384+ Details: noticeDetails(cause),
385+ Time: d.clock.Now(),
386+ })
387+ d.log.Log(d.serveCtx, slogLevelFor(sev), "attachment notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
388+ d.notices.routingMu.Lock()
389+ published := d.publishToast(ac, n)
390+ d.notices.routingMu.Unlock()
391+ if published {
392+ d.repaintForNotice(ac)
393+ }
394+}
395+
373396 // publishToast durably mutates one client's toast state without repainting.
374397 // Routing paths call it while routingMu excludes detach, then release routingMu
375398 // before rendering so a render failure can safely route another notice.
@@ -491,6 +514,13 @@ func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) {
491514 // repaintForNotice asks for an urgent redraw. It must be called with noticeMu
492515 // released: invalidateRender can paint inline, which takes sendMu.
493516 func (d *Daemon) repaintForNotice(ac *attachedClient) {
517+ if ac == nil {
518+ return
519+ }
520+ if _, remote := normalizeAttachmentOwner(ac.currentAttachmentOwner()).(*remoteView); remote {
521+ d.invalidateAttachedOwner(ac, false, "notify.go")
522+ return
523+ }
494524 sess := ac.currentSession()
495525 if sess == nil {
496526 // Mid-handoff or torn down: whoever attaches next repaints anyway, and
M internal/usecase/daemon/output_state_stream.go +4 -3
@@ -250,17 +250,18 @@ func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports
250250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
251251 }
252252
253-func (s *outputStateStream) ack(epoch, state uint64) {
253+func (s *outputStateStream) ack(epoch, state uint64) bool {
254254 if s == nil {
255- return
255+ return false
256256 }
257257 s.lockView()
258258 defer s.unlockView()
259259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
260- return
260+ return false
261261 }
262262 s.acked = state
263263 s.publishOutstanding()
264+ return true
264265 }
265266
266267 func (s *outputStateStream) rebase() {
M internal/usecase/daemon/overlay_runtime.go +5 -2
@@ -31,8 +31,11 @@ type overlayRuntime struct {
3131 pickerRemotePreview picker.Preview
3232 pickerRemotePreviewCancel context.CancelFunc
3333 pickerPreviewGeneration uint64
34- pickerPending []byte
35- pickerESC pendingByteTimer
34+ // pickerRemoteSelection fences one asynchronous remote picker activation.
35+ // It is cleared before the picker accepts another remote activation.
36+ pickerRemoteSelection *remotePickerSelection
37+ pickerPending []byte
38+ pickerESC pendingByteTimer
3639
3740 // Test-only, unsynchronized lifecycle seams. Assign them before picker
3841 // publication or goroutine startup. Hooks run without pickerMu or
M internal/usecase/daemon/palette.go +19 -13
@@ -37,10 +37,16 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
3737
3838 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
3939 // Capture daemon/session state before taking paletteMu: lock ordering forbids
40- // holding an overlay lock while inspecting live sessions.
41- recent := d.recentSessions(sess)
40+ // holding an overlay lock while inspecting live sessions. A remote view is
41+ // not a local session, so its retained local source must remain offered as a
42+ // palette result and JRS candidate.
43+ current := sess
44+ if _, remote := normalizeAttachmentOwner(ac.currentAttachmentOwner()).(*remoteView); remote {
45+ current = nil
46+ }
47+ recent := d.recentSessions(current)
4248 commands := d.paletteCommands()
43- results := d.paletteResults(sess, commands)
49+ results := d.paletteResults(current, commands)
4450 ac.overlays.paletteMu.Lock()
4551 ac.overlays.paletteGeneration++
4652 ac.overlays.palette = palette.New(results)
@@ -49,7 +55,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
4955 ac.overlays.paletteFeedback = ""
5056 ac.overlays.palettePending = nil
5157 ac.overlays.paletteMu.Unlock()
52- d.invalidateRender(sess, ac, true, "palette.go")
58+ d.invalidateAttachedOwner(ac, true, "palette.go")
5359 }
5460
5561 // paletteResults captures eligible named sessions before paletteMu so the
@@ -146,7 +152,7 @@ func (d *Daemon) recordPaletteUse(code string) {
146152 }
147153
148154 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
149- entry := ac.currentAttachmentSession()
155+ entry := ac.navigationSession()
150156 if entry == nil {
151157 return
152158 }
@@ -253,12 +259,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
253259 }
254260 ac.overlays.paletteMu.Unlock()
255261 if cancel {
256- d.invalidateRender(entry, ac, true, "palette.go")
262+ d.invalidateAttachedOwner(ac, true, "palette.go")
257263 return
258264 }
259265 if !execute {
260266 if changed {
261- d.invalidateRender(entry, ac, true, "palette.go")
267+ d.invalidateAttachedOwner(ac, true, "palette.go")
262268 }
263269 return
264270 }
@@ -284,11 +290,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
284290 }
285291 if err != nil {
286292 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
287- d.invalidateRender(entry, ac, true, "palette.go")
293+ d.invalidateAttachedOwner(ac, true, "palette.go")
288294 return
289295 }
290296 if ac.closeExecutedPalette(generation, rawQuery) {
291- d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")
297+ d.invalidateAttachedOwner(ac, true, "palette.go")
292298 }
293299 return
294300 }
@@ -297,7 +303,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
297303 rank, err := command.ParsePositiveDecimal(args)
298304 if err != nil {
299305 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
300- d.invalidateRender(entry, ac, true, "palette.go")
306+ d.invalidateAttachedOwner(ac, true, "palette.go")
301307 return
302308 }
303309 exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect}
@@ -306,12 +312,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
306312 return
307313 }
308314 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
309- d.invalidateRender(entry, ac, true, "palette.go")
315+ d.invalidateAttachedOwner(ac, true, "palette.go")
310316 return
311317 }
312318 if ac.closeExecutedPalette(generation, rawQuery) {
313319 d.recordPaletteUse(cmd.Code)
314- d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")
320+ d.invalidateAttachedOwner(ac, true, "palette.go")
315321 }
316322 return
317323 }
@@ -327,7 +333,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
327333 currentToken := current.attachmentToken(ac, ac.transport())
328334 fresh, admitted := ac.beginAttachmentEffect(currentToken)
329335 if ac.closeExecutedPalette(generation, rawQuery) {
330- d.invalidateRender(current, ac, true, "palette.go")
336+ d.invalidateAttachedOwner(ac, true, "palette.go")
331337 }
332338 if admitted {
333339 fresh.End()
M internal/usecase/daemon/palette_test.go +1 -1
@@ -460,7 +460,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
460460 // Establish a valid back-session target, then remove that target immediately
461461 // after the command releases its role admission.
462462 target := d.sessions[domain.SessionID("recent")]
463- ac.previousSession.Set(target)
463+ ac.previousOwner.Set(target)
464464 invalidations := installPaletteInvalidationObserver(current)
465465 d.enterPalette(current, ac)
466466 d.handlePaletteInput(ac, []byte("BSK"))
M internal/usecase/daemon/picker.go +123 -88
@@ -63,6 +63,8 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
6363 rt := ac.overlays
6464 rt.pickerMu.Lock()
6565 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
66+ previousSelection := rt.pickerRemoteSelection
67+ rt.pickerRemoteSelection = nil
6668 rt.pickerPreviewGeneration++
6769 if rt.pickerRemotePreviewCancel != nil {
6870 rt.pickerRemotePreviewCancel()
@@ -80,9 +82,12 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
8082 rt.pickerPending = nil
8183 rt.pickerESC.stop()
8284 rt.pickerMu.Unlock()
85+ if previousSelection != nil {
86+ previousSelection.cancel()
87+ }
8388 d.teardownPreviewSubscription(ac, previous, previousGeneration)
8489 d.registerPreviewForSelection(ac)
85- d.invalidateRender(sess, ac, true, "picker.go")
90+ d.invalidateAttachedOwner(ac, true, "picker.go")
8691 if rt.beforeRemotePickerRegistration != nil {
8792 rt.beforeRemotePickerRegistration()
8893 }
@@ -247,8 +252,8 @@ func remotePickerReasonText(reason string) string {
247252 return "host unreachable"
248253 case "version_mismatch":
249254 return "version mismatch"
250- case "session_stopped":
251- return "session stopped"
255+ case "session_down":
256+ return "session down"
252257 case "session_broken":
253258 return "session broken"
254259 case "refreshing":
@@ -264,6 +269,8 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
264269 rt := ac.overlays
265270 var previewGeneration uint64
266271 var instance remotePickerInstance
272+ var selection *remotePickerSelection
273+ var closed bool
267274 return listInputState{
268275 pending: &rt.pickerPending,
269276 esc: &rt.pickerESC,
@@ -273,6 +280,12 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
273280 unlock: rt.pickerMu.Unlock,
274281 active: func() bool { return rt.picker != nil },
275282 closeLocked: func() {
283+ if selection = rt.pickerRemoteSelection; selection != nil {
284+ rt.pickerRemoteSelection = nil
285+ rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
286+ return
287+ }
288+ closed = true
276289 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
277290 rt.picker = nil
278291 rt.pickerIntent = pickerNavigate
@@ -280,17 +293,20 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
280293 previewGeneration = rt.pickerPreviewGeneration
281294 },
282295 afterClose: func() {
283- d.clearPreviewGeneration(ac, previewGeneration)
284- d.remotePickerClosed(instance)
285- if sess := ac.currentAttachmentSession(); sess != nil {
286- d.invalidateRender(sess, ac, true, "picker.go")
296+ if selection != nil {
297+ selection.cancel()
287298 }
299+ if closed {
300+ d.clearPreviewGeneration(ac, previewGeneration)
301+ d.remotePickerClosed(instance)
302+ }
303+ d.invalidateAttachedOwner(ac, true, "picker.go")
288304 },
289305 }
290306 }
291307
292308 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
293- sess := ac.currentAttachmentSession()
309+ sess := ac.navigationSession()
294310 if sess == nil {
295311 return
296312 }
@@ -333,12 +349,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
333349 if result.action == 's' {
334350 d.togglePickerSort()
335351 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
336- d.invalidateRender(sess, ac, true, "picker.go")
352+ d.invalidateAttachedOwner(ac, true, "picker.go")
337353 return
338354 }
339355 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
340356 d.notifyRemotePickerUnavailable(sess, cursor)
341- d.invalidateRender(sess, ac, true, "picker.go")
357+ d.invalidateAttachedOwner(ac, true, "picker.go")
342358 return
343359 }
344360 if result.action == 'x' {
@@ -366,7 +382,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
366382 defer fresh.End()
367383 }
368384 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
369- d.invalidateRender(sess, ac, true, "picker.go")
385+ d.invalidateAttachedOwner(ac, true, "picker.go")
370386 return
371387 }
372388 if result.changed {
@@ -380,7 +396,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
380396 if intent == pickerMovePane || intent == pickerMoveTab {
381397 if err := d.movePickerSourceError(source); err != nil {
382398 d.reportAttachmentError(sess, err)
383- d.invalidateRender(sess, ac, true, "picker.go")
399+ d.invalidateAttachedOwner(ac, true, "picker.go")
384400 return
385401 }
386402 if len(effects) != 0 && effects[0] != nil {
@@ -390,14 +406,36 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
390406 err := d.commitMovePickerSelection(intent, source, target)
391407 if err != nil {
392408 d.reportAttachmentError(sess, movePickerUserError(err))
393- d.invalidateRender(sess, ac, true, "picker.go")
409+ d.invalidateAttachedOwner(ac, true, "picker.go")
394410 return
395411 }
396- d.invalidateRender(sess, ac, true, "picker.go")
412+ d.invalidateAttachedOwner(ac, true, "picker.go")
397413 return
398414 }
399415 var err error
400- if len(effects) != 0 && effects[0] != nil {
416+ if target.RemoteTarget != nil {
417+ // Remote picker activation is always an attachment-owned async
418+ // transition. Unit/headless callers may omit the frame effect, so
419+ // admit a short-lived local effect here rather than closing the
420+ // picker and falling through to the client handoff path.
421+ var effect *attachmentEffectTicket
422+ ownedEffect := false
423+ if len(effects) != 0 && effects[0] != nil {
424+ effect = effects[0]
425+ } else {
426+ token := attachmentOwnerToken(ac.currentAttachmentOwner(), ac, ac.transport())
427+ var admitted bool
428+ effect, admitted = ac.beginAttachmentEffect(token)
429+ if !admitted {
430+ return
431+ }
432+ ownedEffect = true
433+ }
434+ err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
435+ if ownedEffect {
436+ effect.End()
437+ }
438+ } else if len(effects) != 0 && effects[0] != nil {
401439 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
402440 } else {
403441 d.closePicker(ac)
@@ -413,7 +451,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
413451 return
414452 }
415453 if result.exit || result.changed {
416- d.invalidateRender(sess, ac, true, "picker.go")
454+ d.invalidateAttachedOwner(ac, true, "picker.go")
417455 }
418456 }
419457
@@ -505,7 +543,7 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ
505543 remoteTarget := *target.RemoteTarget
506544 message := "loading remote preview…"
507545 if remoteTarget.Stopped {
508- message = "stopped session — preview unavailable"
546+ message = "down session — preview unavailable"
509547 }
510548 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)
511549 if remoteTarget.Stopped {
@@ -707,6 +745,8 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
707745 return false
708746 }
709747 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
748+ selection := ac.overlays.pickerRemoteSelection
749+ ac.overlays.pickerRemoteSelection = nil
710750 ac.overlays.picker = nil
711751 ac.overlays.pickerTitle = ""
712752 ac.overlays.pickerIntent = pickerNavigate
@@ -715,6 +755,9 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
715755 ac.overlays.pickerESC.stop()
716756 previewGeneration := ac.overlays.pickerPreviewGeneration
717757 ac.overlays.pickerMu.Unlock()
758+ if selection != nil {
759+ selection.cancel()
760+ }
718761 d.clearPreviewGeneration(ac, previewGeneration)
719762 if instance.model != nil {
720763 d.remotePickerClosed(instance)
@@ -794,7 +837,8 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
794837 }
795838
796839 func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
797- if token.sess == nil || token.ac == nil {
840+ source := token.localSession()
841+ if source == nil || token.ac == nil {
798842 return nil
799843 }
800844 d.mu.Lock()
@@ -804,15 +848,15 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
804848 if !token.attachmentCurrent() {
805849 return nil
806850 }
807- d.invalidateRender(token.sess, token.ac, true, "picker.go")
851+ d.invalidateRender(source, token.ac, true, "picker.go")
808852 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
809853 }
810- if targetSess == token.sess {
854+ if targetSess == source {
811855 return nil
812856 }
813857
814858 transition, err := d.transitionAttachment(attachmentTransitionRequest{
815- source: token.sess, target: targetSess, next: token.ac,
859+ source: source, target: targetSess, next: token.ac,
816860
817861 expectedTransport: token.transport, sourceToken: &token, action: action,
818862 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
@@ -834,7 +878,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
834878 }
835879 }
836880 d.touchMRU(targetSess)
837- token.ac.recordPreviousSession(token.sess)
881+ token.ac.recordPreviousSession(source)
838882 d.deferAttachmentTransitionCleanups(transition)
839883 d.firstPaintForTransition(transition.published)
840884 return nil
@@ -847,92 +891,83 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
847891 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
848892 }
849893
894+// retainedLocalTargetMatches reports whether target is the local session held
895+// as a remote attachment's exact return owner. It compares structured session
896+// and lifecycle identity; a remote attachment must never infer a return route
897+// from a rendered label.
898+func retainedLocalTargetMatches(target picker.Target, owner attachmentOwner) bool {
899+ if target.Session == "" && target.Incarnation == (domain.IncarnationID{}) && target.ExpectedCreatedAt == nil {
900+ return false
901+ }
902+ retained := localSession(owner)
903+ if retained == nil {
904+ return false
905+ }
906+ snapshot := retained.snapshotView(viewOptions{})
907+ if target.Session != "" && target.Session != snapshot.id {
908+ return false
909+ }
910+ if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != snapshot.incarnation {
911+ return false
912+ }
913+ return targetMatchesLifecycle(target, snapshot.name, snapshot.createdAt)
914+}
915+
850916 // switchToTargetForAttachment is the sole client-originated navigation entry. The
851917 // intent captures the exact initiating capability before admission is released;
852918 // every active, stopped, and same-session target then uses transitionAttachment
853919 // for frozen, atomic source-token preflight.
854920 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
855- if token.sess == nil || token.ac == nil || token.effect == nil {
921+ if token.ac == nil {
856922 return nil
857923 }
858- if target.RemoteTarget != nil {
859- var key domain.RemoteSessionKey
924+ if _, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {
925+ if target.RemoteTarget != nil {
926+ return d.startRemotePickerSelection(token, target, guard, action)
927+ }
860928 if target.RemoteKey != nil {
861- key = *target.RemoteKey
929+ return errAttachmentTransition
862930 }
863- return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
864- }
865- if target.RemoteKey != nil {
866- return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
867- }
868- token.effect.bindActionEnd(d, action)
869- token.effect.End()
870- if token.sess == nil {
871- return errAttachmentTransition
872- }
873- return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
874-}
875-
876-// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
877-// endpoint to the thin client. The daemon owns no remote session shadow: after
878-// the target is sent, the local attachment is detached and the client opens a
879-// fresh connection to the owning daemon.
880-func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
881- var handoff ports.AttachTarget
882- if target.RemoteTarget != nil {
883- remoteTarget := *target.RemoteTarget
884- if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
931+ previous := token.ac.previousOwner.Get()
932+ if !retainedLocalTargetMatches(target, previous) {
885933 return errAttachmentTransition
886934 }
887- if target.RemoteKey != nil {
888- if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
889- return errAttachmentTransition
935+ transitioned := false
936+ if err := d.backSessionForAttachment(token, func() {
937+ transitioned = true
938+ if guard.closePicker {
939+ d.closePicker(token.ac)
890940 }
941+ }); err != nil {
942+ return err
891943 }
892- handoff = ports.AttachTarget{
893- Endpoint: remoteTarget.Endpoint,
894- Session: remoteTarget.SessionName,
895- Intent: ports.IntentAttach,
896- RemoteTarget: &remoteTarget,
897- EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
898- }
899- } else {
900- if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
944+ if !transitioned {
901945 return errAttachmentTransition
902946 }
903- handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach}
947+ return nil
904948 }
905- payload := ports.MarshalAttachTarget(handoff)
906- if payload == nil {
907- return errAttachmentTransition
949+ if token.localSession() == nil {
950+ return nil
908951 }
909- if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
910- return err
952+ if token.effect == nil {
953+ return errAttachmentTransition
911954 }
912- if guard.closePicker {
913- d.closePicker(token.ac)
955+ if target.RemoteTarget != nil {
956+ return d.startRemotePickerSelection(token, target, guard, action)
914957 }
915- d.clientGoneForAttachment(token, false)
916- return nil
917-}
918-
919-func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
920- if d == nil {
921- return false
958+ if target.RemoteKey != nil {
959+ // Picker-originated remote activation requires the complete validated
960+ // lifecycle/tab target. Legacy display keys must never fall back to a
961+ // client-owned attach handoff.
962+ return errAttachmentTransition
922963 }
923- d.remoteCatalog.mu.Lock()
924- defer d.remoteCatalog.mu.Unlock()
925- for _, entry := range d.remoteCatalog.cache {
926- if entry.Host != key.Host {
927- continue
928- }
929- for _, session := range entry.Sessions {
930- if session.Name == key.Name {
931- return d.remoteCatalog.status[key.Host] != remoteHostVersionMismatch
932- }
933- }
964+ token.effect.bindActionEnd(d, action)
965+ token.effect.End()
966+ source := token.localSession()
967+ if source == nil {
968+ return errAttachmentTransition
934969 }
935- return false
970+ return d.switchToTargetGuardedForAttachment(source, token.ac, target, guard, &token, action)
936971 }
937972
938973 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
@@ -958,7 +993,7 @@ func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget
958993 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
959994 continue
960995 }
961- if target.Stopped != (session.State == "stopped") || session.State == "broken" {
996+ if target.Stopped != (session.State == "down") || session.State == "broken" {
962997 return false
963998 }
964999 tabs := ports.CatalogTabs(session)
M internal/usecase/daemon/picker_test.go +3 -3
@@ -741,7 +741,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
741741 runtimeState ports.SessionState
742742 }{
743743 {name: "broken", broken: true, runtimeState: ports.SessionBroken},
744- {name: "healthy without restored runtime", runtimeState: ports.SessionStopped},
744+ {name: "healthy without restored runtime", runtimeState: ports.SessionDown},
745745 }
746746
747747 for _, tt := range tests {
@@ -800,7 +800,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
800800 defer release3()
801801 d, from, ac, sends := newManualSessionWithPTYs(t, p1)
802802 d.ptys = newFactorySeq(t, p2, p3)
803- d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionStopped}
803+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionDown}
804804
805805 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
806806 awaitFrame(t, sends, ports.MsgOutput)
@@ -920,7 +920,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
920920 targetPane.screen.Write([]byte("TARGET"))
921921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
922922 require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
923- ac.previousSession.Set(target)
923+ ac.previousOwner.Set(target)
924924
925925 // Exercise the user-facing previous-session route, which delegates to the
926926 // real switchToTarget hand-off and immediately emits its required reset.
A internal/usecase/daemon/proxied_handshake_test.go +251 -0
@@ -0,0 +1,251 @@
1+package daemon
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/bnema/vev/pkg/vt"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestProxiedInputBypassesRemoteDaemonUI(t *testing.T) {
17+ pty := portsmocks.NewMockPTY(t)
18+ pty.EXPECT().Write([]byte{'\x02', 'p'}).Return(2, nil).Once()
19+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
20+ ac.renderMode = ports.RenderModeProxiedContent
21+ token := attachmentToken(sess, ac, ac.transport())
22+ require.True(t, token.attachmentCurrent())
23+
24+ d.handleSequencedInputForAttachment(token, 1, []byte{'\x02', 'p'})
25+}
26+
27+func TestProxiedInputTargetsVisibleFloatingPane(t *testing.T) {
28+ floatingPTY := portsmocks.NewMockPTY(t)
29+ floatingPTY.EXPECT().Write([]byte("remote-input")).Return(len("remote-input"), nil).Once()
30+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
31+ ac.renderMode = ports.RenderModeProxiedContent
32+ installTestFloating(testAttachmentTab(sess), newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5}), true)
33+ token := attachmentToken(sess, ac, ac.transport())
34+ require.True(t, token.attachmentCurrent())
35+
36+ d.handleSequencedInputForAttachment(token, 1, []byte("remote-input"))
37+}
38+
39+func TestProxiedCommandKeepsRequestCorrelation(t *testing.T) {
40+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
41+ ac.renderMode = ports.RenderModeProxiedContent
42+ token := attachmentToken(sess, ac, ac.transport())
43+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
44+ Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "unknown-command",
45+ })
46+ require.NoError(t, err)
47+
48+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
49+ frame := <-sends
50+ require.Equal(t, ports.MsgCommandResult, frame.Type)
51+ result, err := ports.UnmarshalCommandResult(frame.Payload)
52+ require.NoError(t, err)
53+ require.Equal(t, uint64(42), result.RequestID)
54+ require.Equal(t, ports.ErrUnknownCommand, result.Code)
55+}
56+
57+func TestProxiedResizeUsesContentGeometry(t *testing.T) {
58+ pty := portsmocks.NewMockPTY(t)
59+ pty.EXPECT().Resize(domain.Size{Cols: 80, Rows: 20}).Return(nil).Once()
60+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
61+ ac.renderMode = ports.RenderModeProxiedContent
62+ token := attachmentToken(sess, ac, ac.transport())
63+ require.True(t, token.attachmentCurrent())
64+
65+ d.resizeAttachmentForLease(token, domain.Size{Cols: 80, Rows: 20})
66+ ac.sizeMu.RLock()
67+ require.Equal(t, domain.Size{Cols: 80, Rows: 20}, ac.size)
68+ ac.sizeMu.RUnlock()
69+ sess.tabs[0].mu.Lock()
70+ require.Equal(t, domain.Size{Cols: 80, Rows: 20}, sess.tabs[0].size)
71+ sess.tabs[0].mu.Unlock()
72+}
73+
74+func TestProxiedCompositionUsesAttachmentContentWindowAndFloatingSurface(t *testing.T) {
75+ paneFrame := renderer.NewFrame(20, 10)
76+ paneFrame.Set(0, 0, renderer.Cell{Rune: 'p'})
77+ floatingFrame := renderer.NewFrame(3, 2)
78+ floatingFrame.Set(0, 0, renderer.Cell{Rune: 'f'})
79+
80+ composed := composeProxiedContent(capturedRenderState{
81+ window: domain.Size{Cols: 10, Rows: 5},
82+ layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 10}},
83+ panes: []capturedPaneRenderState{{
84+ placement: layout.Placement{Content: domain.Rect{Width: 20, Height: 10}},
85+ frame: paneFrame,
86+ }},
87+ floating: capturedFloatingRenderState{
88+ visible: true,
89+ pane: capturedPaneRenderState{frame: floatingFrame},
90+ geometry: floatingGeometry{Inner: domain.Rect{X: 4, Y: 2, Width: 3, Height: 2}},
91+ },
92+ })
93+
94+ require.Equal(t, 10, composed.frame.Width)
95+ require.Equal(t, 5, composed.frame.Height)
96+ require.Equal(t, 'p', composed.frame.At(0, 0).Rune)
97+ require.Equal(t, 'f', composed.frame.At(4, 2).Rune)
98+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage)
99+}
100+
101+func TestProxiedPaintFlushesRuntimeMarks(t *testing.T) {
102+ observer := &daemonRuntimeObserver{}
103+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
104+ d.runtimeObserver = observer
105+ ac.renderMode = ports.RenderModeProxiedContent
106+
107+ require.Equal(t, paintEmitted, d.paintProxiedContent(sess, ac, true, nil))
108+ require.NotEmpty(t, observer.marks, "proxied rendering must flush observer marks after releasing sendMu")
109+}
110+
111+func TestProxiedOutputStartCommitsOnlyAfterAcceptedFrame(t *testing.T) {
112+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
113+ ac.renderMode = ports.RenderModeProxiedContent
114+ state, ok := captureLocalRenderState(sess, ac, renderCaptureRequest{reset: true})
115+ require.True(t, ok)
116+ composed := composeProxiedContent(*state)
117+
118+ state.attachment = &attachedClient{}
119+ ac.sendMu.Lock()
120+ require.False(t, d.emitFrame(sess, ac, state, composed))
121+ require.False(t, ac.proxiedOutputStarted, "rejected proxied output must remain retryable")
122+
123+ state.attachment = ac
124+ ac.sendMu.Lock()
125+ require.True(t, d.emitFrame(sess, ac, state, composed))
126+ require.True(t, ac.proxiedOutputStarted, "accepted proxied output starts the output boundary")
127+}
128+
129+func TestProxiedMetadataFallsBackFromStaleAttachmentTab(t *testing.T) {
130+ _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
131+ sess.mu.Lock()
132+ sess.incarnation = domain.SessionLifecycleID{1}
133+ sess.tabs[0].stableID = "live-tab"
134+ sess.mu.Unlock()
135+ ac.publishView(attachmentView{tabID: "stale-tab"})
136+
137+ frame, err := frameSessionMeta(sess, ac, 1)
138+ require.NoError(t, err)
139+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
140+ require.NoError(t, err)
141+ require.Equal(t, domain.TabStableID("live-tab"), meta.ActiveTabID)
142+ require.Equal(t, domain.TabStableID("live-tab"), meta.Tabs[0].ID)
143+}
144+
145+func TestProxiedMetadataRevisionPrecedesSubsequentContent(t *testing.T) {
146+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
147+ sess.mu.Lock()
148+ sess.incarnation = domain.SessionLifecycleID{1}
149+ sess.tabs[0].stableID = "tab"
150+ sess.mu.Unlock()
151+ ac.renderMode = ports.RenderModeProxiedContent
152+ ac.proxiedOutputStarted = true
153+ ac.proxiedMetaRevision = 1
154+
155+ ac.sendMu.Lock()
156+ failedTransport, err := d.sendProxiedMetadataLocked(sess, ac, nil)
157+ ac.sendMu.Unlock()
158+ require.NoError(t, err)
159+ require.Same(t, ac.transport(), failedTransport)
160+ frame := <-sends
161+ require.Equal(t, ports.MsgSessionMeta, frame.Type)
162+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
163+ require.NoError(t, err)
164+ require.Equal(t, uint64(2), meta.Revision)
165+ require.Equal(t, uint64(2), ac.proxiedMetaRevision)
166+}
167+
168+func TestProxiedHandshakePublishesStableMetadataBeforeOutput(t *testing.T) {
169+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
170+ base := &closeTrackingTransport{}
171+ sess, _, err := d.route(ports.Hello{
172+ Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "remote",
173+ Size: domain.Size{Cols: 80, Rows: 24},
174+ }, base)
175+ require.NoError(t, err)
176+ sess.tabs[0].focusedPane().screen.Write([]byte("content-marker"))
177+
178+ sess.mu.Lock()
179+ target := domain.RemoteSessionTarget{
180+ Endpoint: "user@host", DisplayOrigin: "host", LifecycleID: sess.incarnation,
181+ SessionName: sess.name, LiveTabID: domain.TabStableID(sess.tabs[0].stableID),
182+ }
183+ sess.mu.Unlock()
184+
185+ hello := ports.Hello{
186+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach,
187+ RenderMode: ports.RenderModeProxiedContent, Name: target.SessionName,
188+ Size: domain.Size{Cols: 80, Rows: 22}, RemoteTarget: &target,
189+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
190+ }
191+ tr, sends, release := newConn(t,
192+ ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)},
193+ ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})},
194+ )
195+ done := make(chan struct{})
196+ go func() {
197+ d.handleConn(tr)
198+ close(done)
199+ }()
200+
201+ welcomeFrame := <-sends
202+ require.Equal(t, ports.MsgWelcome, welcomeFrame.Type)
203+ welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
204+ require.NoError(t, err)
205+ require.Equal(t, ports.RenderModeProxiedContent, welcome.RenderMode)
206+
207+ metadataFrame := <-sends
208+ require.Equal(t, ports.MsgSessionMeta, metadataFrame.Type)
209+ metadata, err := ports.UnmarshalSessionMeta(metadataFrame.Payload)
210+ require.NoError(t, err)
211+ require.Equal(t, target.LifecycleID, metadata.LifecycleID)
212+ require.Equal(t, uint64(1), metadata.Revision)
213+ require.Equal(t, target.SessionName, metadata.SessionName)
214+ require.Equal(t, target.LiveTabID, metadata.ActiveTabID)
215+ require.Len(t, metadata.Tabs, 1)
216+ require.Equal(t, target.LiveTabID, metadata.Tabs[0].ID)
217+
218+ outputFrame := <-sends
219+ require.Equal(t, ports.MsgOutput, outputFrame.Type)
220+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
221+ require.NoError(t, err)
222+ require.Zero(t, output.Base)
223+ require.Equal(t, uint64(1), output.New)
224+ require.True(t, output.Full)
225+ require.Equal(t, hello.Size, output.Size)
226+ private := vt.NewScreen(output.Size.Cols, output.Size.Rows)
227+ private.Write(output.Data)
228+ require.Contains(t, screenLineText(private, 0), "content-marker")
229+ for y := range private.Frame.Height {
230+ require.NotContains(t, strings.TrimSpace(screenLineText(private, y)), "remote", "proxied content must omit remote chrome")
231+ }
232+
233+ resetMetadataFrame := <-sends
234+ require.Equal(t, ports.MsgSessionMeta, resetMetadataFrame.Type)
235+ resetMetadata, err := ports.UnmarshalSessionMeta(resetMetadataFrame.Payload)
236+ require.NoError(t, err)
237+ require.Equal(t, uint64(2), resetMetadata.Revision)
238+ resetOutputFrame := <-sends
239+ require.Equal(t, ports.MsgOutput, resetOutputFrame.Type)
240+ resetOutput, err := ports.UnmarshalOutput(resetOutputFrame.Payload)
241+ require.NoError(t, err)
242+ require.Greater(t, resetOutput.Epoch, output.Epoch)
243+ require.Zero(t, resetOutput.Base)
244+ require.Equal(t, uint64(1), resetOutput.New)
245+ require.True(t, resetOutput.Full)
246+ private.Write(resetOutput.Data)
247+ require.Contains(t, screenLineText(private, 0), "content-marker")
248+
249+ release()
250+ awaitTestCompletion(t, done, "proxied connection did not stop")
251+}
A internal/usecase/daemon/proxied_render.go +151 -0
@@ -0,0 +1,151 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/ports"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// paintProxiedContent publishes only the remote session's content surface. It
10+// deliberately bypasses bars, pane title chrome, overlays, notices, and every
11+// terminal-global side-effect path used by a full terminal attachment.
12+func (d *Daemon) paintProxiedContent(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
13+ if entry == nil || ac == nil {
14+ return paintRejected
15+ }
16+ marks := d.newRuntimeMarkBatch()
17+ defer marks.flush()
18+ if lease != nil {
19+ token := attachmentToken(entry, ac, ac.transport())
20+ token.lease = lease
21+ ticket, admitted := ac.beginAttachmentEffect(token)
22+ if !admitted {
23+ return paintRejected
24+ }
25+ marks.attachmentEffect = ticket
26+ defer ticket.End()
27+ }
28+
29+ ac.sendMu.Lock()
30+ entry.core().mu.Lock()
31+ _, owned := entry.core().attachments[ac]
32+ entry.core().mu.Unlock()
33+ if !owned || ac.currentAttachmentSession() != entry {
34+ ac.sendMu.Unlock()
35+ return paintRejected
36+ }
37+ if lease != nil {
38+ rc := attachmentRenderCoordinator(entry)
39+ if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
40+ ac.sendMu.Unlock()
41+ return paintRejected
42+ }
43+ }
44+ if ac.output != nil {
45+ ac.output.syncCapacityLocked()
46+ if ac.output.atCapacity() {
47+ ac.sendMu.Unlock()
48+ return paintBlockedCapacity
49+ }
50+ }
51+ if ac.proxiedOutputStarted {
52+ failedTransport, err := d.sendProxiedMetadataLocked(entry, ac, &marks)
53+ if err != nil {
54+ ac.sendMu.Unlock()
55+ if marks.attachmentEffect == nil {
56+ d.detachOnSendError(entry, ac, failedTransport)
57+ } else {
58+ token := marks.attachmentEffect.connectionToken()
59+ launchCleanup := d.reserveAttachmentSendErrorCleanup(token, failedTransport)
60+ marks.attachmentEffect.End()
61+ launchCleanup()
62+ }
63+ return paintRejected
64+ }
65+ }
66+
67+ state, ok := captureLocalRenderState(entry, ac, renderCaptureRequest{reset: reset, lease: lease})
68+ if !ok {
69+ ac.sendMu.Unlock()
70+ return paintRejected
71+ }
72+ composed := composeProxiedContent(*state)
73+ if !d.emitFrame(entry, ac, state, composed, &marks) {
74+ return paintRejected
75+ }
76+ return paintEmitted
77+}
78+
79+// sendProxiedMetadataLocked emits a newer authoritative snapshot before every
80+// post-handshake proxied Output. Caller holds ac.sendMu and no architecture
81+// lock; metadata capture itself completes before the transport write.
82+func (d *Daemon) sendProxiedMetadataLocked(entry *session, ac *attachedClient, marks *runtimeMarkBatch) (ports.Transport, error) {
83+ if entry == nil || ac == nil || ac.proxiedMetaRevision == ^uint64(0) {
84+ return nil, errAttachmentTransition
85+ }
86+ meta, err := frameSessionMeta(entry, ac, ac.proxiedMetaRevision+1)
87+ if err != nil {
88+ return nil, err
89+ }
90+ expected := ac.transportSnapshot()
91+ if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
92+ return expected.transport, errTransportReplaced
93+ }
94+ if marks != nil && marks.attachmentEffect != nil {
95+ if !marks.attachmentEffect.beginTransportSend(expected) {
96+ return expected.transport, errAttachmentTransition
97+ }
98+ defer marks.attachmentEffect.endTransportSend()
99+ }
100+ if err := expected.transport.Send(meta); err != nil {
101+ if marks != nil && marks.attachmentEffect != nil {
102+ marks.attachmentEffect.reportTransportFailure(expected)
103+ }
104+ return expected.transport, err
105+ }
106+ ac.proxiedMetaRevision++
107+ return expected.transport, nil
108+}
109+
110+// composeProxiedContent retains the remote terminal layout but excludes every
111+// daemon UI layer. Pane frames are copied at their content placements, so the
112+// emitted size is exactly the proxied content geometry.
113+func composeProxiedContent(state capturedRenderState) composedRenderFrame {
114+ area := state.layout.area
115+ // The proxied attachment owns the remote content window. Shared layout may
116+ // lag an accepted resize or be driven by another local attachment, so never
117+ // derive the remote wire surface from it when the attachment supplied a
118+ // valid content geometry.
119+ if state.window.Valid() {
120+ area.Width, area.Height = state.window.Cols, state.window.Rows
121+ }
122+ if area.Width <= 0 || area.Height <= 0 {
123+ return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
124+ }
125+ frame := renderer.NewFrame(area.Width, area.Height)
126+ full := state.reset || state.floating.visible
127+ damage := make([]renderer.Damage, 0, len(state.panes))
128+ for _, pane := range state.panes {
129+ if pane.placement.Collapsed || pane.placement.Content.Width <= 0 || pane.placement.Content.Height <= 0 {
130+ continue
131+ }
132+ blitPaneFrame(frame, pane.placement.Content, pane.frame, false, themeui.NewDimmer(themeui.Theme{}))
133+ if len(pane.damage) != 0 {
134+ damage = append(damage, pane.damage...)
135+ }
136+ }
137+ if state.floating.visible {
138+ // A floating pane is the active terminal surface. Proxied mode omits its
139+ // local chrome/border but must preserve its content and cursor target.
140+ blitPaneFrame(frame, state.floating.geometry.Inner, state.floating.pane.frame, false, themeui.NewDimmer(themeui.Theme{}))
141+ }
142+ if full {
143+ damage = []renderer.Damage{renderer.FullRedraw()}
144+ }
145+ return composedRenderFrame{
146+ frame: frame,
147+ damage: damage,
148+ cursor: desiredCapturedCursor(state.cursor, 0),
149+ reset: state.reset,
150+ }
151+}
M internal/usecase/daemon/recent_sessions.go +98 -0
@@ -15,6 +15,26 @@ type recentSession struct {
1515 mruAt uint64
1616 }
1717
18+// remoteRecentSession retains display-only recency for a remote view. It
19+// stores value identity rather than a view pointer so a retained warm view
20+// cannot gain navigation authority through the local status bar.
21+type remoteRecentSession struct {
22+ key remoteViewKey
23+ displayOrigin string
24+ mruAt uint64
25+}
26+
27+func (r remoteRecentSession) name() string {
28+ origin := r.displayOrigin
29+ if origin == "" {
30+ origin = r.key.endpoint
31+ }
32+ if origin == "" {
33+ return r.key.sessionName
34+ }
35+ return r.key.sessionName + "@" + origin
36+}
37+
1838 // recentSessions returns the current session's capped named-session MRU list.
1939 // Callers may retain the returned values for the lifetime of an interaction.
2040 func (d *Daemon) recentSessions(current *session) []recentSession {
@@ -35,6 +55,36 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
3555 recent = append(recent, entry)
3656 }
3757 d.mu.Unlock()
58+ return trimRecentSessions(recent)
59+}
60+
61+// recentSessionsForAttachment merges the daemon-wide local MRU with the
62+// attachment's display-only remote entries. Only chrome composition calls this
63+// method; picker, palette, and command routing keep using recentSessions.
64+func (d *Daemon) recentSessionsForAttachment(current *session, ac *attachedClient) []recentSession {
65+ recent := d.recentSessions(current)
66+ if ac == nil {
67+ return recent
68+ }
69+ return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(remoteViewKey{})...))
70+}
71+
72+// recentSessionsForRemoteAttachment merges daemon-local entries with this
73+// attachment's remote history while omitting the remote view currently rendered
74+// in the status bar.
75+func (d *Daemon) recentSessionsForRemoteAttachment(current *remoteView, ac *attachedClient) []recentSession {
76+ recent := d.recentSessions(nil)
77+ if ac == nil {
78+ return recent
79+ }
80+ var excluded remoteViewKey
81+ if current != nil {
82+ excluded = current.key
83+ }
84+ return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(excluded)...))
85+}
86+
87+func trimRecentSessions(recent []recentSession) []recentSession {
3888 sort.SliceStable(recent, func(i, j int) bool {
3989 if recent[i].mruAt == recent[j].mruAt {
4090 return recent[i].name < recent[j].name
@@ -46,3 +96,51 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
4696 }
4797 return recent
4898 }
99+
100+// recordRemoteRecent records only a successfully published remote owner. The
101+// caller invokes it after the attachment hand-off, outside daemon and view
102+// locks, so failed selections cannot affect the local status-bar history.
103+func (d *Daemon) recordRemoteRecent(ac *attachedClient, view *remoteView) {
104+ if d == nil || ac == nil || view == nil {
105+ return
106+ }
107+ view.mu.Lock()
108+ entry := remoteRecentSession{key: view.key, displayOrigin: view.displayOrigin}
109+ view.mu.Unlock()
110+ if entry.key.sessionName == "" {
111+ return
112+ }
113+ entry.mruAt = d.mruSeq.Add(1)
114+ ac.remoteRecent.With(func(entries *[]remoteRecentSession) {
115+ for i := range *entries {
116+ if (*entries)[i].key == entry.key {
117+ (*entries)[i] = entry
118+ return
119+ }
120+ }
121+ *entries = append(*entries, entry)
122+ if len(*entries) > maxMRUSessions {
123+ sort.SliceStable(*entries, func(i, j int) bool {
124+ return (*entries)[i].mruAt > (*entries)[j].mruAt
125+ })
126+ *entries = (*entries)[:maxMRUSessions]
127+ }
128+ })
129+}
130+
131+func (ac *attachedClient) remoteRecentSessionsExcept(excluded remoteViewKey) []recentSession {
132+ if ac == nil {
133+ return nil
134+ }
135+ var recent []recentSession
136+ ac.remoteRecent.With(func(entries *[]remoteRecentSession) {
137+ recent = make([]recentSession, 0, len(*entries))
138+ for _, entry := range *entries {
139+ if entry.key == excluded {
140+ continue
141+ }
142+ recent = append(recent, recentSession{name: entry.name(), mruAt: entry.mruAt})
143+ }
144+ })
145+ return recent
146+}
M internal/usecase/daemon/remote_attach_test.go +79 -51
@@ -17,56 +17,84 @@ func remoteLifecycleForTest() domain.SessionLifecycleID {
1717 return id
1818 }
1919
20-func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
21- d := newRemotePickerDaemon(nil)
22- lifecycle := remoteLifecycleForTest()
23- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
24- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
25- LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
26- }},
27- }})
28- d.remoteCatalog.mu.Lock()
29- d.remoteCatalog.status["arch"] = remoteHostFresh
30- d.remoteCatalog.mu.Unlock()
31- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
32- token := sess.attachmentToken(ac, ac.transport())
33- effect, admitted := ac.beginAttachmentEffect(token)
34- require.True(t, admitted)
35- token.effect = effect
36- defer effect.End()
37- key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
38- remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
39- target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
40- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
41- frame := receiveRemotePicker(t, sends, "rich attach target")
42- got, err := ports.UnmarshalAttachTarget(frame.Payload)
43- require.NoError(t, err)
44- require.NotNil(t, got.RemoteTarget)
45- require.Equal(t, remoteTarget, *got.RemoteTarget)
46- require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy)
47-}
20+func TestRemotePickerSelectionRejectsReplacedLifecycle(t *testing.T) {
21+ tests := []struct {
22+ name string
23+ run func(*testing.T)
24+ }{
25+ {
26+ name: "replaced lifecycle",
27+ run: func(t *testing.T) {
28+ d := newRemotePickerDaemon(nil)
29+ lifecycle := remoteLifecycleForTest()
30+ cachedLifecycle := lifecycle
31+ cachedLifecycle[0]++
32+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
33+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
34+ LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
35+ }},
36+ }})
37+ d.remoteCatalog.mu.Lock()
38+ d.remoteCatalog.status["arch"] = remoteHostFresh
39+ d.remoteCatalog.mu.Unlock()
40+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
41+ ac.overlays.pickerMu.Lock()
42+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
43+ ac.overlays.pickerGeneration++
44+ ac.overlays.pickerMu.Unlock()
45+ token := sess.attachmentToken(ac, ac.transport())
46+ effect, admitted := ac.beginAttachmentEffect(token)
47+ require.True(t, admitted)
48+ token.effect = effect
49+ defer effect.End()
50+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
51+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
52+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
53+
54+ require.ErrorIs(t, d.switchToTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select"), errAttachmentTransition)
55+ require.Same(t, sess, ac.currentAttachmentSession())
56+ require.True(t, ac.overlays.pickerActive())
57+ select {
58+ case frame := <-sends:
59+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
60+ default:
61+ }
62+ },
63+ },
64+ {
65+ name: "replaced picker instance",
66+ run: func(t *testing.T) {
67+ d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
68+ view := &remoteView{key: remoteViewKey{endpoint: "remote.example", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
69+ transport := newRemoteLinkTestTransport()
70+ link := &remoteLink{view: view, generation: 1, transport: transport, active: true, commands: NewCommandRequestTracker()}
71+ view.link = link
72+ view.linkGeneration = 1
73+ d.mu.Lock()
74+ require.NoError(t, d.registerRemoteViewLocked(view))
75+ d.mu.Unlock()
76+ model := picker.New(nil, picker.SelectionConfig{})
77+ ac.overlays.pickerMu.Lock()
78+ ac.overlays.picker = model
79+ ac.overlays.pickerGeneration++
80+ generation := ac.overlays.pickerGeneration
81+ ac.overlays.pickerMu.Unlock()
82+ selection := &remotePickerSelection{model: model, generation: generation, token: source.attachmentToken(ac, ac.transport())}
83+ ac.overlays.pickerMu.Lock()
84+ ac.overlays.pickerRemoteSelection = selection
85+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
86+ ac.overlays.pickerMu.Unlock()
4887
49-func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
50- d := newRemotePickerDaemon(nil)
51- lifecycle := remoteLifecycleForTest()
52- cachedLifecycle := lifecycle
53- cachedLifecycle[0]++
54- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
55- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
56- LifecycleID: cachedLifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
57- }},
58- }})
59- d.remoteCatalog.mu.Lock()
60- d.remoteCatalog.status["arch"] = remoteHostFresh
61- d.remoteCatalog.mu.Unlock()
62- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
63- token := sess.attachmentToken(ac, ac.transport())
64- effect, admitted := ac.beginAttachmentEffect(token)
65- require.True(t, admitted)
66- token.effect = effect
67- defer effect.End()
68- key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
69- remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
70- target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
71- require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
88+ _, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)
89+ require.ErrorIs(t, err, errAttachmentTransition)
90+ require.Same(t, source, ac.currentAttachmentSession())
91+ require.True(t, source.attachmentRegistered(ac))
92+ require.False(t, view.attachmentRegistered(ac))
93+ require.NoError(t, transport.Close())
94+ },
95+ },
96+ }
97+ for _, tt := range tests {
98+ t.Run(tt.name, tt.run)
99+ }
72100 }
A internal/usecase/daemon/remote_attachment_geometry_test.go +110 -0
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func attachRemoteGeometryClient(t *testing.T, view *remoteView, size domain.Size) (*attachedClient, chan ports.Frame) {
13+ t.Helper()
14+ transport, sends := newCapturingTransport(t)
15+ ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: size}
16+ ac.initOverlays()
17+ ac.setAttachmentOwner(view)
18+ view.mu.Lock()
19+ require.True(t, view.registerAttachmentLocked(ac))
20+ view.mu.Unlock()
21+ return ac, sends
22+}
23+
24+func resizeRemoteGeometryAttachment(t *testing.T, d *Daemon, view *remoteView, ac *attachedClient, size domain.Size, remoteSends chan ports.Frame) domain.Size {
25+ t.Helper()
26+ token := attachmentOwnerToken(view, ac, ac.transport())
27+ require.True(t, token.attachmentCurrent())
28+ require.True(t, d.resizeAttachmentForLease(token, size))
29+
30+ frame := awaitFrame(t, remoteSends, ports.MsgResize)
31+ resize, err := ports.UnmarshalResize(frame.Payload)
32+ require.NoError(t, err)
33+ require.Equal(t, contentSize(size), resize.Size)
34+ return resize.Size
35+}
36+
37+func remoteViewScreenSize(t *testing.T, view *remoteView) domain.Size {
38+ t.Helper()
39+ view.mu.Lock()
40+ defer view.mu.Unlock()
41+ return domain.Size{Cols: view.screen.Frame.Width, Rows: view.screen.Frame.Height}
42+}
43+
44+func TestRemoteViewContentGeometryFollowsLatestValidAttachmentClaim(t *testing.T) {
45+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
46+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
47+ first, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24})
48+ second, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 120, Rows: 40})
49+
50+ // A smaller, later claim is authoritative even when a larger peer remains
51+ // attached; shared remote content follows claim order, not max dimensions.
52+ resizeRemoteGeometryAttachment(t, d, view, first, domain.Size{Cols: 120, Rows: 40}, remoteTransport.sent)
53+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, remoteViewScreenSize(t, view))
54+ resizeRemoteGeometryAttachment(t, d, view, second, domain.Size{Cols: 90, Rows: 30}, remoteTransport.sent)
55+ require.Equal(t, domain.Size{Cols: 90, Rows: 28}, remoteViewScreenSize(t, view))
56+
57+ // Invalid claims do not displace the most recent valid geometry.
58+ token := attachmentOwnerToken(view, first, first.transport())
59+ require.False(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 0, Rows: 0}))
60+ require.Equal(t, domain.Size{Cols: 90, Rows: 28}, remoteViewScreenSize(t, view))
61+}
62+
63+func TestNonAuthoritativeRemoteAttachmentRenderCannotResizeOrMutateSharedVT(t *testing.T) {
64+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
65+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
66+ nonAuthoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 60, Rows: 20})
67+ authoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 110, Rows: 35})
68+ resizeRemoteGeometryAttachment(t, d, view, authoritative, domain.Size{Cols: 120, Rows: 40}, remoteTransport.sent)
69+
70+ view.mu.Lock()
71+ view.screen.Write([]byte("shared remote state"))
72+ before := screenLineText(view.screen, 0)
73+ view.mu.Unlock()
74+
75+ state, ok := d.captureRemoteViewRenderState(view, nonAuthoritative, renderCaptureRequest{})
76+ require.True(t, ok)
77+ require.Len(t, state.panes, 1)
78+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, domain.Size{Cols: state.panes[0].frame.Width, Rows: state.panes[0].frame.Height})
79+
80+ // The composed snapshot is detached from the persistent remote VT too.
81+ state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X'})
82+ view.mu.Lock()
83+ after := screenLineText(view.screen, 0)
84+ size := domain.Size{Cols: view.screen.Frame.Width, Rows: view.screen.Frame.Height}
85+ view.mu.Unlock()
86+ require.Equal(t, before, after)
87+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, size)
88+}
89+
90+func TestRemoteResizeForwardingUsesAuthoritativeContentSize(t *testing.T) {
91+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
92+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
93+ authoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24})
94+ peer, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 160, Rows: 50})
95+
96+ // The authoritative attachment's window is smaller than its peer's. The
97+ // forwarded remote resize must describe the authoritative content region,
98+ // exactly once past the local chrome boundary.
99+ want := domain.Size{Cols: 100, Rows: 28}
100+ got := resizeRemoteGeometryAttachment(t, d, view, authoritative, domain.Size{Cols: 100, Rows: 30}, remoteTransport.sent)
101+ require.Equal(t, want, got)
102+ require.Equal(t, want, remoteViewScreenSize(t, view))
103+
104+ // Rendering the larger peer is attachment-local and cannot replace the
105+ // authoritative content size used by the remote link.
106+ state, ok := d.captureRemoteViewRenderState(view, peer, renderCaptureRequest{})
107+ require.True(t, ok)
108+ require.Equal(t, want, domain.Size{Cols: state.panes[0].frame.Width, Rows: state.panes[0].frame.Height})
109+ require.Equal(t, want, remoteViewScreenSize(t, view))
110+}
A internal/usecase/daemon/remote_attachment_transition.go +300 -0
@@ -0,0 +1,300 @@
1+package daemon
2+
3+// transitionToRemoteView publishes an attachment-owned remote surface without
4+// changing the local session registry. Candidate construction and remote-link
5+// I/O deliberately happen before this function; this function only performs
6+// the frozen, exact local ownership hand-off.
7+func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target *remoteView) (attachmentConnectionToken, error) {
8+ return d.transitionToRemoteViewGuarded(token, target, nil, nil, 0)
9+}
10+
11+// transitionToRemoteViewForPicker carries the picker lifecycle fence through
12+// the attachment freeze boundary. A completed remote handshake is insufficient
13+// to move the client if Escape, picker replacement, or source transport loss
14+// won while it was in flight.
15+func (d *Daemon) transitionToRemoteViewForPicker(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection) (attachmentConnectionToken, error) {
16+ if target == nil {
17+ return attachmentConnectionToken{}, errAttachmentTransition
18+ }
19+ target.mu.Lock()
20+ link, generation := target.link, target.linkGeneration
21+ healthy := remoteViewLinkReusableLocked(target)
22+ target.mu.Unlock()
23+ if !healthy {
24+ return attachmentConnectionToken{}, errAttachmentTransition
25+ }
26+ return d.transitionToRemoteViewGuarded(token, target, selection, link, generation)
27+}
28+
29+func (d *Daemon) transitionToRemoteViewGuarded(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection, expectedLink *remoteLink, expectedLinkGeneration uint64) (attachmentConnectionToken, error) {
30+ source := token.localSession()
31+ if d == nil || source == nil || token.ac == nil || target == nil || token.transport.transport == nil {
32+ return attachmentConnectionToken{}, errAttachmentTransition
33+ }
34+ if token.effect != nil {
35+ d.endActionAttachmentEffect(token.effect, "remote-view-transition")
36+ }
37+ frozen := freezeAttachmentEffectGates(token.ac)
38+ defer frozen.unfreeze()
39+ if !frozen.acquired || !frozen.drained {
40+ return attachmentConnectionToken{}, errAttachmentTransition
41+ }
42+ if selection != nil && !selection.current() {
43+ return attachmentConnectionToken{}, errAttachmentTransition
44+ }
45+
46+ d.mu.Lock()
47+ if d.closing || d.sessions[source.id] != source || !d.attachmentOwnerRegisteredByDaemonLocked(target) {
48+ d.mu.Unlock()
49+ return attachmentConnectionToken{}, errAttachmentTransition
50+ }
51+ d.notices.routingMu.Lock()
52+ source.mu.Lock()
53+ target.mu.Lock()
54+ current := sameAttachmentOwner(token.owner, source) &&
55+ token.generation == token.ac.connectionGeneration.Load() &&
56+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) &&
57+ token.ac.transportSnapshotCurrent(token.transport) &&
58+ attachmentRegisteredLocked(source, token.ac) &&
59+ !target.closed
60+ if selection != nil {
61+ current = current && expectedLink != nil && target.link == expectedLink &&
62+ target.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(target)
63+ }
64+ if !current {
65+ target.mu.Unlock()
66+ source.mu.Unlock()
67+ d.notices.routingMu.Unlock()
68+ d.mu.Unlock()
69+ return attachmentConnectionToken{}, errAttachmentTransition
70+ }
71+ if !target.registerAttachmentLocked(token.ac) {
72+ target.mu.Unlock()
73+ source.mu.Unlock()
74+ d.notices.routingMu.Unlock()
75+ d.mu.Unlock()
76+ return attachmentConnectionToken{}, errAttachmentTransition
77+ }
78+ unregisterAttachmentSessionLocked(source, token.ac)
79+ generation := token.ac.connectionGeneration.Add(1)
80+ token.ac.setAttachmentOwner(target)
81+ published := attachmentConnectionToken{
82+ owner: target,
83+ ac: token.ac,
84+ generation: generation,
85+ transport: token.transport,
86+ rebase: true,
87+ }
88+ token.ac.publishFrozenAttachmentCapability(published)
89+ token.ac.recordPreviousOwner(source)
90+ target.mu.Unlock()
91+ source.mu.Unlock()
92+ d.notices.routingMu.Unlock()
93+ d.mu.Unlock()
94+ d.activateRemoteView(target)
95+
96+ // The old coordinator is no longer a lease owner for this attachment. Its
97+ // cleanup runs after publication and outside architecture locks.
98+ if coordinator := source.renderCoordinator(); coordinator != nil {
99+ coordinator.noteDetach(token.ac)
100+ }
101+ d.recalculateSessionGeometryAndInvalidateAsync(source, "remote_attachment_transition.go")
102+ d.recordRemoteRecent(token.ac, target)
103+ return published, nil
104+}
105+
106+// transitionRemoteViewToRemoteViewForPicker moves an attachment between two
107+// daemon-owned remote views after the selected target has completed its
108+// proxied handshake. It keeps the original local predecessor intact so local
109+// navigation and remote-to-local return remain attachment-owned.
110+func (d *Daemon) transitionRemoteViewToRemoteViewForPicker(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection) (attachmentConnectionToken, error) {
111+ source, remote := normalizeAttachmentOwner(token.owner).(*remoteView)
112+ if d == nil || !remote || source == nil || target == nil || token.ac == nil || token.transport.transport == nil {
113+ return attachmentConnectionToken{}, errAttachmentTransition
114+ }
115+ target.mu.Lock()
116+ link, generation := target.link, target.linkGeneration
117+ healthy := remoteViewLinkReusableLocked(target)
118+ target.mu.Unlock()
119+ if !healthy {
120+ return attachmentConnectionToken{}, errAttachmentTransition
121+ }
122+ return d.transitionRemoteViewToRemoteViewGuarded(token, source, target, selection, link, generation)
123+}
124+
125+func (d *Daemon) transitionRemoteViewToRemoteViewGuarded(token attachmentConnectionToken, source, target *remoteView, selection *remotePickerSelection, expectedLink *remoteLink, expectedLinkGeneration uint64) (attachmentConnectionToken, error) {
126+ if d == nil || source == nil || target == nil || token.ac == nil || token.transport.transport == nil {
127+ return attachmentConnectionToken{}, errAttachmentTransition
128+ }
129+ if token.effect != nil {
130+ d.endActionAttachmentEffect(token.effect, "remote-view-transition")
131+ }
132+ frozen := freezeAttachmentEffectGates(token.ac)
133+ defer frozen.unfreeze()
134+ if !frozen.acquired || !frozen.drained || selection == nil || !selection.current() {
135+ return attachmentConnectionToken{}, errAttachmentTransition
136+ }
137+
138+ if source == target {
139+ source.mu.Lock()
140+ _, attached := source.attachments[token.ac]
141+ current := sameAttachmentOwner(token.owner, source) &&
142+ token.generation == token.ac.connectionGeneration.Load() &&
143+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) &&
144+ token.ac.transportSnapshotCurrent(token.transport) &&
145+ attached && !source.closed &&
146+ expectedLink != nil && source.link == expectedLink &&
147+ source.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(source)
148+ source.mu.Unlock()
149+ if !current {
150+ return attachmentConnectionToken{}, errAttachmentTransition
151+ }
152+ d.recordRemoteRecent(token.ac, target)
153+ return token, nil
154+ }
155+
156+ d.mu.Lock()
157+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(source) || !d.attachmentOwnerRegisteredByDaemonLocked(target) {
158+ d.mu.Unlock()
159+ return attachmentConnectionToken{}, errAttachmentTransition
160+ }
161+ d.notices.routingMu.Lock()
162+ // Remote owners are leaf locks. A total ID order prevents ABBA deadlock
163+ // when separate attachments concurrently move between the same two views.
164+ first, second := source, target
165+ if second.id < first.id {
166+ first, second = second, first
167+ }
168+ first.mu.Lock()
169+ second.mu.Lock()
170+ unlock := func() {
171+ second.mu.Unlock()
172+ first.mu.Unlock()
173+ d.notices.routingMu.Unlock()
174+ d.mu.Unlock()
175+ }
176+ _, sourceAttached := source.attachments[token.ac]
177+ _, targetAttached := target.attachments[token.ac]
178+ current := sameAttachmentOwner(token.owner, source) &&
179+ token.generation == token.ac.connectionGeneration.Load() &&
180+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) &&
181+ token.ac.transportSnapshotCurrent(token.transport) &&
182+ sourceAttached && !source.closed && !target.closed && !targetAttached &&
183+ expectedLink != nil && target.link == expectedLink &&
184+ target.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(target)
185+ if !current || !target.registerAttachmentLocked(token.ac) {
186+ unlock()
187+ return attachmentConnectionToken{}, errAttachmentTransition
188+ }
189+ source.unregisterAttachmentLocked(token.ac)
190+ generation := token.ac.connectionGeneration.Add(1)
191+ token.ac.setAttachmentOwner(target)
192+ published := attachmentConnectionToken{
193+ owner: target,
194+ ac: token.ac,
195+ generation: generation,
196+ transport: token.transport,
197+ rebase: true,
198+ }
199+ token.ac.publishFrozenAttachmentCapability(published)
200+ unlock()
201+
202+ d.activateRemoteView(target)
203+ d.parkRemoteViewWarm(source)
204+ d.recordRemoteRecent(token.ac, target)
205+ return published, nil
206+}
207+
208+// transitionFromRemoteView moves an attachment from one exact daemon-owned
209+// remote view back to its previous local session. The remote view has no
210+// coordinator lease, so only the target session coordinator is attached during
211+// this frozen publication.
212+func (d *Daemon) transitionFromRemoteView(token attachmentConnectionToken, source *remoteView, target *session) (attachmentConnectionToken, error) {
213+ if d == nil || source == nil || target == nil || token.ac == nil || token.transport.transport == nil {
214+ return attachmentConnectionToken{}, errAttachmentTransition
215+ }
216+ if token.effect != nil {
217+ d.endActionAttachmentEffect(token.effect, "remote-view-back")
218+ }
219+ frozen := freezeAttachmentEffectGates(token.ac)
220+ defer frozen.unfreeze()
221+ if !frozen.acquired || !frozen.drained {
222+ return attachmentConnectionToken{}, errAttachmentTransition
223+ }
224+
225+ targetCore := target.core()
226+ if targetCore == nil {
227+ return attachmentConnectionToken{}, errAttachmentTransition
228+ }
229+ d.mu.Lock()
230+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(source) || d.sessions[targetCore.id] != target {
231+ d.mu.Unlock()
232+ return attachmentConnectionToken{}, errAttachmentTransition
233+ }
234+ d.notices.routingMu.Lock()
235+ target.mu.Lock()
236+ targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(target)
237+ if targetCoordinator == nil {
238+ target.mu.Unlock()
239+ d.notices.routingMu.Unlock()
240+ d.mu.Unlock()
241+ return attachmentConnectionToken{}, errAttachmentTransition
242+ }
243+ targetCoordinator.mu.Lock()
244+ // remoteView.mu is last: remote owners must never acquire daemon, session,
245+ // or coordinator locks while they are held.
246+ source.mu.Lock()
247+ unlock := func() {
248+ source.mu.Unlock()
249+ targetCoordinator.mu.Unlock()
250+ target.mu.Unlock()
251+ d.notices.routingMu.Unlock()
252+ d.mu.Unlock()
253+ }
254+ isCurrent := func() bool {
255+ _, sourceRegistered := source.attachments[token.ac]
256+ return sameAttachmentOwner(token.owner, source) &&
257+ token.generation == token.ac.connectionGeneration.Load() &&
258+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) &&
259+ token.ac.transportSnapshotCurrent(token.transport) &&
260+ sourceRegistered && !source.closed &&
261+ sameAttachmentOwner(token.ac.previousOwner.Get(), target) &&
262+ !attachmentRegisteredLocked(target, token.ac)
263+ }
264+ if !isCurrent() || targetCoordinator.torndown {
265+ unlock()
266+ return attachmentConnectionToken{}, errAttachmentTransition
267+ }
268+ // Attach session membership before the coordinator lease. The attachment
269+ // gate is frozen and the local owner remains remote until the final owner
270+ // publication, so this temporary overlap is unobservable to effects.
271+ if !registerAttachmentSessionLocked(target, token.ac) {
272+ unlock()
273+ return attachmentConnectionToken{}, errAttachmentTransition
274+ }
275+ lease := targetCoordinator.attachWithReadinessLocked(token.ac, true)
276+ if lease == nil {
277+ unregisterAttachmentSessionLocked(target, token.ac)
278+ unlock()
279+ return attachmentConnectionToken{}, errAttachmentTransition
280+ }
281+ source.unregisterAttachmentLocked(token.ac)
282+ generation := token.ac.connectionGeneration.Add(1)
283+ token.ac.setAttachmentOwner(target)
284+ published := attachmentConnectionToken{
285+ owner: target,
286+ ac: token.ac,
287+ generation: generation,
288+ transport: token.transport,
289+ lease: lease,
290+ rebase: true,
291+ }
292+ token.ac.publishFrozenAttachmentCapability(published)
293+ unlock()
294+
295+ // Record the reverse toggle while the attachment gate is still frozen, so a
296+ // concurrent handoff cannot observe the new local owner without its history.
297+ token.ac.recordPreviousOwner(source)
298+ d.parkRemoteViewWarm(source)
299+ return published, nil
300+}
A internal/usecase/daemon/remote_attachment_transition_back_test.go +149 -0
@@ -0,0 +1,149 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func remoteBackFixture(t *testing.T) (*Daemon, *session, *attachedClient, *remoteView, attachmentConnectionToken) {
11+ t.Helper()
12+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
13+ view := &remoteView{key: remoteViewKey{
14+ endpoint: "host",
15+ lifecycleID: domain.SessionLifecycleID{1},
16+ sessionName: "remote",
17+ }}
18+ d.mu.Lock()
19+ require.NoError(t, d.registerRemoteViewLocked(view))
20+ d.mu.Unlock()
21+
22+ token, err := d.transitionToRemoteView(local.attachmentToken(ac, ac.transport()), view)
23+ require.NoError(t, err)
24+ return d, local, ac, view, token
25+}
26+
27+func TestBackSessionForAttachmentReturnsFromRemoteViewToPreviousLocal(t *testing.T) {
28+ d, local, ac, view, token := remoteBackFixture(t)
29+
30+ require.NoError(t, d.backSessionForAttachment(token))
31+
32+ require.Same(t, local, ac.currentAttachmentSession())
33+ require.True(t, local.attachmentRegistered(ac))
34+ require.False(t, view.attachmentRegistered(ac))
35+ require.Same(t, view, ac.previousOwner.Get(), "reverse navigation records the remote owner for toggling back")
36+
37+ current := local.attachmentToken(ac, ac.transport())
38+ require.True(t, current.current())
39+ require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease")
40+}
41+
42+func TestBackSessionForAttachmentReturnsToPreviousRemoteView(t *testing.T) {
43+ d, local, ac, view, remoteToken := remoteBackFixture(t)
44+
45+ require.NoError(t, d.backSessionForAttachment(remoteToken))
46+ localToken := local.attachmentToken(ac, ac.transport())
47+ require.NoError(t, d.backSessionForAttachment(localToken))
48+
49+ require.Same(t, view, ac.currentAttachmentOwner())
50+ require.True(t, view.attachmentRegistered(ac))
51+ require.False(t, local.attachmentRegistered(ac))
52+ require.Same(t, local, ac.previousOwner.Get())
53+}
54+
55+func TestRemoteRecentAppearsInLocalStatusMRUAfterReturn(t *testing.T) {
56+ d, local, ac, _, token := remoteBackFixture(t)
57+
58+ require.NoError(t, d.backSessionForAttachment(token))
59+
60+ state := d.barStateForAttachmentPaletteHintsFor(local, ac, "", nil, nil)
61+ require.Equal(t, []recentSession{{name: "remote@host", mruAt: 1}}, state.mru)
62+ require.Empty(t, d.recentSessions(local), "remote recency stays out of global navigation MRU")
63+}
64+
65+func TestBackSessionForAttachmentFailsClosedForStaleOrNonLocalPredecessor(t *testing.T) {
66+ sentinel := &remoteView{id: 99}
67+ tests := []struct {
68+ name string
69+ prep func(*Daemon, *session, *attachedClient, *remoteView)
70+ check func(*testing.T, *session, *attachedClient, *remoteView)
71+ }{
72+ {
73+ name: "stale local predecessor",
74+ prep: func(d *Daemon, local *session, _ *attachedClient, _ *remoteView) {
75+ replacement := &session{sessionCore: sessionCore{id: local.id, name: local.name}}
76+ d.mu.Lock()
77+ d.sessions[local.id] = replacement
78+ d.mu.Unlock()
79+ },
80+ check: func(t *testing.T, _ *session, ac *attachedClient, view *remoteView) {
81+ require.Same(t, view, ac.currentAttachmentOwner())
82+ require.True(t, view.attachmentRegistered(ac))
83+ require.Nil(t, ac.previousOwner.Get(), "stale local history is discarded without changing ownership")
84+ },
85+ },
86+ {
87+ name: "non-local predecessor",
88+ prep: func(_ *Daemon, _ *session, ac *attachedClient, _ *remoteView) {
89+ ac.previousOwner.Set(sentinel)
90+ },
91+ check: func(t *testing.T, _ *session, ac *attachedClient, view *remoteView) {
92+ other := ac.previousOwner.Get()
93+ require.Same(t, view, ac.currentAttachmentOwner())
94+ require.True(t, view.attachmentRegistered(ac))
95+ require.Same(t, sentinel, other)
96+ },
97+ },
98+ }
99+
100+ for _, tt := range tests {
101+ t.Run(tt.name, func(t *testing.T) {
102+ d, local, ac, view, token := remoteBackFixture(t)
103+ tt.prep(d, local, ac, view)
104+
105+ require.NoError(t, d.backSessionForAttachment(token))
106+ tt.check(t, local, ac, view)
107+ })
108+ }
109+}
110+
111+func TestBackSessionForAttachmentRejectsReboundRemoteToken(t *testing.T) {
112+ d, local, ac, view, token := remoteBackFixture(t)
113+ ac.replaceTransport(&closeTrackingTransport{})
114+
115+ require.NoError(t, d.backSessionForAttachment(token))
116+ require.Same(t, view, ac.currentAttachmentOwner())
117+ require.True(t, view.attachmentRegistered(ac))
118+ require.Same(t, local, ac.previousOwner.Get(), "a stale token must not consume valid history")
119+}
120+
121+func TestBackSessionForAttachmentClearsRemoteHistoryAfterCurrentTransitionFailure(t *testing.T) {
122+ d, local, ac, view, token := remoteBackFixture(t)
123+ require.NoError(t, d.backSessionForAttachment(token))
124+ localToken := local.attachmentToken(ac, ac.transport())
125+
126+ view.mu.Lock()
127+ view.closed = true
128+ view.mu.Unlock()
129+ err := d.backSessionForAttachment(localToken)
130+
131+ require.Error(t, err)
132+ require.Same(t, local, ac.currentAttachmentOwner())
133+ require.Nil(t, ac.previousOwner.Get(), "current transition failure clears consumed remote history")
134+}
135+
136+func TestBackSessionForAttachmentKeepsRemoteHistoryAfterStaleTransitionCancellation(t *testing.T) {
137+ d, local, ac, view, token := remoteBackFixture(t)
138+ require.NoError(t, d.backSessionForAttachment(token))
139+ localToken := local.attachmentToken(ac, ac.transport())
140+ ac.connectionGeneration.Add(1)
141+
142+ view.mu.Lock()
143+ view.closed = true
144+ view.mu.Unlock()
145+ require.NoError(t, d.backSessionForAttachment(localToken))
146+
147+ require.Same(t, local, ac.currentAttachmentOwner())
148+ require.Same(t, view, ac.previousOwner.Get(), "stale transition cancellation preserves remote history")
149+}
A internal/usecase/daemon/remote_attachment_transition_picker_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/usecase/picker"
10+)
11+
12+func TestPickerTransitionReusesCurrentRemoteView(t *testing.T) {
13+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
14+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
15+ ac, _ := attachRemoteMetadataClient(t, view)
16+ model := picker.New(nil, picker.SelectionConfig{})
17+ token := attachmentOwnerToken(view, ac, ac.transport())
18+ ac.overlays.pickerMu.Lock()
19+ ac.overlays.picker = model
20+ ac.overlays.pickerGeneration++
21+ selection := &remotePickerSelection{model: model, generation: ac.overlays.pickerGeneration, token: token}
22+ ac.overlays.pickerRemoteSelection = selection
23+ ac.overlays.pickerMu.Unlock()
24+
25+ result := make(chan error, 1)
26+ go func() {
27+ _, err := d.transitionRemoteViewToRemoteViewForPicker(token, view, selection)
28+ result <- err
29+ }()
30+ require.NoError(t, awaitTestValue(t, result, "reselecting the current remote view deadlocked"))
31+ require.Same(t, view, ac.currentAttachmentOwner())
32+ require.True(t, view.attachmentRegistered(ac))
33+}
34+
35+func TestPickerTransitionRejectsInactiveRemoteLink(t *testing.T) {
36+ d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
37+ view := &remoteView{key: remoteViewKey{
38+ endpoint: "remote.example", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote",
39+ }}
40+ link := &remoteLink{view: view, generation: 1, transport: newRemoteLinkTestTransport()}
41+ view.mu.Lock()
42+ view.link, view.linkGeneration = link, 1
43+ require.False(t, remoteViewLinkReusableLocked(view), "the fixture must start with an unusable link")
44+ view.mu.Unlock()
45+ d.mu.Lock()
46+ require.NoError(t, d.registerRemoteViewLocked(view))
47+ d.mu.Unlock()
48+
49+ model := picker.New(nil, picker.SelectionConfig{})
50+ token := source.attachmentToken(ac, ac.transport())
51+ ac.overlays.pickerMu.Lock()
52+ ac.overlays.picker = model
53+ ac.overlays.pickerGeneration++
54+ selection := &remotePickerSelection{model: model, generation: ac.overlays.pickerGeneration, token: token}
55+ ac.overlays.pickerRemoteSelection = selection
56+ ac.overlays.pickerMu.Unlock()
57+
58+ _, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)
59+ require.ErrorIs(t, err, errAttachmentTransition)
60+ require.Same(t, source, ac.currentAttachmentSession())
61+ require.True(t, source.attachmentRegistered(ac))
62+ require.False(t, view.attachmentRegistered(ac))
63+}
M internal/usecase/daemon/remote_catalog_state_test.go +4 -4
@@ -42,9 +42,9 @@ func TestRemoteCatalogCacheStartup(t *testing.T) {
4242 {
4343 name: "loads cached entries",
4444 load: func() ([]ports.RemoteCatalogCacheEntry, error) {
45- return []ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}}, nil
45+ return []ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}}}, nil
4646 },
47- wantCache: map[string]ports.RemoteCatalogCacheEntry{"arch": {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}},
47+ wantCache: map[string]ports.RemoteCatalogCacheEntry{"arch": {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}}},
4848 wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached},
4949 },
5050 {
@@ -101,14 +101,14 @@ func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) {
101101 }}
102102 d = New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, cache, nil, ports.RemoteTransportUDP))
103103 d.remoteCatalog.mu.Lock()
104- d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 1), Sessions: []ports.RemoteCatalogSession{{Name: "one", State: "running"}}}
104+ d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 1), Sessions: []ports.RemoteCatalogSession{{Name: "one", State: "up"}}}
105105 d.remoteCatalog.mu.Unlock()
106106
107107 done := make(chan struct{}, 2)
108108 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
109109 <-entered
110110 d.remoteCatalog.mu.Lock()
111- d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 2), Sessions: []ports.RemoteCatalogSession{{Name: "two", State: "running"}}}
111+ d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 2), Sessions: []ports.RemoteCatalogSession{{Name: "two", State: "up"}}}
112112 d.remoteCatalog.mu.Unlock()
113113 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
114114
A internal/usecase/daemon/remote_frame_routing_test.go +360 -0
@@ -0,0 +1,360 @@
1+package daemon
2+
3+import (
4+ "os"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/keys"
10+ "github.com/bnema/vev/internal/usecase/picker"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/bnema/vev/pkg/vt"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func newRemoteFrameRoutingFixture(t *testing.T) (*Daemon, *remoteView, *attachedClient, attachmentConnectionToken, chan ports.Frame) {
17+ t.Helper()
18+ d := newTestDaemon(t, nil, stubClock{})
19+ tr, sends := newCapturingTransport(t)
20+ view := &remoteView{
21+ key: remoteViewKey{
22+ endpoint: "host",
23+ lifecycleID: domain.SessionLifecycleID{1},
24+ sessionName: "remote",
25+ },
26+ screen: vt.NewScreen(80, 22),
27+ }
28+ view.screen.Write([]byte("remote content"))
29+ d.mu.Lock()
30+ require.NoError(t, d.registerRemoteViewLocked(view))
31+ d.mu.Unlock()
32+
33+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
34+ ac.initOverlays()
35+ ac.setAttachmentOwner(view)
36+ view.mu.Lock()
37+ require.True(t, view.registerAttachmentLocked(ac))
38+ view.mu.Unlock()
39+ token := attachmentOwnerToken(view, ac, tr)
40+ require.True(t, token.current())
41+ return d, view, ac, token, sends
42+}
43+
44+func TestRemoteViewRenderSnapshotDoesNotResizeSharedScreen(t *testing.T) {
45+ view := &remoteView{screen: vt.NewScreen(80, 22)}
46+
47+ snapshot, ok := view.renderSnapshot(domain.Size{Cols: 100, Rows: 28})
48+
49+ require.True(t, ok)
50+ require.Equal(t, 80, snapshot.frame.Width)
51+ require.Equal(t, 22, snapshot.frame.Height)
52+ view.mu.Lock()
53+ require.Equal(t, 80, view.screen.Frame.Width)
54+ require.Equal(t, 22, view.screen.Frame.Height)
55+ view.mu.Unlock()
56+}
57+
58+func TestRemoteStatusSnapshotUsesSessionHostSyntax(t *testing.T) {
59+ view := &remoteView{key: remoteViewKey{endpoint: "remote", sessionName: "test1"}}
60+
61+ status := remoteStatusSnapshot(view, remoteViewRenderSnapshot{origin: "remote"})
62+
63+ require.Equal(t, "test1@remote", status.session)
64+}
65+
66+func TestRemoteFrameRoutingKeepsPickerNavigationLocal(t *testing.T) {
67+ d, source, ac, view, token := remoteBackFixture(t)
68+ remoteTransport := newRemoteLinkTestTransport()
69+ view.mu.Lock()
70+ view.linkGeneration = 1
71+ view.link = &remoteLink{view: view, generation: 1, transport: remoteTransport, active: true}
72+ view.mu.Unlock()
73+
74+ otherTab := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
75+ otherTab.stableID = "other-tab"
76+ other := &session{sessionCore: sessionCore{id: "other", name: "other", attachments: make(map[*attachedClient]struct{})}, tabs: []*tab{otherTab}}
77+ publishTiledPaneOwners(other, otherTab)
78+ d.mu.Lock()
79+ d.sessions[other.id] = other
80+ d.mu.Unlock()
81+
82+ d.enterPicker(source, ac)
83+ ac.overlays.pickerMu.Lock()
84+ before, ok := ac.overlays.picker.Selected()
85+ ac.overlays.pickerMu.Unlock()
86+ require.True(t, ok)
87+ d.handleAttachmentClientFrame(token, frameInput([]byte("j")))
88+
89+ ac.overlays.pickerMu.Lock()
90+ selected, ok := ac.overlays.picker.Selected()
91+ ac.overlays.pickerMu.Unlock()
92+ require.True(t, ok)
93+ require.NotEqual(t, before.Session, selected.Session, "picker navigation remains local while viewing remote content")
94+ select {
95+ case frame := <-remoteTransport.sent:
96+ t.Fatalf("picker navigation was forwarded to remote link as %v", frame.Type)
97+ default:
98+ }
99+}
100+
101+func TestRemoteFrameRoutingForwardsOrdinaryTerminalInput(t *testing.T) {
102+ d, _, _, view, token := remoteBackFixture(t)
103+ remoteTransport := newRemoteLinkTestTransport()
104+ view.mu.Lock()
105+ view.linkGeneration = 1
106+ view.link = &remoteLink{view: view, generation: 1, transport: remoteTransport, active: true}
107+ view.mu.Unlock()
108+
109+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
110+
111+ frame := awaitFrame(t, remoteTransport.sent, ports.MsgInput)
112+ input, err := ports.UnmarshalInput(frame.Payload)
113+ require.NoError(t, err)
114+ require.Equal(t, []byte("x"), input.Data)
115+}
116+
117+func TestRemoteFrameRoutingForwardsNonPaletteActions(t *testing.T) {
118+ d, _, _, view, token := remoteBackFixture(t)
119+ remoteTransport := newRemoteLinkTestTransport()
120+ view.mu.Lock()
121+ view.linkGeneration = 1
122+ view.link = &remoteLink{view: view, generation: 1, transport: remoteTransport, active: true}
123+ view.mu.Unlock()
124+
125+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, '1'}))
126+
127+ frame := awaitFrame(t, remoteTransport.sent, ports.MsgInput)
128+ input, err := ports.UnmarshalInput(frame.Payload)
129+ require.NoError(t, err)
130+ require.Equal(t, []byte{keys.ESC, '1'}, input.Data)
131+}
132+
133+func TestRemoteFrameRoutingOpensPaletteLocally(t *testing.T) {
134+ d, _, ac, view, token := remoteBackFixture(t)
135+ remoteTransport := newRemoteLinkTestTransport()
136+ view.mu.Lock()
137+ view.linkGeneration = 1
138+ view.link = &remoteLink{view: view, generation: 1, transport: remoteTransport, active: true}
139+ view.mu.Unlock()
140+
141+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
142+
143+ require.True(t, ac.overlays.paletteActive(), "the local palette remains reachable while remote content is active")
144+ d.handleAttachmentClientFrame(token, frameInput([]byte("SSP\r")))
145+ require.True(t, ac.overlays.pickerActive(), "the local session picker reopens from the remote view")
146+ select {
147+ case frame := <-remoteTransport.sent:
148+ t.Fatalf("palette shortcut was forwarded to remote link as %v", frame.Type)
149+ default:
150+ }
151+}
152+
153+func TestRemoteFrameRoutingReturnsToRetainedLocalSession(t *testing.T) {
154+ for _, test := range []struct {
155+ name string
156+ run func(*Daemon, *attachedClient, attachmentConnectionToken)
157+ }{
158+ {
159+ name: "palette session result",
160+ run: func(d *Daemon, _ *attachedClient, token attachmentConnectionToken) {
161+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
162+ d.handleAttachmentClientFrame(token, frameInput([]byte("work\r")))
163+ },
164+ },
165+ {
166+ name: "jump recent session",
167+ run: func(d *Daemon, _ *attachedClient, token attachmentConnectionToken) {
168+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
169+ d.handleAttachmentClientFrame(token, frameInput([]byte("JRS 1\r")))
170+ },
171+ },
172+ {
173+ name: "session picker",
174+ run: func(d *Daemon, ac *attachedClient, token attachmentConnectionToken) {
175+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC, ' '}))
176+ d.handleAttachmentClientFrame(token, frameInput([]byte("SSP\r")))
177+ require.True(t, ac.overlays.pickerActive())
178+ d.handleAttachmentClientFrame(token, frameInput([]byte("\r")))
179+ require.False(t, ac.overlays.pickerActive(), "the picker closes before the remote-to-local handoff")
180+ },
181+ },
182+ } {
183+ t.Run(test.name, func(t *testing.T) {
184+ d, source, ac, view, token := remoteBackFixture(t)
185+
186+ test.run(d, ac, token)
187+
188+ require.True(t, ac.currentAttachmentSession() == source)
189+ require.True(t, source.attachmentRegistered(ac))
190+ require.False(t, view.attachmentRegistered(ac))
191+ require.True(t, ac.previousOwner.Get() == view)
192+ })
193+ }
194+}
195+
196+func TestRemoteFrameRoutingRejectsUnidentifiedLocalTarget(t *testing.T) {
197+ d, source, ac, view, token := remoteBackFixture(t)
198+
199+ err := d.switchToTargetForAttachment(token, picker.Target{}, sessionHandoffGuard{}, "picker-select")
200+
201+ require.ErrorIs(t, err, errAttachmentTransition)
202+ require.Same(t, view, ac.currentAttachmentOwner())
203+ require.Same(t, source, ac.previousOwner.Get())
204+}
205+
206+func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) {
207+ d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
208+
209+ beforeEpoch := ac.output.currentEpoch()
210+ d.handleAttachmentClientFrame(token, ports.Frame{
211+ Type: ports.MsgOutputResetRequest,
212+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
213+ })
214+ frame := awaitFrame(t, sends, ports.MsgOutput)
215+ output, err := ports.UnmarshalOutput(frame.Payload)
216+ require.NoError(t, err)
217+ require.True(t, output.Full)
218+ require.Greater(t, output.Epoch, beforeEpoch)
219+ require.Nil(t, ac.currentAttachmentSession())
220+ require.Empty(t, d.sessions)
221+
222+ d.handleAttachmentClientFrame(token, ports.Frame{
223+ Type: ports.MsgAck,
224+ Payload: mustMarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New}),
225+ })
226+ ac.sendMu.Lock()
227+ require.Equal(t, output.New, ac.output.acked)
228+ ac.sendMu.Unlock()
229+}
230+
231+func TestRemoteFrameRoutingAckRepaintsPrivateVTAfterOutputWindowBackpressure(t *testing.T) {
232+ d, view, ac, token, sends := newRemoteFrameRoutingFixture(t)
233+ ac.sendMu.Lock()
234+ ac.output = newOutputStateStream(1)
235+ ac.sendMu.Unlock()
236+
237+ require.Equal(t, paintEmitted, d.paintRemoteView(view, ac, true, token))
238+ first := awaitFrame(t, sends, ports.MsgOutput)
239+ firstOutput, err := ports.UnmarshalOutput(first.Payload)
240+ require.NoError(t, err)
241+
242+ view.mu.Lock()
243+ view.screen.Write([]byte(" updated"))
244+ view.mu.Unlock()
245+ require.Equal(t, paintBlockedCapacity, d.paintRemoteView(view, ac, false, token))
246+
247+ d.handleAttachmentClientFrame(token, ports.Frame{
248+ Type: ports.MsgAck,
249+ Payload: mustMarshalAck(ports.Ack{Epoch: firstOutput.Epoch, State: firstOutput.New}),
250+ })
251+ second := awaitFrame(t, sends, ports.MsgOutput)
252+ secondOutput, err := ports.UnmarshalOutput(second.Payload)
253+ require.NoError(t, err)
254+ require.Equal(t, firstOutput.Epoch, secondOutput.Epoch)
255+ require.Equal(t, firstOutput.New, secondOutput.Base)
256+ require.Greater(t, secondOutput.New, firstOutput.New)
257+}
258+
259+func TestRemoteFrameRoutingThemeUpdatesOnlyAttachmentChromeAndRepaints(t *testing.T) {
260+ d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
261+ theme := ports.Theme{
262+ HasForeground: true,
263+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
264+ HasBackground: true,
265+ Background: renderer.RGB{R: 4, G: 5, B: 6},
266+ SchemeKnown: true,
267+ Light: true,
268+ }
269+
270+ d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme)})
271+ frame := awaitFrame(t, sends, ports.MsgOutput)
272+ output, err := ports.UnmarshalOutput(frame.Payload)
273+ require.NoError(t, err)
274+ require.True(t, output.Full)
275+
276+ want := d.resolveAppliedTheme(themeFromMessage(theme))
277+ got := ac.getAppliedTheme()
278+ require.Equal(t, want.Raw, got.Raw)
279+ require.Equal(t, want.Resolved, got.Resolved)
280+ require.Greater(t, got.Generation, uint64(0))
281+ require.Nil(t, ac.currentAttachmentSession())
282+ require.Empty(t, d.sessions)
283+ require.Empty(t, d.notices.history())
284+}
285+
286+func TestRemoteFrameRoutingResizeRebasesOuterAndPrivateScreenWithoutLocalPTY(t *testing.T) {
287+ d, view, ac, token, sends := newRemoteFrameRoutingFixture(t)
288+ beforeEpoch := ac.output.currentEpoch()
289+
290+ d.handleAttachmentClientFrame(token, ports.Frame{
291+ Type: ports.MsgResize,
292+ Payload: mustMarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}}),
293+ })
294+ frame := awaitFrame(t, sends, ports.MsgOutput)
295+ output, err := ports.UnmarshalOutput(frame.Payload)
296+ require.NoError(t, err)
297+ require.True(t, output.Full)
298+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, output.Size)
299+ require.Greater(t, ac.output.currentEpoch(), beforeEpoch)
300+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, ac.sizeSnapshot())
301+
302+ view.mu.Lock()
303+ require.Equal(t, 100, view.screen.Frame.Width)
304+ require.Equal(t, 28, view.screen.Frame.Height)
305+ view.mu.Unlock()
306+ require.Nil(t, ac.currentAttachmentSession())
307+ require.Empty(t, d.sessions)
308+}
309+
310+func TestRemoteFrameRoutingClientNoticeUsesAttachmentToastOnly(t *testing.T) {
311+ d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
312+
313+ d.handleAttachmentClientFrame(token, ports.Frame{
314+ Type: ports.MsgClientNotice,
315+ Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded}),
316+ })
317+ awaitFrame(t, sends, ports.MsgOutput)
318+ toasts, _ := visibleToasts(ac)
319+ require.Len(t, toasts, 1)
320+ require.Equal(t, domain.NoticeConnection, toasts[0].Code)
321+ require.Empty(t, toasts[0].SessionID)
322+ require.Empty(t, d.notices.history(), "remote client notices must not create local session history")
323+
324+ d.handleAttachmentClientFrame(token, ports.Frame{
325+ Type: ports.MsgClientNotice,
326+ Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}),
327+ })
328+ awaitFrame(t, sends, ports.MsgOutput)
329+ toasts, _ = visibleToasts(ac)
330+ require.Empty(t, toasts)
331+}
332+
333+func TestRemoteFrameRoutingImageAndCommandFailClosed(t *testing.T) {
334+ d, _, _, token, sends := newRemoteFrameRoutingFixture(t)
335+ d.tempDir = t.TempDir()
336+
337+ d.handleAttachmentClientFrame(token, ports.Frame{
338+ Type: ports.MsgImagePush,
339+ Payload: ports.MarshalImagePush(ports.ImagePush{Mime: "image/png", Data: []byte("not written")}),
340+ })
341+ entries, err := os.ReadDir(d.tempDir)
342+ require.NoError(t, err)
343+ require.Empty(t, entries)
344+
345+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
346+ Version: ports.ProtocolVersion,
347+ RequestID: 7,
348+ Attached: true,
349+ Slug: "new-session",
350+ })
351+ require.NoError(t, err)
352+ d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})
353+ resultFrame := awaitFrame(t, sends, ports.MsgCommandResult)
354+ result, err := ports.UnmarshalCommandResult(resultFrame.Payload)
355+ require.NoError(t, err)
356+ require.Equal(t, uint64(7), result.RequestID)
357+ require.False(t, result.OK)
358+ require.Equal(t, ports.ErrNoSuchTarget, result.Code)
359+ require.Empty(t, d.sessions)
360+}
A internal/usecase/daemon/remote_link.go +993 -0
@@ -0,0 +1,993 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "errors"
7+ "fmt"
8+ "sync"
9+ "sync/atomic"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/pkg/vt"
14+)
15+
16+var (
17+ errRemoteViewUnavailable = errors.New("remote view: link is unavailable")
18+ errRemoteViewStale = errors.New("remote view: link is stale")
19+)
20+
21+const maxRemoteViewOpenAttempts = 3
22+
23+// remoteViewConstruction reserves one remote-view key while its candidate
24+// dials and handshakes. It is guarded by Daemon.mu. cancel is invoked only
25+// after the daemon lock is released, because it may interrupt adapter I/O.
26+type remoteViewConstruction struct {
27+ done chan struct{}
28+ cancel context.CancelFunc
29+ err error
30+}
31+
32+type remoteViewLinkSnapshot struct {
33+ view *remoteView
34+ link *remoteLink
35+ generation uint64
36+}
37+
38+// remoteLink is one exact transport generation for a remote view. sendMu
39+// orders every outbound remote frame; view.mu guards whether this exact link
40+// remains current. Neither lock is held while transport I/O runs.
41+type remoteLink struct {
42+ view *remoteView
43+ generation uint64
44+ target domain.RemoteSessionTarget
45+ clientID [16]byte
46+ transport ports.Transport
47+ clock ports.Clock
48+ cancel context.CancelFunc
49+
50+ sendMu sync.Mutex
51+ commands *CommandRequestTracker
52+ startOnce sync.Once
53+ workerStarted atomic.Bool
54+ done chan struct{}
55+ active bool // guarded by view.mu
56+}
57+
58+// remoteOutputState is the remote-link equivalent of the thin client's
59+// dependency chain. It remains private to the daemon because this link owns a
60+// persistent VT rather than forwarding output bytes to a terminal.
61+type remoteOutputState struct {
62+ epoch uint64
63+ state uint64
64+ viewRevision uint64
65+ initialized bool
66+}
67+
68+func (s remoteOutputState) next(output ports.Output) (remoteOutputState, bool) {
69+ if output.Epoch == 0 {
70+ return remoteOutputState{}, false
71+ }
72+ if !s.initialized {
73+ if output.New == 0 {
74+ return s, true
75+ }
76+ if output.Base != 0 || !output.Full {
77+ return remoteOutputState{}, false
78+ }
79+ return remoteOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
80+ }
81+ if output.Epoch < s.epoch {
82+ return remoteOutputState{}, false
83+ }
84+ if output.Epoch == s.epoch {
85+ if output.ViewRevision != s.viewRevision {
86+ return remoteOutputState{}, false
87+ }
88+ if output.New == 0 {
89+ if output.Base != 0 || output.Full {
90+ return remoteOutputState{}, false
91+ }
92+ return s, true
93+ }
94+ if output.Full || output.Base != s.state || output.New != output.Base+1 {
95+ return remoteOutputState{}, false
96+ }
97+ return remoteOutputState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
98+ }
99+ if !output.Full || output.Base != 0 {
100+ return remoteOutputState{}, false
101+ }
102+ return remoteOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
103+}
104+
105+// openRemoteView returns the exact healthy warm view for target or elects one
106+// constructor. An unhealthy registry hit is a reconnect candidate, never a
107+// reusable result. The reservation is protected only by Daemon.mu; the dial
108+// and all handshake I/O happen after every architecture lock is released.
109+func (d *Daemon) openRemoteView(ctx context.Context, target domain.RemoteSessionTarget, size domain.Size) (*remoteView, error) {
110+ if d == nil {
111+ return nil, errors.New("remote view: nil daemon")
112+ }
113+ if ctx == nil {
114+ ctx = context.Background()
115+ }
116+ if err := target.Validate(); err != nil {
117+ return nil, fmt.Errorf("remote view: invalid target: %w", err)
118+ }
119+ if !size.Valid() {
120+ return nil, fmt.Errorf("remote view: invalid size %dx%d", size.Cols, size.Rows)
121+ }
122+ if d.remoteDialerFactory == nil {
123+ return nil, errors.New("remote view: remote dialer is not configured")
124+ }
125+ key, err := remoteViewKeyForTarget(target)
126+ if err != nil {
127+ return nil, fmt.Errorf("remote view: key: %w", err)
128+ }
129+
130+ for attempts := 0; ; {
131+ if err := ctx.Err(); err != nil {
132+ return nil, err
133+ }
134+ d.mu.Lock()
135+ existing, observed, reusable := d.remoteViewLinkByKeyLocked(key)
136+ if reusable {
137+ d.mu.Unlock()
138+ return existing, nil
139+ }
140+ if d.closing {
141+ d.mu.Unlock()
142+ return nil, errors.New("remote view: daemon is shutting down")
143+ }
144+ if construction := d.remoteViewConstructions[key]; construction != nil {
145+ done := construction.done
146+ d.mu.Unlock()
147+ select {
148+ case <-ctx.Done():
149+ return nil, ctx.Err()
150+ case <-done:
151+ }
152+ if err := ctx.Err(); err != nil {
153+ return nil, err
154+ }
155+ d.mu.Lock()
156+ winner, _, healthy := d.remoteViewLinkByKeyLocked(key)
157+ constructionErr := construction.err
158+ d.mu.Unlock()
159+ if healthy {
160+ return winner, nil
161+ }
162+ if constructionErr != nil && !errors.Is(constructionErr, context.Canceled) && !errors.Is(constructionErr, context.DeadlineExceeded) {
163+ return nil, constructionErr
164+ }
165+ // A canceled candidate never poisons an independent waiter. A link
166+ // that failed immediately after publication is likewise not reusable.
167+ continue
168+ }
169+
170+ constructionCtx, cancel := context.WithCancel(ctx)
171+ construction := &remoteViewConstruction{done: make(chan struct{}), cancel: cancel}
172+ if d.remoteViewConstructions == nil {
173+ d.remoteViewConstructions = make(map[remoteViewKey]*remoteViewConstruction)
174+ }
175+ d.remoteViewConstructions[key] = construction
176+ d.mu.Unlock()
177+
178+ candidate, constructionErr := d.constructRemoteView(constructionCtx, target, size)
179+ if constructionErr == nil {
180+ constructionErr = ctx.Err()
181+ }
182+ cancel()
183+
184+ var winner *remoteView
185+ var replaced *remoteLink
186+ var repaint []*attachedClient
187+ publishErr := constructionErr
188+ d.mu.Lock()
189+ if publishErr == nil {
190+ switch current := d.remoteViewByKeyLocked(key); {
191+ case d.closing:
192+ publishErr = errors.New("remote view: daemon is shutting down")
193+ case current == nil && observed.view != nil:
194+ publishErr = errRemoteViewStale
195+ case current == nil:
196+ if err := d.registerRemoteViewLocked(candidate); err != nil {
197+ publishErr = fmt.Errorf("remote view: publish candidate: %w", err)
198+ } else {
199+ winner = candidate
200+ // Starting the receiver while d.mu still protects publication
201+ // keeps shutdown from observing a registry-visible link with no
202+ // worker to join.
203+ d.startRemoteLink(candidate.link)
204+ }
205+ case current != observed.view:
206+ current.mu.Lock()
207+ if remoteViewLinkReusableLocked(current) {
208+ winner = current
209+ } else {
210+ publishErr = errRemoteViewStale
211+ }
212+ current.mu.Unlock()
213+ default:
214+ current.mu.Lock()
215+ if remoteViewLinkReusableLocked(current) {
216+ winner = current
217+ } else if current.closed || current.link != observed.link || current.linkGeneration != observed.generation {
218+ publishErr = errRemoteViewStale
219+ } else {
220+ var installed bool
221+ replaced, installed = installRemoteViewCandidateLocked(current, candidate)
222+ if !installed {
223+ publishErr = errors.New("remote view: candidate link is unavailable")
224+ } else {
225+ winner = current
226+ repaint = make([]*attachedClient, 0, len(current.attachments))
227+ for attachment := range current.attachments {
228+ repaint = append(repaint, attachment)
229+ }
230+ d.startRemoteLink(current.link)
231+ }
232+ }
233+ current.mu.Unlock()
234+ }
235+ }
236+ construction.err = publishErr
237+ if d.remoteViewConstructions[key] == construction {
238+ delete(d.remoteViewConstructions, key)
239+ }
240+ close(construction.done)
241+ d.mu.Unlock()
242+
243+ if candidate != nil && candidate != winner {
244+ d.stopUnpublishedRemoteView(candidate)
245+ }
246+ if replaced != nil {
247+ stopAndJoinRemoteLink(replaced)
248+ }
249+ for _, attachment := range repaint {
250+ d.scheduleRemoteViewPaint(winner, attachment, false)
251+ }
252+ if winner != nil {
253+ d.mu.Lock()
254+ current, _, healthy := d.remoteViewLinkByKeyLocked(key)
255+ d.mu.Unlock()
256+ if healthy && current == winner {
257+ return winner, nil
258+ }
259+ // A winner can be invalidated by its receiver immediately after
260+ // publication. Do not spin constructors in a tight loop while the
261+ // remote endpoint is unavailable.
262+ attempts++
263+ if attempts >= maxRemoteViewOpenAttempts {
264+ return nil, errRemoteViewUnavailable
265+ }
266+ if !sleepRemoteViewRetry(ctx, d.clock) {
267+ return nil, ctx.Err()
268+ }
269+ continue
270+ }
271+ return nil, publishErr
272+ }
273+}
274+
275+// remoteViewLinkByKeyLocked snapshots health and replacement authority under
276+// the registry-to-view lock order. Caller holds d.mu.
277+func (d *Daemon) remoteViewLinkByKeyLocked(key remoteViewKey) (*remoteView, remoteViewLinkSnapshot, bool) {
278+ view := d.remoteViewByKeyLocked(key)
279+ if view == nil {
280+ return nil, remoteViewLinkSnapshot{}, false
281+ }
282+ view.mu.Lock()
283+ snapshot := remoteViewLinkSnapshot{view: view, link: view.link, generation: view.linkGeneration}
284+ reusable := remoteViewLinkReusableLocked(view)
285+ view.mu.Unlock()
286+ return view, snapshot, reusable
287+}
288+
289+func remoteViewLinkReusableLocked(view *remoteView) bool {
290+ if view == nil || view.closed || view.link == nil {
291+ return false
292+ }
293+ link := view.link
294+ return link.view == view && link.generation == view.linkGeneration && link.transport != nil && link.active
295+}
296+
297+// installRemoteViewCandidateLocked transfers one fully handshaken candidate
298+// into the exact observed view generation. Attachment membership and warm
299+// retention stay on the stable view; content becomes visible only with the new
300+// validated link publication. Caller holds the existing view's mutex.
301+func installRemoteViewCandidateLocked(view, candidate *remoteView) (*remoteLink, bool) {
302+ if view == nil || candidate == nil || candidate.link == nil || candidate.screen == nil {
303+ return nil, false
304+ }
305+ link := candidate.link
306+ replaced := view.link
307+ view.linkGeneration++
308+ link.view = view
309+ link.generation = view.linkGeneration
310+ link.active = true
311+ view.link = link
312+ view.screen = candidate.screen
313+ view.metadata = cloneRemoteSessionMeta(candidate.metadata)
314+ view.displayOrigin = candidate.displayOrigin
315+ view.reconnectTarget = candidate.reconnectTarget
316+ view.output = candidate.output
317+ view.resetRequested = candidate.resetRequested
318+ view.linkState = remoteViewLinkHealthy
319+ view.reconnectGeneration++
320+ signalRemoteViewMetadataChangedLocked(view)
321+ if replaced != nil {
322+ replaced.active = false
323+ }
324+ candidate.link = nil
325+ return replaced, true
326+}
327+
328+// interruptRemoteLink invalidates an exact link without waiting for its
329+// receiver. Callers may join afterward, but a receiver must never join itself.
330+func interruptRemoteLink(link *remoteLink) {
331+ if link == nil {
332+ return
333+ }
334+ if link.commands != nil {
335+ link.commands.FailGeneration(link.generation, errRemoteViewUnavailable)
336+ }
337+ if link.cancel != nil {
338+ link.cancel()
339+ }
340+ if link.transport != nil {
341+ _ = link.transport.Close()
342+ }
343+}
344+
345+func stopAndJoinRemoteLink(link *remoteLink) {
346+ interruptRemoteLink(link)
347+ joinRemoteLink(link)
348+}
349+
350+// constructRemoteView builds an unexposed candidate. Its caller owns a
351+// constructor reservation but holds no daemon, session, or view lock.
352+func (d *Daemon) constructRemoteView(ctx context.Context, target domain.RemoteSessionTarget, size domain.Size) (_ *remoteView, err error) {
353+ content := contentSize(size)
354+ if !content.Valid() {
355+ return nil, fmt.Errorf("remote view: invalid content size %dx%d", content.Cols, content.Rows)
356+ }
357+ key, err := remoteViewKeyForTarget(target)
358+ if err != nil {
359+ return nil, err
360+ }
361+ var clientID [16]byte
362+ if _, err := rand.Read(clientID[:]); err != nil {
363+ return nil, fmt.Errorf("remote view: generate client ID: %w", err)
364+ }
365+ root := context.Background()
366+ if d.serveCtx != nil {
367+ root = d.serveCtx
368+ }
369+ linkCtx, cancelLink := context.WithCancel(root)
370+ stopCallerCancellation := context.AfterFunc(ctx, cancelLink)
371+ defer stopCallerCancellation()
372+ view := &remoteView{
373+ key: key,
374+ displayOrigin: target.DisplayOrigin,
375+ screen: vt.NewScreen(content.Cols, content.Rows),
376+ linkGeneration: 1,
377+ }
378+ link := &remoteLink{
379+ view: view,
380+ generation: view.linkGeneration,
381+ target: target,
382+ clientID: clientID,
383+ clock: d.clock,
384+ cancel: cancelLink,
385+ commands: NewCommandRequestTracker(),
386+ done: make(chan struct{}),
387+ active: true,
388+ }
389+ view.link = link
390+ published := false
391+ defer func() {
392+ if !published {
393+ d.stopUnpublishedRemoteView(view)
394+ }
395+ }()
396+
397+ handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(linkCtx)
398+ defer finishHandshake()
399+ dialer, err := d.remoteDialerFactory.DialerForRemote(target.Endpoint, target.SessionName, d.remoteTransportMode, d.log)
400+ if err != nil {
401+ return nil, fmt.Errorf("remote view: select remote dialer: %w", err)
402+ }
403+ if dialer == nil {
404+ return nil, errors.New("remote view: remote dialer factory returned nil dialer")
405+ }
406+ transport, err := dialer.Dial(handshakeCtx)
407+ if err != nil {
408+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: dial: %w", err))
409+ }
410+ if transport == nil {
411+ return nil, errors.New("remote view: remote dialer returned nil transport")
412+ }
413+ link.transport = transport
414+ stopWatch := watchHandshakeTransport(handshakeCtx, transport)
415+ defer stopWatch()
416+
417+ helloTarget := target
418+ hello := ports.Hello{
419+ Version: ports.ProtocolVersion,
420+ Intent: ports.IntentAttach,
421+ ClientID: clientID,
422+ Name: target.SessionName,
423+ Size: content,
424+ RenderMode: ports.RenderModeProxiedContent,
425+ MaxOutputInFlight: remoteLinkOutputWindow(transport),
426+ RemoteTarget: &helloTarget,
427+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
428+ }
429+ if err := link.send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
430+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: send hello: %w", err))
431+ }
432+
433+ welcomeFrame, err := recvRemoteLinkHandshakeFrame(handshakeCtx, transport)
434+ if err != nil {
435+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: receive welcome: %w", err))
436+ }
437+ if err := validateRemoteLinkWelcome(welcomeFrame, target); err != nil {
438+ return nil, err
439+ }
440+ metadataFrame, err := recvRemoteLinkHandshakeFrame(handshakeCtx, transport)
441+ if err != nil {
442+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: receive metadata: %w", err))
443+ }
444+ metadata, err := validateInitialRemoteLinkMetadata(metadataFrame, target)
445+ if err != nil {
446+ return nil, err
447+ }
448+ if !view.applyRemoteMetadata(link, metadata, true) {
449+ return nil, errors.New("remote view: initial metadata became stale")
450+ }
451+ outputFrame, err := recvRemoteLinkHandshakeFrame(handshakeCtx, transport)
452+ if err != nil {
453+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: receive initial output: %w", err))
454+ }
455+ if outputFrame.Type != ports.MsgOutput {
456+ return nil, fmt.Errorf("remote view: third server frame is %d, want Output", outputFrame.Type)
457+ }
458+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
459+ if err != nil {
460+ return nil, fmt.Errorf("remote view: decode initial output: %w", err)
461+ }
462+ ack, reset, _, accepted := view.applyRemoteOutput(link, output)
463+ if !accepted || reset || output.New == 0 || !output.Full {
464+ return nil, errors.New("remote view: initial output is not an authoritative reset")
465+ }
466+ if ack != 0 {
467+ ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: ack})
468+ if err != nil {
469+ return nil, fmt.Errorf("remote view: encode initial output acknowledgement: %w", err)
470+ }
471+ if err := link.send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil {
472+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: acknowledge initial output: %w", err))
473+ }
474+ }
475+ if err := handshakeCtx.Err(); err != nil {
476+ return nil, handshakeContextError(ctx, timedOut, err)
477+ }
478+ published = true
479+ return view, nil
480+}
481+
482+func remoteLinkOutputWindow(transport ports.Transport) uint8 {
483+ if _, datagram := transport.(ports.DatagramTransport); datagram {
484+ return 1
485+ }
486+ return maxUnackedOutputStates
487+}
488+
489+func recvRemoteLinkHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
490+ var frame ports.Frame
491+ err := boundedHandshakeOperation(ctx, transport, func() error {
492+ var recvErr error
493+ frame, recvErr = transport.Recv()
494+ return recvErr
495+ })
496+ if err != nil {
497+ return ports.Frame{}, err
498+ }
499+ return frame, nil
500+}
501+
502+func validateRemoteLinkWelcome(frame ports.Frame, target domain.RemoteSessionTarget) error {
503+ if frame.Type == ports.MsgError {
504+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
505+ if err != nil {
506+ return fmt.Errorf("remote view: malformed remote error: %w", err)
507+ }
508+ return fmt.Errorf("remote view: remote rejected handshake (%d): %s", remoteErr.Code, remoteErr.Text)
509+ }
510+ if frame.Type != ports.MsgWelcome {
511+ return fmt.Errorf("remote view: first server frame is %d, want Welcome", frame.Type)
512+ }
513+ welcome, err := ports.UnmarshalWelcome(frame.Payload)
514+ if err != nil {
515+ return fmt.Errorf("remote view: decode welcome: %w", err)
516+ }
517+ if welcome.SessionID == "" || welcome.SessionName != target.SessionName || welcome.RenderMode != ports.RenderModeProxiedContent {
518+ return errors.New("remote view: welcome identity or render mode mismatch")
519+ }
520+ return nil
521+}
522+
523+func validateInitialRemoteLinkMetadata(frame ports.Frame, target domain.RemoteSessionTarget) (ports.SessionMeta, error) {
524+ if frame.Type != ports.MsgSessionMeta {
525+ return ports.SessionMeta{}, fmt.Errorf("remote view: second server frame is %d, want SessionMeta", frame.Type)
526+ }
527+ metadata, err := ports.UnmarshalSessionMeta(frame.Payload)
528+ if err != nil {
529+ return ports.SessionMeta{}, fmt.Errorf("remote view: decode initial metadata: %w", err)
530+ }
531+ if metadata.LifecycleID != target.LifecycleID || metadata.SessionName != target.SessionName || metadata.Revision != 1 {
532+ return ports.SessionMeta{}, errors.New("remote view: metadata identity mismatch")
533+ }
534+ if err := validateInitialRemoteLinkTarget(metadata, target); err != nil {
535+ return ports.SessionMeta{}, err
536+ }
537+ return metadata, nil
538+}
539+
540+func validateInitialRemoteLinkTarget(metadata ports.SessionMeta, target domain.RemoteSessionTarget) error {
541+ if err := ports.ValidateSessionMeta(metadata); err != nil {
542+ return fmt.Errorf("remote view: invalid initial metadata: %w", err)
543+ }
544+ if !target.Stopped {
545+ if metadata.ActiveTabID != target.LiveTabID {
546+ return errors.New("remote view: metadata active tab mismatch")
547+ }
548+ return nil
549+ }
550+
551+ tabs := make([]domain.TabSelectorTab, len(metadata.Tabs))
552+ for i, tab := range metadata.Tabs {
553+ tabs[i] = domain.TabSelectorTab{ID: tab.ID, Name: tab.Name}
554+ }
555+ selected, ok := target.StoppedTab.Resolve(tabs)
556+ if !ok {
557+ return errors.New("remote view: stopped tab selector mismatch")
558+ }
559+ if metadata.ActiveTabID != tabs[selected].ID {
560+ return errors.New("remote view: metadata active tab mismatch")
561+ }
562+ return nil
563+}
564+
565+func cloneRemoteSessionMeta(metadata ports.SessionMeta) ports.SessionMeta {
566+ metadata.Tabs = append([]ports.SessionTabMeta(nil), metadata.Tabs...)
567+ return metadata
568+}
569+
570+// signalRemoteViewMetadataChangedLocked wakes waiters after an exact remote
571+// metadata or link-generation change. The caller holds view.mu and must not
572+// perform rendering or transport work before releasing it.
573+func signalRemoteViewMetadataChangedLocked(view *remoteView) {
574+ if view == nil {
575+ return
576+ }
577+ previous := view.metadataChanged
578+ view.metadataChanged = make(chan struct{})
579+ if previous != nil {
580+ close(previous)
581+ }
582+}
583+
584+type remoteMetadataResult uint8
585+
586+const (
587+ remoteMetadataInvalid remoteMetadataResult = iota
588+ remoteMetadataStale
589+ remoteMetadataAccepted
590+)
591+
592+// applyRemoteMetadata retains the boolean helper used by initial handshake
593+// callers. The detailed result is used by the live-link frame handler so a
594+// valid stale revision can be ignored without stopping the link.
595+func (v *remoteView) applyRemoteMetadata(link *remoteLink, metadata ports.SessionMeta, initial bool) bool {
596+ result, _ := v.applyRemoteMetadataWithAttachments(link, metadata, initial)
597+ return result == remoteMetadataAccepted
598+}
599+
600+// applyRemoteMetadataWithAttachments commits one validated update only for the
601+// exact current link. The view lock protects content state only; no renderer or
602+// callback runs before it is released. Accepted updates return an attachment
603+// snapshot for repainting after the lock is released.
604+func (v *remoteView) applyRemoteMetadataWithAttachments(link *remoteLink, metadata ports.SessionMeta, initial bool) (remoteMetadataResult, []*attachedClient) {
605+ if v == nil || link == nil || ports.ValidateSessionMeta(metadata) != nil {
606+ return remoteMetadataInvalid, nil
607+ }
608+ if metadata.LifecycleID != link.target.LifecycleID || metadata.SessionName != link.target.SessionName {
609+ return remoteMetadataInvalid, nil
610+ }
611+ v.mu.Lock()
612+ defer v.mu.Unlock()
613+ if v.closed || v.link != link || v.linkGeneration != link.generation {
614+ return remoteMetadataInvalid, nil
615+ }
616+ if initial {
617+ if v.metadata.Revision != 0 || metadata.Revision != 1 {
618+ return remoteMetadataInvalid, nil
619+ }
620+ } else if metadata.Revision <= v.metadata.Revision {
621+ return remoteMetadataStale, nil
622+ }
623+ v.metadata = cloneRemoteSessionMeta(metadata)
624+ reconnectTarget := v.reconnectTarget
625+ if reconnectTarget.Validate() != nil {
626+ reconnectTarget = link.target
627+ }
628+ v.reconnectTarget = runningRemoteTarget(reconnectTarget, metadata.ActiveTabID)
629+ signalRemoteViewMetadataChangedLocked(v)
630+ if initial {
631+ return remoteMetadataAccepted, nil
632+ }
633+ attachments := make([]*attachedClient, 0, len(v.attachments))
634+ for attachment := range v.attachments {
635+ attachments = append(attachments, attachment)
636+ }
637+ return remoteMetadataAccepted, attachments
638+}
639+
640+// applyRemoteOutput validates and applies state-bearing ANSI to the persistent
641+// private VT. It returns only immutable post-commit work; callers send ACK or
642+// reset frames and repaint attachments after view.mu is released.
643+func (v *remoteView) applyRemoteOutput(link *remoteLink, output ports.Output) (ack uint64, reset bool, attachments []*attachedClient, accepted bool) {
644+ if v == nil || link == nil {
645+ return 0, false, nil, false
646+ }
647+ v.mu.Lock()
648+ defer v.mu.Unlock()
649+ if v.closed || v.link != link || v.linkGeneration != link.generation {
650+ return 0, false, nil, false
651+ }
652+ next, valid := v.output.next(output)
653+ if !valid || (output.New != 0 && (v.screen == nil || v.screen.Frame.Width != output.Size.Cols || v.screen.Frame.Height != output.Size.Rows)) {
654+ if v.resetRequested {
655+ return 0, false, nil, false
656+ }
657+ v.resetRequested = true
658+ return 0, true, nil, false
659+ }
660+ if output.New == 0 {
661+ if len(output.Data) != 0 && v.screen != nil {
662+ v.screen.Write(output.Data)
663+ }
664+ attachments = make([]*attachedClient, 0, len(v.attachments))
665+ for attachment := range v.attachments {
666+ attachments = append(attachments, attachment)
667+ }
668+ return 0, false, attachments, true
669+ }
670+ v.output = next
671+ v.resetRequested = false
672+ if len(output.Data) != 0 {
673+ v.screen.Write(output.Data)
674+ }
675+ attachments = make([]*attachedClient, 0, len(v.attachments))
676+ for attachment := range v.attachments {
677+ attachments = append(attachments, attachment)
678+ }
679+ return output.New, false, attachments, true
680+}
681+
682+func (d *Daemon) startRemoteLink(link *remoteLink) {
683+ if d == nil || link == nil || link.transport == nil {
684+ return
685+ }
686+ link.startOnce.Do(func() {
687+ link.workerStarted.Store(true)
688+ go d.runRemoteLink(link)
689+ })
690+}
691+
692+// joinRemoteLink waits only for a receiver that was actually started. Candidate
693+// and test-only links may have no transport worker; treating their open done
694+// channel as joinable would deadlock terminal cleanup.
695+func joinRemoteLink(link *remoteLink) {
696+ if link != nil && link.workerStarted.Load() {
697+ <-link.done
698+ }
699+}
700+
701+func (d *Daemon) runRemoteLink(link *remoteLink) {
702+ defer close(link.done)
703+ for {
704+ frame, err := link.transport.Recv()
705+ if err != nil {
706+ d.markRemoteLinkUnavailable(link)
707+ return
708+ }
709+ if err := d.handleRemoteLinkFrame(link, frame); err != nil {
710+ d.log.Warn("remote view link stopped", "endpoint", link.target.Endpoint, "session", link.target.SessionName, "err", err)
711+ d.markRemoteLinkUnavailable(link)
712+ return
713+ }
714+ }
715+}
716+
717+func (d *Daemon) handleRemoteLinkFrame(link *remoteLink, frame ports.Frame) error {
718+ switch frame.Type {
719+ case ports.MsgOutput:
720+ output, err := ports.UnmarshalOutput(frame.Payload)
721+ if err != nil {
722+ return fmt.Errorf("remote view: decode output: %w", err)
723+ }
724+ ack, reset, attachments, accepted := link.view.applyRemoteOutput(link, output)
725+ if reset {
726+ if err := link.send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}); err != nil {
727+ return fmt.Errorf("remote view: request output reset: %w", err)
728+ }
729+ return nil
730+ }
731+ if !accepted {
732+ return nil
733+ }
734+ if ack != 0 {
735+ ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: ack})
736+ if err != nil {
737+ return fmt.Errorf("remote view: encode output acknowledgement: %w", err)
738+ }
739+ if err := link.send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil {
740+ return fmt.Errorf("remote view: acknowledge output: %w", err)
741+ }
742+ }
743+ d.repaintRemoteViewAttachments(link.view, attachments)
744+ return nil
745+ case ports.MsgSessionMeta:
746+ metadata, err := ports.UnmarshalSessionMeta(frame.Payload)
747+ if err != nil {
748+ return fmt.Errorf("remote view: decode metadata: %w", err)
749+ }
750+ result, attachments := link.view.applyRemoteMetadataWithAttachments(link, metadata, false)
751+ switch result {
752+ case remoteMetadataAccepted:
753+ d.repaintRemoteViewAttachments(link.view, attachments)
754+ return nil
755+ case remoteMetadataStale:
756+ return nil
757+ default:
758+ return errors.New("remote view: invalid metadata update")
759+ }
760+ case ports.MsgCommandResult:
761+ result, err := ports.UnmarshalCommandResult(frame.Payload)
762+ if err != nil {
763+ return fmt.Errorf("remote view: decode command result: %w", err)
764+ }
765+ if link.commands != nil {
766+ link.commands.Complete(link.generation, result)
767+ }
768+ return nil
769+ case ports.MsgPing:
770+ if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
771+ return fmt.Errorf("remote view: decode ping: %w", err)
772+ }
773+ return link.send(ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})})
774+ case ports.MsgPong:
775+ _, err := ports.UnmarshalPong(frame.Payload)
776+ return err
777+ case ports.MsgDetached:
778+ detached, err := ports.UnmarshalDetached(frame.Payload)
779+ if err != nil {
780+ return fmt.Errorf("remote view: decode detached: %w", err)
781+ }
782+ d.retireTerminalRemoteView(link, detached.Reason)
783+ return nil
784+ case ports.MsgError:
785+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
786+ if err != nil {
787+ return fmt.Errorf("remote view: malformed remote error: %w", err)
788+ }
789+ return fmt.Errorf("remote view: remote error (%d): %s", remoteErr.Code, remoteErr.Text)
790+ default:
791+ return fmt.Errorf("remote view: unexpected server frame type %d", frame.Type)
792+ }
793+}
794+
795+// repaintRemoteViewAttachments starts one local-chrome repaint for every
796+// attachment captured by an accepted remote update. The snapshot is taken
797+// under view.mu, but all token validation, rendering callbacks, and transport
798+// I/O happen after that lock has been released.
799+func (d *Daemon) repaintRemoteViewAttachments(view *remoteView, attachments []*attachedClient) {
800+ for _, attachment := range attachments {
801+ d.scheduleRemoteViewPaint(view, attachment, false)
802+ }
803+}
804+
805+// failSend invalidates the exact link after an outbound transport failure.
806+func (link *remoteLink) failSend(view *remoteView, transport ports.Transport, err error) {
807+ view.mu.Lock()
808+ if view.link == link && view.linkGeneration == link.generation {
809+ link.active = false
810+ signalRemoteViewMetadataChangedLocked(view)
811+ }
812+ view.mu.Unlock()
813+ if link.commands != nil {
814+ link.commands.FailGeneration(link.generation, err)
815+ }
816+ if link.cancel != nil {
817+ link.cancel()
818+ }
819+ _ = transport.Close()
820+}
821+
822+// send serializes the complete outbound remote stream. It captures the exact
823+// current transport under view.mu, releases every architecture lock, then lets
824+// Close interrupt a blocked Send concurrently.
825+func (link *remoteLink) send(frame ports.Frame) error {
826+ if link == nil || link.view == nil {
827+ return errRemoteViewUnavailable
828+ }
829+ view := link.view
830+ view.mu.Lock()
831+ current := !view.closed && view.link == link && view.linkGeneration == link.generation && link.active
832+ view.mu.Unlock()
833+ if !current {
834+ return errRemoteViewStale
835+ }
836+
837+ link.sendMu.Lock()
838+ view.mu.Lock()
839+ current = !view.closed && view.link == link && view.linkGeneration == link.generation && link.active
840+ transport := link.transport
841+ view.mu.Unlock()
842+ if !current || transport == nil {
843+ link.sendMu.Unlock()
844+ return errRemoteViewStale
845+ }
846+ if link.clock == nil {
847+ err := transport.Send(frame)
848+ link.sendMu.Unlock()
849+ if err != nil {
850+ link.failSend(view, transport, err)
851+ }
852+ return err
853+ }
854+
855+ result := make(chan error, 1)
856+ go func() {
857+ err := transport.Send(frame)
858+ link.sendMu.Unlock()
859+ result <- err
860+ }()
861+ timer := link.clock.NewTimer(detachNotifyTimeout)
862+ select {
863+ case err := <-result:
864+ timer.Stop()
865+ if err != nil {
866+ link.failSend(view, transport, err)
867+ }
868+ return err
869+ case <-timer.C():
870+ // Transport.Close is required to interrupt Send. The sending goroutine
871+ // owns sendMu and releases it once the adapter observes that close.
872+ link.failSend(view, transport, errSendTimedOut)
873+ return errSendTimedOut
874+ }
875+}
876+
877+// handleRemoteViewInput forwards the raw local-terminal bytes and their
878+// original sequence through the exact current remote link. No local key,
879+// overlay, mouse, PTY, or persistence path observes those bytes.
880+func (d *Daemon) handleRemoteViewInput(view *remoteView, token attachmentConnectionToken, inputSeq uint64, data []byte) {
881+ if view == nil || len(data) == 0 || !token.attachmentEffectCurrent() {
882+ return
883+ }
884+ payload := ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})
885+ if err := d.sendRemoteViewFrame(view, ports.Frame{Type: ports.MsgInput, Payload: payload}); err != nil {
886+ d.log.Warn("forwarding remote view input failed", "endpoint", view.key.endpoint, "session", view.key.sessionName, "err", err)
887+ }
888+}
889+
890+// sendRemoteViewFrame snapshots the exact link while holding only view.mu;
891+// remoteLink.send performs the generation check and serialized transport I/O.
892+func (d *Daemon) sendRemoteViewFrame(view *remoteView, frame ports.Frame) error {
893+ if view == nil {
894+ return errRemoteViewUnavailable
895+ }
896+ view.mu.Lock()
897+ link := view.link
898+ view.mu.Unlock()
899+ if link == nil {
900+ return errRemoteViewUnavailable
901+ }
902+ err := link.send(frame)
903+ if err != nil {
904+ d.markRemoteLinkUnavailable(link)
905+ }
906+ return err
907+}
908+
909+func (d *Daemon) markRemoteLinkUnavailable(link *remoteLink) {
910+ if d == nil || link == nil || link.view == nil {
911+ return
912+ }
913+ view := link.view
914+ view.mu.Lock()
915+ current := !view.closed && view.link == link && view.linkGeneration == link.generation
916+ var (
917+ attachments []*attachedClient
918+ startReconnect bool
919+ reconnectGeneration uint64
920+ reconnectSize domain.Size
921+ reconnectTarget domain.RemoteSessionTarget
922+ )
923+ if current {
924+ link.active = false
925+ signalRemoteViewMetadataChangedLocked(view)
926+ attachments = make([]*attachedClient, 0, len(view.attachments))
927+ for attachment := range view.attachments {
928+ attachments = append(attachments, attachment)
929+ }
930+ if view.linkState == remoteViewLinkHealthy {
931+ view.linkState = remoteViewLinkReconnecting
932+ view.reconnectGeneration++
933+ reconnectGeneration = view.reconnectGeneration
934+ startReconnect = len(attachments) != 0
935+ reconnectSize = reconnectSizeLocked(view)
936+ reconnectTarget = view.reconnectTarget
937+ if reconnectTarget.Validate() != nil {
938+ reconnectTarget = link.target
939+ }
940+ }
941+ }
942+ view.mu.Unlock()
943+ if !current {
944+ return
945+ }
946+ interruptRemoteLink(link)
947+ d.repaintRemoteViewAttachments(view, attachments)
948+ if startReconnect {
949+ go d.reconnectRemoteView(view, link, reconnectGeneration, reconnectTarget, reconnectSize)
950+ }
951+}
952+
953+// stopUnpublishedRemoteView is used for candidate failure and lost-election
954+// cleanup. It never touches the daemon registry and closes only the candidate
955+// transport generation after view.mu is released.
956+func (d *Daemon) stopUnpublishedRemoteView(view *remoteView) {
957+ if view == nil {
958+ return
959+ }
960+ view.mu.Lock()
961+ view.closed = true
962+ link := view.link
963+ view.link = nil
964+ view.linkGeneration++
965+ signalRemoteViewMetadataChangedLocked(view)
966+ view.mu.Unlock()
967+ if link != nil {
968+ interruptRemoteLink(link)
969+ }
970+}
971+
972+// stopRemoteViewLink interrupts only the exact currently installed remote
973+// transport. It is called after registry publication has been revoked, so it
974+// cannot affect a successor view or link generation. The returned link has a
975+// started worker, which the terminal shutdown path joins after Close.
976+func (d *Daemon) stopRemoteViewLink(view *remoteView) *remoteLink {
977+ if view == nil {
978+ return nil
979+ }
980+ view.mu.Lock()
981+ link := view.link
982+ if link != nil {
983+ view.link = nil
984+ view.linkGeneration++
985+ link.active = false
986+ signalRemoteViewMetadataChangedLocked(view)
987+ }
988+ view.mu.Unlock()
989+ if link != nil {
990+ interruptRemoteLink(link)
991+ }
992+ return link
993+}
A internal/usecase/daemon/remote_link_test.go +1131 -0
@@ -0,0 +1,1131 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/pkg/vt"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+type remoteLinkTestReceive struct {
19+ frame ports.Frame
20+ err error
21+}
22+
23+type remoteLinkTestTransport struct {
24+ recv chan remoteLinkTestReceive
25+ sent chan ports.Frame
26+ closed chan struct{}
27+ closeOnce sync.Once
28+}
29+
30+func newRemoteLinkTestTransport() *remoteLinkTestTransport {
31+ return &remoteLinkTestTransport{
32+ recv: make(chan remoteLinkTestReceive, 8),
33+ sent: make(chan ports.Frame, 8),
34+ closed: make(chan struct{}),
35+ }
36+}
37+
38+func (t *remoteLinkTestTransport) Send(frame ports.Frame) error {
39+ select {
40+ case <-t.closed:
41+ return io.EOF
42+ case t.sent <- frame:
43+ return nil
44+ }
45+}
46+
47+func (t *remoteLinkTestTransport) Recv() (ports.Frame, error) {
48+ select {
49+ case <-t.closed:
50+ return ports.Frame{}, io.EOF
51+ case received := <-t.recv:
52+ return received.frame, received.err
53+ }
54+}
55+
56+func (t *remoteLinkTestTransport) Close() error {
57+ t.closeOnce.Do(func() { close(t.closed) })
58+ return nil
59+}
60+
61+type remoteLinkTestDialer struct {
62+ transport ports.Transport
63+ started chan<- struct{}
64+ release <-chan struct{}
65+ once sync.Once
66+}
67+
68+func (d *remoteLinkTestDialer) Dial(ctx context.Context) (ports.Transport, error) {
69+ if d.started != nil {
70+ d.once.Do(func() { close(d.started) })
71+ }
72+ if d.release != nil {
73+ select {
74+ case <-ctx.Done():
75+ return nil, ctx.Err()
76+ case <-d.release:
77+ }
78+ }
79+ return d.transport, nil
80+}
81+
82+type remoteLinkTestFactory struct {
83+ dialer ports.Dialer
84+ calls atomic.Int32
85+}
86+
87+func (f *remoteLinkTestFactory) DialerForRemote(_ string, _ string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
88+ f.calls.Add(1)
89+ return f.dialer, nil
90+}
91+
92+type remoteLinkSequenceFactory struct {
93+ mu sync.Mutex
94+ dialers []ports.Dialer
95+ calls atomic.Int32
96+}
97+
98+func (f *remoteLinkSequenceFactory) DialerForRemote(_ string, _ string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
99+ call := int(f.calls.Add(1)) - 1
100+ f.mu.Lock()
101+ defer f.mu.Unlock()
102+ if call < 0 || call >= len(f.dialers) {
103+ return nil, io.EOF
104+ }
105+ return f.dialers[call], nil
106+}
107+
108+func remoteLinkTestTarget() domain.RemoteSessionTarget {
109+ return domain.RemoteSessionTarget{
110+ Endpoint: "remote.example",
111+ DisplayOrigin: "remote.example",
112+ LifecycleID: domain.SessionLifecycleID{1},
113+ SessionName: "remote",
114+ LiveTabID: "tab-1",
115+ }
116+}
117+
118+func enqueueRemoteLinkHandshake(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size) {
119+ t.Helper()
120+ enqueueRemoteLinkHandshakeWithContent(t, transport, target, size, "remote marker")
121+}
122+
123+func enqueueRemoteLinkHandshakeWithContent(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size, contentMarker string) {
124+ t.Helper()
125+ enqueueRemoteLinkHandshakeWithMetadata(t, transport, target, size, ports.SessionMeta{
126+ LifecycleID: target.LifecycleID,
127+ Revision: 1,
128+ SessionName: target.SessionName,
129+ ActiveTabID: target.LiveTabID,
130+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}},
131+ }, contentMarker)
132+}
133+
134+func enqueueRemoteLinkHandshakeWithMetadata(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size, metadata ports.SessionMeta, contentMarker string) {
135+ t.Helper()
136+ content := contentSize(size)
137+ metaPayload, err := ports.MarshalSessionMeta(metadata)
138+ require.NoError(t, err)
139+ outputPayload, err := ports.MarshalOutput(ports.Output{
140+ Epoch: 1, New: 1, Size: content, Full: true, Data: []byte(contentMarker),
141+ })
142+ require.NoError(t, err)
143+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
144+ SessionID: "remote-id", SessionName: target.SessionName, RenderMode: ports.RenderModeProxiedContent,
145+ Capabilities: ports.CapabilityResume,
146+ })}}
147+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: metaPayload}}
148+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: outputPayload}}
149+}
150+
151+func newRemoteMetadataLinkFixture(t *testing.T) (*Daemon, *remoteView, *remoteLink, *remoteLinkTestTransport) {
152+ t.Helper()
153+ d := newTestDaemon(t, nil, stubClock{})
154+ transport := newRemoteLinkTestTransport()
155+ target := remoteLinkTestTarget()
156+ view := &remoteView{
157+ key: remoteViewKey{
158+ endpoint: target.Endpoint,
159+ lifecycleID: target.LifecycleID,
160+ sessionName: target.SessionName,
161+ },
162+ screen: vt.NewScreen(80, 22),
163+ linkGeneration: 1,
164+ metadata: ports.SessionMeta{
165+ LifecycleID: target.LifecycleID,
166+ Revision: 1,
167+ SessionName: target.SessionName,
168+ ActiveTabID: target.LiveTabID,
169+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}},
170+ },
171+ }
172+ link := &remoteLink{
173+ view: view,
174+ generation: 1,
175+ target: target,
176+ transport: transport,
177+ cancel: func() {},
178+ active: true,
179+ }
180+ view.link = link
181+ d.mu.Lock()
182+ require.NoError(t, d.registerRemoteViewLocked(view))
183+ d.mu.Unlock()
184+ return d, view, link, transport
185+}
186+
187+func attachRemoteMetadataClient(t *testing.T, view *remoteView) (*attachedClient, chan ports.Frame) {
188+ t.Helper()
189+ return attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24})
190+}
191+
192+func remoteMetadataFrame(t *testing.T, metadata ports.SessionMeta) ports.Frame {
193+ t.Helper()
194+ payload, err := ports.MarshalSessionMeta(metadata)
195+ require.NoError(t, err)
196+ return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}
197+}
198+
199+func TestStopAndJoinRemoteLinkDoesNotWaitForUnstartedWorker(t *testing.T) {
200+ transport := newRemoteLinkTestTransport()
201+ link := &remoteLink{generation: 1, transport: transport, done: make(chan struct{})}
202+ done := make(chan struct{})
203+ go func() {
204+ stopAndJoinRemoteLink(link)
205+ close(done)
206+ }()
207+ awaitTestCompletion(t, done, "stopping an unstarted remote link blocked")
208+ select {
209+ case <-transport.closed:
210+ default:
211+ t.Fatal("stopping an unstarted remote link did not close its transport")
212+ }
213+}
214+
215+func TestRemoteLinkDetachedPropagatesItsExactReason(t *testing.T) {
216+ d, view, link, _ := newRemoteMetadataLinkFixture(t)
217+ attachment, sends := attachRemoteMetadataClient(t, view)
218+
219+ require.NoError(t, d.handleRemoteLinkFrame(link, ports.Frame{
220+ Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}),
221+ }))
222+
223+ frame := awaitFrame(t, sends, ports.MsgDetached)
224+ detached, err := ports.UnmarshalDetached(frame.Payload)
225+ require.NoError(t, err)
226+ require.Equal(t, ports.ReasonDetach, detached.Reason)
227+ require.Nil(t, attachment.currentAttachmentOwner())
228+}
229+
230+func TestRemoteLinkAcceptedMetadataRepaintsEveryAttachedClientUnderLocalChrome(t *testing.T) {
231+ d, view, link, _ := newRemoteMetadataLinkFixture(t)
232+ first, firstSends := attachRemoteMetadataClient(t, view)
233+ second, secondSends := attachRemoteMetadataClient(t, view)
234+ callbacks := make(chan struct{}, 2)
235+ for _, ac := range []*attachedClient{first, second} {
236+ ac.beforeAttachmentTokenValidation = func() {
237+ // This intentionally empty critical section is a callback-outside-lock
238+ // probe: the callback must be able to acquire view.mu.
239+ view.mu.Lock()
240+ //lint:ignore SA2001 the empty section is the lock-ordering probe
241+ view.mu.Unlock()
242+ callbacks <- struct{}{}
243+ }
244+ }
245+
246+ metadata := ports.SessionMeta{
247+ LifecycleID: view.key.lifecycleID,
248+ Revision: 2,
249+ SessionName: view.key.sessionName,
250+ ActiveTabID: "tab-1",
251+ Tabs: []ports.SessionTabMeta{{ID: "tab-1", Name: "renamed"}},
252+ }
253+ require.NoError(t, d.handleRemoteLinkFrame(link, remoteMetadataFrame(t, metadata)))
254+ for range 2 {
255+ awaitTestValue(t, callbacks, "metadata repaint callback did not run outside remoteView.mu")
256+ }
257+
258+ for _, sends := range []chan ports.Frame{firstSends, secondSends} {
259+ frame := awaitFrame(t, sends, ports.MsgOutput)
260+ output, err := ports.UnmarshalOutput(frame.Payload)
261+ require.NoError(t, err)
262+ require.Contains(t, string(output.Data), "renamed", "accepted metadata must repaint local chrome")
263+ }
264+ view.mu.Lock()
265+ require.Equal(t, uint64(2), view.metadata.Revision)
266+ require.Equal(t, "renamed", view.metadata.Tabs[0].Name)
267+ view.mu.Unlock()
268+}
269+
270+func TestRemoteLinkAppliesSideEffectOutputWithoutAdvancingStateOrAcking(t *testing.T) {
271+ _, view, link, _ := newRemoteMetadataLinkFixture(t)
272+ attachment, _ := attachRemoteMetadataClient(t, view)
273+ view.mu.Lock()
274+ before := view.output
275+ view.mu.Unlock()
276+ ack, reset, attachments, accepted := view.applyRemoteOutput(link, ports.Output{
277+ Epoch: 1, Size: domain.Size{Cols: 80, Rows: 22}, Data: []byte("side effect"),
278+ })
279+ require.True(t, accepted)
280+ require.Zero(t, ack)
281+ require.False(t, reset)
282+ require.Equal(t, []*attachedClient{attachment}, attachments)
283+ view.mu.Lock()
284+ require.Equal(t, before, view.output)
285+ require.Contains(t, screenLineText(view.screen, 0), "side effect")
286+ view.mu.Unlock()
287+}
288+
289+func TestRemoteLinkSendRejectsInactiveLinkBeforeTransportUse(t *testing.T) {
290+ _, view, link, transport := newRemoteMetadataLinkFixture(t)
291+ view.mu.Lock()
292+ link.active = false
293+ view.mu.Unlock()
294+
295+ err := link.send(ports.Frame{Type: ports.MsgPing})
296+ require.ErrorIs(t, err, errRemoteViewStale)
297+ select {
298+ case <-transport.sent:
299+ t.Fatal("stale remote link sent a frame")
300+ default:
301+ }
302+}
303+
304+func TestRemoteLinkIgnoresDuplicateAndOlderMetadataWithoutStopping(t *testing.T) {
305+ d, view, link, transport := newRemoteMetadataLinkFixture(t)
306+ metadata := ports.SessionMeta{
307+ LifecycleID: view.key.lifecycleID,
308+ Revision: 2,
309+ SessionName: view.key.sessionName,
310+ ActiveTabID: "tab-1",
311+ Tabs: []ports.SessionTabMeta{{ID: "tab-1", Name: "new"}},
312+ }
313+ frame := remoteMetadataFrame(t, metadata)
314+ require.NoError(t, d.handleRemoteLinkFrame(link, frame))
315+ require.NoError(t, d.handleRemoteLinkFrame(link, frame), "duplicate metadata must be ignored")
316+ require.NoError(t, d.handleRemoteLinkFrame(link, remoteMetadataFrame(t, ports.SessionMeta{
317+ LifecycleID: view.key.lifecycleID,
318+ Revision: 1,
319+ SessionName: view.key.sessionName,
320+ ActiveTabID: "tab-1",
321+ Tabs: []ports.SessionTabMeta{{ID: "tab-1", Name: "old"}},
322+ })), "older metadata must be ignored")
323+
324+ view.mu.Lock()
325+ require.Same(t, link, view.link)
326+ require.True(t, link.active)
327+ require.Equal(t, uint64(2), view.metadata.Revision)
328+ require.Equal(t, "new", view.metadata.Tabs[0].Name)
329+ view.mu.Unlock()
330+
331+ require.NoError(t, d.handleRemoteLinkFrame(link, ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}))
332+ awaitFrame(t, transport.sent, ports.MsgPong)
333+}
334+
335+func TestRemoteLinkRejectsInvalidMetadataIdentityAndOrdering(t *testing.T) {
336+ tests := []struct {
337+ name string
338+ mutate func(*testing.T, []byte)
339+ }{
340+ {
341+ name: "lifecycle identity",
342+ mutate: func(_ *testing.T, payload []byte) {
343+ payload[0] = 2
344+ },
345+ },
346+ {
347+ name: "session identity",
348+ mutate: func(t *testing.T, payload []byte) {
349+ metadata := ports.SessionMeta{
350+ LifecycleID: domain.SessionLifecycleID{1}, Revision: 2, SessionName: "remote!", ActiveTabID: "tab-1",
351+ Tabs: []ports.SessionTabMeta{{ID: "tab-1", Name: "new"}},
352+ }
353+ encoded, err := ports.MarshalSessionMeta(metadata)
354+ require.NoError(t, err)
355+ copy(payload, encoded)
356+ },
357+ },
358+ {
359+ name: "zero revision",
360+ mutate: func(_ *testing.T, payload []byte) {
361+ clear(payload[16:24])
362+ },
363+ },
364+ }
365+ for _, test := range tests {
366+ t.Run(test.name, func(t *testing.T) {
367+ d, view, link, _ := newRemoteMetadataLinkFixture(t)
368+ metadata := ports.SessionMeta{
369+ LifecycleID: view.key.lifecycleID,
370+ Revision: 2,
371+ SessionName: view.key.sessionName,
372+ ActiveTabID: "tab-1",
373+ Tabs: []ports.SessionTabMeta{{ID: "tab-1", Name: "new"}},
374+ }
375+ payload, err := ports.MarshalSessionMeta(metadata)
376+ require.NoError(t, err)
377+ test.mutate(t, payload)
378+ err = d.handleRemoteLinkFrame(link, ports.Frame{Type: ports.MsgSessionMeta, Payload: payload})
379+ require.Error(t, err)
380+ view.mu.Lock()
381+ require.Same(t, link, view.link)
382+ require.Equal(t, uint64(1), view.metadata.Revision)
383+ view.mu.Unlock()
384+ })
385+ }
386+}
387+
388+func TestValidateInitialRemoteLinkTargetResolvesStoppedSelectorsStrictly(t *testing.T) {
389+ baseTarget := remoteLinkTestTarget()
390+ baseTarget.LiveTabID = ""
391+ baseTarget.Stopped = true
392+ baseMetadata := ports.SessionMeta{
393+ LifecycleID: baseTarget.LifecycleID,
394+ Revision: 1,
395+ SessionName: baseTarget.SessionName,
396+ ActiveTabID: "tab-b",
397+ Tabs: []ports.SessionTabMeta{
398+ {ID: "tab-a", Name: "alpha"},
399+ {ID: "tab-b", Name: "beta"},
400+ },
401+ }
402+ tests := []struct {
403+ name string
404+ mutate func(*domain.RemoteSessionTarget, *ports.SessionMeta)
405+ wantErr string
406+ }{
407+ {
408+ name: "valid stable selector",
409+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
410+ target.StoppedTab = domain.NewStableTabSelector("tab-b")
411+ },
412+ },
413+ {
414+ name: "valid ordinal selector",
415+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
416+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2)
417+ },
418+ },
419+ {
420+ name: "wrong stable ID",
421+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
422+ target.StoppedTab = domain.NewStableTabSelector("tab-missing")
423+ },
424+ wantErr: "stopped tab selector mismatch",
425+ },
426+ {
427+ name: "missing stable ID",
428+ mutate: func(_ *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
429+ metadata.Tabs[0].ID = ""
430+ },
431+ wantErr: "invalid initial metadata",
432+ },
433+ {
434+ name: "duplicate stable IDs",
435+ mutate: func(_ *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
436+ metadata.Tabs[0].ID = metadata.Tabs[1].ID
437+ },
438+ wantErr: "invalid initial metadata",
439+ },
440+ {
441+ name: "reordered ordinal selector",
442+ mutate: func(target *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
443+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2)
444+ metadata.Tabs[0], metadata.Tabs[1] = metadata.Tabs[1], metadata.Tabs[0]
445+ },
446+ wantErr: "stopped tab selector mismatch",
447+ },
448+ {
449+ name: "ordinal name mismatch",
450+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
451+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "renamed", 2)
452+ },
453+ wantErr: "stopped tab selector mismatch",
454+ },
455+ {
456+ name: "ordinal count mismatch",
457+ mutate: func(target *domain.RemoteSessionTarget, _ *ports.SessionMeta) {
458+ target.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 3)
459+ },
460+ wantErr: "stopped tab selector mismatch",
461+ },
462+ {
463+ name: "active tab mismatch",
464+ mutate: func(target *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
465+ target.StoppedTab = domain.NewStableTabSelector("tab-b")
466+ metadata.ActiveTabID = "tab-a"
467+ },
468+ wantErr: "metadata active tab mismatch",
469+ },
470+ {
471+ name: "running target validation remains strict",
472+ mutate: func(target *domain.RemoteSessionTarget, metadata *ports.SessionMeta) {
473+ *target = remoteLinkTestTarget()
474+ metadata.ActiveTabID = "tab-a"
475+ metadata.Tabs[1].ID = target.LiveTabID
476+ },
477+ wantErr: "metadata active tab mismatch",
478+ },
479+ }
480+ for _, test := range tests {
481+ t.Run(test.name, func(t *testing.T) {
482+ target := baseTarget
483+ metadata := baseMetadata
484+ metadata.Tabs = append([]ports.SessionTabMeta(nil), baseMetadata.Tabs...)
485+ test.mutate(&target, &metadata)
486+
487+ err := validateInitialRemoteLinkTarget(metadata, target)
488+ if test.wantErr == "" {
489+ require.NoError(t, err)
490+ return
491+ }
492+ require.ErrorContains(t, err, test.wantErr)
493+ })
494+ }
495+}
496+
497+func TestOpenRemoteViewRejectsStoppedTargetBeforeCandidatePublication(t *testing.T) {
498+ d := newTestDaemon(t, nil, stubClock{})
499+ target := remoteLinkTestTarget()
500+ target.LiveTabID = ""
501+ target.Stopped = true
502+ target.StoppedTab = domain.NewStableTabSelector("tab-b")
503+ transport := newRemoteLinkTestTransport()
504+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
505+ SessionID: "remote-id", SessionName: target.SessionName, RenderMode: ports.RenderModeProxiedContent,
506+ })}}
507+ metadataPayload, err := ports.MarshalSessionMeta(ports.SessionMeta{
508+ LifecycleID: target.LifecycleID,
509+ Revision: 1,
510+ SessionName: target.SessionName,
511+ ActiveTabID: "tab-a",
512+ Tabs: []ports.SessionTabMeta{
513+ {ID: "tab-a", Name: "alpha"},
514+ {ID: "tab-b", Name: "beta"},
515+ },
516+ })
517+ require.NoError(t, err)
518+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: metadataPayload}}
519+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
520+
521+ view, err := d.openRemoteView(context.Background(), target, domain.Size{Cols: 80, Rows: 24})
522+ require.Nil(t, view)
523+ require.ErrorContains(t, err, "metadata active tab mismatch")
524+ select {
525+ case <-transport.closed:
526+ default:
527+ t.Fatal("rejected stopped-target candidate transport was not closed")
528+ }
529+ d.mu.Lock()
530+ require.Empty(t, d.remoteViews)
531+ require.Empty(t, d.remoteViewConstructions)
532+ d.mu.Unlock()
533+}
534+
535+func TestOpenRemoteViewPublishesHandshakeReadyCandidate(t *testing.T) {
536+ d := newTestDaemon(t, nil, stubClock{})
537+ target := remoteLinkTestTarget()
538+ size := domain.Size{Cols: 80, Rows: 24}
539+ transport := newRemoteLinkTestTransport()
540+ enqueueRemoteLinkHandshake(t, transport, target, size)
541+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
542+ d.remoteDialerFactory = factory
543+ d.remoteTransportMode = ports.RemoteTransportUDP
544+
545+ view, err := d.openRemoteView(context.Background(), target, size)
546+ require.NoError(t, err)
547+ require.NotNil(t, view)
548+ require.Equal(t, int32(1), factory.calls.Load())
549+
550+ helloFrame := awaitFrame(t, transport.sent, ports.MsgHello)
551+ hello, err := ports.UnmarshalHello(helloFrame.Payload)
552+ require.NoError(t, err)
553+ require.Equal(t, ports.IntentAttach, hello.Intent)
554+ require.Equal(t, ports.RenderModeProxiedContent, hello.RenderMode)
555+ require.Equal(t, contentSize(size), hello.Size)
556+ require.Equal(t, ports.EnvironmentPolicyDaemonOwned, hello.EnvironmentPolicy)
557+ require.Equal(t, &target, hello.RemoteTarget)
558+
559+ ackFrame := awaitFrame(t, transport.sent, ports.MsgAck)
560+ ack, err := ports.UnmarshalAck(ackFrame.Payload)
561+ require.NoError(t, err)
562+ require.Equal(t, ports.Ack{Epoch: 1, State: 1}, ack)
563+
564+ d.mu.Lock()
565+ require.Same(t, view, d.remoteViewByKeyLocked(view.key))
566+ d.mu.Unlock()
567+ view.mu.Lock()
568+ require.Contains(t, screenLineText(view.screen, 0), "remote marker")
569+ require.Equal(t, target.LifecycleID, view.metadata.LifecycleID)
570+ require.Equal(t, target.LiveTabID, view.metadata.ActiveTabID)
571+ view.mu.Unlock()
572+
573+ require.NoError(t, transport.Close())
574+}
575+
576+func TestOpenRemoteViewReconnectsInactiveExactView(t *testing.T) {
577+ d := newTestDaemon(t, nil, stubClock{})
578+ target := remoteLinkTestTarget()
579+ size := domain.Size{Cols: 80, Rows: 24}
580+ firstTransport := newRemoteLinkTestTransport()
581+ secondTransport := newRemoteLinkTestTransport()
582+ enqueueRemoteLinkHandshakeWithContent(t, firstTransport, target, size, "first generation")
583+ enqueueRemoteLinkHandshakeWithContent(t, secondTransport, target, size, "second generation")
584+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
585+ &remoteLinkTestDialer{transport: firstTransport},
586+ &remoteLinkTestDialer{transport: secondTransport},
587+ }}
588+ d.remoteDialerFactory = factory
589+
590+ view, err := d.openRemoteView(context.Background(), target, size)
591+ require.NoError(t, err)
592+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
593+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
594+
595+ localTransport := &closeTrackingTransport{}
596+ attachment := &attachedClient{tr: localTransport}
597+ attachment.setAttachmentOwner(view)
598+ view.mu.Lock()
599+ require.True(t, view.registerAttachmentLocked(attachment))
600+ oldLink := view.link
601+ oldGeneration := view.linkGeneration
602+ oldLink.active = false
603+ require.Contains(t, screenLineText(view.screen, 0), "first generation")
604+ view.mu.Unlock()
605+
606+ reconnected, err := d.openRemoteView(context.Background(), target, size)
607+ require.NoError(t, err)
608+ require.Same(t, view, reconnected)
609+ require.Equal(t, int32(2), factory.calls.Load(), "an inactive registry link must be re-established")
610+ awaitFrame(t, secondTransport.sent, ports.MsgHello)
611+ awaitFrame(t, secondTransport.sent, ports.MsgAck)
612+
613+ view.mu.Lock()
614+ require.NotSame(t, oldLink, view.link)
615+ require.Equal(t, oldGeneration+1, view.linkGeneration)
616+ require.Same(t, view, view.link.view)
617+ require.Equal(t, view.linkGeneration, view.link.generation)
618+ require.True(t, view.link.active)
619+ require.Contains(t, screenLineText(view.screen, 0), "second generation")
620+ require.Contains(t, view.attachments, attachment, "reconnect must preserve local attachment membership")
621+ view.mu.Unlock()
622+ require.Same(t, view, attachment.currentAttachmentOwner())
623+ select {
624+ case <-firstTransport.closed:
625+ default:
626+ t.Fatal("reconnect did not close the replaced exact transport")
627+ }
628+ awaitTestCompletion(t, oldLink.done, "reconnect did not join the replaced exact transport")
629+
630+ d.shutdownAll(ports.ReasonServerShutdown)
631+}
632+
633+func TestRemoteLinkFailureReconnectsAttachedViewWithoutChangingLocalOwner(t *testing.T) {
634+ d := newTestDaemon(t, nil, stubClock{})
635+ target := remoteLinkTestTarget()
636+ size := domain.Size{Cols: 80, Rows: 24}
637+ firstTransport := newRemoteLinkTestTransport()
638+ secondTransport := newRemoteLinkTestTransport()
639+ enqueueRemoteLinkHandshake(t, firstTransport, target, size)
640+ enqueueRemoteLinkHandshake(t, secondTransport, target, size)
641+ reconnectStarted := make(chan struct{})
642+ releaseReconnect := make(chan struct{})
643+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
644+ &remoteLinkTestDialer{transport: firstTransport},
645+ &remoteLinkTestDialer{transport: secondTransport, started: reconnectStarted, release: releaseReconnect},
646+ }}
647+ d.remoteDialerFactory = factory
648+
649+ view, err := d.openRemoteView(context.Background(), target, size)
650+ require.NoError(t, err)
651+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
652+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
653+ attachment := &attachedClient{tr: &closeTrackingTransport{}}
654+ attachment.setAttachmentOwner(view)
655+ view.mu.Lock()
656+ require.True(t, view.registerAttachmentLocked(attachment))
657+ oldLink := view.link
658+ oldScreen := view.screen
659+ view.mu.Unlock()
660+
661+ firstTransport.recv <- remoteLinkTestReceive{err: io.EOF}
662+ awaitTestCompletion(t, reconnectStarted, "remote link failure did not begin reconnect")
663+ view.mu.Lock()
664+ require.Equal(t, remoteViewLinkReconnecting, view.linkState)
665+ require.Same(t, oldLink, view.link)
666+ require.Same(t, oldScreen, view.screen)
667+ view.mu.Unlock()
668+ require.Same(t, view, attachment.currentAttachmentOwner())
669+
670+ close(releaseReconnect)
671+ require.Eventually(t, func() bool {
672+ view.mu.Lock()
673+ defer view.mu.Unlock()
674+ return view.link != oldLink && view.linkState == remoteViewLinkHealthy && view.link != nil && view.link.active
675+ }, time.Second, 5*time.Millisecond, "validated reconnect should restore the same local remote view")
676+ require.Same(t, view, attachment.currentAttachmentOwner())
677+ require.Equal(t, int32(2), factory.calls.Load())
678+ d.shutdownAll(ports.ReasonServerShutdown)
679+}
680+
681+func TestRemoteLinkFailedAutoReconnectMarksViewUnavailableWithoutDetachingLocalOwner(t *testing.T) {
682+ d := newTestDaemon(t, nil, stubClock{})
683+ target := remoteLinkTestTarget()
684+ size := domain.Size{Cols: 80, Rows: 24}
685+ firstTransport := newRemoteLinkTestTransport()
686+ enqueueRemoteLinkHandshake(t, firstTransport, target, size)
687+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
688+ &remoteLinkTestDialer{transport: firstTransport},
689+ &remoteLinkTestDialer{},
690+ }}
691+ d.remoteDialerFactory = factory
692+
693+ view, err := d.openRemoteView(context.Background(), target, size)
694+ require.NoError(t, err)
695+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
696+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
697+ attachment := &attachedClient{tr: &closeTrackingTransport{}}
698+ attachment.setAttachmentOwner(view)
699+ view.mu.Lock()
700+ require.True(t, view.registerAttachmentLocked(attachment))
701+ oldLink := view.link
702+ view.mu.Unlock()
703+
704+ firstTransport.recv <- remoteLinkTestReceive{err: io.EOF}
705+ require.Eventually(t, func() bool {
706+ view.mu.Lock()
707+ defer view.mu.Unlock()
708+ return view.link == oldLink && view.linkState == remoteViewLinkUnavailable && !oldLink.active
709+ }, time.Second, 5*time.Millisecond, "failed automatic reconnect should leave the local view available for navigation")
710+ snapshot, ok := view.renderSnapshot(size)
711+ require.True(t, ok)
712+ require.Contains(t, remoteStatusSnapshot(view, snapshot).session, "unavailable")
713+ require.Same(t, view, attachment.currentAttachmentOwner())
714+ require.Equal(t, int32(2), factory.calls.Load())
715+ d.shutdownAll(ports.ReasonServerShutdown)
716+}
717+
718+func TestStaleFailedReconnectCannotDegradeReplacementGeneration(t *testing.T) {
719+ d := newTestDaemon(t, nil, stubClock{})
720+ target := remoteLinkTestTarget()
721+ size := domain.Size{Cols: 80, Rows: 24}
722+ firstTransport := newRemoteLinkTestTransport()
723+ candidateTransport := newRemoteLinkTestTransport()
724+ enqueueRemoteLinkHandshake(t, firstTransport, target, size)
725+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
726+ &remoteLinkTestDialer{transport: firstTransport},
727+ &remoteLinkTestDialer{transport: candidateTransport},
728+ }}
729+ d.remoteDialerFactory = factory
730+
731+ view, err := d.openRemoteView(context.Background(), target, size)
732+ require.NoError(t, err)
733+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
734+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
735+ attachment := &attachedClient{tr: &closeTrackingTransport{}}
736+ attachment.setAttachmentOwner(view)
737+ view.mu.Lock()
738+ require.True(t, view.registerAttachmentLocked(attachment))
739+ oldLink := view.link
740+ view.mu.Unlock()
741+
742+ firstTransport.recv <- remoteLinkTestReceive{err: io.EOF}
743+ awaitFrame(t, candidateTransport.sent, ports.MsgHello)
744+
745+ // A newer publication wins while the single automatic attempt is still
746+ // waiting for its candidate handshake. Its later failure must not turn the
747+ // replacement view unavailable or start a second retry.
748+ replacementTransport := newRemoteLinkTestTransport()
749+ view.mu.Lock()
750+ view.linkGeneration++
751+ replacement := &remoteLink{
752+ view: view, generation: view.linkGeneration, target: target, transport: replacementTransport,
753+ cancel: func() {}, done: make(chan struct{}), active: true,
754+ }
755+ view.link = replacement
756+ view.linkState = remoteViewLinkHealthy
757+ view.reconnectGeneration++
758+ view.mu.Unlock()
759+ candidateTransport.recv <- remoteLinkTestReceive{err: io.EOF}
760+ awaitTestCompletion(t, candidateTransport.closed, "stale reconnect candidate was not closed")
761+
762+ view.mu.Lock()
763+ require.Same(t, replacement, view.link)
764+ require.Equal(t, remoteViewLinkHealthy, view.linkState)
765+ view.mu.Unlock()
766+ require.Same(t, view, attachment.currentAttachmentOwner())
767+ require.Equal(t, int32(2), factory.calls.Load(), "a stale failed reconnect must not start another retry")
768+
769+ d.markRemoteLinkUnavailable(oldLink)
770+ require.Equal(t, int32(2), factory.calls.Load(), "a retired link cannot start another retry")
771+ d.shutdownAll(ports.ReasonServerShutdown)
772+}
773+
774+func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) {
775+ d := newTestDaemon(t, nil, stubClock{})
776+ target := remoteLinkTestTarget()
777+ size := domain.Size{Cols: 80, Rows: 24}
778+ firstTransport := newRemoteLinkTestTransport()
779+ failedTransport := newRemoteLinkTestTransport()
780+ enqueueRemoteLinkHandshakeWithContent(t, firstTransport, target, size, "retained generation")
781+ failedTransport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
782+ SessionID: "remote-id", SessionName: target.SessionName, RenderMode: ports.RenderModeProxiedContent,
783+ })}}
784+ invalidMetadata, err := ports.MarshalSessionMeta(ports.SessionMeta{
785+ LifecycleID: domain.SessionLifecycleID{9}, Revision: 1, SessionName: target.SessionName, ActiveTabID: target.LiveTabID,
786+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}},
787+ })
788+ require.NoError(t, err)
789+ failedTransport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: invalidMetadata}}
790+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
791+ &remoteLinkTestDialer{transport: firstTransport},
792+ &remoteLinkTestDialer{transport: failedTransport},
793+ }}
794+ d.remoteDialerFactory = factory
795+
796+ view, err := d.openRemoteView(context.Background(), target, size)
797+ require.NoError(t, err)
798+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
799+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
800+ attachment := &attachedClient{tr: &closeTrackingTransport{}}
801+ attachment.setAttachmentOwner(view)
802+ view.mu.Lock()
803+ require.True(t, view.registerAttachmentLocked(attachment))
804+ oldLink := view.link
805+ oldGeneration := view.linkGeneration
806+ oldScreen := view.screen
807+ oldLink.active = false
808+ view.mu.Unlock()
809+
810+ reconnected, err := d.openRemoteView(context.Background(), target, size)
811+ require.Nil(t, reconnected)
812+ require.ErrorContains(t, err, "metadata identity mismatch")
813+ require.Equal(t, int32(2), factory.calls.Load(), "the inactive registry hit must not be returned")
814+ select {
815+ case <-failedTransport.closed:
816+ default:
817+ t.Fatal("failed reconnect candidate transport was not closed")
818+ }
819+
820+ d.mu.Lock()
821+ require.Same(t, view, d.remoteViewByKeyLocked(view.key))
822+ d.mu.Unlock()
823+ view.mu.Lock()
824+ require.Same(t, oldLink, view.link)
825+ require.Equal(t, oldGeneration, view.linkGeneration)
826+ require.Same(t, oldScreen, view.screen)
827+ require.Contains(t, screenLineText(view.screen, 0), "retained generation")
828+ require.Contains(t, view.attachments, attachment)
829+ view.mu.Unlock()
830+ require.Same(t, view, attachment.currentAttachmentOwner())
831+
832+ d.shutdownAll(ports.ReasonServerShutdown)
833+}
834+
835+func TestOpenRemoteViewReconnectPublicationRejectsStaleLinkGeneration(t *testing.T) {
836+ d := newTestDaemon(t, nil, stubClock{})
837+ target := remoteLinkTestTarget()
838+ size := domain.Size{Cols: 80, Rows: 24}
839+ oldTransport := newRemoteLinkTestTransport()
840+ candidateTransport := newRemoteLinkTestTransport()
841+ enqueueRemoteLinkHandshake(t, oldTransport, target, size)
842+ enqueueRemoteLinkHandshakeWithContent(t, candidateTransport, target, size, "stale candidate")
843+ reconnectStarted := make(chan struct{})
844+ releaseReconnect := make(chan struct{})
845+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
846+ &remoteLinkTestDialer{transport: oldTransport},
847+ &remoteLinkTestDialer{transport: candidateTransport, started: reconnectStarted, release: releaseReconnect},
848+ }}
849+ d.remoteDialerFactory = factory
850+
851+ view, err := d.openRemoteView(context.Background(), target, size)
852+ require.NoError(t, err)
853+ awaitFrame(t, oldTransport.sent, ports.MsgHello)
854+ awaitFrame(t, oldTransport.sent, ports.MsgAck)
855+ view.mu.Lock()
856+ observedLink := view.link
857+ observedLink.active = false
858+ view.mu.Unlock()
859+
860+ type result struct {
861+ view *remoteView
862+ err error
863+ }
864+ resultCh := make(chan result, 1)
865+ go func() {
866+ reconnected, reconnectErr := d.openRemoteView(context.Background(), target, size)
867+ resultCh <- result{view: reconnected, err: reconnectErr}
868+ }()
869+ awaitTestCompletion(t, reconnectStarted, "reconnect dial did not start")
870+
871+ newerTransport := newRemoteLinkTestTransport()
872+ _, cancelNewer := context.WithCancel(context.Background())
873+ view.mu.Lock()
874+ view.linkGeneration++
875+ newerLink := &remoteLink{
876+ view: view, generation: view.linkGeneration, target: target, transport: newerTransport,
877+ cancel: cancelNewer, done: make(chan struct{}), active: true,
878+ }
879+ view.link = newerLink
880+ view.mu.Unlock()
881+ d.startRemoteLink(newerLink)
882+ close(releaseReconnect)
883+
884+ got := awaitTestValue(t, resultCh, "reconnect did not finish")
885+ require.NoError(t, got.err)
886+ require.Same(t, view, got.view)
887+ view.mu.Lock()
888+ require.Same(t, newerLink, view.link, "stale reconnect publication replaced a newer generation")
889+ view.mu.Unlock()
890+ select {
891+ case <-candidateTransport.closed:
892+ default:
893+ t.Fatal("stale reconnect candidate transport was not closed")
894+ }
895+ select {
896+ case <-newerTransport.closed:
897+ t.Fatal("stale reconnect closed the newer exact transport")
898+ default:
899+ }
900+ select {
901+ case <-oldTransport.closed:
902+ t.Fatal("rejected reconnect closed a transport it did not replace")
903+ default:
904+ }
905+
906+ observedLink.cancel()
907+ require.NoError(t, oldTransport.Close())
908+ awaitTestCompletion(t, observedLink.done, "superseded test link did not stop")
909+ d.shutdownAll(ports.ReasonServerShutdown)
910+}
911+
912+func TestOpenRemoteViewCancellationClosesUnpublishedTransport(t *testing.T) {
913+ d := newTestDaemon(t, nil, stubClock{})
914+ target := remoteLinkTestTarget()
915+ size := domain.Size{Cols: 80, Rows: 24}
916+ transport := newRemoteLinkTestTransport()
917+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
918+
919+ ctx, cancel := context.WithCancel(context.Background())
920+ type result struct {
921+ view *remoteView
922+ err error
923+ }
924+ resultCh := make(chan result, 1)
925+ go func() {
926+ view, err := d.openRemoteView(ctx, target, size)
927+ resultCh <- result{view: view, err: err}
928+ }()
929+ awaitFrame(t, transport.sent, ports.MsgHello)
930+ cancel()
931+
932+ got := awaitTestValue(t, resultCh, "canceled remote view construction did not finish")
933+ require.Nil(t, got.view)
934+ require.ErrorIs(t, got.err, context.Canceled)
935+ select {
936+ case <-transport.closed:
937+ default:
938+ t.Fatal("canceled candidate transport was not closed")
939+ }
940+ d.mu.Lock()
941+ require.Empty(t, d.remoteViews)
942+ require.Empty(t, d.remoteViewConstructions)
943+ d.mu.Unlock()
944+}
945+
946+func TestShutdownAllCancelsUnpublishedRemoteViewConstruction(t *testing.T) {
947+ d := newTestDaemon(t, nil, stubClock{})
948+ target := remoteLinkTestTarget()
949+ size := domain.Size{Cols: 80, Rows: 24}
950+ transport := newRemoteLinkTestTransport()
951+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
952+
953+ type result struct {
954+ view *remoteView
955+ err error
956+ }
957+ resultCh := make(chan result, 1)
958+ go func() {
959+ view, err := d.openRemoteView(context.Background(), target, size)
960+ resultCh <- result{view: view, err: err}
961+ }()
962+ awaitFrame(t, transport.sent, ports.MsgHello)
963+
964+ d.shutdownAll(ports.ReasonServerShutdown)
965+ got := awaitTestValue(t, resultCh, "remote view construction did not stop at shutdown")
966+ require.Nil(t, got.view)
967+ require.ErrorIs(t, got.err, context.Canceled)
968+ select {
969+ case <-transport.closed:
970+ default:
971+ t.Fatal("shutdown did not close the unpublished remote transport")
972+ }
973+ d.mu.Lock()
974+ require.Empty(t, d.remoteViews)
975+ require.Empty(t, d.remoteViewConstructions)
976+ d.mu.Unlock()
977+}
978+
979+func TestRemoteLinkAppliesAcceptedOutputAndRequestsOneResetForInvalidState(t *testing.T) {
980+ d := newTestDaemon(t, nil, stubClock{})
981+ target := remoteLinkTestTarget()
982+ size := domain.Size{Cols: 80, Rows: 24}
983+ transport := newRemoteLinkTestTransport()
984+ enqueueRemoteLinkHandshake(t, transport, target, size)
985+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
986+
987+ view, err := d.openRemoteView(context.Background(), target, size)
988+ require.NoError(t, err)
989+ awaitFrame(t, transport.sent, ports.MsgHello)
990+ awaitFrame(t, transport.sent, ports.MsgAck)
991+
992+ content := contentSize(size)
993+ incrementalPayload, err := ports.MarshalOutput(ports.Output{
994+ Epoch: 1, Base: 1, New: 2, Size: content, Data: []byte(" updated"),
995+ })
996+ require.NoError(t, err)
997+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: incrementalPayload}}
998+ ackFrame := awaitFrame(t, transport.sent, ports.MsgAck)
999+ ack, err := ports.UnmarshalAck(ackFrame.Payload)
1000+ require.NoError(t, err)
1001+ require.Equal(t, ports.Ack{Epoch: 1, State: 2}, ack)
1002+ view.mu.Lock()
1003+ require.Contains(t, screenLineText(view.screen, 0), "remote marker updated")
1004+ view.mu.Unlock()
1005+
1006+ invalidPayload, err := ports.MarshalOutput(ports.Output{
1007+ Epoch: 1, Base: 9, New: 10, Size: content, Data: []byte("must not apply"),
1008+ })
1009+ require.NoError(t, err)
1010+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: invalidPayload}}
1011+ awaitFrame(t, transport.sent, ports.MsgOutputResetRequest)
1012+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: invalidPayload}}
1013+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
1014+ afterInvalid := awaitTestValue(t, transport.sent, "remote link did not process duplicate invalid output")
1015+ require.Equal(t, ports.MsgPong, afterInvalid.Type, "duplicate invalid state must not trigger another reset")
1016+
1017+ view.mu.Lock()
1018+ require.NotContains(t, screenLineText(view.screen, 0), "must not apply")
1019+ view.mu.Unlock()
1020+ require.NoError(t, transport.Close())
1021+}
1022+
1023+func TestShutdownAllInterruptsPublishedRemoteViewLink(t *testing.T) {
1024+ d := newTestDaemon(t, nil, stubClock{})
1025+ target := remoteLinkTestTarget()
1026+ size := domain.Size{Cols: 80, Rows: 24}
1027+ transport := newRemoteLinkTestTransport()
1028+ enqueueRemoteLinkHandshake(t, transport, target, size)
1029+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
1030+
1031+ view, err := d.openRemoteView(context.Background(), target, size)
1032+ require.NoError(t, err)
1033+ awaitFrame(t, transport.sent, ports.MsgHello)
1034+ awaitFrame(t, transport.sent, ports.MsgAck)
1035+ view.mu.Lock()
1036+ link := view.link
1037+ view.mu.Unlock()
1038+ require.NotNil(t, link)
1039+
1040+ d.shutdownAll(ports.ReasonServerShutdown)
1041+ select {
1042+ case <-transport.closed:
1043+ default:
1044+ t.Fatal("shutdown did not close the exact remote transport")
1045+ }
1046+ awaitTestCompletion(t, link.done, "remote link worker did not stop after shutdown")
1047+ d.mu.Lock()
1048+ require.Empty(t, d.remoteViews)
1049+ d.mu.Unlock()
1050+}
1051+
1052+func TestRemoteLinkForwardsRemoteViewInputAndContentResize(t *testing.T) {
1053+ d := newTestDaemon(t, nil, stubClock{})
1054+ target := remoteLinkTestTarget()
1055+ size := domain.Size{Cols: 80, Rows: 24}
1056+ transport := newRemoteLinkTestTransport()
1057+ enqueueRemoteLinkHandshake(t, transport, target, size)
1058+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
1059+
1060+ view, err := d.openRemoteView(context.Background(), target, size)
1061+ require.NoError(t, err)
1062+ awaitFrame(t, transport.sent, ports.MsgHello)
1063+ awaitFrame(t, transport.sent, ports.MsgAck)
1064+
1065+ clientTransport, _ := newCapturingTransport(t)
1066+ ac := &attachedClient{tr: clientTransport, output: newOutputStateStream(), size: size}
1067+ ac.initOverlays()
1068+ ac.setAttachmentOwner(view)
1069+ view.mu.Lock()
1070+ require.True(t, view.registerAttachmentLocked(ac))
1071+ view.mu.Unlock()
1072+ token := attachmentOwnerToken(view, ac, clientTransport)
1073+ require.True(t, token.attachmentCurrent())
1074+
1075+ d.handleSequencedInputForAttachment(token, 42, []byte("remote input"))
1076+ inputFrame := awaitFrame(t, transport.sent, ports.MsgInput)
1077+ input, err := ports.UnmarshalInput(inputFrame.Payload)
1078+ require.NoError(t, err)
1079+ require.Equal(t, ports.Input{InputSeq: 42, Data: []byte("remote input")}, input)
1080+
1081+ require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 100, Rows: 30}))
1082+ resizeFrame := awaitFrame(t, transport.sent, ports.MsgResize)
1083+ resize, err := ports.UnmarshalResize(resizeFrame.Payload)
1084+ require.NoError(t, err)
1085+ require.Equal(t, domain.Size{Cols: 100, Rows: 28}, resize.Size)
1086+
1087+ require.NoError(t, transport.Close())
1088+}
1089+
1090+func TestOpenRemoteViewElectsOneConstructorAndCanceledWaiterDoesNotPoisonWinner(t *testing.T) {
1091+ d := newTestDaemon(t, nil, stubClock{})
1092+ target := remoteLinkTestTarget()
1093+ size := domain.Size{Cols: 80, Rows: 24}
1094+ transport := newRemoteLinkTestTransport()
1095+ enqueueRemoteLinkHandshake(t, transport, target, size)
1096+ started := make(chan struct{})
1097+ release := make(chan struct{})
1098+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport, started: started, release: release}}
1099+ d.remoteDialerFactory = factory
1100+
1101+ type result struct {
1102+ view *remoteView
1103+ err error
1104+ }
1105+ ownerResult := make(chan result, 1)
1106+ go func() {
1107+ view, err := d.openRemoteView(context.Background(), target, size)
1108+ ownerResult <- result{view: view, err: err}
1109+ }()
1110+ awaitTestCompletion(t, started, "remote view constructor did not start")
1111+
1112+ waiterCtx, cancelWaiter := context.WithCancel(context.Background())
1113+ cancelWaiter()
1114+ view, err := d.openRemoteView(waiterCtx, target, size)
1115+ require.Nil(t, view)
1116+ require.ErrorIs(t, err, context.Canceled)
1117+ require.Equal(t, int32(1), factory.calls.Load())
1118+
1119+ close(release)
1120+ owner := awaitTestValue(t, ownerResult, "remote view constructor result did not arrive")
1121+ require.NoError(t, owner.err)
1122+ require.NotNil(t, owner.view)
1123+ require.Equal(t, int32(1), factory.calls.Load())
1124+
1125+ warm, err := d.openRemoteView(context.Background(), target, size)
1126+ require.NoError(t, err)
1127+ require.Same(t, owner.view, warm)
1128+ require.Equal(t, int32(1), factory.calls.Load())
1129+
1130+ require.NoError(t, transport.Close())
1131+}
M internal/usecase/daemon/remote_picker.go +18 -17
@@ -133,7 +133,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
133133 key.LifecycleID = session.LifecycleID
134134 key.DisplayOrigin = remoteSessionOrigin(key.Host)
135135 availability := remotePickerAvailability(status)
136- stopped := session.State == "stopped"
136+ stopped := session.State == "down"
137137 broken := session.State == "broken"
138138 tabs := ports.CatalogTabs(session)
139139 viewTabs := make([]picker.TabEntry, 0, len(tabs))
@@ -178,8 +178,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
178178 }
179179 // Keep the structured session identity on rows even when one tab ID is
180180 // malformed or a broken session cannot be activated. Cursor navigation
181- // must remain possible, while sendRemoteAttachTargetForAttachment will
182- // fail closed on the invalid target.
181+ // remains possible for diagnostics, but activation fails closed.
183182 remoteTarget = &target
184183 }
185184 reason := ""
@@ -194,24 +193,21 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
194193 }
195194 ready := status == remoteHostFresh && targetValid && !broken
196195 if legacy {
197- // Count-only peers cannot prove lifecycle/tab identity. Preserve the
198- // historical direct-connect row for compatibility, while all current
199- // schema rows remain gated by exact identity and fresh ownership.
200- ready = status != remoteHostVersionMismatch && !broken
201- if status == remoteHostVersionMismatch {
202- reason = remoteReasonForStatus(status)
203- }
196+ // Proxied picker activation requires exact lifecycle and stable-tab
197+ // identity. A count-only cached row cannot fall back to client-owned
198+ // direct attachment under the v25 protocol contract.
199+ reason = "identity_changed"
204200 }
205201 detail := remotePickerStatusDetail(status, fetchedAt)
206202 if detail == "" {
207203 switch session.State {
208- case "running":
209- detail = "running"
204+ case "up":
205+ detail = "up"
210206 if session.Attached {
211207 detail += " · attached"
212208 }
213- case "stopped":
214- detail = "stopped"
209+ case "down":
210+ detail = "down"
215211 case "broken":
216212 detail = "broken"
217213 }
@@ -457,6 +453,11 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
457453 d.remoteCatalog.mu.Unlock()
458454 if cacheChanged {
459455 d.persistRemoteCatalogCache()
456+ }
457+ // A picker model may have been built from a fresh cached entry just before
458+ // this refresh marked its host pending. Rebuild it before the catalog I/O so
459+ // Enter cannot commit that stale selectable target during the refresh window.
460+ if cacheChanged || len(hosts) != 0 {
460461 d.refreshRemoteOpenPickers()
461462 }
462463
@@ -569,8 +570,8 @@ func (d *Daemon) refreshRemoteOpenPickers() {
569570 continue
570571 }
571572 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
572- if entry := ac.currentAttachmentSession(); entry != nil {
573- d.invalidateRender(entry, ac, true, "remote_picker.go")
574- }
573+ // The picker is attachment-local: catalog changes repaint the actual
574+ // owner, which can be either the retained local session or a remote view.
575+ d.invalidateAttachedOwner(ac, true, "remote_picker.go")
575576 }
576577 }
A internal/usecase/daemon/remote_picker_selection.go +154 -0
@@ -0,0 +1,154 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+)
10+
11+// remotePickerSelection is one generation-fenced picker activation. It keeps
12+// the local attachment capability until the remote link has completed its
13+// validated proxied handshake; no remote endpoint is ever published to the
14+// thin client from this path.
15+type remotePickerSelection struct {
16+ model *picker.Model
17+ generation uint64
18+ token attachmentConnectionToken
19+ target domain.RemoteSessionTarget
20+ ctx context.Context
21+ cancel context.CancelFunc
22+}
23+
24+func (selection *remotePickerSelection) current() bool {
25+ if selection == nil || selection.token.ac == nil || selection.token.ac.overlays == nil {
26+ return false
27+ }
28+ rt := selection.token.ac.overlays
29+ rt.pickerMu.Lock()
30+ current := rt.pickerRemoteSelection == selection &&
31+ rt.picker == selection.model &&
32+ rt.pickerGeneration == selection.generation
33+ rt.pickerMu.Unlock()
34+ return current
35+}
36+
37+func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
38+ if d == nil || token.ac == nil || normalizeAttachmentOwner(token.owner) == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() {
39+ return errAttachmentTransition
40+ }
41+ if target.RemoteTarget == nil {
42+ return errAttachmentTransition
43+ }
44+ remoteTarget := *target.RemoteTarget
45+ if remoteTarget.Validate() != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
46+ return errAttachmentTransition
47+ }
48+ if target.RemoteKey != nil && (target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID) {
49+ return errAttachmentTransition
50+ }
51+
52+ root := d.serveCtx
53+ if root == nil {
54+ root = context.Background()
55+ }
56+ ctx, cancel := context.WithCancel(root)
57+ selection := &remotePickerSelection{
58+ token: token,
59+ target: remoteTarget,
60+ ctx: ctx,
61+ cancel: cancel,
62+ }
63+ rt := token.ac.overlays
64+ rt.pickerMu.Lock()
65+ if rt.picker == nil || rt.pickerRemoteSelection != nil {
66+ rt.pickerMu.Unlock()
67+ cancel()
68+ return errAttachmentTransition
69+ }
70+ selection.model = rt.picker
71+ selection.generation = rt.pickerGeneration
72+ rt.pickerRemoteSelection = selection
73+ rt.pickerTitle = " Sessions · connecting (Esc cancels) "
74+ rt.pickerPending = nil
75+ rt.pickerESC.stop()
76+ rt.pickerMu.Unlock()
77+
78+ if token.effect != nil {
79+ token.effect.bindActionEnd(d, action)
80+ }
81+ d.invalidateAttachedOwner(token.ac, true, "remote_picker_selection.go")
82+ d.sessWg.Go(func() { d.completeRemotePickerSelection(selection) })
83+ return nil
84+}
85+
86+func (d *Daemon) completeRemotePickerSelection(selection *remotePickerSelection) {
87+ if selection == nil {
88+ return
89+ }
90+ defer selection.cancel()
91+ if selection.token.ac == nil {
92+ return
93+ }
94+ view, err := d.openRemoteView(selection.ctx, selection.target, selection.token.ac.sizeSnapshot())
95+ if err != nil {
96+ d.failRemotePickerSelection(selection, err)
97+ return
98+ }
99+ if err := d.activateRemoteViewTab(selection.ctx, view, selection.target); err != nil {
100+ d.parkRemoteViewWarm(view)
101+ d.failRemotePickerSelection(selection, err)
102+ return
103+ }
104+ if !selection.current() {
105+ d.parkRemoteViewWarm(view)
106+ return
107+ }
108+
109+ var published attachmentConnectionToken
110+ if _, remote := normalizeAttachmentOwner(selection.token.owner).(*remoteView); remote {
111+ published, err = d.transitionRemoteViewToRemoteViewForPicker(selection.token, view, selection)
112+ } else {
113+ published, err = d.transitionToRemoteViewForPicker(selection.token, view, selection)
114+ }
115+ if err != nil {
116+ d.parkRemoteViewWarm(view)
117+ d.failRemotePickerSelection(selection, err)
118+ return
119+ }
120+ // The transition freezes the initiating attachment before its final picker
121+ // generation check. Closing this exact picker afterwards cannot hand a
122+ // canceled or replacement picker to the remote owner.
123+ d.closePickerIfCurrent(selection.token.ac, selection.model, selection.generation)
124+ d.firstPaintForTransition(published)
125+}
126+
127+func (d *Daemon) failRemotePickerSelection(selection *remotePickerSelection, err error) {
128+ if selection == nil || selection.token.ac == nil || selection.token.ac.overlays == nil {
129+ return
130+ }
131+ rt := selection.token.ac.overlays
132+ rt.pickerMu.Lock()
133+ current := rt.pickerRemoteSelection == selection &&
134+ rt.picker == selection.model &&
135+ rt.pickerGeneration == selection.generation
136+ if current {
137+ rt.pickerRemoteSelection = nil
138+ rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
139+ }
140+ rt.pickerMu.Unlock()
141+ if !current {
142+ return
143+ }
144+ selection.cancel()
145+ if !errors.Is(err, context.Canceled) {
146+ d.log.Warn("remote picker selection failed", "endpoint", selection.target.Endpoint, "session", selection.target.SessionName, "err", err)
147+ if selection.token.current() {
148+ d.notifyAttachment(selection.token.ac, domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil)
149+ }
150+ }
151+ if selection.token.current() {
152+ d.invalidateAttachedOwner(selection.token.ac, true, "remote_picker_selection.go")
153+ }
154+}
M internal/usecase/daemon/remote_picker_test.go +585 -69
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "io"
88 "log/slog"
9+ "strings"
910 "sync"
1011 "testing"
1112 "time"
@@ -14,6 +15,7 @@ import (
1415
1516 "github.com/bnema/vev/internal/domain"
1617 "github.com/bnema/vev/internal/ports"
18+ "github.com/bnema/vev/internal/usecase/keys"
1719 "github.com/bnema/vev/internal/usecase/picker"
1820 )
1921
@@ -62,7 +64,7 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
6264 lifecycle := remoteLifecycleForTest()
6365 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
6466 Host: "arch", FetchedAt: now.Add(-remotePickerAttachTTL), Sessions: []ports.RemoteCatalogSession{{
65- LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
67+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
6668 }},
6769 }})
6870 d.remoteCatalog.mu.Lock()
@@ -83,7 +85,7 @@ func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testin
8385 lifecycle := remoteLifecycleForTest()
8486 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
8587 Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{
86- LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
88+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
8789 }},
8890 }})
8991 d.remoteCatalog.mu.Lock()
@@ -150,7 +152,7 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
150152 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
151153 LifecycleID: lifecycle,
152154 Name: "vive",
153- State: "running",
155+ State: "up",
154156 Tabs: test.tabs,
155157 ActiveTabID: test.activeTabID,
156158 }},
@@ -189,7 +191,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
189191 t.Run(test.name, func(t *testing.T) {
190192 d := newRemotePickerDaemon(nil)
191193 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
192- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
194+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
193195 }})
194196 d.remoteCatalog.mu.Lock()
195197 d.remoteCatalog.status["arch"] = test.status
@@ -203,12 +205,36 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
203205 }
204206 }
205207
208+func TestRemotePickerViewUsesShortStateLabels(t *testing.T) {
209+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
210+ lifecycle := remoteLifecycleForTest()
211+ for _, test := range []struct {
212+ state string
213+ detail string
214+ stopped bool
215+ }{
216+ {state: "up", detail: "up"},
217+ {state: "down", detail: "down", stopped: true},
218+ } {
219+ t.Run(test.state, func(t *testing.T) {
220+ view := remotePickerView(key, ports.RemoteCatalogSession{
221+ LifecycleID: lifecycle,
222+ Name: key.Name,
223+ State: test.state,
224+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
225+ }, remoteHostFresh, time.Unix(1, 0))
226+ require.Equal(t, test.detail, view.RemoteDetail)
227+ require.Equal(t, test.stopped, view.Stopped)
228+ })
229+ }
230+}
231+
206232 func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
207233 d := newRemotePickerDaemon(nil)
208234 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
209235 failedAt := fetchedAt.Add(6 * time.Hour)
210236 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
211- Host: "arch", FetchedAt: fetchedAt, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
237+ Host: "arch", FetchedAt: fetchedAt, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
212238 }})
213239 d.remoteCatalog.mu.Lock()
214240 d.remoteCatalog.status["arch"] = remoteHostUnreachable
@@ -391,7 +417,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
391417 ac.overlays.pickerMu.Unlock()
392418 require.True(t, d.registerRemotePicker(instance))
393419 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
394- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "cached", State: "running"}},
420+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "cached", State: "up"}},
395421 }})
396422
397423 firstGeneration := d.startRemotePickerRefresh(instance)
@@ -404,7 +430,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
404430 require.Contains(t, first, "mule")
405431 requireNoRemoteLockViolations(t, catalog, nil)
406432 d.remoteCatalog.mu.Lock()
407- require.Equal(t, []ports.RemoteCatalogSession{{Name: "cached", State: "running"}}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
433+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "cached", State: "up"}}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
408434 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
409435 d.remoteCatalog.mu.Unlock()
410436
@@ -429,6 +455,55 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
429455 d.remoteCatalog.mu.Unlock()
430456 }
431457
458+func TestRemotePickerRefreshDisablesCachedTargetUntilCatalogCompletes(t *testing.T) {
459+ now := time.Unix(120, 0)
460+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
461+ d, catalog, cache := newRemoteRefreshDaemon(t, hosts, now)
462+ lifecycle := remoteLifecycleForTest()
463+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
464+ Host: "arch",
465+ FetchedAt: now,
466+ Sessions: []ports.RemoteCatalogSession{{
467+ LifecycleID: lifecycle,
468+ Name: "work",
469+ State: "up",
470+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
471+ }},
472+ }})
473+ d.remoteCatalog.mu.Lock()
474+ d.remoteCatalog.status["arch"] = remoteHostFresh
475+ d.remoteCatalog.mu.Unlock()
476+
477+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
478+ model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
479+ model.Down()
480+ _, selectable := model.Selected()
481+ require.True(t, selectable, "the cached remote tab starts selectable before refresh begins")
482+
483+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
484+ request := receiveRemotePicker(t, catalog.requests, "catalog request")
485+
486+ ac.overlays.pickerMu.Lock()
487+ updated := ac.overlays.picker.Clone()
488+ ac.overlays.pickerMu.Unlock()
489+ cursor, cursorOK := updated.Cursor()
490+ _, selectable = updated.Selected()
491+ require.True(t, cursorOK)
492+ require.NotNil(t, cursor.RemoteTarget)
493+ require.Equal(t, "refreshing", cursor.UnavailableReason)
494+ require.False(t, selectable, "a refresh-in-flight picker must not retain the old selectable target")
495+
496+ request.result <- remoteRefreshResult{catalog: ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{{
497+ LifecycleID: lifecycle,
498+ Name: "work",
499+ State: "up",
500+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
501+ }}}}
502+ receiveRemotePicker(t, cache.stores, "remote cache store")
503+ requireNoRemoteLockViolations(t, catalog, cache)
504+ d.shutdownAll(ports.ReasonServerShutdown)
505+}
506+
432507 func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
433508 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
434509 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
@@ -474,8 +549,8 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
474549 hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
475550 d, _, cache := newRemoteRefreshDaemon(t, hosts, now)
476551 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
477- {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}}},
478- {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},
552+ {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "up"}}},
553+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}},
479554 })
480555 d.remoteCatalog.mu.Lock()
481556 d.remoteCatalog.refresh = 7
@@ -483,7 +558,7 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
483558
484559 require.False(t, d.applyRemoteRefreshResult(7, "mule", ports.RemoteCatalog{}, errors.New("offline")))
485560 d.remoteCatalog.mu.Lock()
486- require.Equal(t, []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}, d.remoteCatalog.cache["mule"].Sessions)
561+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}, d.remoteCatalog.cache["mule"].Sessions)
487562 require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)
488563 require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
489564 d.remoteCatalog.mu.Unlock()
@@ -492,7 +567,7 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
492567 stored := receiveRemotePicker(t, cache.stores, "remote cache store")
493568 require.Equal(t, []ports.RemoteCatalogCacheEntry{
494569 {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},
495- {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},
570+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}},
496571 }, stored, "every successful write persists the newest full cache atomically")
497572 requireNoRemoteLockViolations(t, nil, cache)
498573 d.remoteCatalog.mu.Lock()
@@ -505,15 +580,15 @@ func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
505580 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
506581 d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))
507582 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
508- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}},
583+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "up"}},
509584 }})
510585 d.remoteCatalog.mu.Lock()
511586 d.remoteCatalog.refresh = 9
512587 d.remoteCatalog.mu.Unlock()
513588
514- require.False(t, d.applyRemoteRefreshResult(8, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "late", State: "running"}}}, nil))
589+ require.False(t, d.applyRemoteRefreshResult(8, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "late", State: "up"}}}, nil))
515590 hosts.set()
516- require.True(t, d.applyRemoteRefreshResult(9, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "removed", State: "running"}}}, nil))
591+ require.True(t, d.applyRemoteRefreshResult(9, "arch", ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "removed", State: "up"}}}, nil))
517592 require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))
518593 requireNoRemoteLockViolations(t, nil, cache)
519594 d.remoteCatalog.mu.Lock()
@@ -529,7 +604,7 @@ func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
529604 session ports.RemoteCatalogSession
530605 }{
531606 {name: "unknown state", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "unknown"}},
532- {name: "invalid reason", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "running", Reason: "bogus"}},
607+ {name: "invalid reason", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "up", Reason: "bogus"}},
533608 } {
534609 t.Run(test.name, func(t *testing.T) {
535610 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
@@ -547,7 +622,7 @@ func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
547622 func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
548623 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
549624 d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
550- old := ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}
625+ old := ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}}
551626 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{old})
552627 d.remoteCatalog.mu.Lock()
553628 d.remoteCatalog.refresh = 3
@@ -560,77 +635,502 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
560635 d.remoteCatalog.mu.Unlock()
561636 }
562637
563-func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
638+func TestRemoteCatalogRefreshUpdatesRemoteOwnerPicker(t *testing.T) {
639+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
640+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
641+ source, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
642+
643+ source.mu.Lock()
644+ require.True(t, source.unregisterAttachmentLocked(ac))
645+ source.mu.Unlock()
646+ view.mu.Lock()
647+ require.True(t, view.registerAttachmentLocked(ac))
648+ view.mu.Unlock()
649+ ac.setAttachmentOwner(view)
650+ ac.recordPreviousOwner(source)
651+
652+ setCatalog := func(name string) {
653+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
654+ Host: view.key.endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
655+ LifecycleID: view.key.lifecycleID, Name: name, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
656+ }},
657+ }})
658+ d.remoteCatalog.mu.Lock()
659+ d.remoteCatalog.status[view.key.endpoint] = remoteHostFresh
660+ d.remoteCatalog.mu.Unlock()
661+ }
662+ setCatalog("cached")
663+
664+ d.enterPicker(source, ac)
665+ awaitFrame(t, sends, ports.MsgOutput)
666+
667+ setCatalog("updated")
668+ d.refreshRemoteOpenPickers()
669+
670+ ac.overlays.pickerMu.Lock()
671+ model := ac.overlays.picker.Clone()
672+ ac.overlays.pickerMu.Unlock()
673+ frame := model.Render(domain.Size{Cols: 80, Rows: 20}, picker.Preview{})
674+ var text strings.Builder
675+ for y := range frame.Height {
676+ text.WriteString(rowText(frame.Row(y)))
677+ }
678+ require.Contains(t, text.String(), "updated", "remote-owner picker must use refreshed catalog rows")
679+
680+ output := awaitFrame(t, sends, ports.MsgOutput)
681+ decoded, err := ports.UnmarshalOutput(output.Payload)
682+ require.NoError(t, err)
683+ require.Contains(t, string(decoded.Data), "updated", "remote-owner picker must repaint after refresh")
684+}
685+
686+func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *testing.T) {
564687 d := newRemotePickerDaemon(nil)
688+ target := remoteLinkTestTarget()
689+ require.NoError(t, target.Validate())
565690 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
566- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
691+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
692+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
693+ }},
567694 }})
695+ d.remoteCatalog.mu.Lock()
696+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
697+ d.remoteCatalog.mu.Unlock()
698+ require.True(t, d.remotePickerTargetReadyTarget(target))
699+
700+ remoteTransport := newRemoteLinkTestTransport()
701+ remoteDialStarted := make(chan struct{})
702+ remoteDialRelease := make(chan struct{})
703+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{
704+ transport: remoteTransport,
705+ started: remoteDialStarted,
706+ release: remoteDialRelease,
707+ }}
568708 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
709+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
710+ ac.overlays.pickerMu.Lock()
711+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
712+ ac.overlays.pickerGeneration++
713+ ac.overlays.pickerMu.Unlock()
714+
569715 token := sess.attachmentToken(ac, ac.transport())
570716 effect, admitted := ac.beginAttachmentEffect(token)
571717 require.True(t, admitted)
572718 token.effect = effect
573- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
574- target := picker.Target{Session: key.ID(), RemoteKey: &key}
575- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
719+ pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
720+ require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
721+ effect.End()
722+
723+ receiveRemotePickerClose(t, remoteDialStarted, "remote picker dial")
724+ require.Same(t, sess, ac.currentAttachmentSession(), "the source remains attached while the remote handshake is pending")
725+ require.True(t, ac.overlays.pickerActive(), "the local picker stays available until publication")
726+ require.Never(t, func() bool {
727+ select {
728+ case frame := <-sends:
729+ return frame.Type == ports.MsgAttachTarget
730+ default:
731+ return false
732+ }
733+ }, 100*time.Millisecond, 5*time.Millisecond, "picker selection must not hand the client to the remote daemon")
734+
735+ enqueueRemoteLinkHandshake(t, remoteTransport, target, ac.sizeSnapshot())
736+ close(remoteDialRelease)
737+ require.Eventually(t, func() bool {
738+ _, remote := ac.currentAttachmentOwner().(*remoteView)
739+ return remote
740+ }, time.Second, 5*time.Millisecond, "the ready remote surface is atomically published to the same client")
741+ require.False(t, ac.overlays.pickerActive())
742+ require.Same(t, sess, ac.previousOwner.Get(), "return navigation retains the local source owner")
743+ view, ok := ac.currentAttachmentOwner().(*remoteView)
744+ require.True(t, ok)
745+ remoteToken := attachmentOwnerToken(view, ac, ac.transport())
746+ require.True(t, remoteToken.current())
747+ d.handleAttachmentClientFrame(remoteToken, frameInput([]byte{keys.ESC, ' '}))
748+ require.True(t, ac.overlays.paletteActive(), "the same local connection can reopen its local palette")
749+ d.handleAttachmentClientFrame(remoteToken, frameInput([]byte("SSP\r")))
750+ ac.overlays.pickerMu.Lock()
751+ model := ac.overlays.picker.Clone()
752+ ac.overlays.pickerMu.Unlock()
753+ require.NotNil(t, model)
754+ frame := model.Render(domain.Size{Cols: 80, Rows: 20}, picker.Preview{})
755+ var rendered strings.Builder
756+ for y := range frame.Height {
757+ rendered.WriteString(rowText(frame.Row(y)))
758+ }
759+ require.Contains(t, rendered.String(), sess.name)
760+ require.Contains(t, rendered.String(), target.SessionName, "the reopened picker retains local and remote rows")
576761
577- frame := receiveRemotePicker(t, sends, "attach target")
578- require.Equal(t, ports.MsgAttachTarget, frame.Type)
579- got, err := ports.UnmarshalAttachTarget(frame.Payload)
580- require.NoError(t, err)
581- require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach}, got)
582- require.Nil(t, ac.currentAttachmentSession())
583762 d.mu.Lock()
584- require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow")
763+ require.Len(t, d.remoteViews, 1)
585764 d.mu.Unlock()
765+ require.Never(t, func() bool {
766+ select {
767+ case frame := <-sends:
768+ return frame.Type == ports.MsgAttachTarget
769+ default:
770+ return false
771+ }
772+ }, 100*time.Millisecond, 5*time.Millisecond)
773+ d.shutdownAll(ports.ReasonServerShutdown)
586774 }
587775
588-func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
776+func TestRemotePickerSelectionTransitionsBetweenRemoteViewsAndSharesAttachmentMRU(t *testing.T) {
589777 d := newRemotePickerDaemon(nil)
778+ first := remoteLinkTestTarget()
779+ second := first
780+ second.LifecycleID = domain.SessionLifecycleID{2}
781+ second.SessionName = "other"
782+ second.LiveTabID = "tab-2"
590783 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
591- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
784+ Host: first.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{
785+ {LifecycleID: first.LifecycleID, Name: first.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(first.LiveTabID), Index: 0, Name: "main"}}},
786+ {LifecycleID: second.LifecycleID, Name: second.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(second.LiveTabID), Index: 0, Name: "main"}}},
787+ },
592788 }})
593- const sendErr = "remote attach send failed"
594- tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}
595- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
596- ac.initOverlays()
597- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
598- sess := &session{sessionCore: sessionCore{id: "local", name: "local", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}
599- publishTiledPaneOwners(sess, tb)
600- ac.setSession(sess)
601- d.sessions[sess.id] = sess
789+ d.remoteCatalog.mu.Lock()
790+ d.remoteCatalog.status[first.Endpoint] = remoteHostFresh
791+ d.remoteCatalog.mu.Unlock()
792+
793+ firstTransport := newRemoteLinkTestTransport()
794+ secondTransport := newRemoteLinkTestTransport()
795+ d.remoteDialerFactory = &remoteLinkSequenceFactory{dialers: []ports.Dialer{
796+ &remoteLinkTestDialer{transport: firstTransport},
797+ &remoteLinkTestDialer{transport: secondTransport},
798+ }}
799+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
800+ openPicker := func() {
801+ ac.overlays.pickerMu.Lock()
802+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
803+ ac.overlays.pickerGeneration++
804+ ac.overlays.pickerMu.Unlock()
805+ }
806+ selectTarget := func(token attachmentConnectionToken, target domain.RemoteSessionTarget) {
807+ effect, admitted := ac.beginAttachmentEffect(token)
808+ require.True(t, admitted)
809+ token.effect = effect
810+ key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
811+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &target}, sessionHandoffGuard{closePicker: true}, "picker-select"))
812+ effect.End()
813+ }
814+
815+ openPicker()
816+ selectTarget(sess.attachmentToken(ac, ac.transport()), first)
817+ enqueueRemoteLinkHandshake(t, firstTransport, first, ac.sizeSnapshot())
818+ require.Eventually(t, func() bool {
819+ _, remote := ac.currentAttachmentOwner().(*remoteView)
820+ return remote
821+ }, time.Second, 5*time.Millisecond, "the first remote picker selection should publish its view")
822+ firstView, ok := ac.currentAttachmentOwner().(*remoteView)
823+ require.True(t, ok)
824+
825+ openPicker()
826+ selectTarget(attachmentOwnerToken(firstView, ac, ac.transport()), second)
827+ enqueueRemoteLinkHandshake(t, secondTransport, second, ac.sizeSnapshot())
828+ require.Eventually(t, func() bool {
829+ view, remote := ac.currentAttachmentOwner().(*remoteView)
830+ return remote && view != firstView && view.key == remoteViewKey{endpoint: second.Endpoint, lifecycleID: second.LifecycleID, sessionName: second.SessionName}
831+ }, time.Second, 5*time.Millisecond, "the selected remote view should replace the current remote owner")
832+ secondView, ok := ac.currentAttachmentOwner().(*remoteView)
833+ require.True(t, ok)
834+ require.False(t, firstView.attachmentRegistered(ac))
835+ require.True(t, secondView.attachmentRegistered(ac))
836+ require.Same(t, sess, ac.previousOwner.Get(), "remote-to-remote keeps the retained local return owner")
837+ firstView.mu.Lock()
838+ require.NotNil(t, firstView.warm, "the replaced remote view is retained warm")
839+ firstView.mu.Unlock()
840+
841+ remoteRecent := d.recentSessionsForRemoteAttachment(secondView, ac)
842+ remoteNames := make([]string, 0, len(remoteRecent))
843+ for _, entry := range remoteRecent {
844+ remoteNames = append(remoteNames, entry.name)
845+ }
846+ require.Contains(t, remoteNames, "remote@remote.example")
847+ require.NotContains(t, remoteNames, "other@remote.example", "the remote status bar excludes its current view")
848+ select {
849+ case frame := <-sends:
850+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
851+ default:
852+ }
853+
854+ backToken := attachmentOwnerToken(secondView, ac, ac.transport())
855+ backEffect, admitted := ac.beginAttachmentEffect(backToken)
856+ require.True(t, admitted)
857+ backToken.effect = backEffect
858+ require.NoError(t, d.backSessionForAttachment(backToken))
859+ backEffect.End()
860+ require.Same(t, sess, ac.currentAttachmentSession())
861+ localState := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, nil)
862+ localNames := make([]string, 0, len(localState.mru))
863+ for _, entry := range localState.mru {
864+ localNames = append(localNames, entry.name)
865+ }
866+ require.Equal(t, []string{"other@remote.example", "remote@remote.example"}, localNames)
867+ d.shutdownAll(ports.ReasonServerShutdown)
868+}
869+
870+func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) {
871+ d := newRemotePickerDaemon(nil)
872+ target := remoteLinkTestTarget()
873+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
874+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
875+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
876+ }},
877+ }})
878+ d.remoteCatalog.mu.Lock()
879+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
880+ d.remoteCatalog.mu.Unlock()
881+
882+ remoteTransport := newRemoteLinkTestTransport()
883+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: remoteTransport}}
884+ d.remoteDialerFactory = factory
885+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
886+ pickerKey := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
887+ pickerTarget := picker.Target{Session: pickerKey.ID(), RemoteKey: &pickerKey, RemoteTarget: &target}
888+ model := picker.New([]picker.SessionView{{
889+ ID: pickerTarget.Session,
890+ Name: target.SessionName,
891+ RemoteKey: &pickerKey,
892+ RemoteTarget: &target,
893+ RemoteAttachReady: true,
894+ Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}},
895+ }}, picker.SelectionConfig{})
602896 ac.overlays.pickerMu.Lock()
603- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
897+ ac.overlays.picker = model
604898 ac.overlays.pickerGeneration++
605899 ac.overlays.pickerMu.Unlock()
606900
607- gone := make(chan struct{})
608- d.afterClientGoneDetach = func() { close(gone) }
609- token := sess.attachmentToken(ac, tr)
610- effect, admitted := ac.beginAttachmentEffect(token)
901+ selectRemote := func() {
902+ // Exercise the picker input boundary without a frame effect. The daemon
903+ // must admit a short-lived local effect rather than taking the legacy
904+ // client handoff path or closing the picker before the async selection.
905+ d.handlePickerInput(ac, []byte("\r"))
906+ }
907+ selectRemote()
908+ enqueueRemoteLinkHandshake(t, remoteTransport, target, ac.sizeSnapshot())
909+ require.Eventually(t, func() bool {
910+ _, remote := ac.currentAttachmentOwner().(*remoteView)
911+ return remote
912+ }, time.Second, 5*time.Millisecond, "the first remote picker selection should publish the remote view")
913+
914+ view, ok := ac.currentAttachmentOwner().(*remoteView)
915+ require.True(t, ok)
916+ backToken := attachmentOwnerToken(view, ac, ac.transport())
917+ require.NotNil(t, backToken.ac)
918+ backEffect, admitted := ac.beginAttachmentEffect(backToken)
611919 require.True(t, admitted)
612- token.effect = effect
613- defer effect.End()
614- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
615- target := picker.Target{Session: key.ID(), RemoteKey: &key}
616- err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")
617- require.EqualError(t, err, sendErr)
618- require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")
619- select {
620- case <-gone:
621- t.Fatal("failed control send reached clientGoneForAttachment")
622- default:
920+ backToken.effect = backEffect
921+ require.NoError(t, d.backSessionForAttachment(backToken))
922+ backEffect.End()
923+ require.Eventually(t, func() bool {
924+ return ac.currentAttachmentSession() == sess
925+ }, time.Second, 5*time.Millisecond, "back navigation should return to the local source")
926+ view.mu.Lock()
927+ require.NotNil(t, view.warm, "the detached remote view should be retained warm")
928+ view.mu.Unlock()
929+
930+ ac.overlays.pickerMu.Lock()
931+ ac.overlays.picker = model
932+ ac.overlays.pickerGeneration++
933+ ac.overlays.pickerMu.Unlock()
934+ selectRemote()
935+ require.Eventually(t, func() bool {
936+ owner, remote := ac.currentAttachmentOwner().(*remoteView)
937+ return remote && owner == view
938+ }, time.Second, 5*time.Millisecond, "reselecting should publish the warm remote view")
939+ require.Equal(t, int32(1), factory.calls.Load(), "reselecting a warm remote view must not dial again")
940+ view.mu.Lock()
941+ require.Nil(t, view.warm, "publishing the warm view should cancel its retention timer")
942+ view.mu.Unlock()
943+ d.shutdownAll(ports.ReasonServerShutdown)
944+}
945+
946+func TestRemotePickerWarmReselectActivatesSelectedAlternateTabWithoutSecondDial(t *testing.T) {
947+ d := newRemotePickerDaemon(nil)
948+ target := remoteLinkTestTarget()
949+ alternate := target
950+ alternate.LiveTabID = "tab-2"
951+ const alternateName = "alternate"
952+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
953+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
954+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up",
955+ Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}, {ID: string(alternate.LiveTabID), Index: 1, Name: alternateName}},
956+ }},
957+ }})
958+ d.remoteCatalog.mu.Lock()
959+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
960+ d.remoteCatalog.mu.Unlock()
961+
962+ remoteTransport := newRemoteLinkTestTransport()
963+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: remoteTransport}}
964+ d.remoteDialerFactory = factory
965+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
966+ pickerKey := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
967+ openPicker := func() {
968+ ac.overlays.pickerMu.Lock()
969+ ac.overlays.picker = picker.New([]picker.SessionView{{
970+ ID: pickerKey.ID(), Name: target.SessionName, RemoteKey: &pickerKey, RemoteTarget: &target, RemoteAttachReady: true,
971+ Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}, {TabID: alternate.LiveTabID, Name: alternateName}},
972+ }}, picker.SelectionConfig{})
973+ ac.overlays.pickerGeneration++
974+ ac.overlays.pickerMu.Unlock()
623975 }
624- require.Same(t, sess, ac.currentAttachmentSession())
976+ selectTarget := func(selected domain.RemoteSessionTarget) {
977+ token := sess.attachmentToken(ac, ac.transport())
978+ effect, admitted := ac.beginAttachmentEffect(token)
979+ require.True(t, admitted)
980+ token.effect = effect
981+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: pickerKey.ID(), RemoteKey: &pickerKey, RemoteTarget: &selected}, sessionHandoffGuard{closePicker: true}, "picker-select"))
982+ effect.End()
983+ }
984+
985+ openPicker()
986+ selectTarget(target)
987+ enqueueRemoteLinkHandshakeWithMetadata(t, remoteTransport, target, ac.sizeSnapshot(), ports.SessionMeta{
988+ LifecycleID: target.LifecycleID, Revision: 1, SessionName: target.SessionName, ActiveTabID: target.LiveTabID,
989+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}, {ID: alternate.LiveTabID, Name: alternateName}},
990+ }, "first tab")
991+ require.Eventually(t, func() bool {
992+ _, remote := ac.currentAttachmentOwner().(*remoteView)
993+ return remote
994+ }, time.Second, 5*time.Millisecond)
995+ view, ok := ac.currentAttachmentOwner().(*remoteView)
996+ require.True(t, ok)
997+
998+ backToken := attachmentOwnerToken(view, ac, ac.transport())
999+ backEffect, admitted := ac.beginAttachmentEffect(backToken)
1000+ require.True(t, admitted)
1001+ backToken.effect = backEffect
1002+ require.NoError(t, d.backSessionForAttachment(backToken))
1003+ backEffect.End()
1004+ require.Eventually(t, func() bool { return ac.currentAttachmentSession() == sess }, time.Second, 5*time.Millisecond)
1005+
1006+ openPicker()
1007+ selectTarget(alternate)
1008+ commandFrame := awaitFrame(t, remoteTransport.sent, ports.MsgCommand)
1009+ command, err := ports.UnmarshalCommandRequest(commandFrame.Payload)
1010+ require.NoError(t, err)
1011+ require.Equal(t, "next-tab", command.Slug)
1012+ require.True(t, command.Attached)
1013+ remoteTransport.recv <- remoteLinkTestReceive{frame: remoteMetadataFrame(t, ports.SessionMeta{
1014+ LifecycleID: target.LifecycleID, Revision: 2, SessionName: target.SessionName, ActiveTabID: alternate.LiveTabID,
1015+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}, {ID: alternate.LiveTabID, Name: alternateName}},
1016+ })}
1017+ resultPayload := ports.MarshalCommandResult(ports.CommandResult{RequestID: command.RequestID, OK: true})
1018+ remoteTransport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgCommandResult, Payload: resultPayload}}
1019+
1020+ require.Eventually(t, func() bool {
1021+ owner, remote := ac.currentAttachmentOwner().(*remoteView)
1022+ if !remote || owner != view {
1023+ return false
1024+ }
1025+ view.mu.Lock()
1026+ defer view.mu.Unlock()
1027+ return view.metadata.ActiveTabID == alternate.LiveTabID
1028+ }, time.Second, 5*time.Millisecond)
1029+ require.Equal(t, int32(1), factory.calls.Load(), "alternate tab warm reuse must not dial again")
1030+ d.shutdownAll(ports.ReasonServerShutdown)
6251031 }
6261032
627-type remotePickerSendErrorTransport struct {
628- err error
1033+func TestRemotePickerSelectionFailureKeepsSourceAndPickerOpen(t *testing.T) {
1034+ d := newRemotePickerDaemon(nil)
1035+ target := remoteLinkTestTarget()
1036+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
1037+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
1038+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
1039+ }},
1040+ }})
1041+ d.remoteCatalog.mu.Lock()
1042+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
1043+ d.remoteCatalog.mu.Unlock()
1044+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{}}
1045+
1046+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
1047+ ac.overlays.pickerMu.Lock()
1048+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
1049+ ac.overlays.pickerGeneration++
1050+ ac.overlays.pickerMu.Unlock()
1051+ token := sess.attachmentToken(ac, ac.transport())
1052+ effect, admitted := ac.beginAttachmentEffect(token)
1053+ require.True(t, admitted)
1054+ token.effect = effect
1055+ pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
1056+ require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
1057+ effect.End()
1058+
1059+ require.Eventually(t, func() bool {
1060+ ac.overlays.pickerMu.Lock()
1061+ defer ac.overlays.pickerMu.Unlock()
1062+ return ac.overlays.pickerRemoteSelection == nil
1063+ }, time.Second, 5*time.Millisecond)
1064+ require.True(t, ac.overlays.pickerActive(), "a failed remote link leaves the local picker usable")
1065+ require.Same(t, sess, ac.currentAttachmentSession(), "a failed remote link leaves the source owner untouched")
1066+ require.Never(t, func() bool {
1067+ select {
1068+ case frame := <-sends:
1069+ return frame.Type == ports.MsgAttachTarget
1070+ default:
1071+ return false
1072+ }
1073+ }, 100*time.Millisecond, 5*time.Millisecond)
1074+ d.mu.Lock()
1075+ require.Empty(t, d.remoteViews)
1076+ d.mu.Unlock()
1077+ d.shutdownAll(ports.ReasonServerShutdown)
6291078 }
6301079
631-func (t *remotePickerSendErrorTransport) Send(ports.Frame) error { return t.err }
632-func (*remotePickerSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
633-func (*remotePickerSendErrorTransport) Close() error { return nil }
1080+func TestRemotePickerSelectionEscapeCancelsWithoutDetachingSource(t *testing.T) {
1081+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
1082+ d := newRemotePickerDaemon(nil)
1083+ d.clock = clock
1084+ target := remoteLinkTestTarget()
1085+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
1086+ Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
1087+ LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
1088+ }},
1089+ }})
1090+ d.remoteCatalog.mu.Lock()
1091+ d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
1092+ d.remoteCatalog.mu.Unlock()
1093+ remoteTransport := newRemoteLinkTestTransport()
1094+ dialStarted := make(chan struct{})
1095+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: remoteTransport, started: dialStarted, release: make(chan struct{})}}
1096+
1097+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
1098+ ac.overlays.pickerMu.Lock()
1099+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
1100+ ac.overlays.pickerGeneration++
1101+ ac.overlays.pickerMu.Unlock()
1102+ token := sess.attachmentToken(ac, ac.transport())
1103+ effect, admitted := ac.beginAttachmentEffect(token)
1104+ require.True(t, admitted)
1105+ token.effect = effect
1106+ pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
1107+ require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
1108+ effect.End()
1109+ receiveRemotePickerClose(t, dialStarted, "remote picker dial")
1110+
1111+ d.handlePickerInput(ac, []byte{keys.ESC})
1112+ escTimer := receiveRemotePicker(t, clock.timers, "picker escape timer")
1113+ escTimer.ch <- time.Now()
1114+ require.Eventually(t, func() bool {
1115+ ac.overlays.pickerMu.Lock()
1116+ defer ac.overlays.pickerMu.Unlock()
1117+ return ac.overlays.pickerRemoteSelection == nil
1118+ }, time.Second, 5*time.Millisecond)
1119+ require.True(t, ac.overlays.pickerActive(), "Escape cancels the pending remote activation but keeps the picker open")
1120+ require.Same(t, sess, ac.currentAttachmentSession())
1121+ require.Never(t, func() bool {
1122+ select {
1123+ case frame := <-sends:
1124+ return frame.Type == ports.MsgAttachTarget
1125+ default:
1126+ return false
1127+ }
1128+ }, 100*time.Millisecond, 5*time.Millisecond)
1129+ d.mu.Lock()
1130+ require.Empty(t, d.remoteViews)
1131+ d.mu.Unlock()
1132+ d.shutdownAll(ports.ReasonServerShutdown)
1133+}
6341134
6351135 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
6361136 t.Helper()
@@ -669,7 +1169,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
6691169
6701170 require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{
6711171 ProtocolVersion: ports.ProtocolVersion,
672- Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 2}},
1172+ Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up", Tabs: 2}},
6731173 }, nil))
6741174 receiveRemotePicker(t, cache.stores, "remote cache store")
6751175 requireNoRemoteLockViolations(t, catalog, cache)
@@ -979,15 +1479,31 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
9791479 ac.overlays.pickerMu.Unlock()
9801480 }
9811481
982-func TestRemoteRowsBecomeSelectableOnlyAfterOwnershipCompletion(t *testing.T) {
1482+func TestRemoteRowsRequireFreshRichIdentityForPickerActivation(t *testing.T) {
9831483 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
984- for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} {
985- view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, status, time.Unix(1, 0))
1484+ session := ports.RemoteCatalogSession{
1485+ LifecycleID: remoteLifecycleForTest(), Name: key.Name, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Name: "main"}},
1486+ }
1487+ for _, test := range []struct {
1488+ status remoteHostStatus
1489+ want bool
1490+ }{
1491+ {status: remoteHostCached},
1492+ {status: remoteHostRefreshing},
1493+ {status: remoteHostFresh, want: true},
1494+ {status: remoteHostStale},
1495+ {status: remoteHostUnreachable},
1496+ {status: remoteHostMalformed},
1497+ {status: remoteHostVersionMismatch},
1498+ } {
1499+ view := remotePickerView(key, session, test.status, time.Unix(1, 0))
9861500 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
987- require.True(t, selectable, "status %d is ready after ownership completion", status)
1501+ require.Equal(t, test.want, selectable, "status %d", test.status)
1502+ require.Equal(t, test.want, view.RemoteAttachReady, "status %d", test.status)
9881503 }
989- mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{})
990- require.False(t, mismatch.RemoteAttachReady)
991- _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1504+
1505+ legacy := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostFresh, time.Unix(1, 0))
1506+ require.False(t, legacy.RemoteAttachReady, "count-only cached rows cannot initiate proxied picker activation")
1507+ _, selectable := picker.New([]picker.SessionView{legacy}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
9921508 require.False(t, selectable)
9931509 }
A internal/usecase/daemon/remote_reconnect.go +78 -0
@@ -0,0 +1,78 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+const remoteViewRetryDelay = 100 * time.Millisecond
12+
13+func sleepRemoteViewRetry(ctx context.Context, clock ports.Clock) bool {
14+ if ctx == nil {
15+ ctx = context.Background()
16+ }
17+ if clock == nil {
18+ clock = systemClock{}
19+ }
20+ timer := clock.NewTimer(remoteViewRetryDelay)
21+ defer timer.Stop()
22+ select {
23+ case <-timer.C():
24+ return true
25+ case <-ctx.Done():
26+ return false
27+ }
28+}
29+
30+// reconnectRemoteView preserves the registered view and its local attachments
31+// while openRemoteView verifies a replacement link. It changes presentation
32+// state only when the failed exact link and reconnect generation are still
33+// current, so a later picker open, terminal retirement, or shutdown wins.
34+func (d *Daemon) reconnectRemoteView(view *remoteView, failed *remoteLink, reconnectGeneration uint64, target domain.RemoteSessionTarget, size domain.Size) {
35+ if d == nil || view == nil || failed == nil {
36+ return
37+ }
38+ ctx := d.serveCtx
39+ if ctx == nil {
40+ ctx = context.Background()
41+ }
42+ _, err := d.openRemoteView(ctx, target, size)
43+ if err == nil {
44+ return
45+ }
46+
47+ view.mu.Lock()
48+ current := !view.closed &&
49+ view.link == failed &&
50+ view.linkGeneration == failed.generation &&
51+ view.linkState == remoteViewLinkReconnecting &&
52+ view.reconnectGeneration == reconnectGeneration
53+ if !current {
54+ view.mu.Unlock()
55+ return
56+ }
57+ view.linkState = remoteViewLinkUnavailable
58+ view.reconnectGeneration++
59+ attachments := make([]*attachedClient, 0, len(view.attachments))
60+ for attachment := range view.attachments {
61+ attachments = append(attachments, attachment)
62+ }
63+ view.mu.Unlock()
64+ d.repaintRemoteViewAttachments(view, attachments)
65+}
66+
67+// reconnectSizeLocked derives an outer geometry matching the retained remote
68+// content surface. The caller holds view.mu. Existing content is authoritative
69+// until a validated replacement publication adopts a newer attachment claim.
70+func reconnectSizeLocked(view *remoteView) domain.Size {
71+ if view != nil && view.screen != nil {
72+ cols, rows := view.screen.Frame.Width, view.screen.Frame.Height
73+ if cols > 0 && rows > 0 {
74+ return domain.Size{Cols: cols, Rows: rows + tabChromeRows}
75+ }
76+ }
77+ return defaultSize
78+}
A internal/usecase/daemon/remote_render.go +287 -0
@@ -0,0 +1,287 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+ "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/bnema/vev/pkg/vt"
9+)
10+
11+const remoteContentPaneID = "remote-content"
12+
13+type remoteViewRenderSnapshot struct {
14+ frame renderer.Frame
15+ cursor vt.CursorSnapshot
16+ title string
17+ tabs []ports.SessionTabMeta
18+ activeID domain.TabStableID
19+ origin string
20+ linkState remoteViewLinkState
21+}
22+
23+// resizeScreen updates only the private remote VT surface. The attachment
24+// owns the outer window and callers must not route this through local session
25+// geometry or PTY resize machinery.
26+func (v *remoteView) resizeScreen(window domain.Size) bool {
27+ if v == nil || !window.Valid() {
28+ return false
29+ }
30+ content := contentSize(window)
31+ v.mu.Lock()
32+ defer v.mu.Unlock()
33+ if v.closed {
34+ return false
35+ }
36+ if v.screen == nil {
37+ v.screen = vt.NewScreen(content.Cols, content.Rows)
38+ } else if v.screen.Frame.Width != content.Cols || v.screen.Frame.Height != content.Rows {
39+ v.screen.Resize(content.Cols, content.Rows)
40+ }
41+ return true
42+}
43+
44+// renderSnapshot is the remote-content ownership boundary. It copies every
45+// mutable VT and metadata value while view.mu is held, then releases that lock
46+// before any attachment, overlay, renderer, or transport work begins.
47+func (v *remoteView) renderSnapshot(content domain.Size) (remoteViewRenderSnapshot, bool) {
48+ if v == nil || !content.Valid() {
49+ return remoteViewRenderSnapshot{}, false
50+ }
51+ v.mu.Lock()
52+ defer v.mu.Unlock()
53+ if v.closed {
54+ return remoteViewRenderSnapshot{}, false
55+ }
56+ if v.screen == nil {
57+ v.screen = vt.NewScreen(content.Cols, content.Rows)
58+ }
59+ // The shared remote screen follows the latest valid content-size claim via
60+ // resizeScreen. Rendering another local attachment must only capture it;
61+ // resizing here would let a non-authoritative window corrupt the remote
62+ // output chain without sending a matching remote resize.
63+ screen := v.screen.Snapshot()
64+ frame := renderer.NewFrame(screen.Columns(), screen.Rows())
65+ for y := range frame.Height {
66+ copy(frame.Row(y), screen.BorrowedRow(y))
67+ }
68+ return remoteViewRenderSnapshot{
69+ frame: frame,
70+ cursor: screen.Cursor(),
71+ title: screen.Title(),
72+ tabs: append([]ports.SessionTabMeta(nil), v.metadata.Tabs...),
73+ activeID: v.metadata.ActiveTabID,
74+ origin: v.displayOrigin,
75+ linkState: v.linkState,
76+ }, true
77+}
78+
79+// scheduleRemoteViewPaint coalesces asynchronous remote-view invalidations. A
80+// single worker per attachment preserves sendMu serialization without allowing
81+// ACK, metadata, and overlay callbacks to accumulate blocked goroutines.
82+func (d *Daemon) scheduleRemoteViewPaint(view *remoteView, ac *attachedClient, reset bool) {
83+ if d == nil || view == nil || ac == nil {
84+ return
85+ }
86+ ac.remotePaintMu.Lock()
87+ ac.remotePaintView = view
88+ ac.remotePaintPending = true
89+ ac.remotePaintReset = ac.remotePaintReset || reset
90+ start := !ac.remotePaintRunning
91+ if start {
92+ ac.remotePaintRunning = true
93+ }
94+ ac.remotePaintMu.Unlock()
95+ if !start {
96+ return
97+ }
98+ go func() {
99+ for {
100+ ac.remotePaintMu.Lock()
101+ if !ac.remotePaintPending {
102+ ac.remotePaintRunning = false
103+ ac.remotePaintMu.Unlock()
104+ return
105+ }
106+ view := ac.remotePaintView
107+ reset := ac.remotePaintReset
108+ ac.remotePaintPending = false
109+ ac.remotePaintReset = false
110+ ac.remotePaintMu.Unlock()
111+ token := attachmentOwnerToken(view, ac, ac.transport())
112+ if token.ac != nil {
113+ d.paintRemoteView(view, ac, reset, token)
114+ }
115+ }
116+ }()
117+}
118+
119+// invalidateAttachedOwner repaints an attachment through its actual owner.
120+// Local UI overlays remain attachment-local while remote content is active,
121+// so they must never enqueue work on the source session's coordinator after
122+// that attachment has moved to a remote view.
123+func (d *Daemon) invalidateAttachedOwner(ac *attachedClient, reset bool, producer string) {
124+ if d == nil || ac == nil {
125+ return
126+ }
127+ owner := ac.currentAttachmentOwner()
128+ if view, remote := owner.(*remoteView); remote {
129+ d.scheduleRemoteViewPaint(view, ac, reset)
130+ return
131+ }
132+ if sess := localSession(owner); sess != nil {
133+ d.invalidateRender(sess, ac, reset, producer)
134+ }
135+}
136+
137+func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statusSnapshot {
138+ name := ""
139+ if view != nil {
140+ name = view.key.sessionName
141+ if snap.origin == "" {
142+ snap.origin = view.key.endpoint
143+ }
144+ }
145+ if snap.origin != "" {
146+ name += "@" + snap.origin
147+ }
148+ switch snap.linkState {
149+ case remoteViewLinkReconnecting:
150+ name += " · reconnecting"
151+ case remoteViewLinkUnavailable:
152+ name += " · unavailable"
153+ }
154+ status := statusSnapshot{session: name, tabs: make([]statusTab, 0, max(1, len(snap.tabs)))}
155+ for _, tab := range snap.tabs {
156+ status.tabs = append(status.tabs, statusTab{
157+ name: tab.Name,
158+ active: tab.ID == snap.activeID,
159+ attention: tab.Attention,
160+ })
161+ }
162+ if len(status.tabs) == 0 {
163+ tabName := "1"
164+ if view != nil && view.key.sessionName != "" {
165+ tabName = view.key.sessionName
166+ }
167+ status.tabs = append(status.tabs, statusTab{name: tabName, paneTitle: snap.title, active: true})
168+ }
169+ return status
170+}
171+
172+// captureRemoteViewRenderState builds one local chrome composition from an
173+// immutable private remote VT snapshot. The one synthetic pane is deliberately
174+// content-only: local pane titles, layout, and PTY state cannot leak into a
175+// remote view.
176+func (d *Daemon) captureRemoteViewRenderState(view *remoteView, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
177+ if view == nil || ac == nil {
178+ return nil, false
179+ }
180+ window := ac.sizeSnapshot()
181+ content := contentSize(window)
182+ snapshot, ok := view.renderSnapshot(content)
183+ if !ok || !attachmentOwnerRegistered(view, ac) || !sameAttachmentOwner(ac.currentAttachmentOwner(), view) {
184+ return nil, false
185+ }
186+ placement := domain.Rect{Width: content.Cols, Height: content.Rows}
187+ bars := request.bars
188+ bars.status = remoteStatusSnapshot(view, snapshot)
189+ if d != nil {
190+ bars.attentionFrame = d.attentionFrame()
191+ bars.mru = d.recentSessionsForRemoteAttachment(view, ac)
192+ }
193+ state := &capturedRenderState{
194+ attachment: ac,
195+ lease: request.lease,
196+ window: window,
197+ reset: request.reset,
198+ bars: bars,
199+ theme: bars.theme,
200+ styles: request.styles,
201+ styleGeneration: request.styleGeneration,
202+ overlays: request.overlays,
203+ preview: request.preview,
204+ layout: capturedTabLayout{area: placement, focus: remoteContentPaneID, valid: true},
205+ floatingGeneration: 0,
206+ panes: []capturedPaneRenderState{{
207+ id: remoteContentPaneID,
208+ frame: snapshot.frame,
209+ placement: layout.Placement{ID: remoteContentPaneID, Content: placement},
210+ focused: true,
211+ damage: []renderer.Damage{renderer.FullRedraw()},
212+ }},
213+ cursor: capturedCursorInputs{
214+ row: snapshot.cursor.Row,
215+ col: snapshot.cursor.Col,
216+ style: snapshot.cursor.Style,
217+ hasStyle: snapshot.cursor.StyleSet,
218+ visible: snapshot.cursor.Visible,
219+ renderable: placement.Width > 0 && placement.Height > 0,
220+ content: placement,
221+ },
222+ }
223+ return state, true
224+}
225+
226+// paintRemoteView composes daemon-owned bars and overlays over the private
227+// remote terminal image. It uses the same output-state stream as local
228+// rendering, but never consults local session, pane, or coordinator state.
229+func (d *Daemon) paintRemoteView(view *remoteView, ac *attachedClient, reset bool, token attachmentConnectionToken) paintResult {
230+ if view == nil || ac == nil || !sameAttachmentOwner(token.owner, view) {
231+ return paintRejected
232+ }
233+ ticket, admitted := ac.beginAttachmentEffect(token)
234+ if !admitted {
235+ return paintRejected
236+ }
237+ token.effect = ticket
238+ marks := d.newRuntimeMarkBatch()
239+ marks.attachmentEffect = ticket
240+ defer func() {
241+ ticket.End()
242+ marks.flush()
243+ }()
244+
245+ ac.sendMu.Lock()
246+ if !token.attachmentEffectCurrent() || ac.output == nil {
247+ ac.sendMu.Unlock()
248+ return paintRejected
249+ }
250+ ac.output.syncCapacityLocked()
251+ if ac.output.atCapacity() {
252+ ac.sendMu.Unlock()
253+ return paintBlockedCapacity
254+ }
255+ ac.initOverlays()
256+ overlays := ac.overlays.SnapshotForRender()
257+ defer overlays.Unlock()
258+ statusFeedback := overlays.statusFeedback
259+ if statusFeedback == "" && overlays.resizeActive {
260+ statusFeedback = "resize unavailable for remote view"
261+ }
262+ applied := ac.getAppliedTheme()
263+ bars := barState{statusFeedback: statusFeedback, theme: applied.Raw}
264+ capturedOverlays := capturedOverlayRenderState{
265+ copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
266+ pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
267+ resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
268+ }
269+ state, ok := d.captureRemoteViewRenderState(view, ac, renderCaptureRequest{
270+ bars: bars,
271+ overlays: capturedOverlays,
272+ preview: snapshotPickerPreview(nil),
273+ styles: applied.Resolved.Styles,
274+ styleGeneration: applied.Generation,
275+ reset: reset,
276+ })
277+ if !ok {
278+ ac.sendMu.Unlock()
279+ return paintRejected
280+ }
281+ captureOverlayLayers(state, overlays, d.currentPaletteConfig())
282+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
283+ if d.emitAttachmentFrame(view, ac, state, composed, &marks) {
284+ return paintEmitted
285+ }
286+ return paintRejected
287+}
A internal/usecase/daemon/remote_resume.go +217 -0
@@ -0,0 +1,217 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
7+
8+// resumeParkedRemoteView claims and republishes a parked local-client binding
9+// for one exact remote view. It deliberately owns only the local-client
10+// connection lifecycle: remote-link transport and reconnect work remain
11+// separate Phase 4 responsibilities.
12+//
13+// handled is false when the resume credential does not identify a remote view,
14+// allowing the existing local-session resume route to handle it. Once a remote
15+// parked entry is observed, the function is authoritative for that credential
16+// and returns a fail-closed error on every validation failure.
17+func (d *Daemon) resumeParkedRemoteView(h ports.Hello, tr ports.Transport) (view *remoteView, ac *attachedClient, handled bool, err error) {
18+ if d == nil || h.ResumeToken == 0 || tr == nil || !h.Size.Valid() {
19+ return nil, nil, false, nil
20+ }
21+
22+ // A reconnect can race the old local transport's Recv failure between its
23+ // pre-detach parking publication and final parked entry. Once that marker
24+ // identifies a remote view, this path remains authoritative for the token
25+ // and waits for its exact publication rather than falling into local-session
26+ // resume logic.
27+ seenRemoteCredential := false
28+ var parked *parkedAttachment
29+ for {
30+ d.mu.Lock()
31+ parked = d.parked[h.ResumeToken]
32+ if parked != nil {
33+ view, handled = normalizeAttachmentOwner(parked.owner).(*remoteView)
34+ d.mu.Unlock()
35+ if !handled {
36+ return nil, nil, false, nil
37+ }
38+ break
39+ }
40+ pending := d.parking[h.ResumeToken]
41+ pendingView, pendingRemote := (*remoteView)(nil), false
42+ if pending != nil {
43+ pendingView, pendingRemote = normalizeAttachmentOwner(pending.owner).(*remoteView)
44+ }
45+ d.mu.Unlock()
46+ if !pendingRemote {
47+ if seenRemoteCredential {
48+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
49+ }
50+ return nil, nil, false, nil
51+ }
52+ if pendingView == nil || pending.ac == nil || pending.ac.clientID != h.ClientID {
53+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
54+ }
55+ seenRemoteCredential = true
56+ if !d.waitParkingInFlight(h) {
57+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
58+ }
59+ }
60+ ac = parked.ac
61+ if ac == nil {
62+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
63+ }
64+
65+ if d.beforeResumeParkedSendMu != nil {
66+ d.beforeResumeParkedSendMu()
67+ }
68+ ac.sendMu.Lock()
69+ d.mu.Lock()
70+ if d.closing {
71+ d.mu.Unlock()
72+ ac.sendMu.Unlock()
73+ return nil, nil, true, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
74+ }
75+ if d.parked[h.ResumeToken] != parked || parked.claimed || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
76+ d.mu.Unlock()
77+ ac.sendMu.Unlock()
78+ return nil, nil, true, errResumeTokenLifecycleRace
79+ }
80+ if ac.clientID != h.ClientID {
81+ d.mu.Unlock()
82+ ac.sendMu.Unlock()
83+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
84+ }
85+
86+ // Keep the previous credential claimed until Welcome commits. A failed
87+ // handshake restores this exact parked owner through abortResumeClaim.
88+ parked.claimed = true
89+ ac.resumeClaimToken = h.ResumeToken
90+ ac.rebaseOutput()
91+ if ac.output != nil {
92+ ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
93+ ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
94+ }
95+ ac.replaceTransport(tr)
96+ ac.setSize(h.Size)
97+ ac.resumeToken = d.nextResumeTokenLocked()
98+ ac.parked = false
99+ d.mu.Unlock()
100+ ac.sendMu.Unlock()
101+
102+ if _, transitionErr := d.resumeRemoteAttachment(view, ac, ac.transportSnapshot()); transitionErr != nil {
103+ d.abortResumeClaim(ac)
104+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
105+ }
106+ // The successful first local composition resizes the private VT from the
107+ // new attachment window. Deferring it until after Welcome preserves a
108+ // parked view's content when this handshake fails.
109+ d.log.Info("client resumed", "session", attachmentOwnerName(view))
110+ return view, ac, true, nil
111+}
112+
113+// resumeRemoteAttachment republishes a parked attachment under the exact
114+// remote owner. The gate is frozen across membership and owner publication so
115+// no stale local-client effect can observe the temporary unowned state.
116+func (d *Daemon) resumeRemoteAttachment(view *remoteView, ac *attachedClient, transport transportSnapshot) (attachmentConnectionToken, error) {
117+ if d == nil || view == nil || ac == nil || transport.transport == nil {
118+ return attachmentConnectionToken{}, errAttachmentTransition
119+ }
120+ frozen := freezeAttachmentEffectGates(ac)
121+ defer frozen.unfreeze()
122+ if !frozen.acquired || !frozen.drained {
123+ return attachmentConnectionToken{}, errAttachmentTransition
124+ }
125+
126+ d.mu.Lock()
127+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
128+ d.mu.Unlock()
129+ return attachmentConnectionToken{}, errAttachmentTransition
130+ }
131+ view.mu.Lock()
132+ _, registered := view.attachments[ac]
133+ current := !view.closed &&
134+ ac.currentAttachmentOwner() == nil &&
135+ ac.transportSnapshotCurrent(transport) &&
136+ !registered
137+ if !current || !view.registerAttachmentLocked(ac) {
138+ view.mu.Unlock()
139+ d.mu.Unlock()
140+ return attachmentConnectionToken{}, errAttachmentTransition
141+ }
142+ generation := ac.connectionGeneration.Add(1)
143+ ac.setAttachmentOwner(view)
144+ published := attachmentConnectionToken{
145+ owner: view,
146+ ac: ac,
147+ generation: generation,
148+ transport: transport,
149+ }
150+ ac.publishFrozenAttachmentCapability(published)
151+ view.mu.Unlock()
152+ d.mu.Unlock()
153+ d.activateRemoteView(view)
154+ return published, nil
155+}
156+
157+// resumeLiveRemoteView handles the narrow reconnect race where the old local
158+// transport still owns a remote attachment when the same resume credential
159+// arrives. It parks that exact binding first, then reuses the parked resume
160+// publication so the old and replacement transports cannot both own the view.
161+func (d *Daemon) resumeLiveRemoteView(h ports.Hello, tr ports.Transport) (view *remoteView, ac *attachedClient, handled bool, err error) {
162+ if d == nil || h.ResumeToken == 0 || tr == nil || !h.Size.Valid() {
163+ return nil, nil, false, nil
164+ }
165+
166+ var clientMismatch bool
167+ d.mu.Lock()
168+ for _, candidateView := range d.remoteViews {
169+ candidateView.mu.Lock()
170+ for candidate := range candidateView.attachments {
171+ if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
172+ continue
173+ }
174+ if candidate.clientID != h.ClientID {
175+ clientMismatch = true
176+ } else {
177+ view, ac = candidateView, candidate
178+ }
179+ break
180+ }
181+ candidateView.mu.Unlock()
182+ if view != nil || clientMismatch {
183+ break
184+ }
185+ }
186+ closing := d.closing
187+ d.mu.Unlock()
188+ if view == nil && !clientMismatch {
189+ return nil, nil, false, nil
190+ }
191+ if closing {
192+ return nil, nil, true, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
193+ }
194+ if clientMismatch {
195+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
196+ }
197+
198+ oldTransport := ac.transport()
199+ token := attachmentOwnerToken(view, ac, oldTransport)
200+ if token.ac != nil {
201+ _ = d.clientGoneRemote(view, token, false)
202+ }
203+ view, ac, handled, err = d.resumeParkedRemoteView(h, tr)
204+ if handled || err != nil {
205+ return view, ac, handled, err
206+ }
207+ return nil, nil, true, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
208+}
209+
210+func remoteViewMatchesTarget(view *remoteView, target domain.RemoteSessionTarget) bool {
211+ if view == nil || target.Validate() != nil {
212+ return false
213+ }
214+ return view.key.endpoint == target.Endpoint &&
215+ view.key.lifecycleID == target.LifecycleID &&
216+ view.key.sessionName == target.SessionName
217+}
A internal/usecase/daemon/remote_terminal.go +50 -0
@@ -0,0 +1,50 @@
1+package daemon
2+
3+// retireTerminalRemoteView invalidates one exact remote-link generation after
4+// the remote daemon reports that its session detached. It removes the local
5+// view before interrupting transports, so stale receive callbacks, warm timers,
6+// and resume credentials cannot resurrect it. This is called synchronously
7+// only from MsgDetached in runRemoteLink; it must not join link here because
8+// joining the current receiver would deadlock.
9+func (d *Daemon) retireTerminalRemoteView(link *remoteLink, reason uint8) {
10+ if d == nil || link == nil || link.view == nil {
11+ return
12+ }
13+ view := link.view
14+ d.mu.Lock()
15+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
16+ d.mu.Unlock()
17+ return
18+ }
19+ view.mu.Lock()
20+ if view.closed || view.link != link || view.linkGeneration != link.generation {
21+ view.mu.Unlock()
22+ d.mu.Unlock()
23+ return
24+ }
25+ view.closed = true
26+ attachments := make([]*attachedClient, 0, len(view.attachments))
27+ for attachment := range view.attachments {
28+ attachments = append(attachments, attachment)
29+ }
30+ clear(view.attachments)
31+ warm := view.warm
32+ view.warm = nil
33+ view.warmGeneration++
34+ retirements := d.purgeParkedForRemoteViewLocked(view)
35+ d.purgeParkingForRemoteViewLocked(view)
36+ view.link = nil
37+ view.linkGeneration++
38+ link.active = false
39+ signalRemoteViewMetadataChangedLocked(view)
40+ _ = d.unregisterRemoteViewLocked(view)
41+ view.mu.Unlock()
42+ d.mu.Unlock()
43+
44+ warm.stop()
45+ d.finishParkedAttachmentRetirements(retirements)
46+ for _, attachment := range attachments {
47+ d.retireShutdownRemoteAttachment(view, attachment, reason)
48+ }
49+ interruptRemoteLink(link)
50+}
A internal/usecase/daemon/remote_view_tabs.go +221 -0
@@ -0,0 +1,221 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+var errRemoteViewTabUnavailable = errors.New("remote view: selected tab is unavailable")
13+
14+func runningRemoteTarget(target domain.RemoteSessionTarget, active domain.TabStableID) domain.RemoteSessionTarget {
15+ target.Stopped = false
16+ target.StoppedTab = domain.TabSelector{}
17+ target.LiveTabID = active
18+ return target
19+}
20+
21+// activateRemoteViewTab moves an already-handshaken remote view to the exact
22+// stable tab selected in the local picker. The remote protocol presently
23+// exposes relative tab commands, so each command waits for the metadata
24+// revision it causes and recalculates from the authoritative remote order.
25+// The caller remains locally attached until this succeeds.
26+func (d *Daemon) activateRemoteViewTab(ctx context.Context, view *remoteView, target domain.RemoteSessionTarget) error {
27+ if d == nil || view == nil {
28+ return errRemoteViewUnavailable
29+ }
30+ if ctx == nil {
31+ ctx = context.Background()
32+ }
33+ if err := target.Validate(); err != nil {
34+ return fmt.Errorf("remote view: invalid tab target: %w", err)
35+ }
36+ view.tabActivationMu.Lock()
37+ defer view.tabActivationMu.Unlock()
38+
39+ for attempts := 0; ; attempts++ {
40+ link, generation, revision, active, selected, tabCount, err := remoteViewTabActivationSnapshot(view, target)
41+ if err != nil {
42+ return err
43+ }
44+ if active == selected {
45+ view.mu.Lock()
46+ if !view.closed && view.link == link && view.linkGeneration == generation && view.metadata.ActiveTabID == selected {
47+ view.reconnectTarget = runningRemoteTarget(target, selected)
48+ }
49+ view.mu.Unlock()
50+ return nil
51+ }
52+ // Each successful relative command must advance the metadata revision.
53+ // A remote peer that continually races us cannot make this selection
54+ // converge indefinitely.
55+ if attempts >= tabCount {
56+ return errRemoteViewTabUnavailable
57+ }
58+ slug, err := remoteViewRelativeTabCommand(view, link, generation, selected)
59+ if err != nil {
60+ return err
61+ }
62+ if slug == "" {
63+ continue
64+ }
65+ if err := d.sendRemoteViewCommand(ctx, link, generation, slug); err != nil {
66+ return err
67+ }
68+ if err := d.waitRemoteViewMetadataRevision(ctx, view, link, generation, revision); err != nil {
69+ return err
70+ }
71+ }
72+}
73+
74+func remoteViewTabActivationSnapshot(view *remoteView, target domain.RemoteSessionTarget) (link *remoteLink, generation, revision uint64, active, selected domain.TabStableID, tabCount int, err error) {
75+ view.mu.Lock()
76+ defer view.mu.Unlock()
77+ if !remoteViewLinkReusableLocked(view) {
78+ return nil, 0, 0, "", "", 0, errRemoteViewUnavailable
79+ }
80+ selected, err = remoteTargetTabID(target, view.metadata)
81+ if err != nil {
82+ return nil, 0, 0, "", "", 0, err
83+ }
84+ if _, ok := remoteSessionTabIndex(view.metadata.Tabs, selected); !ok {
85+ return nil, 0, 0, "", "", 0, errRemoteViewTabUnavailable
86+ }
87+ return view.link, view.linkGeneration, view.metadata.Revision, view.metadata.ActiveTabID, selected, len(view.metadata.Tabs), nil
88+}
89+
90+func remoteTargetTabID(target domain.RemoteSessionTarget, metadata ports.SessionMeta) (domain.TabStableID, error) {
91+ if !target.Stopped {
92+ return target.LiveTabID, nil
93+ }
94+ tabs := make([]domain.TabSelectorTab, len(metadata.Tabs))
95+ for i, tab := range metadata.Tabs {
96+ tabs[i] = domain.TabSelectorTab{ID: tab.ID, Name: tab.Name}
97+ }
98+ index, ok := target.StoppedTab.Resolve(tabs)
99+ if !ok {
100+ return "", errRemoteViewTabUnavailable
101+ }
102+ return tabs[index].ID, nil
103+}
104+
105+func remoteSessionTabIndex(tabs []ports.SessionTabMeta, id domain.TabStableID) (int, bool) {
106+ found := -1
107+ for index, tab := range tabs {
108+ if tab.ID != id {
109+ continue
110+ }
111+ if found != -1 {
112+ return 0, false
113+ }
114+ found = index
115+ }
116+ return found, found >= 0
117+}
118+
119+func remoteViewRelativeTabCommand(view *remoteView, link *remoteLink, generation uint64, selected domain.TabStableID) (string, error) {
120+ view.mu.Lock()
121+ defer view.mu.Unlock()
122+ if view.closed || view.link != link || view.linkGeneration != generation || !link.active {
123+ return "", errRemoteViewStale
124+ }
125+ activeIndex, activeOK := remoteSessionTabIndex(view.metadata.Tabs, view.metadata.ActiveTabID)
126+ selectedIndex, selectedOK := remoteSessionTabIndex(view.metadata.Tabs, selected)
127+ if !activeOK || !selectedOK || len(view.metadata.Tabs) < 2 {
128+ return "", errRemoteViewTabUnavailable
129+ }
130+ forward := (selectedIndex - activeIndex + len(view.metadata.Tabs)) % len(view.metadata.Tabs)
131+ backward := (activeIndex - selectedIndex + len(view.metadata.Tabs)) % len(view.metadata.Tabs)
132+ if forward == 0 {
133+ return "", nil
134+ }
135+ if forward <= backward {
136+ return "next-tab", nil
137+ }
138+ return "previous-tab", nil
139+}
140+
141+// sendRemoteViewCommand is intentionally narrower than the remote daemon's
142+// attached-command executor. It is not palette forwarding: stable remote
143+// metadata only supports the two no-argument relative tab operations.
144+func (d *Daemon) sendRemoteViewCommand(ctx context.Context, link *remoteLink, generation uint64, slug string) error {
145+ if d == nil || link == nil || link.commands == nil {
146+ return errRemoteViewUnavailable
147+ }
148+ if slug != "next-tab" && slug != "previous-tab" {
149+ return fmt.Errorf("remote view: command %q is not allowed", slug)
150+ }
151+ requestID, outcome := link.commands.Publish(generation)
152+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
153+ Version: ports.ProtocolVersion, RequestID: requestID, Attached: true, Slug: slug,
154+ })
155+ if err != nil {
156+ link.commands.Fail(requestID, generation, err)
157+ return fmt.Errorf("remote view: encode command: %w", err)
158+ }
159+ if err := link.send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
160+ link.commands.Fail(requestID, generation, err)
161+ d.markRemoteLinkUnavailable(link)
162+ return fmt.Errorf("remote view: send command: %w", err)
163+ }
164+ clock := d.clock
165+ if clock == nil {
166+ clock = systemClock{}
167+ }
168+ result, err := link.commands.Wait(ctx, clock, requestID, generation, outcome)
169+ if err != nil {
170+ return fmt.Errorf("remote view: wait for command: %w", err)
171+ }
172+ if !result.OK {
173+ return fmt.Errorf("remote view: command %q failed (%d): %s", slug, result.Code, result.Text)
174+ }
175+ return nil
176+}
177+
178+func (d *Daemon) waitRemoteViewMetadataRevision(ctx context.Context, view *remoteView, link *remoteLink, generation, revision uint64) error {
179+ if d == nil || view == nil || link == nil {
180+ return errRemoteViewUnavailable
181+ }
182+ if ctx == nil {
183+ ctx = context.Background()
184+ }
185+ clock := d.clock
186+ if clock == nil {
187+ clock = systemClock{}
188+ }
189+ timer := clock.NewTimer(CommandRequestTimeout)
190+ if timer == nil {
191+ return ErrCommandRequestTimeout
192+ }
193+ defer timer.Stop()
194+
195+ for {
196+ view.mu.Lock()
197+ current := !view.closed && view.link == link && view.linkGeneration == generation && link.active
198+ if !current {
199+ view.mu.Unlock()
200+ return errRemoteViewStale
201+ }
202+ if view.metadata.Revision > revision {
203+ view.mu.Unlock()
204+ return nil
205+ }
206+ changed := view.metadataChanged
207+ if changed == nil {
208+ changed = make(chan struct{})
209+ view.metadataChanged = changed
210+ }
211+ view.mu.Unlock()
212+
213+ select {
214+ case <-ctx.Done():
215+ return ctx.Err()
216+ case <-timer.C():
217+ return ErrCommandRequestTimeout
218+ case <-changed:
219+ }
220+ }
221+}
A internal/usecase/daemon/remote_view_tabs_test.go +96 -0
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestSendRemoteViewCommandAllowsOnlyRelativeTabCommands(t *testing.T) {
12+ for _, slug := range []string{"next-tab", "previous-tab"} {
13+ t.Run(slug, func(t *testing.T) {
14+ d, _, link, transport := newRemoteMetadataLinkFixture(t)
15+ link.commands = NewCommandRequestTracker()
16+
17+ done := make(chan error, 1)
18+ go func() {
19+ done <- d.sendRemoteViewCommand(context.Background(), link, link.generation, slug)
20+ }()
21+
22+ frame := awaitFrame(t, transport.sent, ports.MsgCommand)
23+ request, err := ports.UnmarshalCommandRequest(frame.Payload)
24+ require.NoError(t, err)
25+ require.Equal(t, ports.ProtocolVersion, request.Version)
26+ require.NotZero(t, request.RequestID)
27+ require.True(t, request.Attached)
28+ require.False(t, request.Self)
29+ require.Equal(t, slug, request.Slug)
30+ require.Empty(t, request.Args)
31+ require.Empty(t, request.TargetSession)
32+ require.Empty(t, request.TargetTab)
33+ require.Empty(t, request.TargetPane)
34+
35+ resultPayload := ports.MarshalCommandResult(ports.CommandResult{RequestID: request.RequestID, OK: true})
36+ require.NoError(t, d.handleRemoteLinkFrame(link, ports.Frame{Type: ports.MsgCommandResult, Payload: resultPayload}))
37+ require.NoError(t, awaitTestValue(t, done, "relative remote tab command did not complete"))
38+ require.Zero(t, link.commands.PendingCount())
39+ })
40+ }
41+}
42+
43+func TestSendRemoteViewCommandReportsRemoteFailureAndCancellation(t *testing.T) {
44+ t.Run("remote failure", func(t *testing.T) {
45+ d, _, link, transport := newRemoteMetadataLinkFixture(t)
46+ link.commands = NewCommandRequestTracker()
47+ done := make(chan error, 1)
48+ go func() {
49+ done <- d.sendRemoteViewCommand(context.Background(), link, link.generation, "next-tab")
50+ }()
51+ frame := awaitFrame(t, transport.sent, ports.MsgCommand)
52+ request, err := ports.UnmarshalCommandRequest(frame.Payload)
53+ require.NoError(t, err)
54+ resultPayload := ports.MarshalCommandResult(ports.CommandResult{RequestID: request.RequestID, Code: 12, Text: "rejected"})
55+ require.NoError(t, d.handleRemoteLinkFrame(link, ports.Frame{Type: ports.MsgCommandResult, Payload: resultPayload}))
56+ require.ErrorContains(t, awaitTestValue(t, done, "failed remote tab command did not complete"), "command \"next-tab\" failed (12): rejected")
57+ require.Zero(t, link.commands.PendingCount())
58+ })
59+
60+ t.Run("canceled context", func(t *testing.T) {
61+ d, _, link, transport := newRemoteMetadataLinkFixture(t)
62+ link.commands = NewCommandRequestTracker()
63+ ctx, cancel := context.WithCancel(context.Background())
64+ done := make(chan error, 1)
65+ go func() {
66+ done <- d.sendRemoteViewCommand(ctx, link, link.generation, "previous-tab")
67+ }()
68+ awaitFrame(t, transport.sent, ports.MsgCommand)
69+ cancel()
70+ require.ErrorIs(t, awaitTestValue(t, done, "canceled remote tab command did not complete"), context.Canceled)
71+ require.Zero(t, link.commands.PendingCount())
72+ })
73+}
74+
75+func TestSendRemoteViewCommandRejectsOperationsOutsideRelativeTabAllowlist(t *testing.T) {
76+ for _, slug := range []string{
77+ "new-session",
78+ "focus-pane-left",
79+ "resize-pane",
80+ "move-tab",
81+ } {
82+ t.Run(slug, func(t *testing.T) {
83+ d, _, link, transport := newRemoteMetadataLinkFixture(t)
84+ link.commands = NewCommandRequestTracker()
85+
86+ err := d.sendRemoteViewCommand(context.Background(), link, link.generation, slug)
87+ require.ErrorContains(t, err, "is not allowed")
88+ require.Zero(t, link.commands.PendingCount())
89+ select {
90+ case frame := <-transport.sent:
91+ t.Fatalf("unallowlisted remote operation sent frame type %d", frame.Type)
92+ default:
93+ }
94+ })
95+ }
96+}
A internal/usecase/daemon/remote_warm.go +168 -0
@@ -0,0 +1,168 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+const remoteViewWarmTTL = 5 * time.Minute
11+
12+// remoteViewWarm is a single generation-fenced retention timer. It owns no
13+// view state: callers snapshot it while holding view.mu, then stop it after
14+// every architecture lock is released.
15+type remoteViewWarm struct {
16+ generation uint64
17+ timer ports.Timer
18+ done chan struct{}
19+ doneOnce sync.Once
20+}
21+
22+func (warm *remoteViewWarm) stop() {
23+ if warm == nil {
24+ return
25+ }
26+ if warm.timer != nil {
27+ warm.timer.Stop()
28+ }
29+ warm.doneOnce.Do(func() { close(warm.done) })
30+}
31+
32+// parkRemoteViewWarm retains an exact registered remote view for a bounded
33+// time after its final local attachment leaves. It never makes a remote view a
34+// daemon-liveness root: daemon shutdown revokes this timer with the registry.
35+func (d *Daemon) parkRemoteViewWarm(view *remoteView) {
36+ if d == nil || view == nil {
37+ return
38+ }
39+ clock := d.clock
40+ if clock == nil {
41+ clock = systemClock{}
42+ }
43+ timer := clock.NewTimer(remoteViewWarmTTL)
44+ if timer == nil {
45+ return
46+ }
47+ warm := &remoteViewWarm{timer: timer, done: make(chan struct{})}
48+
49+ d.mu.Lock()
50+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
51+ d.mu.Unlock()
52+ warm.stop()
53+ return
54+ }
55+ view.mu.Lock()
56+ if view.closed || len(view.attachments) != 0 {
57+ view.mu.Unlock()
58+ d.mu.Unlock()
59+ warm.stop()
60+ return
61+ }
62+ view.warmGeneration++
63+ warm.generation = view.warmGeneration
64+ old := view.warm
65+ view.warm = warm
66+ view.mu.Unlock()
67+ d.mu.Unlock()
68+
69+ old.stop()
70+ d.watchRemoteViewWarm(view, warm)
71+}
72+
73+// activateRemoteView revokes a previously scheduled expiry when an attachment
74+// becomes visible again. The exact-pointer/generation checks in the timer
75+// callback make a racing stale expiry harmless.
76+func (d *Daemon) activateRemoteView(view *remoteView) {
77+ if d == nil || view == nil {
78+ return
79+ }
80+ d.mu.Lock()
81+ if !d.attachmentOwnerRegisteredByDaemonLocked(view) {
82+ d.mu.Unlock()
83+ return
84+ }
85+ view.mu.Lock()
86+ warm := view.warm
87+ if warm != nil {
88+ view.warm = nil
89+ view.warmGeneration++
90+ }
91+ view.mu.Unlock()
92+ d.mu.Unlock()
93+ warm.stop()
94+}
95+
96+func (d *Daemon) watchRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
97+ if d == nil || view == nil || warm == nil || warm.timer == nil {
98+ return
99+ }
100+ go func() {
101+ select {
102+ case <-warm.timer.C():
103+ d.expireRemoteViewWarm(view, warm)
104+ case <-warm.done:
105+ }
106+ }()
107+}
108+
109+// expireRemoteViewWarm removes only the exact, still-detached registered view.
110+// It marks the candidate closed under registry/view locks, then interrupts and
111+// joins its exact remote link without holding either lock.
112+func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
113+ if d == nil || view == nil || warm == nil {
114+ return
115+ }
116+
117+ d.mu.Lock()
118+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(view) {
119+ d.mu.Unlock()
120+ return
121+ }
122+ view.mu.Lock()
123+ current := !view.closed && view.warm == warm && view.warmGeneration == warm.generation && len(view.attachments) == 0
124+ if !current {
125+ view.mu.Unlock()
126+ d.mu.Unlock()
127+ return
128+ }
129+ view.warm = nil
130+ view.warmGeneration++
131+ view.closed = true
132+ retirements := d.purgeParkedForRemoteViewLocked(view)
133+ d.purgeParkingForRemoteViewLocked(view)
134+ link := view.link
135+ view.link = nil
136+ view.linkGeneration++
137+ signalRemoteViewMetadataChangedLocked(view)
138+ if link != nil {
139+ link.active = false
140+ }
141+ _ = d.unregisterRemoteViewLocked(view)
142+ view.mu.Unlock()
143+ d.mu.Unlock()
144+
145+ warm.stop()
146+ d.finishParkedAttachmentRetirements(retirements)
147+ if link == nil {
148+ return
149+ }
150+ interruptRemoteLink(link)
151+ joinRemoteLink(link)
152+}
153+
154+// stopRemoteViewWarm clears and returns only the current timer. Its caller is
155+// responsible for stopping it after all architecture locks are released.
156+func (d *Daemon) stopRemoteViewWarm(view *remoteView) *remoteViewWarm {
157+ if view == nil {
158+ return nil
159+ }
160+ view.mu.Lock()
161+ warm := view.warm
162+ if warm != nil {
163+ view.warm = nil
164+ view.warmGeneration++
165+ }
166+ view.mu.Unlock()
167+ return warm
168+}
A internal/usecase/daemon/remote_warm_test.go +115 -0
@@ -0,0 +1,115 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestRemoteViewWarmExpiryRemovesOnlyDetachedExactView(t *testing.T) {
13+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
14+ d := newTestDaemon(t, nil, clock)
15+ view := registerRemoteWarmTestView(t, d)
16+ view.mu.Lock()
17+ view.link = &remoteLink{view: view, generation: view.linkGeneration, cancel: func() {}, done: make(chan struct{}), active: true}
18+ view.mu.Unlock()
19+
20+ d.parkRemoteViewWarm(view)
21+ warm, timer := remoteWarmTimer(t, clock, view)
22+ require.Equal(t, remoteViewWarmTTL, timer.duration)
23+ timer.ch <- time.Now()
24+ awaitTestCompletion(t, warm.done, "remote warm expiry did not complete")
25+
26+ d.mu.Lock()
27+ require.Nil(t, d.remoteViewByKeyLocked(view.key))
28+ d.mu.Unlock()
29+ view.mu.Lock()
30+ require.True(t, view.closed)
31+ require.Nil(t, view.link)
32+ view.mu.Unlock()
33+}
34+
35+func TestRemoteViewWarmExpiryPurgesRemoteResumeCredentials(t *testing.T) {
36+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
37+ d := newTestDaemon(t, nil, clock)
38+ view := registerRemoteWarmTestView(t, d)
39+ parked := &attachedClient{resumeToken: 41, parked: true}
40+ parking := &attachedClient{resumeToken: 42}
41+ d.mu.Lock()
42+ d.parked[parked.resumeToken] = &parkedAttachment{owner: view, ac: parked, timer: stubTimer{}, done: make(chan struct{})}
43+ d.parking[parking.resumeToken] = &parkingAttachment{owner: view, ac: parking, done: make(chan struct{})}
44+ d.mu.Unlock()
45+
46+ d.parkRemoteViewWarm(view)
47+ warm, timer := remoteWarmTimer(t, clock, view)
48+ timer.ch <- time.Now()
49+ awaitTestCompletion(t, warm.done, "remote warm expiry did not complete")
50+
51+ d.mu.Lock()
52+ require.Empty(t, d.parked)
53+ require.Empty(t, d.parking)
54+ d.mu.Unlock()
55+ require.Zero(t, parked.resumeToken)
56+ require.False(t, parked.parked)
57+ require.Zero(t, parking.resumeToken)
58+}
59+
60+func TestRemoteViewWarmActivationFencesStaleExpiry(t *testing.T) {
61+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
62+ d := newTestDaemon(t, nil, clock)
63+ view := registerRemoteWarmTestView(t, d)
64+
65+ d.parkRemoteViewWarm(view)
66+ warm, timer := remoteWarmTimer(t, clock, view)
67+ attachment := &attachedClient{}
68+ view.mu.Lock()
69+ require.True(t, view.registerAttachmentLocked(attachment))
70+ view.mu.Unlock()
71+ d.activateRemoteView(view)
72+ awaitTestCompletion(t, warm.done, "activating remote view did not cancel warm expiry")
73+ // A timer event already queued by the clock belongs to the canceled
74+ // generation and cannot remove the newly active remote view.
75+ timer.ch <- time.Now()
76+
77+ d.mu.Lock()
78+ require.Same(t, view, d.remoteViewByKeyLocked(view.key))
79+ d.mu.Unlock()
80+ view.mu.Lock()
81+ require.False(t, view.closed)
82+ view.mu.Unlock()
83+}
84+
85+func TestShutdownAllCancelsRemoteViewWarmTimer(t *testing.T) {
86+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
87+ d := newTestDaemon(t, nil, clock)
88+ view := registerRemoteWarmTestView(t, d)
89+
90+ d.parkRemoteViewWarm(view)
91+ warm, _ := remoteWarmTimer(t, clock, view)
92+ d.shutdownAll(ports.ReasonServerShutdown)
93+ awaitTestCompletion(t, warm.done, "shutdown did not cancel remote warm timer")
94+}
95+
96+func registerRemoteWarmTestView(t *testing.T, d *Daemon) *remoteView {
97+ t.Helper()
98+ key, err := remoteViewKeyForTarget(remoteLinkTestTarget())
99+ require.NoError(t, err)
100+ view := &remoteView{key: key}
101+ d.mu.Lock()
102+ require.NoError(t, d.registerRemoteViewLocked(view))
103+ d.mu.Unlock()
104+ return view
105+}
106+
107+func remoteWarmTimer(t *testing.T, clock *signalClock, view *remoteView) (*remoteViewWarm, *signalTimer) {
108+ t.Helper()
109+ timer := awaitTestValue(t, clock.timers, "remote warm timer was not created")
110+ view.mu.Lock()
111+ warm := view.warm
112+ view.mu.Unlock()
113+ require.NotNil(t, warm)
114+ return warm, timer
115+}
M internal/usecase/daemon/render.go +6 -2
@@ -299,6 +299,9 @@ const (
299299 )
300300
301301 func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
302+ if ac != nil && ac.renderMode == ports.RenderModeProxiedContent {
303+ return d.paintProxiedContent(entry, ac, reset, lease)
304+ }
302305 // Session-owned PTY preparation remains local; attachment rendering is
303306 // captured through session.captureRenderState.
304307 sess := entry
@@ -424,7 +427,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
424427 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
425428 }
426429 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
427- state, ok := entry.captureRenderState(ac, renderCaptureRequest{
430+ captureRequest := renderCaptureRequest{
428431 bars: bars,
429432 overlays: capturedOverlays,
430433 preview: preview,
@@ -433,7 +436,8 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
433436 styleGeneration: applied.Generation,
434437 reset: reset,
435438 lease: lease,
436- })
439+ }
440+ state, ok := entry.captureRenderState(ac, captureRequest)
437441 endCapture(0, ok)
438442 if !ok {
439443 ac.sendMu.Unlock()
M internal/usecase/daemon/render_pipeline.go +34 -10
@@ -426,9 +426,22 @@ func commitDamageReceipts(receipts []damageReceipt) {
426426 }
427427 }
428428
429-// emitFrame is the sole side-effecting half of the pipeline. The caller holds
430-// sendMu for the complete capture/compose/emit transaction.
429+// emitFrame is the local-session compatibility entry point for the sole
430+// side-effecting half of the pipeline. The caller holds sendMu for the
431+// complete capture/compose/emit transaction.
431432 func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
433+ return d.emitAttachmentFrame(entry, ac, state, composed, batches...)
434+}
435+
436+// emitAttachmentFrame commits a composed attachment frame only while its
437+// exact owner binding remains current. It deliberately knows nothing about
438+// session panes or remote VT state; those belong to capture. A coordinator
439+// lease remains a local-session capability and is therefore rejected for
440+// remote owners.
441+func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
442+ owner = normalizeAttachmentOwner(owner)
443+ entry := localSession(owner)
444+ view, remote := owner.(*remoteView)
432445 var ownedMarks runtimeMarkBatch
433446 var marks *runtimeMarkBatch
434447 if len(batches) != 0 {
@@ -440,22 +453,19 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
440453 // follows emitFrame's release of attachment ownership.
441454 defer marks.flush()
442455 }
443- if entry == nil || entry.core() == nil || ac == nil || state == nil {
456+ if owner == nil || ac == nil || state == nil {
444457 if ac != nil {
445458 ac.sendMu.Unlock()
446459 }
447460 return false
448461 }
449- entry.core().mu.Lock()
450- _, owned := entry.core().attachments[ac]
451- entry.core().mu.Unlock()
452- if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
462+ if !attachmentOwnerRegistered(owner, ac) || state.attachment != ac || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) {
453463 ac.sendMu.Unlock()
454464 return false
455465 }
456466 if state.lease != nil {
457467 rc := attachmentRenderCoordinator(entry)
458- if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
468+ if entry == nil || rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
459469 ac.sendMu.Unlock()
460470 return false
461471 }
@@ -476,7 +486,12 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
476486 endDiff(0, err == nil)
477487 if err != nil {
478488 ac.sendMu.Unlock()
479- d.log.Error("render draw failed", "err", err, "session", entry.core().name)
489+ d.log.Error("render draw failed", "err", err, "session", attachmentOwnerName(owner))
490+ if entry == nil {
491+ // A remote view has no session-scoped notice history or coordinator.
492+ // Its next remote publication will retry from the retained private VT.
493+ return true
494+ }
480495 // Without a coordinator reportError repaints synchronously. Suppress only
481496 // that nested notice repaint; leave the guard before returning so a later,
482497 // independent failed transaction can still notify the user.
@@ -551,6 +566,11 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
551566 // represents the captured frame. Lock panes only under sendMu and with no
552567 // session guard held.
553568 commitDamageReceipts(state.receipts)
569+ if ac.renderMode == ports.RenderModeProxiedContent {
570+ // A proxied output boundary is established only after preparation and
571+ // transport emission succeed, while sendMu still serializes the transition.
572+ ac.proxiedOutputStarted = true
573+ }
554574 if ac.renderStages.emit != nil {
555575 ac.renderStages.emit()
556576 }
@@ -561,7 +581,11 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
561581 // admission first. Detachment freezes the gate and therefore cannot mutate
562582 // ownership until any enclosing admitted operation has also ended.
563583 if marks.attachmentEffect == nil {
564- d.detachOnSendError(entry, ac, sendTr)
584+ if entry != nil {
585+ d.detachOnSendError(entry, ac, sendTr)
586+ } else if remote {
587+ d.clientGoneRemote(view, attachmentOwnerToken(view, ac, sendTr), false)
588+ }
565589 } else {
566590 // Capture the exact admitted capability, including its coordinator
567591 // lease, before End permits a new attachment publication. Reserve
M internal/usecase/daemon/resize_publication.go +2 -2
@@ -28,11 +28,11 @@ func (p resizeCommitPublication) current() bool {
2828 if p.connection.effect == nil {
2929 return false
3030 }
31- if p.connection.effect.ended.Load() || p.connection.sess != p.session ||
31+ if p.connection.effect.ended.Load() || !sameAttachmentOwner(p.connection.owner, p.session) ||
3232 p.connection.ac != p.attachment || p.connection.lease != p.lease ||
3333 p.connection.generation != p.attachment.connectionGeneration.Load() ||
3434 !p.attachment.transportSnapshotCurrent(p.connection.transport) ||
35- p.attachment.currentSession() != p.session || p.coordinator == nil {
35+ !sameAttachmentOwner(p.attachment.currentAttachmentOwner(), p.session) || p.coordinator == nil {
3636 return false
3737 }
3838 registered := attachmentRegistered(p.session, p.attachment)
M internal/usecase/daemon/resume.go +83 -45
@@ -41,47 +41,48 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
4141 // it returns 0 and never creates a marker after terminal
4242 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.
4343 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
44- if sess == nil || ac == nil || !ac.resumeCapable {
44+ return d.markParkingInFlightOwner(sess, ac)
45+}
46+
47+// markParkingInFlightOwner advertises a stable attachment binding before its
48+// live membership is removed. The resume credential identifies this owner
49+// binding, never a local session name.
50+func (d *Daemon) markParkingInFlightOwner(owner attachmentOwner, ac *attachedClient) uint64 {
51+ if normalizeAttachmentOwner(owner) == nil || ac == nil || !ac.resumeCapable {
4552 return 0
4653 }
4754 d.mu.Lock()
4855 defer d.mu.Unlock()
49- if d.closing || d.sessions[sess.id] != sess {
50- return 0
51- }
52- sess.mu.Lock()
53- registered := attachmentRegisteredLocked(sess, ac)
54- sess.mu.Unlock()
55- if !registered {
56+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) || !attachmentOwnerRegistered(owner, ac) {
5657 return 0
5758 }
5859 if ac.resumeToken == 0 {
5960 ac.resumeToken = d.nextResumeTokenLocked()
6061 }
61- d.ensureParkingInFlightLocked(sess, ac)
62- d.log.Info("parking in flight", "session", sess.name)
62+ d.ensureParkingInFlightLocked(owner, ac)
63+ d.log.Info("parking in flight", "session", attachmentOwnerName(owner))
6364 return ac.resumeToken
6465 }
6566
6667 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
6768 // Caller holds d.mu and has verified the daemon is not closing.
68-func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) {
69+func (d *Daemon) ensureParkingInFlightLocked(owner attachmentOwner, ac *attachedClient) {
6970 token := ac.resumeToken
7071 if token == 0 {
7172 return
7273 }
7374 if existing := d.parking[token]; existing != nil {
7475 if existing.ac == ac {
75- existing.sess = sess
76+ existing.owner = owner
7677 return
7778 }
7879 delete(d.parking, token)
7980 existing.closeDone()
8081 }
8182 d.parking[token] = &parkingAttachment{
82- sess: sess,
83- ac: ac,
84- done: make(chan struct{}),
83+ owner: owner,
84+ ac: ac,
85+ done: make(chan struct{}),
8586 }
8687 }
8788
@@ -99,21 +100,16 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
99100 // If another path already detached/unrouted, that path
100101 // owns park publication.
101102 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {
103+ d.clearParkingInFlightIfAbandonedOwner(sess, ac, token)
104+}
105+
106+func (d *Daemon) clearParkingInFlightIfAbandonedOwner(owner attachmentOwner, ac *attachedClient, token uint64) {
102107 if token == 0 || ac == nil {
103108 return
104109 }
105110 d.mu.Lock()
106111 defer d.mu.Unlock()
107- if d.parked[token] != nil {
108- return
109- }
110- stillOwner := false
111- if sess != nil {
112- sess.mu.Lock()
113- stillOwner = attachmentRegisteredLocked(sess, ac)
114- sess.mu.Unlock()
115- }
116- if !stillOwner {
112+ if d.parked[token] != nil || !d.attachmentOwnerRegisteredByDaemonLocked(owner) || !attachmentOwnerRegistered(owner, ac) {
117113 return
118114 }
119115 d.clearParkingInFlightLocked(token, ac)
@@ -143,8 +139,22 @@ func (d *Daemon) purgeAllParkingLocked() {
143139 // the session is removed before park publication. Caller holds d.mu.
144140 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
145141 for token, pending := range d.parking {
146- if pending.sess == sess {
142+ if localSession(pending.owner) == sess {
143+ delete(d.parking, token)
144+ pending.closeDone()
145+ }
146+ }
147+}
148+
149+// purgeParkingForRemoteViewLocked invalidates in-flight resume publication for
150+// one terminal remote view. At this point the view has no attachment members,
151+// so clearing the credential cannot race a live owner.
152+func (d *Daemon) purgeParkingForRemoteViewLocked(view *remoteView) {
153+ for token, pending := range d.parking {
154+ if owner, remote := normalizeAttachmentOwner(pending.owner).(*remoteView); remote && owner == view {
147155 delete(d.parking, token)
156+ pending.ac.resumeToken = 0
157+ pending.ac.parked = false
148158 pending.closeDone()
149159 }
150160 }
@@ -167,7 +177,7 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
167177 d.mu.Unlock()
168178 return false
169179 }
170- if h.Name != "" && pending.sess != nil && pending.sess.name != h.Name {
180+ if sess := localSession(pending.owner); h.Name != "" && sess != nil && sess.name != h.Name {
171181 d.mu.Unlock()
172182 return false
173183 }
@@ -182,27 +192,34 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
182192 }
183193
184194 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
185- if !ac.resumeCapable {
195+ return d.prepareParkAttachmentOwner(sess, ac)
196+}
197+
198+func (d *Daemon) prepareParkAttachmentOwner(owner attachmentOwner, ac *attachedClient) bool {
199+ if normalizeAttachmentOwner(owner) == nil || ac == nil || !ac.resumeCapable {
186200 return false
187201 }
188202 d.mu.Lock()
189203 defer d.mu.Unlock()
190- if d.closing {
204+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {
191205 return false
192206 }
193207 if ac.resumeToken == 0 {
194208 ac.resumeToken = d.nextResumeTokenLocked()
195209 }
196210 // Do not recreate parking markers here: post-detach publication races
197- // terminal session removal and can strand IntentResume waiters. Callers
198- // that need the detach→park gap advertised must markParkingInFlight while
199- // still the exact live owner. Direct parkAttachment still parks below.
200- d.log.Info("parking client prepared", "session", sess.name)
211+ // terminal owner removal and can strand IntentResume waiters. Callers that
212+ // need the detach→park gap advertised must publish it while still exact.
213+ d.log.Info("parking client prepared", "session", attachmentOwnerName(owner))
201214 return true
202215 }
203216
204217 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
205- if !d.prepareParkAttachment(sess, ac) {
218+ return d.parkAttachmentOwner(sess, ac)
219+}
220+
221+func (d *Daemon) parkAttachmentOwner(owner attachmentOwner, ac *attachedClient) bool {
222+ if !d.prepareParkAttachmentOwner(owner, ac) {
206223 return false
207224 }
208225 // A parked attachment has no session owner. Release pane snapshots before
@@ -217,7 +234,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
217234 ac.overlays.pickerMu.Unlock()
218235 }
219236 d.mu.Lock()
220- if d.closing || d.sessions[sess.id] != sess {
237+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {
221238 d.clearParkingInFlightLocked(ac.resumeToken, ac)
222239 d.mu.Unlock()
223240 return false
@@ -237,7 +254,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
237254 }
238255 grace := d.resumeParkGrace
239256 timer := d.clock.NewTimer(grace)
240- parked := &parkedAttachment{sess: sess, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
257+ parked := &parkedAttachment{owner: owner, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
241258 ac.parked = true
242259 d.parked[token] = parked
243260 d.clearParkingInFlightLocked(token, ac)
@@ -251,7 +268,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
251268 }
252269 oldSame.closeDone()
253270 }
254- d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
271+ d.log.Info("client parked for resume", "session", attachmentOwnerName(owner), "grace", grace)
255272 d.watchParkedTimer(token, parked)
256273 return true
257274 }
@@ -280,7 +297,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
280297 d.removeParkedLocked(token, parked)
281298 d.mu.Unlock()
282299 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
283- d.log.Warn("parked client expired", "session", parked.sess.name)
300+ d.log.Warn("parked client expired", "session", attachmentOwnerName(parked.owner))
284301 return
285302 }
286303 d.mu.Unlock()
@@ -349,11 +366,16 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
349366 ac.resumeToken = token
350367 ac.parked = true
351368 ac.connectionGeneration.Add(1)
352- sess := parked.sess
369+ sess := localSession(parked.owner)
370+ view, remote := normalizeAttachmentOwner(parked.owner).(*remoteView)
353371 if sess != nil {
354372 sess.mu.Lock()
355373 sess.unregisterAttachmentLocked(ac)
356374 sess.mu.Unlock()
375+ } else if remote {
376+ view.mu.Lock()
377+ view.unregisterAttachmentLocked(ac)
378+ view.mu.Unlock()
357379 }
358380 // Recreate the parked entry and its watcher instead of restoring a timer
359381 // that may already have fired while the claim held the old entry. This also
@@ -363,7 +385,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
363385 }
364386 parked.closeDone()
365387 rearmed := &parkedAttachment{
366- sess: parked.sess,
388+ owner: parked.owner,
367389 ac: ac,
368390 pickerGeneration: parked.pickerGeneration,
369391 timer: d.clock.NewTimer(d.resumeParkGrace),
@@ -376,6 +398,9 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
376398 if sess != nil {
377399 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
378400 }
401+ if remote {
402+ d.parkRemoteViewWarm(view)
403+ }
379404 d.watchParkedTimer(token, rearmed)
380405 if captured != nil {
381406 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
@@ -409,7 +434,20 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
409434 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
410435 var retirements []parkedAttachmentRetirement
411436 for token, parked := range d.parked {
412- if parked.sess == sess {
437+ if localSession(parked.owner) == sess {
438+ retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
439+ }
440+ }
441+ return retirements
442+}
443+
444+// purgeParkedForRemoteViewLocked retires every resume credential owned by the
445+// exact remote view. The caller completes the returned external cleanup after
446+// releasing d.mu.
447+func (d *Daemon) purgeParkedForRemoteViewLocked(view *remoteView) []parkedAttachmentRetirement {
448+ var retirements []parkedAttachmentRetirement
449+ for token, parked := range d.parked {
450+ if owner, remote := normalizeAttachmentOwner(parked.owner).(*remoteView); remote && owner == view {
413451 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
414452 }
415453 }
@@ -562,7 +600,7 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
562600 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
563601 }
564602 if d.parked[h.ResumeToken] != parked || parked.claimed {
565- if d.sessions[parked.sess.id] == parked.sess {
603+ if sess := localSession(parked.owner); sess != nil && d.sessions[sess.id] == sess {
566604 d.mu.Unlock()
567605 ac.sendMu.Unlock()
568606 return nil, nil, false, errResumeTokenLifecycleRace
@@ -587,11 +625,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
587625 if parked == nil || h.ResumeToken == 0 {
588626 return nil, nil, false, nil
589627 }
590- sess := parked.sess
591- registered := d.sessions[sess.id] == sess
628+ sess := localSession(parked.owner)
629+ registered := sess != nil && d.sessions[sess.id] == sess
592630 if !registered {
593631 d.removeParkedLocked(h.ResumeToken, parked)
594- d.log.Warn("resume rejected", "session", sess.name, "registered", false)
632+ d.log.Warn("resume rejected", "session", attachmentOwnerName(parked.owner), "registered", false)
595633 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
596634 }
597635 ac := parked.ac
M internal/usecase/daemon/resume_test.go +7 -7
@@ -533,7 +533,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
533533 d.mu.Unlock()
534534 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
535535 require.Same(t, ac, parkingInGap.ac)
536- require.Same(t, sess, parkingInGap.sess)
536+ require.Same(t, sess, localSession(parkingInGap.owner))
537537 require.False(t, parkedInGap, "fixture: park must not have published yet")
538538
539539 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
@@ -1268,7 +1268,7 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
12681268 require.NoError(t, err)
12691269
12701270 previous := &session{sessionCore: sessionCore{id: "previous"}}
1271- ac.previousSession.Set(previous)
1271+ ac.previousOwner.Set(previous)
12721272 d.clientGone(sess, ac, ac.transport(), false)
12731273
12741274 token := ac.resumeToken
@@ -1276,14 +1276,14 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
12761276 parked := d.parked[token]
12771277 d.mu.Unlock()
12781278 require.NotNil(t, parked)
1279- require.Same(t, previous, ac.previousSession.Get(), "a live parked attachment keeps its toggle")
1279+ require.Same(t, previous, ac.previousOwner.Get(), "a live parked attachment keeps its toggle")
12801280
12811281 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
12821282 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
12831283 require.NoError(t, err)
12841284 require.Same(t, sess, resumedSess)
12851285 require.Same(t, ac, resumedAC)
1286- require.Same(t, previous, resumedAC.previousSession.Get(), "resume keeps the live attachment toggle")
1286+ require.Same(t, previous, resumedAC.previousOwner.Get(), "resume keeps the live attachment toggle")
12871287 }
12881288
12891289 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
@@ -1301,7 +1301,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13011301 {
13021302 name: "session purge",
13031303 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
1304- return d.purgeParkedForSessionLocked(parked.sess)
1304+ return d.purgeParkedForSessionLocked(localSession(parked.owner))
13051305 },
13061306 },
13071307 } {
@@ -1313,7 +1313,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13131313 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
13141314 require.NoError(t, err)
13151315
1316- ac.previousSession.Set(&session{sessionCore: sessionCore{id: "previous"}})
1316+ ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "previous"}})
13171317 d.clientGone(sess, ac, ac.transport(), false)
13181318
13191319 d.mu.Lock()
@@ -1323,7 +1323,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13231323 d.mu.Unlock()
13241324 d.finishParkedAttachmentRetirements(retirements)
13251325
1326- require.Nil(t, ac.previousSession.Get())
1326+ require.Nil(t, ac.previousOwner.Get())
13271327 })
13281328 }
13291329 }
M internal/usecase/daemon/session.go +20 -23
@@ -467,7 +467,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
467467 if err := domain.ValidateSessionName(name); err != nil {
468468 return err
469469 }
470- if token.sess == nil || token.ac == nil || token.effect == nil {
470+ source := token.localSession()
471+ if source == nil || token.ac == nil || token.effect == nil {
471472 return errAttachmentTransition
472473 }
473474 token.effect.bindActionEnd(d, "create-session")
@@ -475,15 +476,11 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
475476
476477 var created *session
477478 transition, err := d.transitionAttachment(attachmentTransitionRequest{
478- source: token.sess, next: token.ac,
479+ source: source, next: token.ac,
479480
480481 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
481482 copySourceEnvironment: true, ready: true,
482483 createTargetLocked: func() (*session, error) {
483- source := token.sess
484- if source == nil {
485- return nil, errAttachmentTransition
486- }
487484 if d.closing {
488485 return nil, errors.New("daemon is shutting down")
489486 }
@@ -506,8 +503,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
506503 }
507504
508505 d.touchMRU(created)
509- if token.sess != nil {
510- token.ac.recordPreviousSession(token.sess)
506+ if source != nil {
507+ token.ac.recordPreviousSession(source)
511508 }
512509 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
513510 d.deferAttachmentTransitionCleanups(transition)
@@ -773,14 +770,15 @@ func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool
773770 }
774771
775772 func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
776- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
773+ sess := token.localSession()
774+ if sess == nil || token.ac == nil || token.transport.transport == nil {
777775 return false
778776 }
779777 // Attachments without the session's optional primary render lease still
780778 // own an independent connection lifecycle and detach through the same exact
781779 // transport/generation fence.
782780 if token.lease == nil {
783- return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
781+ return d.detachIfCurrentTransport(sess, token.ac, token.transport)
784782 }
785783 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
786784 defer frozen.unfreeze()
@@ -791,8 +789,8 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
791789 d.afterDetachAttachmentEffectsFrozen()
792790 }
793791 d.mu.Lock()
794- core := token.sess.core()
795- if core == nil || d.sessions[core.id] != token.sess {
792+ core := sess.core()
793+ if core == nil || d.sessions[core.id] != sess {
796794 d.mu.Unlock()
797795 return false
798796 }
@@ -803,11 +801,11 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
803801 coordinator.mu.Lock()
804802 }
805803 req := attachmentTransitionRequest{
806- source: token.sess, next: token.ac, expectedTransport: token.transport,
804+ source: sess, next: token.ac, expectedTransport: token.transport,
807805 }
808- current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
806+ current := coordinator != nil && transitionSourceTokenCurrentLocked(token, sess, coordinator, req)
809807 if current {
810- unregisterAttachmentSessionLocked(token.sess, token.ac)
808+ unregisterAttachmentSessionLocked(sess, token.ac)
811809 token.ac.connectionGeneration.Add(1)
812810 token.ac.setSession(nil)
813811 token.ac.invalidateFrozenAttachmentCapability()
@@ -1168,13 +1166,11 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
11681166 snapshot := sessionKillParticipants{target: target}
11691167 if admission != nil {
11701168 token := admission.token
1171- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1172- return sessionKillParticipants{}, false
1173- }
1174- snapshot.source = token.sess
1175- if snapshot.source == nil {
1169+ source := token.localSession()
1170+ if source == nil || token.ac == nil || token.transport.transport == nil {
11761171 return sessionKillParticipants{}, false
11771172 }
1173+ snapshot.source = source
11781174 snapshot.sourceToken = &admission.token
11791175 }
11801176
@@ -1271,10 +1267,11 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
12711267 return true
12721268 }
12731269 token := *snapshot.sourceToken
1270+ source := token.localSession()
12741271 req := attachmentTransitionRequest{
1275- source: token.sess, next: token.ac, expectedTransport: token.transport,
1272+ source: source, next: token.ac, expectedTransport: token.transport,
12761273 }
1277- return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
1274+ return transitionSourceTokenCurrentLocked(token, source, sourceCoordinator, req)
12781275 }
12791276
12801277 // stopInMemoryLifecycle cancels every session producer and closes every PTY
@@ -1579,7 +1576,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15791576 unlockSessions()
15801577 d.notices.routingMu.Unlock()
15811578 if !ephemeral {
1582- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, tabRecords: tabRecords, record: stoppedRecord, state: ports.SessionStopped, purging: purge}
1579+ 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}
15831580 d.stopped[stoppedName] = stopped
15841581 }
15851582 empty := len(d.sessions) == 0
M internal/usecase/daemon/session_back.go +78 -17
@@ -5,15 +5,64 @@ import (
55 "github.com/bnema/vev/internal/usecase/picker"
66 )
77
8-// backSession toggles this attachment between its current session and the
9-// immediately preceding successfully activated session.
10-func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
11- if d == nil || token.sess == nil || token.ac == nil {
8+// backSession toggles this attachment between its current local session and
9+// the immediately preceding successfully activated local session. A remote
10+// owner is retained in the owner history but is not interpreted as a local
11+// session; remote back navigation is published with remote transitions.
12+func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken, afterRemoteTransition ...func()) error {
13+ if d == nil || token.ac == nil {
1214 return nil
1315 }
14- target := token.ac.previousSession.Get()
15- if target == nil || target == token.sess || target.core() == nil || d.sessionByID(target.core().id) != target {
16- token.ac.clearPreviousSessionIf(target)
16+ if view, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {
17+ previous := token.ac.previousOwner.Get()
18+ target := localSession(previous)
19+ if target == nil {
20+ // A non-local predecessor is not a valid reverse target. Keep it as
21+ // stable history for the owner-specific transition that can interpret it.
22+ return nil
23+ }
24+ if target.core() == nil || d.sessionByID(target.core().id) != target {
25+ token.ac.clearPreviousOwnerIf(previous)
26+ return nil
27+ }
28+ published, err := d.transitionFromRemoteView(token, view, target)
29+ if err != nil {
30+ if !token.current() {
31+ return nil
32+ }
33+ return err
34+ }
35+ if len(afterRemoteTransition) != 0 && afterRemoteTransition[0] != nil {
36+ afterRemoteTransition[0]()
37+ }
38+ d.touchMRU(target)
39+ d.firstPaintForTransition(published)
40+ return nil
41+ }
42+
43+ current := token.localSession()
44+ if current == nil {
45+ return nil
46+ }
47+ previous := token.ac.previousOwner.Get()
48+ if view, remote := normalizeAttachmentOwner(previous).(*remoteView); remote {
49+ published, err := d.transitionToRemoteView(token, view)
50+ if err != nil {
51+ if !token.current() {
52+ return nil
53+ }
54+ token.ac.clearPreviousOwnerIf(previous)
55+ return err
56+ }
57+ d.firstPaintForTransition(published)
58+ return nil
59+ }
60+ target := localSession(previous)
61+ if target == nil {
62+ return nil
63+ }
64+ if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
65+ token.ac.clearPreviousOwnerIf(previous)
1766 return nil
1867 }
1968 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}
@@ -24,9 +73,16 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
2473 if d == nil || current == nil || ac == nil {
2574 return
2675 }
27- target := ac.previousSession.Get()
28- if target == nil || target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
29- ac.clearPreviousSessionIf(target)
76+ previous := ac.previousOwner.Get()
77+ target := localSession(previous)
78+ if target == nil {
79+ if previous == nil {
80+ d.invalidateRender(current, ac, true, "session_back.go")
81+ }
82+ return
83+ }
84+ if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
85+ ac.clearPreviousOwnerIf(previous)
3086 d.invalidateRender(current, ac, true, "session_back.go")
3187 return
3288 }
@@ -43,24 +99,29 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
4399 }
44100 }
45101
46-// clearPreviousSessionIf clears target only if it has not been replaced since
102+// clearPreviousOwnerIf clears target only if it has not been replaced since
47103 // the caller observed it. This preserves a concurrent successful hand-off.
48-func (ac *attachedClient) clearPreviousSessionIf(target *session) {
104+func (ac *attachedClient) clearPreviousOwnerIf(target attachmentOwner) {
49105 if ac == nil {
50106 return
51107 }
52- ac.previousSession.With(func(previous **session) {
53- if *previous == target {
108+ ac.previousOwner.With(func(previous *attachmentOwner) {
109+ if sameAttachmentOwner(*previous, target) {
54110 *previous = nil
55111 }
56112 })
57113 }
58114
59-// recordPreviousSession is called only after a cross-session hand-off has
115+// recordPreviousOwner is called only after a cross-owner hand-off has
60116 // committed. Keeping this after the commit makes failed/displaced switches
61117 // leave the toggle pair intact.
62-func (ac *attachedClient) recordPreviousSession(origin *session) {
118+func (ac *attachedClient) recordPreviousOwner(origin attachmentOwner) {
119+ origin = normalizeAttachmentOwner(origin)
63120 if ac != nil && origin != nil {
64- ac.previousSession.Set(origin)
121+ ac.previousOwner.Set(origin)
65122 }
66123 }
124+
125+func (ac *attachedClient) recordPreviousSession(origin *session) {
126+ ac.recordPreviousOwner(origin)
127+}
M internal/usecase/daemon/session_geometry.go +9 -1
@@ -1,6 +1,9 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/domain"
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
47
58 // sessionGeometrySource identifies the attachment whose latest attach,
69 // resume, or resize claim controls shared PTY/layout geometry. claim is zero
@@ -62,6 +65,11 @@ func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource,
6265 return domain.Size{}, sessionGeometrySource{}, false
6366 }
6467 content := contentSize(source.size)
68+ if source.attachment.renderMode == ports.RenderModeProxiedContent {
69+ // A proxied Hello already carries content geometry; do not subtract the
70+ // remote daemon's local chrome rows from it.
71+ content = source.size
72+ }
6573 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true
6674 }
6775
M internal/usecase/daemon/session_test.go +2 -2
@@ -779,7 +779,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
779779 require.Equal(t, int64(7), stopped.createdAt)
780780 require.Equal(t, incarnation, stopped.incarnation)
781781 require.Equal(t, uint64(9), stopped.lastUsedSeq)
782- require.Equal(t, ports.SessionStopped, stopped.state)
782+ require.Equal(t, ports.SessionDown, stopped.state)
783783 require.NotNil(t, stopped.restoreDone)
784784 require.Equal(t, uint64(9), d.mruSeq.Load())
785785 }
@@ -1718,7 +1718,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
17181718
17191719 alpha := d.stopped["alpha"]
17201720 require.Equal(t, records[0], alpha.record)
1721- require.Equal(t, ports.SessionStopped, alpha.state)
1721+ require.Equal(t, ports.SessionDown, alpha.state)
17221722 require.NotNil(t, alpha.restoreDone)
17231723 select {
17241724 case <-alpha.restoreDone:
M internal/usecase/daemon/snapshot_restore_incremental.go +4 -4
@@ -21,7 +21,7 @@ var errRetryableRestoreLoad = errors.New("snapshot: retryable checkpoint load")
2121
2222 func initialSessionState(record domain.CatalogueRecord) (ports.SessionState, chan struct{}) {
2323 done := make(chan struct{})
24- state := ports.SessionStopped
24+ state := ports.SessionDown
2525 if record.DegradedReason != "" {
2626 state = ports.SessionBroken
2727 close(done)
@@ -179,7 +179,7 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
179179 return nil
180180 }
181181 if record.Committed == nil {
182- d.setStoppedRecovery(record, ports.SessionStopped)
182+ d.setStoppedRecovery(record, ports.SessionDown)
183183 d.logSessionRestoreComplete(record, 0, false)
184184 return nil
185185 }
@@ -231,7 +231,7 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
231231 if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation, selected); err != nil {
232232 return err
233233 }
234- d.setStoppedRecovery(record, ports.SessionStopped)
234+ d.setStoppedRecovery(record, ports.SessionDown)
235235 d.logSessionRestoreComplete(record, selected.Generation, false)
236236 return nil
237237 }
@@ -252,7 +252,7 @@ func (d *Daemon) resetIncompatibleCheckpoint(ctx context.Context, record domain.
252252
253253 d.mu.Lock()
254254 if entry, ok := d.stopped[record.Name]; ok {
255- d.stopped[record.Name] = stoppedSessionFromRecord(fresh, ports.SessionStopped, entry.restoreDone)
255+ d.stopped[record.Name] = stoppedSessionFromRecord(fresh, ports.SessionDown, entry.restoreDone)
256256 }
257257 d.mu.Unlock()
258258
M internal/usecase/daemon/status.go +2 -2
@@ -193,7 +193,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
193193 name += "*"
194194 }
195195 if ac != nil && ac.remoteOrigin != "" {
196- name += " at " + ac.remoteOrigin
196+ name += "@" + ac.remoteOrigin
197197 }
198198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
199199 activeIndex := 0
@@ -276,7 +276,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
276276 if ranked != nil {
277277 state.rankedRecent = ranked
278278 } else if d != nil {
279- state.mru = d.recentSessions(cur)
279+ state.mru = d.recentSessionsForAttachment(cur, ac)
280280 }
281281 return state
282282 }
M internal/usecase/daemon/status_test.go +2 -1
@@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
115115 remoteOrigin string
116116 wantAttachmentBar string
117117 }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"},
118+ {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},
119119 {name: "local attachment", wantAttachmentBar: "vive"},
120120 }
121121
@@ -1549,4 +1549,5 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
15491549 releasePTY2()
15501550 hg.Wait()
15511551 d.sessWg.Wait()
1552+ d.waitNotifies()
15521553 }
M internal/usecase/daemon/wire.go +73 -7
@@ -2,23 +2,89 @@
22 package daemon
33
44 import (
5+ "strconv"
6+
57 "github.com/bnema/vev/internal/domain"
68 "github.com/bnema/vev/internal/ports"
79 )
810
911 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
10- s.mu.Lock()
11- w := ports.Welcome{
12- SessionID: string(s.id),
13- SessionName: s.name,
14- Ephemeral: s.ephemeral,
15- Capabilities: ports.CapabilityResume,
12+ return frameWelcomeForAttachment(s, ac)
13+}
14+
15+// frameWelcomeForAttachment emits the local-client handshake identity for one
16+// attachment owner. A remote view is a local daemon object rather than a
17+// synthetic session, so its opaque local view ID is used only for this wire
18+// identity while its remote session name remains presentation data.
19+func frameWelcomeForAttachment(owner attachmentOwner, ac *attachedClient) ports.Frame {
20+ if ac == nil {
21+ return ports.Frame{}
22+ }
23+ var w ports.Welcome
24+ switch owner := normalizeAttachmentOwner(owner).(type) {
25+ case *session:
26+ owner.mu.Lock()
27+ w = ports.Welcome{
28+ SessionID: string(owner.id),
29+ SessionName: owner.name,
30+ Ephemeral: owner.ephemeral,
31+ RenderMode: ac.renderMode,
32+ Capabilities: ports.CapabilityResume,
33+ }
34+ owner.mu.Unlock()
35+ case *remoteView:
36+ w = ports.Welcome{
37+ SessionID: "remote-view-" + strconv.FormatUint(uint64(owner.id), 10),
38+ SessionName: owner.key.sessionName,
39+ RenderMode: ac.renderMode,
40+ Capabilities: ports.CapabilityResume,
41+ }
42+ default:
43+ return ports.Frame{}
1644 }
17- s.mu.Unlock()
1845 w.ResumeToken = ac.resumeToken
1946 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
2047 }
2148
49+// frameSessionMeta snapshots the authoritative remote session identity and
50+// ordered stable tab metadata for a proxied attachment. Its first publication
51+// is emitted during the handshake before that attachment may render output.
52+func frameSessionMeta(s *session, ac *attachedClient, revision uint64) (ports.Frame, error) {
53+ if s == nil || ac == nil {
54+ return ports.Frame{}, ports.ErrInvalidSessionMeta
55+ }
56+ // Capture the session tab set and this attachment's active stable tab under
57+ // the same session snapshot. A stale attachment view must fall back to the
58+ // first current tab instead of emitting metadata whose active ID is absent
59+ // from Tabs and therefore invalid on the wire.
60+ s.mu.Lock()
61+ active := ac.viewSnapshot().tabID
62+ meta := ports.SessionMeta{
63+ LifecycleID: s.incarnation,
64+ Revision: revision,
65+ SessionName: s.name,
66+ Tabs: make([]ports.SessionTabMeta, 0, len(s.tabs)),
67+ }
68+ activePresent := false
69+ for i, tab := range s.tabs {
70+ id := domain.TabStableID(tab.stableID)
71+ meta.Tabs = append(meta.Tabs, ports.SessionTabMeta{ID: id, Name: tabDisplayName(tab, i), Attention: tab.attention})
72+ activePresent = activePresent || active == id
73+ }
74+ if len(meta.Tabs) == 0 {
75+ active = ""
76+ } else if !activePresent {
77+ active = meta.Tabs[0].ID
78+ }
79+ meta.ActiveTabID = active
80+ s.mu.Unlock()
81+ payload, err := ports.MarshalSessionMeta(meta)
82+ if err != nil {
83+ return ports.Frame{}, err
84+ }
85+ return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}, nil
86+}
87+
2288 func frameError(code uint16, text string) ports.Frame {
2389 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
2490 }
M internal/usecase/picker/model.go +28 -5
@@ -267,10 +267,21 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
267267 common.remote = common.hasRemoteKey || common.hasRemoteTarget || session.RemoteHost != ""
268268 header := common
269269 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
270+ if common.hasRemoteKey {
271+ header.dispName = common.remoteKey.Name
272+ display := common.remoteKey.Display()
273+ header.detail = display[len(common.remoteKey.Name):]
274+ }
270275 header.selectable = header.kind.selectable(config.Mode)
271276 header.focusable = header.selectable
272277 if common.remote {
273- header.detail = remoteRowDetail(session)
278+ if remoteDetail := remoteRowDetail(session); remoteDetail != "" {
279+ if header.detail != "" {
280+ header.detail += " (" + remoteDetail + ")"
281+ } else {
282+ header.detail = remoteDetail
283+ }
284+ }
274285 // Rich catalog rows use tab targets. Legacy count-only/connect-only
275286 // callers retain their header target until all peers carry the richer
276287 // identity contract.
@@ -585,8 +596,8 @@ func (r row) remoteKeyPointer() *domain.RemoteSessionKey {
585596 // renderList draws each visible row as up to three segments: a name segment
586597 // (bold when styles came from a truecolor theme), a base-styled attention
587598 // marker right after the name, and a muted detail segment (tab rows only) —
588-// or a base-styled "(stopped)" suffix for stopped session headers. A tight
589-// width ellipsizes the detail segment before eating into the name.
599+// or a base-styled "(down)" suffix for down session headers. A tight width
600+// ellipsizes the detail segment before eating into the name.
590601 func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
591602 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
592603 return
@@ -618,12 +629,24 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
618629 if !r.kind.rendersAsHeader() {
619630 name = " " + name
620631 }
621- name = ui.TruncateText(name, rect.Width)
632+ suffix := ""
633+ if r.kind.rendersAsHeader() && r.stopped {
634+ suffix = "(down)"
635+ }
636+ name = ui.TruncateText(name, max(rect.Width-len(suffix), 0))
622637 x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
623638
624639 if r.kind.rendersAsHeader() {
625640 if r.stopped {
626- ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
641+ detail := r.detail
642+ if detail == "" {
643+ detail = " "
644+ } else {
645+ detail += " "
646+ }
647+ detail = ui.TruncateText(detail, max(clipX-x-len(suffix), 0))
648+ x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, base)
649+ ui.DrawText(frame, x, rect.Y+y, clipX, suffix, base)
627650 } else {
628651 ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)
629652 }
M internal/usecase/picker/model_test.go +29 -12
@@ -2,6 +2,7 @@ package picker
22
33 import (
44 "reflect"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/vev/internal/domain"
@@ -490,14 +491,30 @@ func TestSelectedIndexReportsRawSelectedRow(t *testing.T) {
490491 require.Equal(t, 1, m.SelectedIndex())
491492 }
492493
493-func TestStoppedSessionSelectableAndRendered(t *testing.T) {
494- m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
495- got, ok := m.Selected()
496- require.True(t, ok)
497- require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
498- frame := m.Render(domain.Size{Cols: 24, Rows: 4}, Preview{})
499- require.Equal(t, 'w', frame.At(0, 0).Rune)
500- require.Equal(t, '(', frame.At(5, 0).Rune)
494+func TestStoppedSessionRendersDownSuffix(t *testing.T) {
495+ for _, tt := range []struct {
496+ name string
497+ sessionID domain.SessionID
498+ sessionName string
499+ size domain.Size
500+ }{
501+ {name: "normal", sessionID: "stopped:work", sessionName: "work", size: domain.Size{Cols: 24, Rows: 4}},
502+ {name: "constrained", sessionID: "stopped:long", sessionName: "long-session", size: domain.Size{Cols: 8, Rows: 2}},
503+ } {
504+ t.Run(tt.name, func(t *testing.T) {
505+ m := New([]SessionView{{ID: tt.sessionID, Name: tt.sessionName, Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
506+ got, ok := m.Selected()
507+ require.True(t, ok)
508+ require.Equal(t, Target{Session: tt.sessionID, Name: tt.sessionName, TabIndex: 0, Stopped: true}, got)
509+
510+ frame := m.Render(tt.size, Preview{})
511+ var label strings.Builder
512+ for _, cell := range frame.Row(0) {
513+ label.WriteRune(cell.Rune)
514+ }
515+ require.Contains(t, label.String(), "(down)")
516+ })
517+ }
501518 }
502519
503520 func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
@@ -510,7 +527,7 @@ func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
510527 require.Equal(t, stoppedStyle, styles.Stopped)
511528
512529 frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
513- // Rows: 0 "work" header, 1 " tab" (selected), 2 "old (stopped)" header, 3 its tab row.
530+ // Rows: 0 "work" header, 1 " tab" (selected), 2 "old (down)" header, 3 its tab row.
514531 require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic")
515532 require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic")
516533 require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style")
@@ -587,7 +604,7 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
587604 }
588605
589606 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
590- {Name: "alpha", Detail: " (running)"},
607+ {Name: "alpha", Detail: " (up)"},
591608 {Name: "beta", Detail: " (idle)", Attention: true},
592609 }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
593610
@@ -748,9 +765,9 @@ func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) {
748765 require.True(t, pickerRow.hasRemoteKey)
749766 require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])
750767 require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i])
751- wantDetail := "proxy activation pending"
768+ wantDetail := "@mule (proxy activation pending)"
752769 if availability[i] == RemoteStale || availability[i] == RemoteVersionMismatch {
753- wantDetail = "remote state detail"
770+ wantDetail = "@mule (remote state detail)"
754771 }
755772 require.Equal(t, wantDetail, pickerRow.detail)
756773 }
M internal/usecase/picker/remote_parity_test.go +23 -0
@@ -6,6 +6,7 @@ import (
66 "github.com/stretchr/testify/require"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/pkg/renderer"
910 )
1011
1112 func remoteParityLifecycle(seed byte) domain.SessionLifecycleID {
@@ -14,6 +15,28 @@ func remoteParityLifecycle(seed byte) domain.SessionLifecycleID {
1415 return id
1516 }
1617
18+func TestRemoteSessionHeaderIncludesOrigin(t *testing.T) {
19+ lifecycle := remoteParityLifecycle(1)
20+ key := domain.RemoteSessionKey{Host: "vev@arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
21+ target := domain.RemoteSessionTarget{Endpoint: key.Host, DisplayOrigin: key.DisplayOrigin, LifecycleID: lifecycle, SessionName: key.Name, LiveTabID: "tab-1"}
22+ model := New([]SessionView{{
23+ ID: key.ID(), Name: key.Name, Tabs: []TabEntry{{TabID: "tab-1", Name: "main"}},
24+ RemoteKey: &key, RemoteTarget: &target, RemoteAvailability: RemoteFresh, RemoteAttachReady: true,
25+ }}, SelectionConfig{Mode: SelectNavigationTab})
26+
27+ require.Equal(t, "work", model.rows[0].dispName)
28+ require.Equal(t, "@arch", model.rows[0].detail)
29+ frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{
30+ Name: renderer.Style{Bold: true}, Detail: renderer.Style{Attrs: renderer.AttrDim},
31+ })
32+ require.Equal(t, '@', frame.At(4, 0).Rune)
33+ require.True(t, frame.At(4, 0).Style.Equal(renderer.Style{Attrs: renderer.AttrDim}))
34+ selected, ok := model.Selected()
35+ require.True(t, ok)
36+ require.Equal(t, key.ID(), selected.Session)
37+ require.Equal(t, &target, selected.RemoteTarget)
38+}
39+
1740 func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) {
1841 firstLifecycle := remoteParityLifecycle(1)
1942 secondLifecycle := remoteParityLifecycle(2)
M scripts/remote-picker-harness/README.md +14 -0
@@ -18,6 +18,20 @@ Environment overrides:
1818 containers (default: `ubuntu:24.04`).
1919 - `VEV_HARNESS_TARGET` selects the SSH target passed to the harness binary
2020 (default: `test@remote`).
21+- `VEV_HARNESS_ARTIFACT_DIR`, when set, writes a bounded
22+ `remote-picker-harness.json` containing output-state, checkpoint, and event
23+ metadata. It is unset by default; the artifact contains no terminal bytes,
24+ raw screen text, credentials, or target output. `run.sh` copies the report to
25+ that host directory before it removes the disposable containers.
26+
27+Each logical transport has one persistent `vt.Screen` and a client-equivalent
28+output-state chain. The probe writes accepted full, incremental, and side-effect
29+frames to that screen, ACKs only accepted state-bearing frames, and retains a
30+bounded set of screen checkpoints plus non-sensitive event metadata. The local
31+picker phase captures unified local/remote rows before selection, keeps the same
32+local client transport while the daemon publishes proxied remote content,
33+rejects direct `MsgAttachTarget` handoff and remote daemon chrome, verifies
34+continued input, and captures the unified rows again from the remote view.
2135
2236 The run exercises real SSH-stdio and UDP attachment, typed live catalog and
2337 preview requests, tab creation/removal fencing, exact lifecycle fencing after
M scripts/remote-picker-harness/main.go +168 -134
@@ -20,7 +20,6 @@ import (
2020 "github.com/bnema/vev/internal/adapters/sshstdio"
2121 "github.com/bnema/vev/internal/domain"
2222 "github.com/bnema/vev/internal/ports"
23- "github.com/bnema/vev/pkg/vt"
2423 )
2524
2625 const (
@@ -38,7 +37,7 @@ func main() {
3837 fmt.Println("remote picker harness: PASS")
3938 }
4039
41-func run() error {
40+func run() (runErr error) {
4241 target := os.Getenv("VEV_HARNESS_TARGET")
4342 if target == "" {
4443 target = defaultTarget
@@ -46,6 +45,16 @@ func run() error {
4645 ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
4746 defer cancel()
4847
48+ artifact := newHarnessArtifact(os.Getenv("VEV_HARNESS_ARTIFACT_DIR"))
49+ if artifact != nil {
50+ ctx = withHarnessArtifact(ctx, artifact)
51+ defer func() {
52+ if err := artifact.write(runErr == nil); err != nil {
53+ runErr = errors.Join(runErr, err)
54+ }
55+ }()
56+ }
57+
4958 catalog := remoteadapter.NewCatalogClient()
5059 preview := remoteadapter.NewPreviewClient()
5160 if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {
@@ -85,7 +94,7 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
8594 if err := sendInputAndAwait(ctx, stdio, "printf 'VEV_REMOTE_HARNESS_ENV=%s:%s:%s:%s\\n' \"$VEV_TEST_ENV\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$VEV\"", "local:/run/local:wayland-local:"); err != nil {
8695 return fmt.Errorf("direct client environment: %w", err)
8796 }
88- live, err := waitForSession(ctx, catalog, target, sessionName, "running")
97+ live, err := waitForSession(ctx, catalog, target, sessionName, "up")
8998 if err != nil {
9099 return fmt.Errorf("live catalog: %w", err)
91100 }
@@ -99,7 +108,7 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
99108 if err := sendCommand(ctx, stdio, "new-tab"); err != nil {
100109 return fmt.Errorf("create second tab: %w", err)
101110 }
102- liveWithTabs, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "running", 2)
111+ liveWithTabs, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "up", 2)
103112 if err != nil {
104113 return fmt.Errorf("multi-tab catalog: %w", err)
105114 }
@@ -107,7 +116,7 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
107116 if err := sendCommand(ctx, stdio, "close-tab"); err != nil {
108117 return fmt.Errorf("remove tab: %w", err)
109118 }
110- after, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "running", 1)
119+ after, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "up", 1)
111120 if err != nil {
112121 return fmt.Errorf("removed-tab catalog: %w", err)
113122 }
@@ -139,19 +148,22 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
139148
140149 // runLocalPickerUnitedPhase drives the actual local picker against the SSH
141150 // daemon container. It validates the seam that individual catalog, preview,
142-// and attach tests cannot: catalog active-tab selection, live remote preview,
143-// emitted rich handoff, and the remote attachment's visible provenance.
151+// and attach tests cannot: unified rows before and after selection, remote
152+// content publication on the same local client transport, local chrome
153+// composition, and continued input forwarding.
144154 func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) {
145155 const (
146156 localSession = "local-picker"
147157 remoteSession = "picker"
148158 previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
159+ inputMarker = "VEV_REMOTE_PICKER_INPUT_OK"
149160 )
150161
151162 remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
152163 if err != nil {
153164 return fmt.Errorf("open remote picker source: %w", err)
154165 }
166+ remote.probe.configure("remote-picker-source")
155167 ownsRemoteSession := false
156168 defer func() {
157169 _ = remote.Close()
@@ -180,7 +192,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
180192 if err := sendInputAndAwait(ctx, remote, "printf '"+previewMarker+"\\n'", previewMarker); err != nil {
181193 return fmt.Errorf("seed remote picker preview: %w", err)
182194 }
183- remoteCatalog, err := waitForSessionWithTabCount(ctx, catalog, target, remoteSession, "running", 2)
195+ remoteCatalog, err := waitForSessionWithTabCount(ctx, catalog, target, remoteSession, "up", 2)
184196 if err != nil {
185197 return fmt.Errorf("remote picker catalog: %w", err)
186198 }
@@ -229,6 +241,10 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
229241 if err != nil {
230242 return fmt.Errorf("open local picker transport: %w", err)
231243 }
244+ // This is the one client-equivalent connection for the proof. Remote
245+ // selection must replace its daemon-side owner without sending an endpoint
246+ // or MsgAttachTarget to this transport.
247+ local.probe.configure("local-picker")
232248 defer func() { _ = local.Close() }()
233249 if err := attachAndCheck(ctx, local, ports.Hello{
234250 Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
@@ -248,6 +264,10 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
248264 if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
249265 return fmt.Errorf("await remote picker catalog row: %w", err)
250266 }
267+ beforeRows := capturePickerRows(local.probe)
268+ if err := assertUnifiedPickerRows(beforeRows, localSession, remoteSession); err != nil {
269+ return fmt.Errorf("capture unified picker rows before remote selection: %w", err)
270+ }
251271 // The local selected tab is first and the remote session header is not
252272 // selectable, so one down reaches the first remote tab. Derive the rest
253273 // from the catalog's stable active tab identity instead of assuming an
@@ -261,41 +281,51 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
261281 if err := sendRawInput(local, "\r"); err != nil {
262282 return fmt.Errorf("commit remote picker target: %w", err)
263283 }
264- handoffFrame, err := receiveFrame(ctx, local, ports.MsgAttachTarget)
265- if err != nil {
266- return fmt.Errorf("await remote picker handoff: %w", err)
284+ if err := awaitOutputContains(ctx, local, remoteSession+" at remote"); err != nil {
285+ return fmt.Errorf("publish remote content on local transport: %w", err)
267286 }
268- handoff, err := ports.UnmarshalAttachTarget(handoffFrame.Payload)
269- if err != nil {
270- return fmt.Errorf("decode remote picker handoff: %w", err)
287+ if !local.probe.contains(previewMarker) {
288+ return errors.New("published remote content omitted preview marker")
271289 }
272- if handoff.RemoteTarget == nil {
273- return errors.New("remote picker handoff omitted the remote target")
290+ if err := assertNoDirectHandoff(local); err != nil {
291+ return err
274292 }
275- if handoff.RemoteTarget.LiveTabID != selectedTarget.LiveTabID {
276- return fmt.Errorf("remote picker handoff tab = %q, want %q", handoff.RemoteTarget.LiveTabID, selectedTarget.LiveTabID)
293+ if err := assertNoRemoteChrome(local.probe, remoteSession); err != nil {
294+ return fmt.Errorf("remote content chrome: %w", err)
277295 }
278- if handoff.RemoteTarget.DisplayOrigin != "remote" {
279- return fmt.Errorf("remote picker handoff origin = %q, want remote", handoff.RemoteTarget.DisplayOrigin)
296+ // Split the marker so the terminal's echoed command line cannot satisfy the
297+ // awaited-output assertion before the shell executes it.
298+ if err := sendInputAndAwait(ctx, local, "printf '%s%s' 'VEV_REMOTE_PICKER' '_INPUT_OK'", inputMarker); err != nil {
299+ return fmt.Errorf("continued input on local transport: %w", err)
280300 }
281- if handoff.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
282- return fmt.Errorf("remote picker handoff policy = %d, want daemon-owned", handoff.EnvironmentPolicy)
301+ if err := assertNoDirectHandoff(local); err != nil {
302+ return err
283303 }
284304
285- selected, err := openTransport(ctx, ports.RemoteTransportStdio, target)
286- if err != nil {
287- return fmt.Errorf("open selected remote transport: %w", err)
305+ // Reopen the picker through the same local connection while its owner is a
306+ // remote view. The navigation shell must remain local and retain both sides
307+ // of the unified session list.
308+ if err := sendRawInput(local, "\x1b "); err != nil {
309+ return fmt.Errorf("reopen command palette from remote content: %w", err)
288310 }
289- defer func() { _ = selected.Close() }()
290- if err := attachAndCheck(ctx, selected, ports.Hello{
291- Intent: ports.IntentAttach, Name: handoff.Session, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
292- TermEnv: "xterm-256color", TrueColor: true,
293- RemoteTarget: handoff.RemoteTarget, EnvironmentPolicy: handoff.EnvironmentPolicy,
294- }); err != nil {
295- return fmt.Errorf("attach selected remote target: %w", err)
311+ if err := awaitOutputContains(ctx, local, "Commands"); err != nil {
312+ return fmt.Errorf("await reopened command palette: %w", err)
313+ }
314+ if err := sendRawInput(local, "SSP\r"); err != nil {
315+ return fmt.Errorf("reopen session picker from remote content: %w", err)
316+ }
317+ if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
318+ return fmt.Errorf("await reopened remote picker row: %w", err)
319+ }
320+ afterRows := capturePickerRows(local.probe)
321+ if err := assertUnifiedPickerRows(afterRows, localSession, remoteSession); err != nil {
322+ return fmt.Errorf("capture unified picker rows after remote selection: %w", err)
323+ }
324+ if err := assertNoDirectHandoff(local); err != nil {
325+ return err
296326 }
297- if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {
298- return fmt.Errorf("remote provenance status bar: %w", err)
327+ if err := sendRawInput(local, "\x1b"); err != nil {
328+ return fmt.Errorf("close reopened session picker: %w", err)
299329 }
300330 return nil
301331 }
@@ -307,7 +337,7 @@ func runRestartResumePhase(ctx context.Context, target string, catalog ports.Rem
307337 if err := runRemoteCommand(ctx, target, "vev", "--daemon-launcher"); err != nil {
308338 return domain.RemoteSessionTarget{}, fmt.Errorf("restart remote daemon: %w", err)
309339 }
310- resumedCatalog, err := waitForSession(ctx, catalog, target, sessionName, "running")
340+ resumedCatalog, err := waitForSession(ctx, catalog, target, sessionName, "up")
311341 if err != nil {
312342 return domain.RemoteSessionTarget{}, fmt.Errorf("resumed catalog: %w", err)
313343 }
@@ -433,14 +463,21 @@ type harnessFrame struct {
433463
434464 type harnessTransport struct {
435465 ports.Transport
466+ probe *visualProbe
436467 frames chan harnessFrame
468+ queued []harnessFrame
437469 stop chan struct{}
438470 closeOnce sync.Once
439471 closeErr error
440472 }
441473
442474 func newHarnessTransport(transport ports.Transport) *harnessTransport {
443- h := &harnessTransport{Transport: transport, frames: make(chan harnessFrame, 16), stop: make(chan struct{})}
475+ h := &harnessTransport{
476+ Transport: transport,
477+ probe: newVisualProbe(domain.Size{Cols: defaultProbeCols, Rows: defaultProbeRows}),
478+ frames: make(chan harnessFrame, 16),
479+ stop: make(chan struct{}),
480+ }
444481 go h.receiveLoop()
445482 return h
446483 }
@@ -484,7 +521,12 @@ func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target s
484521 if err != nil {
485522 return nil, err
486523 }
487- return newHarnessTransport(transport), nil
524+ h := newHarnessTransport(transport)
525+ h.probe.configure(string(mode))
526+ if artifact := harnessArtifactFromContext(ctx); artifact != nil {
527+ artifact.registerProbe(h.probe)
528+ }
529+ return h, nil
488530 }
489531
490532 const commandStderrLimit = 4 << 10
@@ -585,7 +627,12 @@ func openLocalTransport(ctx context.Context) (*harnessTransport, error) {
585627 if err != nil {
586628 return nil, err
587629 }
588- return newHarnessTransport(transport), nil
630+ h := newHarnessTransport(transport)
631+ h.probe.configure("local")
632+ if artifact := harnessArtifactFromContext(ctx); artifact != nil {
633+ artifact.registerProbe(h.probe)
634+ }
635+ return h, nil
589636 }
590637
591638 func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {
@@ -612,6 +659,7 @@ func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello
612659 if frame.Type != ports.MsgWelcome {
613660 return fmt.Errorf("welcome frame type %d", frame.Type)
614661 }
662+ tr.probe.recordControl(probeEventWelcome)
615663 if err := tr.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil {
616664 return fmt.Errorf("send initial theme: %w", err)
617665 }
@@ -622,22 +670,44 @@ func sendRawInput(tr *harnessTransport, data string) error {
622670 return tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})})
623671 }
624672
625-func acknowledgeOutput(tr *harnessTransport, output ports.Output) error {
626- if output.Epoch == 0 || output.New == 0 {
673+func processOutputFrame(tr *harnessTransport, frame ports.Frame) error {
674+ output, err := ports.UnmarshalOutput(frame.Payload)
675+ if err != nil {
676+ return err
677+ }
678+ result := tr.probe.apply(output)
679+ if !result.Accepted {
680+ if !tr.probe.resetPending {
681+ if err := tr.Send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}); err != nil {
682+ return err
683+ }
684+ tr.probe.resetPending = true
685+ tr.probe.recordControl(probeEventOutputReset)
686+ }
687+ return nil
688+ }
689+ if !result.StateBearing {
627690 return nil
628691 }
629- payload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})
692+ payload, err := ports.MarshalAck(result.Ack)
630693 if err != nil {
631694 return err
632695 }
633- return tr.Send(ports.Frame{Type: ports.MsgAck, Payload: payload})
696+ if err := tr.Send(ports.Frame{Type: ports.MsgAck, Payload: payload}); err != nil {
697+ return err
698+ }
699+ tr.probe.resetPending = false
700+ tr.probe.markAcked(result)
701+ return nil
634702 }
635703
636704 func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {
637705 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
638706 defer cancel()
639- var received strings.Builder
640- screen := vt.NewScreen(80, 24)
707+ visible := tr.probe.contains(want)
708+ if visible {
709+ return nil
710+ }
641711 for {
642712 frame, err := receiveWithTimeout(deadline, tr)
643713 if err != nil {
@@ -646,73 +716,44 @@ func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string)
646716 if frame.Type != ports.MsgOutput {
647717 continue
648718 }
649- output, err := ports.UnmarshalOutput(frame.Payload)
650- if err != nil {
651- return err
652- }
653- if err := acknowledgeOutput(tr, output); err != nil {
719+ if err := processOutputFrame(tr, frame); err != nil {
654720 return err
655721 }
656- received.Write(output.Data)
657- screen.Write(output.Data)
658- if strings.Contains(received.String(), want) || strings.Contains(harnessScreenText(screen), want) {
722+ nowVisible := tr.probe.contains(want)
723+ if !visible && nowVisible {
659724 return nil
660725 }
661- if received.Len() > 64<<10 {
662- text := received.String()
663- received.Reset()
664- received.WriteString(text[len(text)-16<<10:])
665- }
726+ visible = nowVisible
666727 }
667728 }
668729
669730 func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {
670731 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
671732 defer cancel()
672- screen := vt.NewScreen(80, 24)
673- outputFrames := 0
674733 sawLoading := false
734+ markerVisible := tr.probe.contains(marker)
675735 for {
676736 frame, err := receiveWithTimeout(deadline, tr)
677737 if err != nil {
678- return fmt.Errorf("no rendered remote preview after %d output frames (loading=%t): %w", outputFrames, sawLoading, err)
738+ return fmt.Errorf("no rendered remote preview after %d output frames (loading=%t): %w", tr.probe.outputFrames, sawLoading, err)
679739 }
680740 if frame.Type != ports.MsgOutput {
681741 continue
682742 }
683- output, err := ports.UnmarshalOutput(frame.Payload)
684- if err != nil {
743+ if err := processOutputFrame(tr, frame); err != nil {
685744 return err
686745 }
687- if err := acknowledgeOutput(tr, output); err != nil {
688- return err
689- }
690- outputFrames++
691- screen.Write(output.Data)
692- text := harnessScreenText(screen)
693- sawLoading = sawLoading || strings.Contains(text, "loading remote preview") || strings.Contains(string(output.Data), "loading remote preview")
694- if strings.Contains(text, marker) || strings.Contains(string(output.Data), marker) {
695- return nil
696- }
746+ text := tr.probe.text()
747+ sawLoading = sawLoading || strings.Contains(text, "loading remote preview")
697748 if strings.Contains(text, "remote preview unavailable") {
698749 return errors.New("remote preview unavailable")
699750 }
700- }
701-}
702-
703-func harnessScreenText(screen *vt.Screen) string {
704- var text strings.Builder
705- for y := range screen.Frame.Height {
706- for x := range screen.Frame.Width {
707- r := screen.Frame.At(x, y).Rune
708- if r == 0 {
709- r = ' '
710- }
711- text.WriteRune(r)
751+ nowMarkerVisible := strings.Contains(text, marker)
752+ if !markerVisible && nowMarkerVisible {
753+ return nil
712754 }
713- text.WriteByte('\n')
755+ markerVisible = nowMarkerVisible
714756 }
715- return text.String()
716757 }
717758
718759 func remotePreviewContains(preview ports.RemotePreview, want string) bool {
@@ -725,64 +766,29 @@ func remotePreviewContains(preview ports.RemotePreview, want string) bool {
725766 return strings.Contains(text.String(), want)
726767 }
727768
728-func receiveFrame(ctx context.Context, tr *harnessTransport, typ ports.MsgType) (ports.Frame, error) {
729- deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
730- defer cancel()
731- for {
732- frame, err := receiveWithTimeout(deadline, tr)
733- if err != nil {
734- return ports.Frame{}, err
735- }
736- if frame.Type == typ {
737- return frame, nil
738- }
739- if frame.Type == ports.MsgOutput {
740- output, decodeErr := ports.UnmarshalOutput(frame.Payload)
741- if decodeErr != nil {
742- return ports.Frame{}, decodeErr
743- }
744- if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
745- return ports.Frame{}, ackErr
746- }
747- }
748- }
749-}
750-
751769 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {
752770 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
753771 return err
754772 }
755- var received strings.Builder
756- var outputFrames int
757- var outputBytes int
758773 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
759774 defer cancel()
775+ visible := tr.probe.contains(want)
760776 for {
761777 frame, err := receiveWithTimeout(deadline, tr)
762778 if err != nil {
763- return fmt.Errorf("waiting for marker after %d output frames/%d bytes: %w", outputFrames, outputBytes, err)
779+ return fmt.Errorf("waiting for marker after %d output frames/%d bytes: %w", tr.probe.outputFrames, tr.probe.outputBytes, err)
764780 }
765781 if frame.Type != ports.MsgOutput {
766782 continue
767783 }
768- output, err := ports.UnmarshalOutput(frame.Payload)
769- if err != nil {
770- return err
771- }
772- outputFrames++
773- outputBytes += len(output.Data)
774- if err := acknowledgeOutput(tr, output); err != nil {
775- return fmt.Errorf("ack output: %w", err)
784+ if err := processOutputFrame(tr, frame); err != nil {
785+ return fmt.Errorf("process output: %w", err)
776786 }
777- received.Write(output.Data)
778- if strings.Contains(received.String(), want) {
787+ nowVisible := tr.probe.contains(want)
788+ if !visible && nowVisible {
779789 return nil
780790 }
781- if received.Len() > 64<<10 {
782- text := received.String()
783- received.Reset()
784- received.WriteString(text[len(text)-16<<10:])
785- }
791+ visible = nowVisible
786792 }
787793 }
788794
@@ -809,12 +815,8 @@ func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error {
809815 return err
810816 }
811817 if frame.Type == ports.MsgOutput {
812- output, decodeErr := ports.UnmarshalOutput(frame.Payload)
813- if decodeErr != nil {
814- return decodeErr
815- }
816- if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
817- return ackErr
818+ if err := processOutputFrame(tr, frame); err != nil {
819+ return err
818820 }
819821 continue
820822 }
@@ -990,11 +992,43 @@ func runRemoteCommand(ctx context.Context, target string, command ...string) err
990992 return runCapturedCommand(ctx, spec.Path, spec.Args...)
991993 }
992994
995+func assertNoDirectHandoff(tr *harnessTransport) error {
996+ if tr == nil || tr.probe == nil {
997+ return errors.New("local picker transport has no visual probe")
998+ }
999+ // Inspect only the frames already queued at assertion start, retaining
1000+ // non-handoff frames for the next receiveWithTimeout call.
1001+ queued := len(tr.frames)
1002+ for range queued {
1003+ outcome := <-tr.frames
1004+ if outcome.frame.Type == ports.MsgAttachTarget {
1005+ tr.probe.recordIncoming(outcome.frame)
1006+ } else {
1007+ tr.queued = append(tr.queued, outcome)
1008+ }
1009+ }
1010+ if tr.probe.unexpectedHandoffs != 0 {
1011+ return fmt.Errorf("picker emitted %d direct MsgAttachTarget handoff frame(s)", tr.probe.unexpectedHandoffs)
1012+ }
1013+ return nil
1014+}
1015+
9931016 func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (ports.Frame, error) {
1017+ if len(tr.queued) != 0 {
1018+ outcome := tr.queued[0]
1019+ tr.queued = tr.queued[1:]
1020+ if outcome.err == nil {
1021+ tr.probe.recordIncoming(outcome.frame)
1022+ }
1023+ return outcome.frame, outcome.err
1024+ }
9941025 select {
9951026 case <-ctx.Done():
9961027 return ports.Frame{}, ctx.Err()
9971028 case outcome := <-tr.frames:
1029+ if outcome.err == nil {
1030+ tr.probe.recordIncoming(outcome.frame)
1031+ }
9981032 return outcome.frame, outcome.err
9991033 }
10001034 }
M scripts/remote-picker-harness/main_test.go +21 -0
@@ -4,6 +4,8 @@ import (
44 "errors"
55 "strings"
66 "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
79 )
810
911 func TestBoundedStderrRetainsAtMostDiagnosticLimit(t *testing.T) {
@@ -42,3 +44,22 @@ func TestRunCapturedCommandIncludesStderr(t *testing.T) {
4244 t.Fatalf("command error = %v", err)
4345 }
4446 }
47+
48+func TestAssertNoDirectHandoffProcessesInitialFramesAndRetainsOutput(t *testing.T) {
49+ tr := &harnessTransport{
50+ probe: &visualProbe{},
51+ frames: make(chan harnessFrame, 2),
52+ }
53+ tr.frames <- harnessFrame{frame: ports.Frame{Type: ports.MsgOutput}}
54+ tr.frames <- harnessFrame{frame: ports.Frame{Type: ports.MsgAttachTarget}}
55+
56+ if err := assertNoDirectHandoff(tr); err == nil {
57+ t.Fatal("direct handoff was not rejected")
58+ }
59+ if len(tr.queued) != 1 || tr.queued[0].frame.Type != ports.MsgOutput {
60+ t.Fatalf("queued frames = %#v, want one retained output", tr.queued)
61+ }
62+ if tr.probe.unexpectedHandoffs != 1 {
63+ t.Fatalf("unexpected handoffs = %d, want 1", tr.probe.unexpectedHandoffs)
64+ }
65+}
M scripts/remote-picker-harness/run.sh +26 -3
@@ -6,8 +6,9 @@ usage() {
66 Usage: scripts/remote-picker-harness/run.sh
77
88 Builds the current vev binary into a disposable two-container environment and
9-runs local-picker rich-handoff plus SSH-stdio, UDP, catalog, preview,
10-lifecycle-fence, and environment acceptance checks. Docker is invoked normally,
9+runs the unified same-connection local-picker proof plus SSH-stdio, UDP,
10+catalog, preview, lifecycle-fence, and environment acceptance checks. Docker is
11+invoked normally,
1112 so DOCKER_HOST and the active
1213 Docker context select the daemon; no socket path is assumed.
1314
@@ -16,6 +17,9 @@ Environment:
1617 already select the rootless daemon.
1718 VEV_HARNESS_BASE_IMAGE Base image for the disposable containers
1819 (default: ubuntu:24.04).
20+ VEV_HARNESS_ARTIFACT_DIR Host directory for the bounded probe artifact.
21+ The JSON report is copied there before container
22+ cleanup; no report is written when unset.
1923 USAGE
2024 }
2125
@@ -33,6 +37,11 @@ esac
3337
3438 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
3539 base_image="${VEV_HARNESS_BASE_IMAGE:-ubuntu:24.04}"
40+artifact_dir="${VEV_HARNESS_ARTIFACT_DIR:-}"
41+if [ -n "$artifact_dir" ]; then
42+ umask 077
43+ mkdir -p "$artifact_dir"
44+fi
3645 run_id="$(date +%s)-$$"
3746 image="vev-remote-picker-harness:${run_id}"
3847 network="vev-remote-picker-harness-${run_id}"
@@ -106,4 +115,18 @@ if ! docker exec --user test -e HOME=/home/test "$local_container" ssh -o BatchM
106115 fi
107116
108117 printf 'remote picker harness: running acceptance checks\n'
109-docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness
118+harness_status=0
119+if [ -n "$artifact_dir" ]; then
120+ docker exec --user test -e HOME=/home/test -e VEV_HARNESS_ARTIFACT_DIR=/tmp/vev-harness-artifact "$local_container" /usr/local/bin/remote-picker-harness || harness_status=$?
121+ if ! docker cp "$local_container:/tmp/vev-harness-artifact/remote-picker-harness.json" "$artifact_dir/remote-picker-harness.json"; then
122+ echo "remote picker harness: failed to collect probe artifact (acceptance status $harness_status)" >&2
123+ if [ "$harness_status" -ne 0 ]; then
124+ exit "$harness_status"
125+ fi
126+ exit 1
127+ fi
128+ chmod 0600 "$artifact_dir/remote-picker-harness.json"
129+else
130+ docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness || harness_status=$?
131+fi
132+exit "$harness_status"
A scripts/remote-picker-harness/visual_probe.go +481 -0
@@ -0,0 +1,481 @@
1+package main
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/pkg/vt"
15+)
16+
17+const (
18+ defaultProbeCols = 80
19+ defaultProbeRows = 24
20+
21+ maxProbeEvents = 128
22+ maxProbeCheckpoints = 16
23+ maxArtifactBytes = 64 << 10
24+
25+ probeEventOutput = "output"
26+ probeEventWelcome = "welcome"
27+ probeEventOutputReset = "output_reset_request"
28+ probeEventUnexpectedHandoff = "unexpected_attach_target"
29+)
30+
31+// visualOutputState mirrors the client's output dependency chain. A probe may
32+// inspect output only after the frame's epoch, base, and view revision continue
33+// the last accepted state.
34+type visualOutputState struct {
35+ epoch uint64
36+ state uint64
37+ viewRevision uint64
38+ initialized bool
39+}
40+
41+func (s visualOutputState) next(output ports.Output) (visualOutputState, bool) {
42+ if output.Epoch == 0 {
43+ return visualOutputState{}, false
44+ }
45+ if !s.initialized {
46+ if output.New == 0 {
47+ if output.Base != 0 || output.Full {
48+ return visualOutputState{}, false
49+ }
50+ return s, true
51+ }
52+ if output.Base != 0 || !output.Full {
53+ return visualOutputState{}, false
54+ }
55+ return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
56+ }
57+ if output.Epoch < s.epoch {
58+ return visualOutputState{}, false
59+ }
60+ if output.Epoch == s.epoch {
61+ if output.ViewRevision != s.viewRevision {
62+ return visualOutputState{}, false
63+ }
64+ if output.New == 0 {
65+ if output.Base != 0 || output.Full {
66+ return visualOutputState{}, false
67+ }
68+ return s, true
69+ }
70+ if output.Full || output.Base != s.state || output.New != output.Base+1 {
71+ return visualOutputState{}, false
72+ }
73+ return visualOutputState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
74+ }
75+ if !output.Full || output.Base != 0 || output.New == 0 {
76+ return visualOutputState{}, false
77+ }
78+ return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
79+}
80+
81+type probeEvent struct {
82+ Sequence uint64
83+ Transport string
84+ Kind string
85+ Accepted bool
86+ StateBearing bool
87+ Acked bool
88+ Epoch uint64
89+ Base uint64
90+ New uint64
91+ ViewRevision uint64
92+ Full bool
93+ DataBytes int
94+}
95+
96+type visualCheckpoint struct {
97+ event *probeEvent
98+ state visualOutputState
99+ snapshot vt.ScreenSnapshot
100+}
101+
102+type visualOutputResult struct {
103+ Accepted bool
104+ StateBearing bool
105+ Ack ports.Ack
106+ event *probeEvent
107+}
108+
109+// visualProbe is deliberately transport-local. A new physical transport gets
110+// one screen and one output state; waiting for a particular marker never
111+// creates a replacement screen and therefore cannot lose prior terminal state.
112+type visualProbe struct {
113+ label string
114+
115+ screen *vt.Screen
116+ state visualOutputState
117+
118+ events []*probeEvent
119+ checkpoints []visualCheckpoint
120+ nextEvent uint64
121+
122+ outputFrames int
123+ outputBytes int
124+ resetPending bool
125+
126+ unexpectedHandoffs int
127+}
128+
129+type harnessArtifactContextKey struct{}
130+
131+func withHarnessArtifact(ctx context.Context, artifact *harnessArtifact) context.Context {
132+ return context.WithValue(ctx, harnessArtifactContextKey{}, artifact)
133+}
134+
135+func harnessArtifactFromContext(ctx context.Context) *harnessArtifact {
136+ if ctx == nil {
137+ return nil
138+ }
139+ artifact, _ := ctx.Value(harnessArtifactContextKey{}).(*harnessArtifact)
140+ return artifact
141+}
142+
143+func newVisualProbe(size domain.Size) *visualProbe {
144+ width, height := size.Cols, size.Rows
145+ if width <= 0 {
146+ width = defaultProbeCols
147+ }
148+ if height <= 0 {
149+ height = defaultProbeRows
150+ }
151+ return &visualProbe{screen: vt.NewScreen(width, height)}
152+}
153+
154+func (p *visualProbe) configure(label string) {
155+ if p == nil {
156+ return
157+ }
158+ p.label = label
159+}
160+
161+func (p *visualProbe) record(event probeEvent) *probeEvent {
162+ if p == nil {
163+ return nil
164+ }
165+ p.nextEvent++
166+ event.Sequence = p.nextEvent
167+ event.Transport = p.label
168+ owned := &event
169+ if len(p.events) == maxProbeEvents {
170+ p.events = p.events[1:]
171+ }
172+ p.events = append(p.events, owned)
173+ return owned
174+}
175+
176+func (p *visualProbe) recordControl(kind string) {
177+ if p == nil {
178+ return
179+ }
180+ p.record(probeEvent{Kind: kind, Accepted: true})
181+}
182+
183+func (p *visualProbe) apply(output ports.Output) visualOutputResult {
184+ if p == nil {
185+ return visualOutputResult{}
186+ }
187+ p.outputFrames++
188+ p.outputBytes += len(output.Data)
189+ accepted := ports.ValidateOutput(output) == nil
190+ next := visualOutputState{}
191+ if accepted {
192+ next, accepted = p.state.next(output)
193+ }
194+ event := p.record(probeEvent{
195+ Kind: probeEventOutput,
196+ Accepted: accepted,
197+ StateBearing: output.New != 0,
198+ Epoch: output.Epoch,
199+ Base: output.Base,
200+ New: output.New,
201+ ViewRevision: output.ViewRevision,
202+ Full: output.Full,
203+ DataBytes: len(output.Data),
204+ })
205+ if !accepted {
206+ return visualOutputResult{event: event}
207+ }
208+
209+ // The client writes side effects as well as state-bearing frames, but only
210+ // state-bearing frames advance the dependency chain and receive an ACK.
211+ if p.screen.Frame.Width != output.Size.Cols || p.screen.Frame.Height != output.Size.Rows {
212+ p.screen.Resize(output.Size.Cols, output.Size.Rows)
213+ }
214+ p.screen.Write(output.Data)
215+ p.state = next
216+ checkpoint := visualCheckpoint{event: event, state: p.state, snapshot: p.screen.Snapshot()}
217+ if len(p.checkpoints) == maxProbeCheckpoints {
218+ p.checkpoints = p.checkpoints[1:]
219+ }
220+ p.checkpoints = append(p.checkpoints, checkpoint)
221+ result := visualOutputResult{Accepted: true, StateBearing: output.New != 0, event: event}
222+ if result.StateBearing {
223+ result.Ack = ports.Ack{Epoch: output.Epoch, State: output.New}
224+ }
225+ return result
226+}
227+
228+func (p *visualProbe) text() string {
229+ if p == nil || p.screen == nil {
230+ return ""
231+ }
232+ return visualScreenText(p.screen)
233+}
234+
235+func (p *visualProbe) contains(want string) bool {
236+ return strings.Contains(p.text(), want)
237+}
238+
239+func (p *visualProbe) recordIncoming(frame ports.Frame) {
240+ if p == nil || frame.Type != ports.MsgAttachTarget {
241+ return
242+ }
243+ p.unexpectedHandoffs++
244+ p.record(probeEvent{Kind: probeEventUnexpectedHandoff})
245+}
246+
247+func (p *visualProbe) markAcked(result visualOutputResult) {
248+ if result.event != nil && result.StateBearing && result.Accepted {
249+ result.event.Acked = true
250+ }
251+}
252+
253+func visualScreenLines(screen *vt.Screen) []string {
254+ if screen == nil {
255+ return nil
256+ }
257+ lines := make([]string, screen.Frame.Height)
258+ for y := range screen.Frame.Height {
259+ var line strings.Builder
260+ for x := range screen.Frame.Width {
261+ r := screen.Frame.At(x, y).Rune
262+ if r == 0 {
263+ r = ' '
264+ }
265+ line.WriteRune(r)
266+ }
267+ lines[y] = line.String()
268+ }
269+ return lines
270+}
271+
272+func visualScreenText(screen *vt.Screen) string {
273+ lines := visualScreenLines(screen)
274+ if len(lines) == 0 {
275+ return ""
276+ }
277+ return strings.Join(lines, "\n") + "\n"
278+}
279+
280+func capturePickerRows(probe *visualProbe) []string {
281+ if probe == nil {
282+ return nil
283+ }
284+ var rows []string
285+ for _, line := range visualScreenLines(probe.screen) {
286+ if line = strings.TrimSpace(line); line != "" {
287+ rows = append(rows, line)
288+ }
289+ }
290+ return rows
291+}
292+
293+func assertUnifiedPickerRows(rows []string, localSession, remoteSession string) error {
294+ if len(rows) == 0 {
295+ return errors.New("picker screen had no visible rows")
296+ }
297+ contains := func(want string) bool {
298+ for _, row := range rows {
299+ if strings.Contains(row, want) {
300+ return true
301+ }
302+ }
303+ return false
304+ }
305+ if !contains(localSession) {
306+ return fmt.Errorf("unified picker rows omitted local session %q", localSession)
307+ }
308+ remoteRow := remoteSession + "@remote"
309+ if !contains(remoteRow) {
310+ return fmt.Errorf("unified picker rows omitted remote session %q", remoteRow)
311+ }
312+ return nil
313+}
314+
315+func assertNoRemoteChrome(probe *visualProbe, remoteSession string) error {
316+ if probe == nil {
317+ return errors.New("remote view probe was not created")
318+ }
319+ lines := visualScreenLines(probe.screen)
320+ if len(lines) < 3 {
321+ return errors.New("remote view did not expose a content region")
322+ }
323+ // The first and last rows belong to the local attachment's bars. Remote
324+ // daemon chrome must not be replayed into the content rows.
325+ for _, line := range lines[1 : len(lines)-1] {
326+ if strings.Contains(line, remoteSession+"@remote") || strings.Contains(line, " Sessions · ") {
327+ return fmt.Errorf("remote chrome leaked into content row %q", strings.TrimSpace(line))
328+ }
329+ }
330+ return nil
331+}
332+
333+type harnessArtifact struct {
334+ dir string
335+ probes []*visualProbe
336+}
337+
338+func newHarnessArtifact(dir string) *harnessArtifact {
339+ if dir == "" {
340+ return nil
341+ }
342+ return &harnessArtifact{dir: dir}
343+}
344+
345+func (a *harnessArtifact) registerProbe(probe *visualProbe) {
346+ if a == nil || probe == nil {
347+ return
348+ }
349+ a.probes = append(a.probes, probe)
350+}
351+
352+type artifactEvent struct {
353+ Sequence uint64 `json:"sequence"`
354+ Transport string `json:"transport"`
355+ Kind string `json:"kind"`
356+ Accepted bool `json:"accepted"`
357+ StateBearing bool `json:"state_bearing"`
358+ Acked bool `json:"acked"`
359+ Epoch uint64 `json:"epoch,omitempty"`
360+ Base uint64 `json:"base,omitempty"`
361+ New uint64 `json:"new,omitempty"`
362+ ViewRevision uint64 `json:"view_revision,omitempty"`
363+ Full bool `json:"full,omitempty"`
364+ DataBytes int `json:"data_bytes,omitempty"`
365+}
366+
367+type artifactCheckpoint struct {
368+ Sequence uint64 `json:"sequence"`
369+ Epoch uint64 `json:"epoch,omitempty"`
370+ State uint64 `json:"state,omitempty"`
371+ ViewRevision uint64 `json:"view_revision,omitempty"`
372+ Columns int `json:"columns"`
373+ Rows int `json:"rows"`
374+ CursorRow int `json:"cursor_row"`
375+ CursorColumn int `json:"cursor_column"`
376+ AlternateScreen bool `json:"alternate_screen"`
377+ BracketedPaste bool `json:"bracketed_paste"`
378+ SynchronizedUpdate bool `json:"synchronized_update"`
379+}
380+
381+type artifactProbe struct {
382+ Transport string `json:"transport"`
383+ Events []artifactEvent `json:"events"`
384+ Checkpoints []artifactCheckpoint `json:"checkpoints"`
385+}
386+
387+type artifactDocument struct {
388+ Version int `json:"version"`
389+ Passed bool `json:"passed"`
390+ Probes []artifactProbe `json:"probes"`
391+}
392+
393+func (a *harnessArtifact) document(passed bool) artifactDocument {
394+ document := artifactDocument{Version: 1, Passed: passed}
395+ for _, probe := range a.probes {
396+ if probe == nil {
397+ continue
398+ }
399+ item := artifactProbe{Transport: probe.label}
400+ for _, event := range probe.events {
401+ if event == nil {
402+ continue
403+ }
404+ item.Events = append(item.Events, artifactEventFrom(event))
405+ }
406+ for _, checkpoint := range probe.checkpoints {
407+ snapshot := checkpoint.snapshot
408+ cursor := snapshot.Cursor()
409+ modes := snapshot.Modes()
410+ item.Checkpoints = append(item.Checkpoints, artifactCheckpoint{
411+ Sequence: checkpoint.event.Sequence,
412+ Epoch: checkpoint.state.epoch,
413+ State: checkpoint.state.state,
414+ ViewRevision: checkpoint.state.viewRevision,
415+ Columns: snapshot.Columns(),
416+ Rows: snapshot.Rows(),
417+ CursorRow: cursor.Row,
418+ CursorColumn: cursor.Col,
419+ AlternateScreen: modes.AlternateScreen,
420+ BracketedPaste: modes.BracketedPaste,
421+ SynchronizedUpdate: modes.SynchronizedUpdate,
422+ })
423+ }
424+ document.Probes = append(document.Probes, item)
425+ }
426+ return document
427+}
428+
429+func artifactEventFrom(event *probeEvent) artifactEvent {
430+ return artifactEvent{
431+ Sequence: event.Sequence,
432+ Transport: event.Transport,
433+ Kind: event.Kind,
434+ Accepted: event.Accepted,
435+ StateBearing: event.StateBearing,
436+ Acked: event.Acked,
437+ Epoch: event.Epoch,
438+ Base: event.Base,
439+ New: event.New,
440+ ViewRevision: event.ViewRevision,
441+ Full: event.Full,
442+ DataBytes: event.DataBytes,
443+ }
444+}
445+
446+func (a *harnessArtifact) write(passed bool) error {
447+ if a == nil || a.dir == "" {
448+ return nil
449+ }
450+ data, err := json.MarshalIndent(a.document(passed), "", " ")
451+ if err != nil {
452+ return fmt.Errorf("marshal harness artifact: %w", err)
453+ }
454+ if len(data) > maxArtifactBytes {
455+ return fmt.Errorf("harness artifact exceeds %d bytes", maxArtifactBytes)
456+ }
457+ if err := os.MkdirAll(a.dir, 0o700); err != nil {
458+ return fmt.Errorf("create harness artifact directory: %w", err)
459+ }
460+ tmp, err := os.CreateTemp(a.dir, ".remote-picker-harness-*.tmp")
461+ if err != nil {
462+ return fmt.Errorf("create harness artifact: %w", err)
463+ }
464+ tmpName := tmp.Name()
465+ defer func() { _ = os.Remove(tmpName) }()
466+ if err := tmp.Chmod(0o600); err != nil {
467+ _ = tmp.Close()
468+ return fmt.Errorf("protect harness artifact: %w", err)
469+ }
470+ if _, err := tmp.Write(data); err != nil {
471+ _ = tmp.Close()
472+ return fmt.Errorf("write harness artifact: %w", err)
473+ }
474+ if err := tmp.Close(); err != nil {
475+ return fmt.Errorf("close harness artifact: %w", err)
476+ }
477+ if err := os.Rename(tmpName, filepath.Join(a.dir, "remote-picker-harness.json")); err != nil {
478+ return fmt.Errorf("publish harness artifact: %w", err)
479+ }
480+ return nil
481+}
A scripts/remote-picker-harness/visual_probe_test.go +345 -0
@@ -0,0 +1,345 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "os"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+const (
16+ probeTestCols = 32
17+ probeTestRows = 2
18+)
19+
20+type probeTestTransport struct {
21+ sent []ports.Frame
22+}
23+
24+func (t *probeTestTransport) Send(frame ports.Frame) error {
25+ t.sent = append(t.sent, frame)
26+ return nil
27+}
28+
29+func (*probeTestTransport) Recv() (ports.Frame, error) { return ports.Frame{}, errors.New("not used") }
30+func (*probeTestTransport) Close() error { return nil }
31+
32+func probeTestOutput(t *testing.T, output ports.Output) ports.Frame {
33+ t.Helper()
34+ payload, err := ports.MarshalOutput(output)
35+ if err != nil {
36+ t.Fatalf("marshal output: %v", err)
37+ }
38+ return ports.Frame{Type: ports.MsgOutput, Payload: payload}
39+}
40+
41+func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) {
42+ size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
43+ probe := newVisualProbe(size)
44+ for _, tt := range []struct {
45+ name string
46+ output ports.Output
47+ want string
48+ state uint64
49+ }{
50+ {
51+ name: "full",
52+ output: ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")},
53+ want: "full",
54+ state: 1,
55+ },
56+ {
57+ name: "incremental",
58+ output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte(" incremental")},
59+ want: "full incremental",
60+ state: 2,
61+ },
62+ } {
63+ t.Run(tt.name, func(t *testing.T) {
64+ result := probe.apply(tt.output)
65+ if !result.Accepted || !result.StateBearing {
66+ t.Fatalf("result = %+v, want accepted state-bearing output", result)
67+ }
68+ if result.Ack.Epoch != tt.output.Epoch || result.Ack.State != tt.state {
69+ t.Fatalf("ack = %+v, want epoch=%d state=%d", result.Ack, tt.output.Epoch, tt.state)
70+ }
71+ if !strings.Contains(probe.text(), tt.want) {
72+ t.Fatalf("screen = %q, want %q", probe.text(), tt.want)
73+ }
74+ if probe.state.state != tt.state {
75+ t.Fatalf("state = %d, want %d", probe.state.state, tt.state)
76+ }
77+ })
78+ }
79+ if got := len(probe.checkpoints); got != 2 {
80+ t.Fatalf("checkpoints = %d, want 2", got)
81+ }
82+ if got := len(probe.events); got != 2 {
83+ t.Fatalf("events = %d, want 2", got)
84+ }
85+}
86+
87+func TestVisualProbeResizesToAcceptedOutput(t *testing.T) {
88+ probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
89+ outputSize := domain.Size{Cols: 48, Rows: 3}
90+ result := probe.apply(ports.Output{
91+ Epoch: 1, New: 1, Full: true, ViewRevision: 1, Size: outputSize, Data: []byte("resized"),
92+ })
93+
94+ require.True(t, result.Accepted)
95+ require.Equal(t, outputSize.Cols, probe.screen.Frame.Width)
96+ require.Equal(t, outputSize.Rows, probe.screen.Frame.Height)
97+ require.Len(t, probe.checkpoints, 1)
98+ require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns())
99+ require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows())
100+}
101+
102+func TestVisualProbeRejectsOutputWithoutMutationOrAck(t *testing.T) {
103+ size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
104+ for _, tt := range []struct {
105+ name string
106+ output ports.Output
107+ }{
108+ {
109+ name: "base gap",
110+ output: ports.Output{Epoch: 1, Base: 0, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("rejected")},
111+ },
112+ {
113+ name: "revision gap",
114+ output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 5, Size: size, Data: []byte("rejected")},
115+ },
116+ {
117+ name: "stale epoch",
118+ output: ports.Output{Epoch: 0, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte("rejected")},
119+ },
120+ } {
121+ t.Run(tt.name, func(t *testing.T) {
122+ probe := newVisualProbe(size)
123+ first := ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("keep")}
124+ if result := probe.apply(first); !result.Accepted {
125+ t.Fatal("seed output was rejected")
126+ }
127+ beforeText, beforeState, beforeCheckpoints := probe.text(), probe.state, len(probe.checkpoints)
128+ result := probe.apply(tt.output)
129+ if result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
130+ t.Fatalf("result = %+v, want rejected output without ACK", result)
131+ }
132+ if got := probe.text(); got != beforeText {
133+ t.Fatalf("screen changed from %q to %q", beforeText, got)
134+ }
135+ if probe.state != beforeState {
136+ t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState)
137+ }
138+ if len(probe.checkpoints) != beforeCheckpoints {
139+ t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints)
140+ }
141+ })
142+ }
143+}
144+
145+func TestVisualProbeRejectsInvalidUninitializedSideEffects(t *testing.T) {
146+ size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
147+ for _, output := range []ports.Output{
148+ {Epoch: 1, Base: 1, Size: size, Data: []byte("invalid base")},
149+ {Epoch: 1, Full: true, Size: size, Data: []byte("invalid full")},
150+ } {
151+ probe := newVisualProbe(size)
152+ result := probe.apply(output)
153+ if result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
154+ t.Fatalf("result = %+v, want rejected uninitialized side effect", result)
155+ }
156+ require.Len(t, probe.events, 1)
157+ require.False(t, probe.events[0].Accepted)
158+ require.Empty(t, probe.checkpoints)
159+ }
160+}
161+
162+func TestVisualProbeSideEffectsApplyWithoutAck(t *testing.T) {
163+ size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
164+ for _, tt := range []struct {
165+ name string
166+ seed *ports.Output
167+ effect ports.Output
168+ }{
169+ {
170+ name: "before state-bearing output",
171+ effect: ports.Output{Epoch: 1, Size: size, Data: []byte("side effect")},
172+ },
173+ {
174+ name: "after state-bearing output",
175+ seed: &ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")},
176+ effect: ports.Output{Epoch: 1, ViewRevision: 4, Size: size, Data: []byte(" side effect")},
177+ },
178+ } {
179+ t.Run(tt.name, func(t *testing.T) {
180+ probe := newVisualProbe(size)
181+ if tt.seed != nil && !probe.apply(*tt.seed).Accepted {
182+ t.Fatal("seed output was rejected")
183+ }
184+ beforeState, beforeCheckpoints := probe.state, len(probe.checkpoints)
185+ result := probe.apply(tt.effect)
186+ if !result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
187+ t.Fatalf("result = %+v, want accepted side effect without ACK", result)
188+ }
189+ if !strings.Contains(probe.text(), string(tt.effect.Data)) {
190+ t.Fatalf("screen = %q, want side effect %q", probe.text(), tt.effect.Data)
191+ }
192+ if probe.state != beforeState {
193+ t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState)
194+ }
195+ if len(probe.checkpoints) != beforeCheckpoints+1 {
196+ t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints+1)
197+ }
198+ })
199+ }
200+}
201+
202+func TestProcessOutputFrameACKsOnlyAcceptedStateBearingFrames(t *testing.T) {
203+ size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
204+ for _, tt := range []struct {
205+ name string
206+ seed *ports.Output
207+ output ports.Output
208+ wantACKs int
209+ wantResets int
210+ }{
211+ {
212+ name: "accepted full",
213+ output: ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")},
214+ wantACKs: 1,
215+ },
216+ {
217+ name: "accepted side effect",
218+ output: ports.Output{Epoch: 1, Size: size, Data: []byte("side effect")},
219+ },
220+ {
221+ name: "rejected state",
222+ seed: &ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")},
223+ output: ports.Output{Epoch: 1, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("gap")},
224+ wantResets: 1,
225+ },
226+ } {
227+ t.Run(tt.name, func(t *testing.T) {
228+ transport := &probeTestTransport{}
229+ tr := &harnessTransport{Transport: transport, probe: newVisualProbe(size)}
230+ if tt.seed != nil {
231+ if err := processOutputFrame(tr, probeTestOutput(t, *tt.seed)); err != nil {
232+ t.Fatal(err)
233+ }
234+ transport.sent = nil
235+ }
236+ if err := processOutputFrame(tr, probeTestOutput(t, tt.output)); err != nil {
237+ t.Fatal(err)
238+ }
239+ var ackCount, resetCount int
240+ for _, frame := range transport.sent {
241+ switch frame.Type {
242+ case ports.MsgAck:
243+ ackCount++
244+ case ports.MsgOutputResetRequest:
245+ resetCount++
246+ }
247+ }
248+ if ackCount != tt.wantACKs {
249+ t.Fatalf("ACK count = %d, want %d", ackCount, tt.wantACKs)
250+ }
251+ if resetCount != tt.wantResets {
252+ t.Fatalf("reset count = %d, want %d", resetCount, tt.wantResets)
253+ }
254+ })
255+ }
256+}
257+
258+func TestCaptureUnifiedPickerRowsRequiresLocalAndRemoteEntries(t *testing.T) {
259+ probe := newVisualProbe(domain.Size{Cols: 48, Rows: 4})
260+ probe.screen.Write([]byte("local-picker\npicker@remote"))
261+
262+ rows := capturePickerRows(probe)
263+ if err := assertUnifiedPickerRows(rows, "local-picker", "picker"); err != nil {
264+ t.Fatalf("assert unified picker rows: %v; rows = %#v", err, rows)
265+ }
266+}
267+
268+func TestVisualProbeRecordsUnexpectedAttachTarget(t *testing.T) {
269+ probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
270+ probe.recordIncoming(ports.Frame{Type: ports.MsgAttachTarget})
271+
272+ if probe.unexpectedHandoffs != 1 {
273+ t.Fatalf("unexpected handoffs = %d, want 1", probe.unexpectedHandoffs)
274+ }
275+ if len(probe.events) != 1 || probe.events[0].Kind != probeEventUnexpectedHandoff {
276+ t.Fatalf("events = %#v, want one unexpected handoff event", probe.events)
277+ }
278+}
279+
280+func TestAssertNoRemoteChromeIgnoresLocalBarsButRejectsContentLeak(t *testing.T) {
281+ require.Error(t, assertNoRemoteChrome(nil, "picker"))
282+ for _, test := range []struct {
283+ name string
284+ content string
285+ wantErr bool
286+ }{
287+ {name: "local bars", content: "picker@remote\r\nremote content\r\nlocal bottom", wantErr: false},
288+ {name: "remote status in content", content: "local top\r\npicker@remote\r\nlocal bottom", wantErr: true},
289+ } {
290+ t.Run(test.name, func(t *testing.T) {
291+ probe := newVisualProbe(domain.Size{Cols: 48, Rows: 3})
292+ probe.screen.Write([]byte(test.content))
293+ err := assertNoRemoteChrome(probe, "picker")
294+ if (err != nil) != test.wantErr {
295+ t.Fatalf("error = %v, want error = %t", err, test.wantErr)
296+ }
297+ })
298+ }
299+}
300+
301+func TestHarnessArtifactIsBoundedAndContainsOnlyMetadata(t *testing.T) {
302+ dir := t.TempDir()
303+ artifact := newHarnessArtifact(dir)
304+ size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
305+ probe := newVisualProbe(size)
306+ probe.configure("local-picker")
307+ artifact.registerProbe(probe)
308+ secret := "not-for-artifact"
309+ for state := uint64(1); state <= maxProbeEvents+1; state++ {
310+ output := ports.Output{Epoch: 1, Base: state - 1, New: state, Size: size, Data: []byte(secret)}
311+ if state == 1 {
312+ output.Full = true
313+ }
314+ if result := probe.apply(output); !result.Accepted {
315+ t.Fatalf("probe output %d was rejected", state)
316+ }
317+ }
318+ if err := artifact.write(true); err != nil {
319+ t.Fatal(err)
320+ }
321+ path := dir + "/remote-picker-harness.json"
322+ data, err := os.ReadFile(path)
323+ if err != nil {
324+ t.Fatal(err)
325+ }
326+ if len(data) > maxArtifactBytes {
327+ t.Fatalf("artifact bytes = %d, want <= %d", len(data), maxArtifactBytes)
328+ }
329+ if strings.Contains(string(data), secret) {
330+ t.Fatal("artifact contained raw output")
331+ }
332+ var document artifactDocument
333+ if err := json.Unmarshal(data, &document); err != nil {
334+ t.Fatalf("artifact JSON: %v", err)
335+ }
336+ if !document.Passed || len(document.Probes) != 1 {
337+ t.Fatalf("artifact document = %+v, want one passed probe", document)
338+ }
339+ if len(document.Probes[0].Events) != maxProbeEvents {
340+ t.Fatalf("artifact events = %d, want %d", len(document.Probes[0].Events), maxProbeEvents)
341+ }
342+ if len(document.Probes[0].Checkpoints) != maxProbeCheckpoints {
343+ t.Fatalf("artifact checkpoints = %d, want %d", len(document.Probes[0].Checkpoints), maxProbeCheckpoints)
344+ }
345+}

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