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 {
185 185
186 func validCatalogState(state string) bool {186 func validCatalogState(state string) bool {
187 switch state {187 switch state {
188- case "running", "stopped", "broken":188+ case "up", "down", "broken":
189 return true189 return true
190 default:190 default:
191 return false191 return false
M internal/adapters/remote/catalog_cache_test.go +5 -5
@@ -30,8 +30,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
30 Host: "zebra",30 Host: "zebra",
31 FetchedAt: fetchedAt,31 FetchedAt: fetchedAt,
32 Sessions: []ports.RemoteCatalogSession{32 Sessions: []ports.RemoteCatalogSession{
33- {Name: "work", State: "running", Tabs: 2},33+ {Name: "work", State: "up", Tabs: 2},
34- {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true},34+ {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},
35 },35 },
36 },36 },
37 {37 {
@@ -72,8 +72,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
72 Host: "zebra",72 Host: "zebra",
73 FetchedAt: fetchedAt,73 FetchedAt: fetchedAt,
74 Sessions: []ports.RemoteCatalogSession{74 Sessions: []ports.RemoteCatalogSession{
75- {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true},75+ {Name: "alpha", State: "down", Ephemeral: true, Tabs: 1, Attached: true},
76- {Name: "work", State: "running", Tabs: 2},76+ {Name: "work", State: "up", Tabs: 2},
77 },77 },
78 },78 },
79 }79 }
@@ -122,7 +122,7 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
122 {name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},122 {name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},
123 {name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},123 {name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},
124 {name: "duplicate hosts", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},124 {name: "duplicate hosts", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},
125- {name: "duplicate sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"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}]}]}`)},
126 {name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},126 {name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},
127 }127 }
128 for _, tc := range cases {128 for _, tc := range cases {
M internal/adapters/remote/catalog_test.go +2 -2
@@ -83,7 +83,7 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
83 valid := ports.RemoteCatalog{83 valid := ports.RemoteCatalog{
84 ProtocolVersion: ports.ProtocolVersion,84 ProtocolVersion: ports.ProtocolVersion,
85 Sessions: []ports.RemoteCatalogSession{85 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},
87 },87 },
88 }88 }
89 validJSON := mustCatalogJSON(t, valid)89 validJSON := mustCatalogJSON(t, valid)
@@ -148,7 +148,7 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
148 },148 },
149 {149 {
150 name: "invalid session name",150 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"}}}),
152 wantErr: errCatalogSession,152 wantErr: errCatalogSession,
153 },153 },
154 {154 {
M internal/app/remote_hosts.go +4 -4
@@ -205,10 +205,10 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [
205 Attached: session.Attached,205 Attached: session.Attached,
206 }206 }
207 switch session.State {207 switch session.State {
208- case "running":208+ case "up":
209- info.State = ports.SessionRunning209+ info.State = ports.SessionUp
210- case "stopped":210+ case "down":
211- info.State = ports.SessionStopped211+ info.State = ports.SessionDown
212 case "broken":212 case "broken":
213 info.State = ports.SessionBroken213 info.State = ports.SessionBroken
214 default:214 default:
M internal/app/remote_test.go +9 -9
@@ -171,7 +171,7 @@ func TestRemoteHostListingUsesUnifiedStore(t *testing.T) {
171 store := portsmocks.NewMockRemoteHostStore(t)171 store := portsmocks.NewMockRemoteHostStore(t)
172 store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()172 store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()
173 catalog := portsmocks.NewMockRemoteCatalogClient(t)173 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()
175 var out bytes.Buffer175 var out bytes.Buffer
176 err := runRemoteList(context.Background(), command{listHost: "arch"}, remoteHostDeps{176 err := runRemoteList(context.Background(), command{listHost: "arch"}, remoteHostDeps{
177 store: store,177 store: store,
@@ -194,7 +194,7 @@ func TestListAllSessionsInvariants(t *testing.T) {
194 var out bytes.Buffer194 var out bytes.Buffer
195 err := listAllSessions(context.Background(), remoteHostDeps{195 err := listAllSessions(context.Background(), remoteHostDeps{
196 localList: func(context.Context) ([]ports.SessionInfo, error) {196 localList: func(context.Context) ([]ports.SessionInfo, error) {
197- return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, localErr197+ return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, localErr
198 },198 },
199 stdout: &out,199 stdout: &out,
200 }, nil)200 }, nil)
@@ -210,13 +210,13 @@ func TestListAllSessionsInvariants(t *testing.T) {
210 catalog := portsmocks.NewMockRemoteCatalogClient(t)210 catalog := portsmocks.NewMockRemoteCatalogClient(t)
211 catalog.EXPECT().List(mock.Anything, "arch").RunAndReturn(func(context.Context, string) (ports.RemoteCatalog, error) {211 catalog.EXPECT().List(mock.Anything, "arch").RunAndReturn(func(context.Context, string) (ports.RemoteCatalog, error) {
212 cancel()212 cancel()
213- return ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "running"}}}, nil213+ return ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil
214 }).Once()214 }).Once()
215 var out bytes.Buffer215 var out bytes.Buffer
216 err := listAllSessions(ctx, remoteHostDeps{216 err := listAllSessions(ctx, remoteHostDeps{
217 catalog: catalog,217 catalog: catalog,
218 localList: func(context.Context) ([]ports.SessionInfo, error) {218 localList: func(context.Context) ([]ports.SessionInfo, error) {
219- return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, nil219+ return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, nil
220 },220 },
221 stdout: &out,221 stdout: &out,
222 }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})222 }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
@@ -233,15 +233,15 @@ func TestListAllSessionsInvariants(t *testing.T) {
233 var calls []string233 var calls []string
234 catalog.EXPECT().List(mock.Anything, "arch").Run(func(_ context.Context, target string) {234 catalog.EXPECT().List(mock.Anything, "arch").Run(func(_ context.Context, target string) {
235 calls = append(calls, target)235 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()
237 catalog.EXPECT().List(mock.Anything, "mule").Run(func(_ context.Context, target string) {237 catalog.EXPECT().List(mock.Anything, "mule").Run(func(_ context.Context, target string) {
238 calls = append(calls, target)238 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()
240 var out bytes.Buffer240 var out bytes.Buffer
241 err := listAllSessions(context.Background(), remoteHostDeps{241 err := listAllSessions(context.Background(), remoteHostDeps{
242 catalog: catalog,242 catalog: catalog,
243 localList: func(context.Context) ([]ports.SessionInfo, error) {243 localList: func(context.Context) ([]ports.SessionInfo, error) {
244- return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, nil244+ return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, nil
245 },245 },
246 stdout: &out,246 stdout: &out,
247 }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})247 }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
@@ -265,8 +265,8 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
265 wantState ports.SessionState265 wantState ports.SessionState
266 wantTabs uint16266 wantTabs uint16
267 }{267 }{
268- {name: "running", session: ports.RemoteCatalogSession{Name: "dev", State: "running", Tabs: 2}, wantState: ports.SessionRunning, wantTabs: 2},268+ {name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: 2}, wantState: ports.SessionUp, wantTabs: 2},
269- {name: "stopped", session: ports.RemoteCatalogSession{Name: "dev", State: "stopped", Tabs: 2}, wantState: ports.SessionStopped, wantTabs: 2},269+ {name: "down", session: ports.RemoteCatalogSession{Name: "dev", State: "down", Tabs: 2}, wantState: ports.SessionDown, wantTabs: 2},
270 {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},270 {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
271 {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},271 {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
272 }272 }
M internal/app/run.go +4 -4
@@ -1434,7 +1434,7 @@ func listLocalSessions(ctx context.Context) (_ []ports.SessionInfo, retErr error
1434 }1434 }
1435 infos := make([]ports.SessionInfo, 0, len(records))1435 infos := make([]ports.SessionInfo, 0, len(records))
1436 for _, r := range records {1436 for _, r := range records {
1437- state := ports.SessionStopped1437+ state := ports.SessionDown
1438 if r.DegradedReason != "" {1438 if r.DegradedReason != "" {
1439 state = ports.SessionBroken1439 state = ports.SessionBroken
1440 }1440 }
@@ -1481,12 +1481,12 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
1481 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)1481 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
1482 _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED")1482 _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED")
1483 for _, s := range sessions {1483 for _, s := range sessions {
1484- state := "running"1484+ state := "up"
1485 tabs := fmt.Sprintf("%d", s.Tabs)1485 tabs := fmt.Sprintf("%d", s.Tabs)
1486 attached := "no"1486 attached := "no"
1487 switch s.State {1487 switch s.State {
1488- case ports.SessionStopped:1488+ case ports.SessionDown:
1489- state = "stopped"1489+ state = "down"
1490 tabs = "-"1490 tabs = "-"
1491 case ports.SessionBroken:1491 case ports.SessionBroken:
1492 state = "broken"1492 state = "broken"
M internal/app/run_test.go +14 -14
@@ -575,7 +575,7 @@ func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
575 t.Run(tt.name, func(t *testing.T) {575 t.Run(tt.name, func(t *testing.T) {
576 cmd, err := parseArgs([]string{"attach", tt.arg})576 cmd, err := parseArgs([]string{"attach", tt.arg})
577 require.NoError(t, err)577 require.NoError(t, err)
578- connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{578+ connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.AttachRequest{
579 Intent: cmd.intent,579 Intent: cmd.intent,
580 SessionName: cmd.name,580 SessionName: cmd.name,
581 })581 })
@@ -588,26 +588,26 @@ func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
588 func TestListShowsBroken(t *testing.T) {588 func TestListShowsBroken(t *testing.T) {
589 var out bytes.Buffer589 var out bytes.Buffer
590 printSessions(&out, []ports.SessionInfo{590 printSessions(&out, []ports.SessionInfo{
591- {Name: "fresh", State: ports.SessionStopped},591+ {Name: "fresh", State: ports.SessionDown},
592- {Name: "loading", State: ports.SessionStopped},592+ {Name: "loading", State: ports.SessionDown},
593 {Name: "broken", State: ports.SessionBroken},593 {Name: "broken", State: ports.SessionBroken},
594 })594 })
595 require.Contains(t, out.String(), "fresh")595 require.Contains(t, out.String(), "fresh")
596- require.Contains(t, out.String(), "stopped")596+ require.Contains(t, out.String(), "down")
597 require.Contains(t, out.String(), "loading")597 require.Contains(t, out.String(), "loading")
598 require.Contains(t, out.String(), "broken")598 require.Contains(t, out.String(), "broken")
599 require.NotContains(t, out.String(), "restoring")599 require.NotContains(t, out.String(), "restoring")
600 require.NotContains(t, out.String(), "degraded")600 require.NotContains(t, out.String(), "degraded")
601 }601 }
602 602
603-func TestPrintSessionsShowsStoppedState(t *testing.T) {603+func TestPrintSessionsShowsDownState(t *testing.T) {
604 var out bytes.Buffer604 var out bytes.Buffer
605 printSessions(&out, []ports.SessionInfo{605 printSessions(&out, []ports.SessionInfo{
606- {Name: "main", State: ports.SessionRunning, Tabs: 2, Attached: true},606+ {Name: "main", State: ports.SessionUp, Tabs: 2, Attached: true},
607- {Name: "old", State: ports.SessionStopped},607+ {Name: "old", State: ports.SessionDown},
608 })608 })
609 got := out.String()609 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", "-"} {
611 if !strings.Contains(got, want) {611 if !strings.Contains(got, want) {
612 t.Fatalf("printSessions output %q missing %q", got, want)612 t.Fatalf("printSessions output %q missing %q", got, want)
613 }613 }
@@ -617,12 +617,12 @@ func TestPrintSessionsShowsStoppedState(t *testing.T) {
617 func TestPrintSessionsMarksEphemeral(t *testing.T) {617 func TestPrintSessionsMarksEphemeral(t *testing.T) {
618 var buf bytes.Buffer618 var buf bytes.Buffer
619 printSessions(&buf, []ports.SessionInfo{619 printSessions(&buf, []ports.SessionInfo{
620- {Name: "0", State: ports.SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},620+ {Name: "0", State: ports.SessionUp, Ephemeral: true, Tabs: 1, Attached: false},
621- {Name: "work", State: ports.SessionRunning, Tabs: 2, Attached: true},621+ {Name: "work", State: ports.SessionUp, Tabs: 2, Attached: true},
622- {Name: "old", State: ports.SessionStopped},622+ {Name: "old", State: ports.SessionDown},
623 })623 })
624 out := buf.String()624 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"} {
626 require.Contains(t, out, want)626 require.Contains(t, out, want)
627 }627 }
628 }628 }
@@ -634,7 +634,7 @@ func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
634 return persist.New(store)634 return persist.New(store)
635 }635 }
636 636
637-func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {637+func TestRunListReadsDownSessionsWithoutDaemon(t *testing.T) {
638 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()638 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
639 t.Setenv("XDG_STATE_HOME", stateRoot)639 t.Setenv("XDG_STATE_HOME", stateRoot)
640 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)640 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
@@ -653,7 +653,7 @@ func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
653 t.Fatalf("runList error = %v", err)653 t.Fatalf("runList error = %v", err)
654 }654 }
655 })655 })
656- for _, want := range []string{"stored", "stopped", "-"} {656+ for _, want := range []string{"stored", "down", "-"} {
657 if !strings.Contains(got, want) {657 if !strings.Contains(got, want) {
658 t.Fatalf("runList output %q missing %q", got, want)658 t.Fatalf("runList output %q missing %q", got, want)
659 }659 }
M internal/domain/lifecycle.go +3 -3
@@ -138,15 +138,15 @@ func (t RemoteSessionTarget) Validate() error {
138 }138 }
139 if t.Stopped {139 if t.Stopped {
140 if t.LiveTabID != "" || t.StoppedTab.Validate() != nil {140 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")
142 }142 }
143 return nil143 return nil
144 }144 }
145 if err := ValidateTabStableID(t.LiveTabID); err != nil {145 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")
147 }147 }
148 if t.StoppedTab != (TabSelector{}) {148 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")
150 }150 }
151 return nil151 return nil
152 }152 }
M internal/ports/frame.go +1 -1
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 2415+const ProtocolVersion uint16 = 25
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 24 {6+ if ProtocolVersion != 25 {
7- t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/remote.go +9 -9
@@ -64,7 +64,7 @@ type RemotePreviewClient interface {
64 const (64 const (
65 // RemoteCatalogSchemaVersion is independent from the IPC protocol. A65 // RemoteCatalogSchemaVersion is independent from the IPC protocol. A
66 // catalogue can be rejected without changing the attachment wire layout.66 // catalogue can be rejected without changing the attachment wire layout.
67- RemoteCatalogSchemaVersion uint16 = 167+ RemoteCatalogSchemaVersion uint16 = 2
68 68
69 RemoteCatalogMaxHosts = 6469 RemoteCatalogMaxHosts = 64
70 RemoteCatalogMaxSessions = 25670 RemoteCatalogMaxSessions = 256
@@ -103,7 +103,7 @@ type RemoteCatalogTab struct {
103 }103 }
104 104
105 // RemoteCatalogSession is one session in the remote discovery catalog. State105 // 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.
107 //107 //
108 // Tabs is interface-typed only to keep old v0.x callers that supplied a count108 // Tabs is interface-typed only to keep old v0.x callers that supplied a count
109 // source-compatible while the versioned schema carries []RemoteCatalogTab.109 // source-compatible while the versioned schema carries []RemoteCatalogTab.
@@ -301,7 +301,7 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
301 if session.ActiveTabID != "" {301 if session.ActiveTabID != "" {
302 return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)302 return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
303 }303 }
304- if session.State == "running" && session.Tabs == nil {304+ if session.State == "up" && session.Tabs == nil {
305 return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)305 return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
306 }306 }
307 }307 }
@@ -320,8 +320,8 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
320 if tab.Index != uint16(i) {320 if tab.Index != uint16(i) {
321 return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)321 return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
322 }322 }
323- if session.State == "running" && tab.ID == "" {323+ if session.State == "up" && tab.ID == "" {
324- return fmt.Errorf("%w: running tab has zero ID", ErrInvalidRemoteCatalog)324+ return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)
325 }325 }
326 if tab.ID != "" {326 if tab.ID != "" {
327 if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil {327 if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil {
@@ -335,8 +335,8 @@ func ValidateRemoteCatalog(c RemoteCatalog) error {
335 bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)335 bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
336 }336 }
337 if session.ActiveTabID != "" {337 if session.ActiveTabID != "" {
338- if session.State != "running" {338+ if session.State != "up" {
339- return fmt.Errorf("%w: stopped or broken session has an active tab", ErrInvalidRemoteCatalog)339+ return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)
340 }340 }
341 if _, ok := tabIDs[session.ActiveTabID]; !ok {341 if _, ok := tabIDs[session.ActiveTabID]; !ok {
342 return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)342 return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
@@ -404,7 +404,7 @@ func validRemoteCatalogText(value string) bool {
404 404
405 func validRemoteCatalogState(state string) bool {405 func validRemoteCatalogState(state string) bool {
406 switch state {406 switch state {
407- case "running", "stopped", "broken":407+ case "up", "down", "broken":
408 return true408 return true
409 default:409 default:
410 return false410 return false
@@ -413,7 +413,7 @@ func validRemoteCatalogState(state string) bool {
413 413
414 func validRemoteCatalogReason(reason string) bool {414 func validRemoteCatalogReason(reason string) bool {
415 switch reason {415 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":
417 return true417 return true
418 default:418 default:
419 return false419 return false
M internal/ports/remote_preview_hardening_test.go +1 -1
@@ -194,7 +194,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
194 want := []byte{194 want := []byte{
195 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',195 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
196 0x00, 0x04, 'w', 'o', 'r', 'k', IntentAttach,196 0x00, 0x04, 'w', 'o', 'r', 'k', IntentAttach,
197- 0x01, 0x01,197+ 0x01,
198 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',198 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
199 0x00, 0x09, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',199 0x00, 0x09, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
200 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,200 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
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "reflect"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -79,15 +80,54 @@ func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) {
79 }80 }
80 }81 }
81 82
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+
82 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {123 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
83 target := testRemoteTarget(false)124 target := testRemoteTarget(false)
84 tests := []struct {125 tests := []struct {
85- name string126+ name string
86- validate func() error127+ validate func() error
87- marshalBad func() []byte128+ marshalBad func() []byte
88- marshalGood func() []byte129+ marshalGood func() []byte
89- marshalLegacy func() []byte130+ unmarshal func([]byte) error
90- unmarshal func([]byte) error
91 }{131 }{
92 {132 {
93 name: "hello",133 name: "hello",
@@ -112,12 +152,6 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
112 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,152 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
113 })153 })
114 },154 },
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- },
121 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },155 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
122 },156 },
123 {157 {
@@ -140,11 +174,6 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
140 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,174 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
141 })175 })
142 },176 },
143- marshalLegacy: func() []byte {
144- return MarshalAttachTarget(AttachTarget{
145- Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach,
146- })
147- },
148 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },177 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
149 },178 },
150 }179 }
@@ -161,16 +190,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
161 if payload == nil {190 if payload == nil {
162 t.Fatal("failed to marshal daemon-owned control payload")191 t.Fatal("failed to marshal daemon-owned control payload")
163 }192 }
164- legacyLen := len(tt.marshalLegacy())193+ for i := range len(payload) {
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- }
174 if err := tt.unmarshal(payload[:i]); err == nil {194 if err := tt.unmarshal(payload[:i]); err == nil {
175 t.Fatalf("prefix %d unexpectedly decoded", i)195 t.Fatalf("prefix %d unexpectedly decoded", i)
176 }196 }
@@ -187,10 +207,10 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
187 }207 }
188 }208 }
189 209
190-func TestLegacyTargetWireBytesRemainUnchanged(t *testing.T) {210+func TestTargetWireIncludesRequiredV25Section(t *testing.T) {
191- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach}211+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}
192 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})212 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
193 if !bytes.Equal(got, want) {213 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)
195 }215 }
196 }216 }
M internal/ports/wire.go +127 -73
@@ -80,7 +80,18 @@ func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
80 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned80 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
81 }81 }
82 82
83-const remoteTargetWireVersion uint8 = 183+// 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+}
84 95
85 // Capability bits advertised in Welcome.96 // Capability bits advertised in Welcome.
86 const (97 const (
@@ -116,6 +127,7 @@ const (
116 type Hello struct {127 type Hello struct {
117 Version uint16128 Version uint16
118 Intent uint8129 Intent uint8
130+ RenderMode RenderMode
119 ClientID [16]byte131 ClientID [16]byte
120 ResumeToken uint64132 ResumeToken uint64
121 Name string133 Name string
@@ -224,6 +236,7 @@ type Welcome struct {
224 SessionName string236 SessionName string
225 Ephemeral bool237 Ephemeral bool
226 ResumeToken uint64238 ResumeToken uint64
239+ RenderMode RenderMode
227 Capabilities uint32240 Capabilities uint32
228 }241 }
229 242
@@ -275,8 +288,8 @@ type Kill struct {
275 type SessionState uint8288 type SessionState uint8
276 289
277 const (290 const (
278- SessionRunning SessionState = iota291+ SessionUp SessionState = iota
279- SessionStopped292+ SessionDown
280 SessionBroken293 SessionBroken
281 )294 )
282 295
@@ -299,16 +312,19 @@ type Sessions struct {
299 type OutputResetRequest struct{}312 type OutputResetRequest struct{}
300 313
301 // SessionTabMeta describes one tab in a remote session's metadata snapshot.314 // 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.
302 type SessionTabMeta struct {316 type SessionTabMeta struct {
303- Index uint16317+ ID domain.TabStableID
304 Name string318 Name string
305 Attention bool319 Attention bool
306 }320 }
307 321
308-// SessionMeta is authoritative tab metadata for a remote session.322+// SessionMeta is an authoritative, revisioned snapshot of remote tab metadata.
309 type SessionMeta struct {323 type SessionMeta struct {
324+ LifecycleID domain.SessionLifecycleID
325+ Revision uint64
310 SessionName string326 SessionName string
311- Active uint16327+ ActiveTabID domain.TabStableID
312 Tabs []SessionTabMeta328 Tabs []SessionTabMeta
313 }329 }
314 330
@@ -368,8 +384,10 @@ func (w *payloadWriter) putLongBytes(b []byte) {
368 w.b = append(w.b, b...)384 w.b = append(w.b, b...)
369 }385 }
370 386
371-func marshalRemoteTargetExtension(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {387+// marshalRemoteTargetSection writes the required v25 target/policy section.
372- w.putUint8(remoteTargetWireVersion)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) {
373 if target == nil {391 if target == nil {
374 w.putBool(false)392 w.putBool(false)
375 } else {393 } else {
@@ -393,14 +411,7 @@ func marshalRemoteTargetExtension(w *payloadWriter, target *domain.RemoteSession
393 w.putUint8(uint8(policy))411 w.putUint8(uint8(policy))
394 }412 }
395 413
396-func skipRemoteTargetExtension(r *payloadReader) error {414+func skipRemoteTargetSection(r *payloadReader) error {
397- version, err := r.getUint8()
398- if err != nil {
399- return err
400- }
401- if version != remoteTargetWireVersion {
402- return errInvalidEnum
403- }
404 present, err := r.getBool()415 present, err := r.getBool()
405 if err != nil {416 if err != nil {
406 return err417 return err
@@ -518,14 +529,7 @@ func unmarshalRemoteTarget(r *payloadReader) (*domain.RemoteSessionTarget, error
518 return target, nil529 return target, nil
519 }530 }
520 531
521-func unmarshalRemoteTargetExtension(r *payloadReader) (*domain.RemoteSessionTarget, EnvironmentPolicy, error) {532+func unmarshalRemoteTargetSection(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- }
529 target, err := unmarshalRemoteTarget(r)533 target, err := unmarshalRemoteTarget(r)
530 if err != nil {534 if err != nil {
531 return nil, 0, err535 return nil, 0, err
@@ -534,6 +538,9 @@ func unmarshalRemoteTargetExtension(r *payloadReader) (*domain.RemoteSessionTarg
534 if err != nil {538 if err != nil {
535 return nil, 0, err539 return nil, 0, err
536 }540 }
541+ if !validEnvironmentPolicy(EnvironmentPolicy(policy)) {
542+ return nil, 0, errInvalidEnum
543+ }
537 return target, EnvironmentPolicy(policy), nil544 return target, EnvironmentPolicy(policy), nil
538 }545 }
539 546
@@ -692,6 +699,9 @@ func ValidateHello(h Hello) error {
692 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {699 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
693 return ErrInvalidHello700 return ErrInvalidHello
694 }701 }
702+ if !validRenderMode(h.RenderMode) {
703+ return ErrInvalidHello
704+ }
695 if err := ValidateSize(h.Size); err != nil {705 if err := ValidateSize(h.Size); err != nil {
696 return fmt.Errorf("%w: size", ErrInvalidHello)706 return fmt.Errorf("%w: size", ErrInvalidHello)
697 }707 }
@@ -707,7 +717,7 @@ func ValidateHello(h Hello) error {
707 }717 }
708 }718 }
709 if h.RemoteTarget == nil {719 if h.RemoteTarget == nil {
710- if h.EnvironmentPolicy != EnvironmentPolicyClientOwned {720+ if h.EnvironmentPolicy != EnvironmentPolicyClientOwned || h.RenderMode == RenderModeProxiedContent {
711 return ErrInvalidHello721 return ErrInvalidHello
712 }722 }
713 return nil723 return nil
@@ -804,6 +814,7 @@ func MarshalHello(h Hello) []byte {
804 w := payloadWriter{}814 w := payloadWriter{}
805 w.putUint16(h.Version)815 w.putUint16(h.Version)
806 w.putUint8(h.Intent)816 w.putUint8(h.Intent)
817+ w.putUint8(uint8(h.RenderMode))
807 w.putBytes(h.ClientID[:])818 w.putBytes(h.ClientID[:])
808 w.putUint64(h.ResumeToken)819 w.putUint64(h.ResumeToken)
809 w.putString(h.Name)820 w.putString(h.Name)
@@ -817,9 +828,7 @@ func MarshalHello(h Hello) []byte {
817 for _, entry := range h.Env {828 for _, entry := range h.Env {
818 w.putLongString(entry)829 w.putLongString(entry)
819 }830 }
820- if h.RemoteTarget != nil || h.EnvironmentPolicy != EnvironmentPolicyClientOwned {831+ marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
821- marshalRemoteTargetExtension(&w, h.RemoteTarget, h.EnvironmentPolicy)
822- }
823 return w.b832 return w.b
824 }833 }
825 834
@@ -835,6 +844,13 @@ func preflightHello(b []byte) error {
835 if _, err := r.getUint8(); err != nil {844 if _, err := r.getUint8(); err != nil {
836 return err845 return err
837 }846 }
847+ mode, err := r.getUint8()
848+ if err != nil {
849+ return err
850+ }
851+ if !validRenderMode(RenderMode(mode)) {
852+ return errInvalidEnum
853+ }
838 if len(r.b) < 16 {854 if len(r.b) < 16 {
839 return errShortPayload855 return errShortPayload
840 }856 }
@@ -875,10 +891,8 @@ func preflightHello(b []byte) error {
875 return err891 return err
876 }892 }
877 }893 }
878- if len(r.b) != 0 {894+ if err := skipRemoteTargetSection(&r); err != nil {
879- if err := skipRemoteTargetExtension(&r); err != nil {895+ return err
880- return err
881- }
882 }896 }
883 return r.done()897 return r.done()
884 }898 }
@@ -901,6 +915,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
901 if h.Intent, err = r.getUint8(); err != nil {915 if h.Intent, err = r.getUint8(); err != nil {
902 return Hello{}, err916 return Hello{}, err
903 }917 }
918+ mode, err := r.getUint8()
919+ if err != nil {
920+ return Hello{}, err
921+ }
922+ h.RenderMode = RenderMode(mode)
904 clientID, err := r.getBytes(len(h.ClientID))923 clientID, err := r.getBytes(len(h.ClientID))
905 if err != nil {924 if err != nil {
906 return Hello{}, err925 return Hello{}, err
@@ -952,10 +971,8 @@ func UnmarshalHello(b []byte) (Hello, error) {
952 h.Env = append(h.Env, entry)971 h.Env = append(h.Env, entry)
953 }972 }
954 }973 }
955- if len(r.b) != 0 {974+ if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
956- if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil {975+ return Hello{}, err
957- return Hello{}, err
958- }
959 }976 }
960 if err := r.done(); err != nil {977 if err := r.done(); err != nil {
961 return Hello{}, err978 return Hello{}, err
@@ -1241,6 +1258,9 @@ func UnmarshalAck(b []byte) (Ack, error) {
1241 1258
1242 // MarshalWelcome encodes m into a Welcome message payload.1259 // MarshalWelcome encodes m into a Welcome message payload.
1243 func MarshalWelcome(m Welcome) []byte {1260 func MarshalWelcome(m Welcome) []byte {
1261+ if !validRenderMode(m.RenderMode) {
1262+ return nil
1263+ }
1244 w := payloadWriter{}1264 w := payloadWriter{}
1245 w.putString(m.SessionID)1265 w.putString(m.SessionID)
1246 w.putString(m.SessionName)1266 w.putString(m.SessionName)
@@ -1250,6 +1270,7 @@ func MarshalWelcome(m Welcome) []byte {
1250 w.putUint8(0)1270 w.putUint8(0)
1251 }1271 }
1252 w.putUint64(m.ResumeToken)1272 w.putUint64(m.ResumeToken)
1273+ w.putUint8(uint8(m.RenderMode))
1253 w.putUint32(m.Capabilities)1274 w.putUint32(m.Capabilities)
1254 return w.b1275 return w.b
1255 }1276 }
@@ -1274,6 +1295,14 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
1274 if m.ResumeToken, err = r.getUint64(); err != nil {1295 if m.ResumeToken, err = r.getUint64(); err != nil {
1275 return Welcome{}, err1296 return Welcome{}, err
1276 }1297 }
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+ }
1277 if m.Capabilities, err = r.getUint32(); err != nil {1306 if m.Capabilities, err = r.getUint32(); err != nil {
1278 return Welcome{}, err1307 return Welcome{}, err
1279 }1308 }
@@ -1400,9 +1429,7 @@ func MarshalAttachTarget(m AttachTarget) []byte {
1400 w.putString(m.Endpoint)1429 w.putString(m.Endpoint)
1401 w.putString(m.Session)1430 w.putString(m.Session)
1402 w.putUint8(m.Intent)1431 w.putUint8(m.Intent)
1403- if m.RemoteTarget != nil || m.EnvironmentPolicy != EnvironmentPolicyClientOwned {1432+ marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)
1404- marshalRemoteTargetExtension(&w, m.RemoteTarget, m.EnvironmentPolicy)
1405- }
1406 return w.b1433 return w.b
1407 }1434 }
1408 1435
@@ -1424,10 +1451,8 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
1424 if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) {1451 if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) {
1425 return AttachTarget{}, ErrInvalidAttachTarget1452 return AttachTarget{}, ErrInvalidAttachTarget
1426 }1453 }
1427- if len(probe.b) != 0 {1454+ if err := skipRemoteTargetSection(&probe); err != nil {
1428- if err := skipRemoteTargetExtension(&probe); err != nil {1455+ return AttachTarget{}, ErrInvalidAttachTarget
1429- return AttachTarget{}, ErrInvalidAttachTarget
1430- }
1431 }1456 }
1432 if err := probe.done(); err != nil {1457 if err := probe.done(); err != nil {
1433 return AttachTarget{}, ErrInvalidAttachTarget1458 return AttachTarget{}, ErrInvalidAttachTarget
@@ -1444,10 +1469,8 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
1444 if m.Intent, err = r.getUint8(); err != nil {1469 if m.Intent, err = r.getUint8(); err != nil {
1445 return AttachTarget{}, err1470 return AttachTarget{}, err
1446 }1471 }
1447- if len(r.b) != 0 {1472+ if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
1448- if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil {1473+ return AttachTarget{}, err
1449- return AttachTarget{}, err
1450- }
1451 }1474 }
1452 if err := r.done(); err != nil {1475 if err := r.done(); err != nil {
1453 return AttachTarget{}, err1476 return AttachTarget{}, err
@@ -1509,66 +1532,94 @@ func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) {
1509 return OutputResetRequest{}, nil1532 return OutputResetRequest{}, nil
1510 }1533 }
1511 1534
1512-// MarshalSessionMeta encodes m when it satisfies the protocol's authoritative1535+// ValidateSessionMeta validates a revisioned authoritative remote metadata
1513-// ordered-tab constraints.1536+// snapshot. Ordered tabs remain display order, while IDs are the only identity.
1514-func MarshalSessionMeta(m SessionMeta) ([]byte, error) {1537+func ValidateSessionMeta(m SessionMeta) error {
1515- if len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 || int(m.Active) >= len(m.Tabs) {1538+ if m.LifecycleID == (domain.SessionLifecycleID{}) || m.Revision == 0 ||
1516- return nil, ErrInvalidSessionMeta1539+ 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
1517 }1544 }
1518- if len(m.SessionName) > math.MaxUint16 {1545+ if err := domain.ValidateTabStableID(m.ActiveTabID); err != nil {
1519- return nil, ErrSessionMetaStringTooLong1546+ return ErrInvalidSessionMeta
1520 }1547 }
1521- for i, tab := range m.Tabs {1548+ activeFound := false
1522- if tab.Index != uint16(i) {1549+ seen := make(map[domain.TabStableID]struct{}, len(m.Tabs))
1523- return nil, ErrInvalidSessionMeta1550+ 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
1524 }1556 }
1525- if len(tab.Name) > math.MaxUint16 {1557+ if _, duplicate := seen[tab.ID]; duplicate {
1526- return nil, ErrSessionMetaStringTooLong1558+ return ErrInvalidSessionMeta
1527 }1559 }
1560+ seen[tab.ID] = struct{}{}
1561+ activeFound = activeFound || tab.ID == m.ActiveTabID
1528 }1562 }
1563+ if !activeFound {
1564+ return ErrInvalidSessionMeta
1565+ }
1566+ return nil
1567+}
1529 1568
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+ }
1530 w := payloadWriter{}1574 w := payloadWriter{}
1575+ w.putBytes(m.LifecycleID[:])
1576+ w.putUint64(m.Revision)
1531 w.putString(m.SessionName)1577 w.putString(m.SessionName)
1532- w.putUint16(m.Active)1578+ w.putString(string(m.ActiveTabID))
1533 w.putUint16(uint16(len(m.Tabs)))1579 w.putUint16(uint16(len(m.Tabs)))
1534 for _, tab := range m.Tabs {1580 for _, tab := range m.Tabs {
1535- w.putUint16(tab.Index)1581+ w.putString(string(tab.ID))
1536 w.putString(tab.Name)1582 w.putString(tab.Name)
1537 w.putBool(tab.Attention)1583 w.putBool(tab.Attention)
1538 }1584 }
1539 return w.b, nil1585 return w.b, nil
1540 }1586 }
1541 1587
1542-// UnmarshalSessionMeta decodes a strict authoritative metadata snapshot.1588+// UnmarshalSessionMeta decodes a strict v25 authoritative metadata snapshot.
1543 func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {1589 func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
1544 r := payloadReader{b: b}1590 r := payloadReader{b: b}
1545 var m SessionMeta1591 var m SessionMeta
1546- var err error1592+ 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+ }
1547 if m.SessionName, err = r.getString(); err != nil {1600 if m.SessionName, err = r.getString(); err != nil {
1548 return SessionMeta{}, err1601 return SessionMeta{}, err
1549 }1602 }
1550- if m.Active, err = r.getUint16(); err != nil {1603+ activeTabID, err := r.getString()
1604+ if err != nil {
1551 return SessionMeta{}, err1605 return SessionMeta{}, err
1552 }1606 }
1607+ m.ActiveTabID = domain.TabStableID(activeTabID)
1553 count, err := r.getUint16()1608 count, err := r.getUint16()
1554 if err != nil {1609 if err != nil {
1555 return SessionMeta{}, err1610 return SessionMeta{}, err
1556 }1611 }
1557- if count == 0 || int(m.Active) >= int(count) {1612+ if count == 0 || uint64(count) > uint64(len(r.b)/5) {
1558- return SessionMeta{}, ErrInvalidSessionMeta
1559- }
1560- if uint64(count) > uint64(len(r.b)/5) {
1561 return SessionMeta{}, errShortPayload1613 return SessionMeta{}, errShortPayload
1562 }1614 }
1563 m.Tabs = make([]SessionTabMeta, 0, int(count))1615 m.Tabs = make([]SessionTabMeta, 0, int(count))
1564- for i := range int(count) {1616+ for range int(count) {
1565 var tab SessionTabMeta1617 var tab SessionTabMeta
1566- if tab.Index, err = r.getUint16(); err != nil {1618+ id, err := r.getString()
1619+ if err != nil {
1567 return SessionMeta{}, err1620 return SessionMeta{}, err
1568 }1621 }
1569- if tab.Index != uint16(i) {1622+ tab.ID = domain.TabStableID(id)
1570- return SessionMeta{}, ErrInvalidSessionMeta
1571- }
1572 if tab.Name, err = r.getString(); err != nil {1623 if tab.Name, err = r.getString(); err != nil {
1573 return SessionMeta{}, err1624 return SessionMeta{}, err
1574 }1625 }
@@ -1580,6 +1631,9 @@ func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
1580 if err := r.done(); err != nil {1631 if err := r.done(); err != nil {
1581 return SessionMeta{}, err1632 return SessionMeta{}, err
1582 }1633 }
1634+ if err := ValidateSessionMeta(m); err != nil {
1635+ return SessionMeta{}, err
1636+ }
1583 return m, nil1637 return m, nil
1584 }1638 }
1585 1639
M internal/ports/wire_final_test.go +9 -9
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 24 {14+ if ProtocolVersion != 25 {
15- t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 24, 2}, make([]byte, 16+8)...)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)30+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
33 t.Fatalf("Hello bytes = %x, want %x", got, want)33 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -178,7 +178,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
178 178
179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
180 msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}180 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}
182 got := MarshalAttachTarget(msg)182 got := MarshalAttachTarget(msg)
183 if !bytes.Equal(got, want) {183 if !bytes.Equal(got, want) {
184 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)184 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -197,7 +197,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
197 }197 }
198 198
199 func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {199 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"}}})
201 if err != nil {201 if err != nil {
202 t.Fatal(err)202 t.Fatal(err)
203 }203 }
@@ -210,7 +210,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
210 name: "hello boolean",210 name: "hello boolean",
211 payload: func() []byte {211 payload: func() []byte {
212 b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})212 b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
213- b[len(b)-6] = 2213+ b[len(b)-8] = 2
214 return b214 return b
215 }(),215 }(),
216 decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },216 decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },
@@ -237,7 +237,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
237 {237 {
238 name: "sessions ephemeral boolean",238 name: "sessions ephemeral boolean",
239 payload: func() []byte {239 payload: func() []byte {
240- b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})240+ b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionUp}}})
241 b[6] = 2241 b[6] = 2
242 return b242 return b
243 }(),243 }(),
@@ -246,7 +246,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
246 {246 {
247 name: "sessions attached boolean",247 name: "sessions attached boolean",
248 payload: func() []byte {248 payload: func() []byte {
249- b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}})249+ b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionUp}}})
250 b[9] = 2250 b[9] = 2
251 return b251 return b
252 }(),252 }(),
M internal/ports/wire_test.go +60 -31
@@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
73 TrueColor: true,73 TrueColor: true,
74 MaxOutputInFlight: 8,74 MaxOutputInFlight: 8,
75 },75 },
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},
77 },77 },
78 {78 {
79 name: "empty strings",79 name: "empty strings",
@@ -86,7 +86,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
86 Cwd: "",86 Cwd: "",
87 TrueColor: false,87 TrueColor: false,
88 },88 },
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},
90 },90 },
91 }91 }
92 92
@@ -124,7 +124,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
124 }124 }
125 got := MarshalHello(msg)125 got := MarshalHello(msg)
126 want := []byte{126 want := []byte{
127- 0x00, 0x18, 0x00, // version, intent127+ 0x00, 0x19, 0x00, 0x00, // version, intent, render mode
128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -136,6 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136 0x00, 0x00, 0x00, 0x02, // uint32 environment entry count136 0x00, 0x00, 0x00, 0x02, // uint32 environment entry count
137 0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',137 0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',
138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
139+ 0x00, 0x00, // no remote target, client-owned environment
139 }140 }
140 require.Equal(t, want, got)141 require.Equal(t, want, got)
141 decoded, err := UnmarshalHello(want)142 decoded, err := UnmarshalHello(want)
@@ -164,7 +165,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
164 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})165 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
165 withCount := func(count byte) []byte {166 withCount := func(count byte) []byte {
166 payload := append([]byte(nil), base...)167 payload := append([]byte(nil), base...)
167- payload[len(payload)-1] = count168+ payload[len(payload)-3] = count
168 return payload169 return payload
169 }170 }
170 tests := []struct {171 tests := []struct {
@@ -341,7 +342,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
341 }342 }
342 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)343 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
343 require.Equal(t, want, MarshalTheme(cleared))344 require.Equal(t, want, MarshalTheme(cleared))
344- require.Equal(t, uint16(24), ProtocolVersion)345+ require.Equal(t, uint16(25), ProtocolVersion)
345 }346 }
346 347
347 func TestResizeGoldenAndRoundTrip(t *testing.T) {348 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -421,12 +422,12 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
421 {422 {
422 name: "ephemeral",423 name: "ephemeral",
423 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},424 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},
425 },426 },
426 {427 {
427 name: "non-ephemeral empty name",428 name: "non-ephemeral empty name",
428 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},429 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},
430 },431 },
431 }432 }
432 433
@@ -451,6 +452,13 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
451 assertTrailingGarbageFails(t, full, UnmarshalWelcome)452 assertTrailingGarbageFails(t, full, UnmarshalWelcome)
452 }453 }
453 454
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+
454 func TestCommandErrorCodes(t *testing.T) {462 func TestCommandErrorCodes(t *testing.T) {
455 require.Equal(t, uint16(6), ErrUnknownCommand)463 require.Equal(t, uint16(6), ErrUnknownCommand)
456 require.Equal(t, uint16(7), ErrNotScriptable)464 require.Equal(t, uint16(7), ErrNotScriptable)
@@ -469,7 +477,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
469 name: "minimal",477 name: "minimal",
470 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},478 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
471 want: []byte{479 want: []byte{
472- 0x00, 0x18,480+ 0x00, 0x19,
473 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID481 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
474 0x00, // attached482 0x00, // attached
475 0x00, // self483 0x00, // self
@@ -492,7 +500,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
492 JSON: true,500 JSON: true,
493 },501 },
494 want: []byte{502 want: []byte{
495- 0x00, 0x18,503+ 0x00, 0x19,
496 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID504 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
497 0x00, // attached505 0x00, // attached
498 0x01, // self506 0x01, // self
@@ -768,8 +776,8 @@ func TestSessionInfoRecoveryState(t *testing.T) {
768 state SessionState776 state SessionState
769 want []byte777 want []byte
770 }{778 }{
771- {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}},779+ {name: "up", state: SessionUp, 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}},780+ {name: "down", state: SessionDown, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
773 {name: "broken", state: SessionBroken, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}},781 {name: "broken", state: SessionBroken, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}},
774 }782 }
775 for _, tt := range tests {783 for _, tt := range tests {
@@ -805,8 +813,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
805 {813 {
806 name: "two",814 name: "two",
807 msg: Sessions{Sessions: []SessionInfo{815 msg: Sessions{Sessions: []SessionInfo{
808- {SessionID: "0", Name: "0", State: SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},816+ {SessionID: "0", Name: "0", State: SessionUp, Ephemeral: true, Tabs: 1, Attached: false},
809- {SessionID: "work", Name: "proj", State: SessionStopped, Ephemeral: false, Tabs: 5, Attached: true},817+ {SessionID: "work", Name: "proj", State: SessionDown, Ephemeral: false, Tabs: 5, Attached: true},
810 }},818 }},
811 want: []byte{819 want: []byte{
812 0x00, 0x02,820 0x00, 0x02,
@@ -890,9 +898,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
890 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {898 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
891 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}899 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
892 // The empty Hello has a fixed 1x1 size before the negotiated output window.900 // 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)
894 want[1] = 14902 want[1] = 14
895- want[30], want[32] = 1, 1903+ want[31], want[33] = 1, 1
896 got := MarshalHello(hello)904 got := MarshalHello(hello)
897 requireBytesEqual(t, want, got)905 requireBytesEqual(t, want, got)
898 back, err := UnmarshalHello(got)906 back, err := UnmarshalHello(got)
@@ -920,7 +928,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
920 name: "attached",928 name: "attached",
921 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},929 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
922 want: []byte{930 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,
924 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',932 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
925 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',933 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
926 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,934 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -930,7 +938,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
930 name: "control",938 name: "control",
931 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},939 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
932 want: []byte{940 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,
934 0x00, 0x02, 'l', 's', 0x00, 0x00,942 0x00, 0x02, 'l', 's', 0x00, 0x00,
935 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,943 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
936 },944 },
@@ -989,6 +997,7 @@ func TestOutputResetRequestStrict(t *testing.T) {
989 }997 }
990 998
991 func TestSessionMetaGoldenAndStrict(t *testing.T) {999 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}
992 tests := []struct {1001 tests := []struct {
993 name string1002 name string
994 msg SessionMeta1003 msg SessionMeta
@@ -996,13 +1005,29 @@ func TestSessionMetaGoldenAndStrict(t *testing.T) {
996 }{1005 }{
997 {1006 {
998 name: "one unicode tab",1007 name: "one unicode tab",
999- msg: SessionMeta{SessionName: "démo", Active: 0, Tabs: []SessionTabMeta{{Index: 0, Name: "é", Attention: true}}},1008+ msg: SessionMeta{LifecycleID: lifecycle, Revision: 2, SessionName: "démo", ActiveTabID: "tab-é", Tabs: []SessionTabMeta{{ID: "tab-é", Name: "é", Attention: true}}},
1000- want: []byte{0x00, 0x05, 'd', 0xc3, 0xa9, 'm', 'o', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0xc3, 0xa9, 0x01},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+ },
1001 },1018 },
1002 {1019 {
1003 name: "multiple tabs",1020 name: "multiple tabs",
1004- msg: SessionMeta{SessionName: "work", Active: 1, Tabs: []SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build", Attention: true}}},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}}},
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},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+ },
1006 },1031 },
1007 }1032 }
1008 for _, tt := range tests {1033 for _, tt := range tests {
@@ -1019,36 +1044,40 @@ func TestSessionMetaGoldenAndStrict(t *testing.T) {
1019 }1044 }
1020 1045
1021 maxString := string(bytes.Repeat([]byte{'x'}, math.MaxUint16))1046 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}}})
1023 require.NoError(t, err)1048 require.NoError(t, err)
1024 back, err := UnmarshalSessionMeta(payload)1049 back, err := UnmarshalSessionMeta(payload)
1025 require.NoError(t, err)1050 require.NoError(t, err)
1026 require.Equal(t, maxString, back.SessionName)1051 require.Equal(t, maxString, back.SessionName)
1027 require.Equal(t, maxString, back.Tabs[0].Name)1052 require.Equal(t, maxString, back.Tabs[0].Name)
1028 1053
1054+ validMeta := func() SessionMeta {
1055+ return SessionMeta{LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}}
1056+ }
1029 invalid := []SessionMeta{1057 invalid := []SessionMeta{
1030 {},1058 {},
1031- {Tabs: []SessionTabMeta{{Name: "one"}}, Active: 1},1059+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1032- {Tabs: []SessionTabMeta{{Index: 1, Name: "one"}}},1060+ {LifecycleID: lifecycle, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1033- {Tabs: []SessionTabMeta{{Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}, Active: 0},1061+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "missing", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1034- {SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), Tabs: []SessionTabMeta{{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"}}},
1035 }1065 }
1036 for _, msg := range invalid {1066 for _, msg := range invalid {
1037 _, err := MarshalSessionMeta(msg)1067 _, err := MarshalSessionMeta(msg)
1038 require.Error(t, err)1068 require.Error(t, err)
1039 }1069 }
1040 1070
1041- valid, err := MarshalSessionMeta(tests[1].msg)1071+ valid, err := MarshalSessionMeta(validMeta())
1042 require.NoError(t, err)1072 require.NoError(t, err)
1043 for _, tt := range []struct {1073 for _, tt := range []struct {
1044 name string1074 name string
1045 mutate func([]byte)1075 mutate func([]byte)
1046 wantError error1076 wantError error
1047 }{1077 }{
1048- {name: "active outside tab count", mutate: func(b []byte) { b[6] = 0; b[7] = 2 }, wantError: ErrInvalidSessionMeta},1078+ {name: "zero lifecycle", mutate: func(b []byte) { clear(b[:16]) }, wantError: ErrInvalidSessionMeta},
1049- // The first tab index is the uint16 at bytes 10–11, after the session1079+ {name: "zero revision", mutate: func(b []byte) { clear(b[16:24]) }, wantError: ErrInvalidSessionMeta},
1050- // name, active index, and tab count.1080+ {name: "invalid active stable ID", mutate: func(b []byte) { b[32] = ' ' }, wantError: ErrInvalidSessionMeta},
1051- {name: "first tab index out of order", mutate: func(b []byte) { b[10] = 0; b[11] = 1 }, wantError: ErrInvalidSessionMeta},
1052 {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }},1081 {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }},
1053 } {1082 } {
1054 t.Run(tt.name, func(t *testing.T) {1083 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 {
40 return fmt.Sprintf("vev: %s", e.Text)40 return fmt.Sprintf("vev: %s", e.Text)
41 }41 }
42 42
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+
43 // DetachedError reports that the daemon detached this client for a reason47 // DetachedError reports that the daemon detached this client for a reason
44 // other than an ordinary user-initiated detach.48 // other than an ordinary user-initiated detach.
45 type DetachedError struct {49 type DetachedError struct {
@@ -171,6 +175,7 @@ type Dependencies struct {
171 type AttachRequest struct {175 type AttachRequest struct {
172 Intent uint8176 Intent uint8
173 SessionName string177 SessionName string
178+ RenderMode ports.RenderMode
174 Remote bool179 Remote bool
175 RemoteTarget *domain.RemoteSessionTarget180 RemoteTarget *domain.RemoteSessionTarget
176 EnvironmentPolicy ports.EnvironmentPolicy181 EnvironmentPolicy ports.EnvironmentPolicy
@@ -227,7 +232,32 @@ func (r *Runner) terminalInput() *terminalInputPump {
227 }232 }
228 233
229 func validateAttachRequest(request AttachRequest) error {234 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+ }
230 if request.RemoteTarget == nil {254 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+ }
231 return nil261 return nil
232 }262 }
233 if err := request.RemoteTarget.Validate(); err != nil {263 if err := request.RemoteTarget.Validate(); err != nil {
@@ -239,6 +269,9 @@ func validateAttachRequest(request AttachRequest) error {
239 if request.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {269 if request.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
240 return errors.New("vev: remote attach target requires daemon-owned environment")270 return errors.New("vev: remote attach target requires daemon-owned environment")
241 }271 }
272+ if request.Intent != ports.IntentAttach && request.Intent != ports.IntentResume {
273+ return errors.New("vev: remote attach target requires attach or resume intent")
274+ }
242 return nil275 return nil
243 }276 }
244 277
@@ -246,6 +279,7 @@ func validateAttachRequest(request AttachRequest) error {
246 // above attach attempts so raw mode remains active while a live client process279 // above attach attempts so raw mode remains active while a live client process
247 // redials a lost link.280 // redials a lost link.
248 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {281 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
282+ request = cloneAttachRequest(request)
249 if err := validateAttachRequest(request); err != nil {283 if err := validateAttachRequest(request); err != nil {
250 return err284 return err
251 }285 }
@@ -378,10 +412,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
378 return errors.New("vev: dialer returned nil transport")412 return errors.New("vev: dialer returned nil transport")
379 }413 }
380 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)414 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
381- connection, err := NewSessionConnection(transport, SessionTarget{415+ connection, err := NewSessionConnection(transport, attemptRequest)
382- Intent: attemptRequest.Intent,
383- SessionName: attemptRequest.SessionName,
384- })
385 if err != nil {416 if err != nil {
386 stopHandshakeTransport()417 stopHandshakeTransport()
387 finishHandshake()418 finishHandshake()
@@ -430,6 +461,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
430 if handoffErr != nil {461 if handoffErr != nil {
431 return handoffErr462 return handoffErr
432 }463 }
464+ nextRequest = cloneAttachRequest(nextRequest)
433 if err := validateAttachRequest(nextRequest); err != nil {465 if err := validateAttachRequest(nextRequest); err != nil {
434 return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)466 return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)
435 }467 }
@@ -439,6 +471,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
439 dialer = nextDialer471 dialer = nextDialer
440 attemptRequest = nextRequest472 attemptRequest = nextRequest
441 remote = attemptRequest.Remote || r.remote473 remote = attemptRequest.Remote || r.remote
474+ reconnect.remote = remote
442 resumeToken = 0475 resumeToken = 0
443 backoff = defaultReconnectBackoff.initial476 backoff = defaultReconnectBackoff.initial
444 continue477 continue
@@ -679,7 +712,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
679 enterRaw := a.enterRaw712 enterRaw := a.enterRaw
680 reconnect := a.reconnect713 reconnect := a.reconnect
681 linkEvents := a.linkEvents714 linkEvents := a.linkEvents
682- remote := a.remote || a.request.Remote715+ remote := a.remote || request.Remote
683 clipboard := a.runner.clipboard716 clipboard := a.runner.clipboard
684 log := a.runner.logger717 log := a.runner.logger
685 observer := a.runner.runtimeObserver718 observer := a.runner.runtimeObserver
@@ -737,6 +770,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
737 ClientID: clientID,770 ClientID: clientID,
738 ResumeToken: resumeToken,771 ResumeToken: resumeToken,
739 Name: name,772 Name: name,
773+ RenderMode: request.RenderMode,
740 Size: size,774 Size: size,
741 TermEnv: termEnv,775 TermEnv: termEnv,
742 Cwd: cwd,776 Cwd: cwd,
@@ -771,6 +805,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
771 if derr != nil {805 if derr != nil {
772 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))806 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
773 }807 }
808+ if welcome.RenderMode != request.RenderMode {
809+ return result(false, fmt.Errorf("%w %d, want %d", errRenderModeMismatch, welcome.RenderMode, request.RenderMode))
810+ }
774 resumeToken = welcome.ResumeToken811 resumeToken = welcome.ResumeToken
775 name = welcome.SessionName812 name = welcome.SessionName
776 ms.welcomed = true813 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 }
41 func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }41 func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
42 func (r *realTimer) Stop() bool { return r.t.Stop() }42 func (r *realTimer) Stop() bool { return r.t.Stop() }
43 43
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+
44 // recvItem is one scripted Recv result.82 // recvItem is one scripted Recv result.
45 type recvItem struct {83 type recvItem struct {
46 f ports.Frame84 f ports.Frame
@@ -789,11 +827,15 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
789 type recordingTransport struct {827 type recordingTransport struct {
790 recvs []recvItem828 recvs []recvItem
791 sends []ports.Frame829 sends []ports.Frame
830+ onSend func(ports.Frame)
792 closed atomic.Int32831 closed atomic.Int32
793 }832 }
794 833
795 func (t *recordingTransport) Send(f ports.Frame) error {834 func (t *recordingTransport) Send(f ports.Frame) error {
796 t.sends = append(t.sends, f)835 t.sends = append(t.sends, f)
836+ if t.onSend != nil {
837+ t.onSend(f)
838+ }
797 return nil839 return nil
798 }840 }
799 841
@@ -846,33 +888,203 @@ func welcomeFrame(token uint64) ports.Frame {
846 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))888 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
847 }889 }
848 890
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+ }
850 term := newRunTerminal()990 term := newRunTerminal()
851 defer term.in.unblock()991 defer term.in.unblock()
852- tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}992+ tr := &recordingTransport{recvs: []recvItem{
853- tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}993+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
854- tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}994+ SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
855- d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}995+ }))},
856-996+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
857- err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})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+ })
858 require.NoError(t, err)1003 require.NoError(t, err)
859- require.Equal(t, int32(3), d.calls.Load())1004+ require.Equal(t, ports.RenderModeProxiedContent, helloFromSend(t, tr).RenderMode)
860- require.Equal(t, int32(1), term.rawCount.Load())1005+}
861- require.Equal(t, int32(1), term.restoreCount.Load())
862 1006
863- h1 := helloFromSend(t, tr1)1007+func TestRunRejectsMismatchedProxiedWelcomeBeforeEnteringRawMode(t *testing.T) {
864- h2 := helloFromSend(t, tr2)1008+ lifecycle := domain.SessionLifecycleID{1}
865- h3 := helloFromSend(t, tr3)1009+ target := domain.RemoteSessionTarget{
866- require.Equal(t, ports.IntentAttach, h1.Intent)1010+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
867- require.Zero(t, h1.ResumeToken)1011+ SessionName: "main", LiveTabID: "tab-live",
868- require.Equal(t, ports.IntentResume, h2.Intent)1012+ }
869- require.Equal(t, uint64(11), h2.ResumeToken)1013+ term := newRunTerminal()
870- require.Equal(t, ports.IntentResume, h3.Intent)1014+ defer term.in.unblock()
871- require.Equal(t, uint64(22), h3.ResumeToken)1015+ tr := &recordingTransport{recvs: []recvItem{{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
872- require.Equal(t, h1.ClientID, h2.ClientID)1016+ SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeFullTerminal,
873- require.Equal(t, h1.ClientID, h3.ClientID)1017+ }))}}}
874- require.Contains(t, term.out.String(), "reconnecting…")1018+
875- require.Contains(t, term.out.String(), "\x1b[2K")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+ }
876 }1088 }
877 1089
878 func TestAttachRememberRemoteHost(t *testing.T) {1090 func TestAttachRememberRemoteHost(t *testing.T) {
M internal/usecase/client/palette_attach_generation_test.go +1 -1
@@ -35,7 +35,7 @@ type attachPaletteTimer struct {
35 func newAttachPaletteClock() *attachPaletteClock {35 func newAttachPaletteClock() *attachPaletteClock {
36 return &attachPaletteClock{36 return &attachPaletteClock{
37 timers: make(chan *attachPaletteTimer, 16),37 timers: make(chan *attachPaletteTimer, 16),
38- handshakeTimers: make(chan *attachPaletteTimer, 1),38+ handshakeTimers: make(chan *attachPaletteTimer, 4),
39 }39 }
40 }40 }
41 41
M internal/usecase/client/reconnect.go +3 -0
@@ -56,6 +56,9 @@ func shouldReconnect(err error) bool {
56 if _, ok := errors.AsType[*ProtocolError](err); ok {56 if _, ok := errors.AsType[*ProtocolError](err); ok {
57 return false57 return false
58 }58 }
59+ if errors.Is(err, errRenderModeMismatch) {
60+ return false
61+ }
59 _, ok := errors.AsType[*DetachedError](err)62 _, ok := errors.AsType[*DetachedError](err)
60 return !ok63 return !ok
61 }64 }
M internal/usecase/client/session_connection.go +17 -32
@@ -4,52 +4,38 @@ import (
4 "errors"4 "errors"
5 "fmt"5 "fmt"
6 6
7- "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
9 )8 )
10 9
11 var errNilSessionTransport = errors.New("client: nil session transport")10 var errNilSessionTransport = errors.New("client: nil session transport")
12 11
13-// SessionTarget is the validated daemon-facing session selection. Endpoint12+func cloneAttachRequest(request AttachRequest) AttachRequest {
14-// details are resolved before a transport is opened and never enter this type.13+ if request.RemoteTarget != nil {
15-type SessionTarget struct {14+ target := *request.RemoteTarget
16- Intent uint815+ request.RemoteTarget = &target
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)
32 }16 }
33- return domain.ValidateSessionName(t.SessionName)17+ return request
34 }18 }
35 19
36-// SessionConnection owns one already-open transport and its daemon-facing20+// SessionConnection owns one already-open transport and the complete
37-// session target. Local sockets and remote carriages use this same owner.21+// validated daemon-facing attach request. Local sockets and remote carriages
22+// use this same owner.
38 type SessionConnection struct {23 type SessionConnection struct {
39 transport ports.Transport24 transport ports.Transport
40- target SessionTarget25+ request AttachRequest
41 }26 }
42 27
43 // NewSessionConnection creates a connection for an already-selected transport.28 // NewSessionConnection creates a connection for an already-selected transport.
44-// Target validation happens before the connection is used for a Hello.29+// Request validation happens before the connection is used for a Hello.
45-func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) {30+func NewSessionConnection(transport ports.Transport, request AttachRequest) (*SessionConnection, error) {
46 if transport == nil {31 if transport == nil {
47 return nil, errNilSessionTransport32 return nil, errNilSessionTransport
48 }33 }
49- if err := target.validate(); err != nil {34+ request = cloneAttachRequest(request)
50- return nil, fmt.Errorf("invalid session target: %w", err)35+ if err := validateAttachRequest(request); err != nil {
36+ return nil, fmt.Errorf("invalid attach request: %w", err)
51 }37 }
52- return &SessionConnection{transport: transport, target: target}, nil38+ return &SessionConnection{transport: transport, request: request}, nil
53 }39 }
54 40
55 // Transport returns the owned carriage for this connection.41 // Transport returns the owned carriage for this connection.
@@ -60,13 +46,12 @@ func (c *SessionConnection) Transport() ports.Transport {
60 return c.transport46 return c.transport
61 }47 }
62 48
63-// AttachRequest returns the common request shape used after transport49+// AttachRequest returns the complete request retained for this connection.
64-// selection. It contains no remote/proxy semantic.
65 func (c *SessionConnection) AttachRequest() AttachRequest {50 func (c *SessionConnection) AttachRequest() AttachRequest {
66 if c == nil {51 if c == nil {
67 return AttachRequest{}52 return AttachRequest{}
68 }53 }
69- return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName}54+ return cloneAttachRequest(c.request)
70 }55 }
71 56
72 // Close releases the owned transport.57 // Close releases the owned transport.
M internal/usecase/client/session_connection_test.go +47 -16
@@ -3,46 +3,77 @@ package client_test
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 portsmocks "github.com/bnema/vev/internal/ports/mocks"8 portsmocks "github.com/bnema/vev/internal/ports/mocks"
8 "github.com/bnema/vev/internal/usecase/client"9 "github.com/bnema/vev/internal/usecase/client"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
11 12
12-func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) {13+func TestSessionConnectionOwnsCompleteAttachRequestForLocalAndRemoteTransports(t *testing.T) {
13- target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}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
14 local := portsmocks.NewMockTransport(t)31 local := portsmocks.NewMockTransport(t)
15 remote := portsmocks.NewMockTransport(t)32 remote := portsmocks.NewMockTransport(t)
16 33
17- localConnection, err := client.NewSessionConnection(local, target)34+ localConnection, err := client.NewSessionConnection(local, request)
18 require.NoError(t, err)35 require.NoError(t, err)
19- remoteConnection, err := client.NewSessionConnection(remote, target)36+ remoteConnection, err := client.NewSessionConnection(remote, request)
20 require.NoError(t, err)37 require.NoError(t, err)
21 38
39+ target.LiveTabID = "mutated-tab"
40+ target.LifecycleID = domain.SessionLifecycleID{}
41+ returned := localConnection.AttachRequest()
42+ returned.RemoteTarget.LiveTabID = "mutated-returned-tab"
43+
22 require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest())44 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())
24 require.Same(t, local, localConnection.Transport())46 require.Same(t, local, localConnection.Transport())
25 require.Same(t, remote, remoteConnection.Transport())47 require.Same(t, remote, remoteConnection.Transport())
26 }48 }
27 49
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+ }
29 tests := []struct {56 tests := []struct {
30 name string57 name string
31- target client.SessionTarget58+ request client.AttachRequest
32 wantErr bool59 wantErr bool
33 }{60 }{
34- {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}},61+ {name: "ephemeral", request: client.AttachRequest{Intent: ports.IntentEphemeral}},
35- {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}},62+ {name: "new", request: client.AttachRequest{Intent: ports.IntentNew, SessionName: "work"}},
36- {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}},63+ {name: "attach", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
37- {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}},64+ {name: "resume", request: client.AttachRequest{Intent: ports.IntentResume, SessionName: "work"}},
38- {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true},65+ {name: "missing named session", request: client.AttachRequest{Intent: ports.IntentAttach}, wantErr: true},
39- {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},66+ {name: "unsafe session", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
40- {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "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},
41 }72 }
42 for _, tt := range tests {73 for _, tt := range tests {
43 t.Run(tt.name, func(t *testing.T) {74 t.Run(tt.name, func(t *testing.T) {
44 transport := portsmocks.NewMockTransport(t)75 transport := portsmocks.NewMockTransport(t)
45- _, err := client.NewSessionConnection(transport, tt.target)76+ _, err := client.NewSessionConnection(transport, tt.request)
46 if tt.wantErr {77 if tt.wantErr {
47 require.Error(t, err)78 require.Error(t, err)
48 return79 return
@@ -53,6 +84,6 @@ func TestSessionTargetValidation(t *testing.T) {
53 }84 }
54 85
55 func TestSessionConnectionRequiresTransport(t *testing.T) {86 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})
57 require.Error(t, err)88 require.Error(t, err)
58 }89 }
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
3 import "github.com/bnema/vev/internal/ports"3 import "github.com/bnema/vev/internal/ports"
4 4
5 // attachmentConnectionToken is the exact capability for one registered5 // attachmentConnectionToken is the exact capability for one registered
6-// attachment connection incarnation. It is invalid as soon as any of the6+// attachment connection incarnation. It is invalid as soon as the owner,
7-// session, attachment membership, connection generation, transport link, or7+// attachment membership, connection generation, transport link, or
8 // coordinator lease changes.8 // coordinator lease changes.
9 type attachmentConnectionToken struct {9 type attachmentConnectionToken struct {
10- sess *session10+ owner attachmentOwner
11 ac *attachedClient11 ac *attachedClient
12 generation uint6412 generation uint64
13 transport transportSnapshot13 transport transportSnapshot
@@ -18,11 +18,19 @@ type attachmentConnectionToken struct {
18 rebase bool18 rebase bool
19 }19 }
20 20
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.
21 func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {24 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 {
23 return attachmentConnectionToken{}31 return attachmentConnectionToken{}
24 }32 }
25- // Capture one concrete link incarnation before taking the session lock. The33+ // Capture one concrete link incarnation before taking the owner lock. The
26 // same snapshot is revalidated below; do not take a second snapshot and34 // same snapshot is revalidated below; do not take a second snapshot and
27 // accidentally bind a replacement link.35 // accidentally bind a replacement link.
28 transport := ac.transportSnapshot()36 transport := ac.transportSnapshot()
@@ -32,23 +40,38 @@ func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) att
32 if ac.beforeAttachmentTokenValidation != nil {40 if ac.beforeAttachmentTokenValidation != nil {
33 ac.beforeAttachmentTokenValidation()41 ac.beforeAttachmentTokenValidation()
34 }42 }
35- core := entry.core()43+
36- core.mu.Lock()44+ token := attachmentConnectionToken{owner: owner, ac: ac, transport: transport}
37- if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(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+ }
38 core.mu.Unlock()62 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:
39 return attachmentConnectionToken{}73 return attachmentConnectionToken{}
40 }74 }
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()
52 if !token.current() {75 if !token.current() {
53 return attachmentConnectionToken{}76 return attachmentConnectionToken{}
54 }77 }
@@ -60,27 +83,39 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
60 return attachmentToken(s, ac, tr)83 return attachmentToken(s, ac, tr)
61 }84 }
62 85
63-// current validates every immutable identity captured by the token. Membership86+// localSession narrows a token only for explicitly local operations. It is
64-// is the sole authority; there is no owner, replacement, or compatibility role.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.
65 func (t attachmentConnectionToken) current() bool {96 func (t attachmentConnectionToken) current() bool {
66- return t.sess != nil && t.ac != nil &&97+ return t.owner != nil && t.ac != nil &&
67 t.ac.connectionGeneration.Load() == t.generation &&98 t.ac.connectionGeneration.Load() == t.generation &&
68- t.ac.currentAttachmentSession() == t.sess &&99+ sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) &&
69 t.ac.transportSnapshotCurrent(t.transport) &&100 t.ac.transportSnapshotCurrent(t.transport) &&
70- attachmentRegistered(t.sess, t.ac)101+ attachmentOwnerRegistered(t.owner, t.ac)
71 }102 }
72 103
73 // attachmentCurrent additionally validates the optional coordinator lease used104 // attachmentCurrent additionally validates the optional coordinator lease used
74 // by render and resize effects. Attachments without that optional lease still105 // by render and resize effects. Attachments without that optional lease still
75 // own their independent connection lifecycle.106 // own their independent connection lifecycle.
76 func (t attachmentConnectionToken) attachmentCurrent() bool {107 func (t attachmentConnectionToken) attachmentCurrent() bool {
77- if !t.current() || t.ac.currentAttachmentSession() != t.sess {108+ if !t.current() {
78 return false109 return false
79 }110 }
80 if t.lease == nil {111 if t.lease == nil {
81 return true112 return true
82 }113 }
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()
84 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)119 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
85 }120 }
86 121
@@ -108,23 +143,27 @@ func (t *attachmentConnectionToken) endAttachmentEffect() {
108 t.effect = nil143 t.effect = nil
109 }144 }
110 145
111-// attachmentEffectCurrentSessionLocked is the same check at a session-owned146+// attachmentEffectCurrentSessionLocked is the same check at a local
112-// mutation boundary where sess.core().mu is already held.147+// session-owned mutation boundary where sess.core().mu is already held.
113 func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {148 func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {
149+ entry := t.localSession()
150+ if entry == nil {
151+ return false
152+ }
114 if t.effect != nil && !t.effect.ended.Load() {153 if t.effect != nil && !t.effect.ended.Load() {
115 return true154 return true
116 }155 }
117- if t.sess == nil || t.ac == nil ||156+ if t.ac == nil ||
118 t.ac.connectionGeneration.Load() != t.generation ||157 t.ac.connectionGeneration.Load() != t.generation ||
119 !t.ac.transportSnapshotCurrent(t.transport) ||158 !t.ac.transportSnapshotCurrent(t.transport) ||
120- t.ac.currentAttachmentSession() != t.sess ||159+ !sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) ||
121- !attachmentRegisteredLocked(t.sess, t.ac) {160+ !attachmentRegisteredLocked(entry, t.ac) {
122 return false161 return false
123 }162 }
124 if t.lease == nil {163 if t.lease == nil {
125 return true164 return true
126 }165 }
127- rc := t.sess.core().coordinator.Load()166+ rc := entry.core().coordinator.Load()
128 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)167 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
129 }168 }
130 169
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 (
18 // effect admission. It deliberately contains no independently mutable state:18 // effect admission. It deliberately contains no independently mutable state:
19 // a ticket is admitted only when every field matches the frame's token.19 // a ticket is admitted only when every field matches the frame's token.
20 type attachmentCapability struct {20 type attachmentCapability struct {
21- sess *session21+ owner attachmentOwner
22 generation uint6422 generation uint64
23 transport transportSnapshot23 transport transportSnapshot
24 lease *attachmentLease24 lease *attachmentLease
@@ -26,7 +26,7 @@ type attachmentCapability struct {
26 26
27 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {27 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
28 return attachmentCapability{28 return attachmentCapability{
29- sess: token.sess,29+ owner: token.owner,
30 generation: token.generation,30 generation: token.generation,
31 transport: token.transport,31 transport: token.transport,
32 lease: token.lease,32 lease: token.lease,
@@ -34,7 +34,7 @@ func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
34 }34 }
35 35
36 func (c attachmentCapability) matches(token attachmentConnectionToken) bool {36 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) &&
38 c.generation == token.generation && c.transport.transport == token.transport.transport &&38 c.generation == token.generation && c.transport.transport == token.transport.transport &&
39 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease39 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
40 }40 }
@@ -219,11 +219,19 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken)
219 // after Welcome has completed so a replacement blocked behind that send can219 // after Welcome has completed so a replacement blocked behind that send can
220 // publish before readiness or first paint.220 // publish before readiness or first paint.
221 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {221 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 {
223 return attachmentConnectionToken{}, nil, false231 return attachmentConnectionToken{}, nil, false
224 }232 }
225 for {233 for {
226- token := sess.attachmentToken(ac, tr)234+ token := attachmentOwnerToken(owner, ac, tr)
227 if token.ac == nil {235 if token.ac == nil {
228 return token, nil, false236 return token, nil, false
229 }237 }
@@ -271,13 +279,13 @@ func (ac *attachedClient) publishAttachmentCapability(token attachmentConnection
271 // bootstrapAttachmentCapability supports direct/headless session construction. It279 // bootstrapAttachmentCapability supports direct/headless session construction. It
272 // never changes an existing or frozen production publication.280 // never changes an existing or frozen production publication.
273 func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {281 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 {
275 return283 return
276 }284 }
277 g := &ac.attachmentEffects285 g := &ac.attachmentEffects
278 g.mu.Lock()286 g.mu.Lock()
279 g.initLocked()287 g.initLocked()
280- if g.phase == attachmentEffectsStable && g.capability.sess == nil {288+ if g.phase == attachmentEffectsStable && g.capability.owner == nil {
281 g.capability = capabilityFromToken(token)289 g.capability = capabilityFromToken(token)
282 g.failedTransport = transportSnapshot{}290 g.failedTransport = transportSnapshot{}
283 }291 }
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 {
45 }45 }
46 46
47 func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {47 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 &&
49 token.generation == req.next.connectionGeneration.Load() &&49 token.generation == req.next.connectionGeneration.Load() &&
50 token.transport.transport == req.expectedTransport.transport &&50 token.transport.transport == req.expectedTransport.transport &&
51 token.transport.incarnation == req.expectedTransport.incarnation51 token.transport.incarnation == req.expectedTransport.incarnation
@@ -55,9 +55,9 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source
55 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection55 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection
56 // handoff check for client-originated navigation and lifecycle mutations.56 // handoff check for client-originated navigation and lifecycle mutations.
57 func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {57 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 ||
59 token.generation != req.next.connectionGeneration.Load() ||59 token.generation != req.next.connectionGeneration.Load() ||
60- token.sess == nil || token.ac.currentAttachmentSession() != source ||60+ token.localSession() == nil || !sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) ||
61 !attachmentRegisteredLocked(source, req.next) ||61 !attachmentRegisteredLocked(source, req.next) ||
62 !req.next.transportSnapshotCurrent(token.transport) {62 !req.next.transportSnapshotCurrent(token.transport) {
63 return false63 return false
M internal/usecase/daemon/attachment_transition_publish.go +9 -8
@@ -29,25 +29,26 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
29 }29 }
30 30
31 func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {31 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 {
33 return false34 return false
34 }35 }
35 d.mu.Lock()36 d.mu.Lock()
36 defer d.mu.Unlock()37 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 {
38 return false39 return false
39 }40 }
40 d.notices.routingMu.Lock()41 d.notices.routingMu.Lock()
41 defer d.notices.routingMu.Unlock()42 defer d.notices.routingMu.Unlock()
42- token.sess.core().mu.Lock()43+ sess.core().mu.Lock()
43- defer token.sess.core().mu.Unlock()44+ defer sess.core().mu.Unlock()
44- coordinator := token.sess.core().coordinator.Load()45+ coordinator := sess.core().coordinator.Load()
45 if coordinator != nil {46 if coordinator != nil {
46 coordinator.mu.Lock()47 coordinator.mu.Lock()
47 defer coordinator.mu.Unlock()48 defer coordinator.mu.Unlock()
48 }49 }
49- req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}50+ req := attachmentTransitionRequest{source: sess, next: token.ac, expectedTransport: token.transport}
50- return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)51+ return transitionSourceTokenCurrentLocked(token, sess, coordinator, req)
51 }52 }
52 53
53 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {54 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
@@ -215,7 +216,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
215 }216 }
216 }217 }
217 result.published = attachmentConnectionToken{218 result.published = attachmentConnectionToken{
218- sess: req.target, ac: req.next,219+ owner: req.target, ac: req.next,
219 generation: publication.nextGeneration,220 generation: publication.nextGeneration,
220 transport: req.expectedTransport, lease: lease,221 transport: req.expectedTransport, lease: lease,
221 rebase: publication.source != req.target,222 rebase: publication.source != req.target,
M internal/usecase/daemon/client.go +156 -37
@@ -42,6 +42,14 @@ type attachedClient struct {
42 // remoteOrigin is attachment-specific presentation metadata supplied by a42 // remoteOrigin is attachment-specific presentation metadata supplied by a
43 // validated picker handoff; it never participates in routing.43 // validated picker handoff; it never participates in routing.
44 remoteOrigin string44 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
45 // connectionGeneration is the wire-facing capability generation. attachmentEffects is53 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
46 // the linearization gate for every attachment-bound observable operation. A role54 // the linearization gate for every attachment-bound observable operation. A role
47 // transition freezes and drains this gate before changing either generation55 // transition freezes and drains this gate before changing either generation
@@ -85,18 +93,29 @@ type attachedClient struct {
85 // tabs and panes without changing shared session ownership.93 // tabs and panes without changing shared session ownership.
86 viewMu sync.Mutex94 viewMu sync.Mutex
87 view attachmentView95 view attachmentView
88- sess Guarded[*session]96+ owner Guarded[attachmentOwner]
89 mouseScan mouse.Scanner97 mouseScan mouse.Scanner
90 themeMu sync.Mutex98 themeMu sync.Mutex
91 clientTheme themeui.Theme99 clientTheme themeui.Theme
92 appliedTheme appliedTheme100 appliedTheme appliedTheme
93 lastCursor cursorOut101 lastCursor cursorOut
94 renderStages renderStageHooks // optional render and handoff observability hooks102 renderStages renderStageHooks // optional render and handoff observability hooks
95- // previousSession is guarded independently. It is retained through temporary103+ // previousOwner is guarded independently. It retains navigation history
96- // setSession(nil) hand-offs and cleared only on terminal teardown.104+ // through temporary owner hand-offs and is cleared only on terminal teardown.
97- previousSession Guarded[*session]105+ previousOwner Guarded[attachmentOwner]
98- linkMu sync.Mutex106+ // remoteRecent is attachment-local, display-only MRU state. Remote views do
99- sendMu sync.Mutex107+ // 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
100 // routeCreatedSession marks a session created by this attachment's route.119 // routeCreatedSession marks a session created by this attachment's route.
101 // A handshake that never commits Welcome must tear down that exact empty120 // A handshake that never commits Welcome must tear down that exact empty
102 // session, while an attachment routed to an existing session must not.121 // session, while an attachment routed to an existing session must not.
@@ -209,18 +228,50 @@ func (ac *attachedClient) clearGeometryClaim() {
209 ac.sizeMu.Unlock()228 ac.sizeMu.Unlock()
210 }229 }
211 230
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+}
213 240
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.
215 func (ac *attachedClient) currentSession() *session {247 func (ac *attachedClient) currentSession() *session {
216 return ac.currentAttachmentSession()248 return ac.currentAttachmentSession()
217 }249 }
218 250
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) }
220 271
221 func (ac *attachedClient) clearPreviousSession() {272 func (ac *attachedClient) clearPreviousSession() {
222 if ac != nil {273 if ac != nil {
223- ac.previousSession.Set(nil)274+ ac.previousOwner.Set(nil)
224 }275 }
225 }276 }
226 277
@@ -569,6 +620,7 @@ type attachClientOptions struct {
569 resumeCapable bool620 resumeCapable bool
570 maxOutputInFlight uint8621 maxOutputInFlight uint8
571 remoteOrigin string622 remoteOrigin string
623+ renderMode ports.RenderMode
572 }624 }
573 625
574 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {626 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,
615 view: attachmentView{windowRows: sz.Rows, windowSet: true},667 view: attachmentView{windowRows: sz.Rows, windowSet: true},
616 clientID: opts.clientID,668 clientID: opts.clientID,
617 remoteOrigin: opts.remoteOrigin,669 remoteOrigin: opts.remoteOrigin,
670+ renderMode: opts.renderMode,
618 resumeCapable: opts.resumeCapable,671 resumeCapable: opts.resumeCapable,
619 resumeToken: resumeToken,672 resumeToken: resumeToken,
620 }673 }
@@ -759,10 +812,23 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
759 token.ac.captureFrames = nil812 token.ac.captureFrames = nil
760 token.ac.sendMu.Unlock()813 token.ac.sendMu.Unlock()
761 }814 }
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 {
763 return false820 return false
764 }821 }
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)
766 return true832 return true
767 }833 }
768 834
@@ -833,15 +899,23 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
833 return false899 return false
834 }900 }
835 clientTheme := themeFromMessage(msg)901 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+ }
836 token.ac.setClientTheme(clientTheme)910 token.ac.setClientTheme(clientTheme)
837- sess := token.sess911+ sess := token.localSession()
838 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {912 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
839 return false913 return false
840 }914 }
841 if !token.attachmentEffectCurrent() {915 if !token.attachmentEffectCurrent() {
842 return false916 return false
843 }917 }
844- if rc := token.sess.core().coordinator.Load(); rc != nil {918+ if rc := sess.renderCoordinator(); rc != nil {
845 return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})919 return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
846 }920 }
847 return false921 return false
@@ -880,6 +954,23 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
880 }954 }
881 ac.sendMu.Unlock()955 ac.sendMu.Unlock()
882 956
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+
883 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {974 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {
884 // The picker preview geometry is attachment-local. A resize must975 // The picker preview geometry is attachment-local. A resize must
885 // invalidate any in-flight remote request and fetch the selected row976 // invalidate any in-flight remote request and fetch the selected row
@@ -887,7 +978,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
887 d.registerPreviewForSelection(ac)978 d.registerPreviewForSelection(ac)
888 }979 }
889 980
890- sess := token.sess981+ sess := token.localSession()
891 if sess == nil {982 if sess == nil {
892 return false983 return false
893 }984 }
@@ -945,11 +1036,21 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
945 d.notices.routingMu.Unlock()1036 d.notices.routingMu.Unlock()
946 return1037 return
947 }1038 }
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)
949 d.notices.routingMu.Unlock()1047 d.notices.routingMu.Unlock()
1048+ if repaint && token.attachmentEffectCurrent() {
1049+ d.scheduleRemoteViewPaint(view, token.ac, false)
1050+ }
950 return1051 return
951 }1052 }
952- repaint := d.mutateClientNotice(token.sess, token.ac, notice)1053+ repaint := d.mutateClientNotice(sess, token.ac, notice)
953 d.notices.routingMu.Unlock()1054 d.notices.routingMu.Unlock()
954 if repaint && token.attachmentEffectCurrent() {1055 if repaint && token.attachmentEffectCurrent() {
955 d.repaintForNotice(token.ac)1056 d.repaintForNotice(token.ac)
@@ -976,34 +1077,52 @@ func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, not
976 }1077 }
977 1078
978 func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {1079 func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {
979- switch notice.Action {1080+ if notice.Action == ports.ClientNoticeLinkConnected {
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:
987 return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)1081 return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
988- default:1082+ }
1083+ n, ok := d.clientNoticeNotification(notice)
1084+ if !ok {
989 return false1085 return false
990 }1086 }
1087+ n.SessionID = sess.id
1088+ return d.publishToast(ac, d.notices.record(n))
991 }1089 }
992 1090
993-// recordClientNotice mutates notice history and the selected attachment while1091+// mutateRemoteClientNotice keeps client-originated feedback local to the
994-// routingMu keeps another connection from changing that selection. Rendering is left1092+// attachment. A remoteView has no local session scope or notice history to
995-// to the caller after it releases routingMu.1093+// mutate; only its visible toast stack is part of the local composition.
996-func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {1094+func (d *Daemon) mutateRemoteClientNotice(ac *attachedClient, notice ports.ClientNotice) bool {
997- n := d.notices.record(domain.Notification{1095+ if ac == nil {
998- Code: code,1096+ return false
999- Severity: sev,1097+ }
1000- Message: message,1098+ if notice.Action == ports.ClientNoticeLinkConnected {
1001- Time: d.clock.Now(),1099+ return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, "")
1002- SessionID: sess.id,1100+ }
1003- })1101+ n, ok := d.clientNoticeNotification(notice)
1102+ if !ok {
1103+ return false
1104+ }
1105+ n.Count = 1
1004 return d.publishToast(ac, n)1106 return d.publishToast(ac, n)
1005 }1107 }
1006 1108
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+
1007 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The1126 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
1008 // returned value reports whether the synchronous request was accepted.1127 // returned value reports whether the synchronous request was accepted.
1009 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {1128 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
13 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a13 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
14 // handoff lands between those reads. The attachment gate still performs the final14 // handoff lands between those reads. The attachment gate still performs the final
15 // mutation admission after this function returns.15 // 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) {
17 for range connectionSnapshotAttempts {17 for range connectionSnapshotAttempts {
18 if !ac.currentTransportIs(tr) {18 if !ac.currentTransportIs(tr) {
19 break19 break
20 }20 }
21- sess := ac.currentAttachmentSession()21+ owner := ac.currentAttachmentOwner()
22- if sess == nil {22+ if owner == nil {
23 return nil, attachmentConnectionToken{}, false23 return nil, attachmentConnectionToken{}, false
24 }24 }
25- if d.afterConnectionSessionSnapshot != nil {25+ if sess := localSession(owner); sess != nil && d.afterConnectionSessionSnapshot != nil {
26 d.afterConnectionSessionSnapshot(sess)26 d.afterConnectionSessionSnapshot(sess)
27 }27 }
28- token := attachmentToken(sess, ac, tr)28+ token := attachmentOwnerToken(owner, ac, tr)
29- if token.ac == nil || ac.currentAttachmentSession() != sess {29+ if token.ac == nil || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) {
30 runtime.Gosched()30 runtime.Gosched()
31 continue31 continue
32 }32 }
33- return sess, token, true33+ return owner, token, true
34 }34 }
35 return nil, attachmentConnectionToken{}, false35 return nil, attachmentConnectionToken{}, false
36 }36 }
@@ -44,19 +44,23 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
44 return44 return
45 }45 }
46 for {46 for {
47- if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil {47+ if !ac.currentTransportIs(tr) || ac.currentAttachmentOwner() == nil {
48 return48 return
49 }49 }
50 f, err := tr.Recv()50 f, err := tr.Recv()
51 if err != nil {51 if err != nil {
52 for range connectionSnapshotAttempts {52 for range connectionSnapshotAttempts {
53- sess, _, ok := d.currentAttachmentConnection(ac, tr)53+ owner, token, ok := d.currentAttachmentConnection(ac, tr)
54 if !ok {54 if !ok {
55 return55 return
56 }56 }
57- d.clientGone(sess, ac, tr, false)57+ if sess := localSession(owner); sess != nil {
58- current := ac.currentAttachmentSession()58+ d.clientGone(sess, ac, tr, false)
59- if current == sess || !ac.currentTransportIs(tr) || current == nil {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 {
60 return64 return
61 }65 }
62 }66 }
@@ -171,12 +175,17 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
171 result.Text = "attached commands cannot target themselves"175 result.Text = "attached commands cannot target themselves"
172 return result176 return result
173 }177 }
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+ }
174 if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {183 if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {
175 result.Code = ports.ErrInvalidCommandArgs184 result.Code = ports.ErrInvalidCommandArgs
176 result.Text = "attached commands cannot override their active session target"185 result.Text = "attached commands cannot override their active session target"
177 return result186 return result
178 }187 }
179- sess := token.sess188+ sess := token.localSession()
180 if sess == nil || !token.attachmentEffectCurrent() {189 if sess == nil || !token.attachmentEffectCurrent() {
181 result.Code = ports.ErrNoSuchTarget190 result.Code = ports.ErrNoSuchTarget
182 result.Text = "attached session is no longer active"191 result.Text = "attached session is no longer active"
@@ -234,7 +243,11 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
234 ac.pipelineCache = composeCacheInput{}243 ac.pipelineCache = composeCacheInput{}
235 ac.pipelineScratch = composeCacheInput{}244 ac.pipelineScratch = composeCacheInput{}
236 ac.sendMu.Unlock()245 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+ }
238 return true251 return true
239 }252 }
240 253
@@ -244,10 +257,25 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
244 return false257 return false
245 }258 }
246 ac.sendMu.Lock()259 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)
248 ac.sendMu.Unlock()265 ac.sendMu.Unlock()
249- if rc := token.sess.core().coordinator.Load(); rc != nil {266+ if !advanced {
250- rc.notifyAckForLease(token.lease)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)
251 }279 }
252 return true280 return true
253 }281 }
M internal/usecase/daemon/client_transport_errors.go +102 -2
@@ -37,7 +37,10 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
37 }37 }
38 token.effect.bindActionEnd(d, "detach")38 token.effect.bindActionEnd(d, "detach")
39 token.effect.End()39 token.effect.End()
40- sess := token.sess40+ if view, remote := token.owner.(*remoteView); remote {
41+ return d.clientGoneRemote(view, token, explicit)
42+ }
43+ sess := token.localSession()
41 if sess == nil {44 if sess == nil {
42 return false45 return false
43 }46 }
@@ -53,6 +56,94 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
53 return true56 return true
54 }57 }
55 58
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+
56 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {147 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
57 if sess == nil || ac == nil {148 if sess == nil || ac == nil {
58 return149 return
@@ -127,7 +218,16 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
127 if failed == nil || failed != token.transport.transport {218 if failed == nil || failed != token.transport.transport {
128 return219 return
129 }220 }
130- sess := token.sess221+ 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()
131 if sess == nil {231 if sess == nil {
132 return232 return
133 }233 }
M internal/usecase/daemon/clipboard.go +12 -4
@@ -27,14 +27,14 @@ var (
27 )27 )
28 28
29 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {29 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 {
31 return31 return
32 }32 }
33 d.handleImagePushForAttachment(token, ip)33 d.handleImagePushForAttachment(token, ip)
34 }34 }
35 35
36 func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {36 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() {
38 return38 return
39 }39 }
40 path, err := d.writeClipboardImageForAttachment(token, ip)40 path, err := d.writeClipboardImageForAttachment(token, ip)
@@ -85,11 +85,14 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
85 }85 }
86 86
87 func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {87 func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
88+ if token.localSession() == nil || !token.attachmentEffectCurrent() {
89+ return "", errAttachmentTransition
90+ }
88 path, err := d.createClipboardImage(ip)91 path, err := d.createClipboardImage(ip)
89 if err != nil {92 if err != nil {
90 return "", err93 return "", err
91 }94 }
92- sess := token.sess95+ sess := token.localSession()
93 if sess == nil {96 if sess == nil {
94 _ = os.Remove(path)97 _ = os.Remove(path)
95 return "", errAttachmentTransition98 return "", errAttachmentTransition
@@ -135,7 +138,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
135 }138 }
136 139
137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {140 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138- sess := token.sess141+ sess := token.localSession()
139 if sess == nil || !token.attachmentEffectCurrent() {142 if sess == nil || !token.attachmentEffectCurrent() {
140 return143 return
141 }144 }
@@ -236,6 +239,11 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
236 239
237 items := make([]clipboardForward, 0, len(attachments))240 items := make([]clipboardForward, 0, len(attachments))
238 for _, ac := range attachments {241 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+ }
239 transport := ac.transport()247 transport := ac.transport()
240 token := sess.attachmentToken(ac, transport)248 token := sess.attachmentToken(ac, transport)
241 if token.attachmentCurrent() {249 if token.attachmentCurrent() {
M internal/usecase/daemon/coderabbit_regression_test.go +35 -4
@@ -8,6 +8,7 @@ import (
8 "sync"8 "sync"
9 "syscall"9 "syscall"
10 "testing"10 "testing"
11+ "time"
11 12
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
13 14
@@ -120,7 +121,7 @@ func TestRestoreFailureCannotOverwriteDiscardReplacement(t *testing.T) {
120 entry := d.stopped[record.Name]121 entry := d.stopped[record.Name]
121 d.mu.Unlock()122 d.mu.Unlock()
122 require.Equal(t, persisted, entry.record)123 require.Equal(t, persisted, entry.record)
123- require.Equal(t, ports.SessionStopped, entry.state)124+ require.Equal(t, ports.SessionDown, entry.state)
124 }125 }
125 126
126 func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) {127 func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) {
@@ -138,7 +139,7 @@ func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) {
138 record.Committed = &ref139 record.Committed = &ref
139 return record140 return record
140 },141 },
141- state: ports.SessionStopped,142+ state: ports.SessionDown,
142 },143 },
143 {144 {
144 name: "newer incarnation",145 name: "newer incarnation",
@@ -146,7 +147,7 @@ func TestStaleIncompatibleRestoreCannotOverwriteNewerAuthority(t *testing.T) {
146 record.IncarnationID = domain.IncarnationID{9}147 record.IncarnationID = domain.IncarnationID{9}
147 return record148 return record
148 },149 },
149- state: ports.SessionStopped,150+ state: ports.SessionDown,
150 },151 },
151 {152 {
152 name: "degraded authority",153 name: "degraded authority",
@@ -223,7 +224,7 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
223 d := newTestDaemon(t, nil, stubClock{})224 d := newTestDaemon(t, nil, stubClock{})
224 d.catalogue = catalogue225 d.catalogue = catalogue
225 d.persistEnabled = true226 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{}))
227 228
228 result := make(chan error, 1)229 result := make(chan error, 1)
229 go func() {230 go func() {
@@ -243,6 +244,36 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
243 require.Empty(t, d.sessions)244 require.Empty(t, d.sessions)
244 }245 }
245 246
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+
246 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {277 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {
247 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}278 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
248 _, cancel := snapshotStopContext(deadline)279 _, 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) {
135 t.finish(requestID, generation, CommandRequestOutcome{Err: err})135 t.finish(requestID, generation, CommandRequestOutcome{Err: err})
136 }136 }
137 137
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+
138 func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {160 func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {
139 if t == nil {161 if t == nil {
140 return162 return
M internal/usecase/daemon/control.go +4 -4
@@ -643,7 +643,7 @@ func (e controlExec) SessionRecovery(action string) (string, error) {
643 return "", err643 return "", err
644 }644 }
645 if ok {645 if ok {
646- e.d.setStoppedRecovery(record, ports.SessionStopped)646+ e.d.setStoppedRecovery(record, ports.SessionDown)
647 }647 }
648 return "", nil648 return "", nil
649 }649 }
@@ -736,7 +736,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
736 row := ports.RemoteCatalogSession{736 row := ports.RemoteCatalogSession{
737 LifecycleID: snap.incarnation,737 LifecycleID: snap.incarnation,
738 Name: snap.name,738 Name: snap.name,
739- State: "running",739+ State: "up",
740 Ephemeral: snap.ephemeral,740 Ephemeral: snap.ephemeral,
741 Tabs: tabs,741 Tabs: tabs,
742 Attached: snap.attached,742 Attached: snap.attached,
@@ -752,7 +752,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
752 if err != nil {752 if err != nil {
753 return "", err753 return "", err
754 }754 }
755- state := "stopped"755+ state := "down"
756 reason := ""756 reason := ""
757 if entry.state == ports.SessionBroken || entry.record.DegradedReason != "" {757 if entry.state == ports.SessionBroken || entry.record.DegradedReason != "" {
758 state = "broken"758 state = "broken"
@@ -791,7 +791,7 @@ func (e controlExec) remoteCatalogLegacy(sessions []*session) (string, error) {
791 snap := sess.snapshotView(viewOptions{})791 snap := sess.snapshotView(viewOptions{})
792 rows = append(rows, ports.RemoteCatalogSession{792 rows = append(rows, ports.RemoteCatalogSession{
793 Name: snap.name,793 Name: snap.name,
794- State: "running",794+ State: "up",
795 Ephemeral: snap.ephemeral,795 Ephemeral: snap.ephemeral,
796 Tabs: ports.SaturateUint16(snap.tabCount),796 Tabs: ports.SaturateUint16(snap.tabCount),
797 Attached: snap.attached,797 Attached: snap.attached,
M internal/usecase/daemon/control_test.go +5 -5
@@ -735,7 +735,7 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
735 build.mruAt.Store(1)735 build.mruAt.Store(1)
736 736
737 d.mu.Lock()737 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}
739 d.mu.Unlock()739 d.mu.Unlock()
740 740
741 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})741 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
@@ -765,12 +765,12 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
765 require.True(t, hasProtocol && hasSessions)765 require.True(t, hasProtocol && hasSessions)
766 766
767 require.Equal(t, []ports.RemoteCatalogSession{767 require.Equal(t, []ports.RemoteCatalogSession{
768- {Name: "build", State: "running", Ephemeral: true, Tabs: 1, Attached: false},768+ {Name: "build", State: "up", Ephemeral: true, Tabs: 1, Attached: false},
769- {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},769+ {Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true},
770 }, catalog.Sessions)770 }, catalog.Sessions)
771 771
772 for _, session := range catalog.Sessions {772 for _, session := range catalog.Sessions {
773- require.Equal(t, "running", session.State)773+ require.Equal(t, "up", session.State)
774 require.NotEqual(t, "old", session.Name)774 require.NotEqual(t, "old", session.Name)
775 }775 }
776 776
@@ -797,7 +797,7 @@ func TestRemoteCatalogTabCountSaturates(t *testing.T) {
797 var catalog ports.RemoteCatalog797 var catalog ports.RemoteCatalog
798 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))798 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
799 require.Equal(t, []ports.RemoteCatalogSession{{799 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,
801 }}, catalog.Sessions)801 }}, catalog.Sessions)
802 }802 }
803 803
M internal/usecase/daemon/daemon.go +143 -110
@@ -45,6 +45,10 @@ const (
45 // the in-flight send. Teardown is never gated on a client draining its socket.45 // the in-flight send. Teardown is never gated on a client draining its socket.
46 const detachNotifyTimeout = time.Second46 const detachNotifyTimeout = time.Second
47 47
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+
48 // maxUnackedOutputStates caps how many output states may be in flight (sent but52 // maxUnackedOutputStates caps how many output states may be in flight (sent but
49 // not yet acked by the client) before paint defers rather than composing53 // not yet acked by the client) before paint defers rather than composing
50 // another diff. It bounds the daemon's paint rate to the client's ack rate, so54 // another diff. It bounds the daemon's paint rate to the client's ack rate, so
@@ -72,6 +76,16 @@ type Daemon struct {
72 mu sync.Mutex76 mu sync.Mutex
73 sessions map[domain.SessionID]*session77 sessions map[domain.SessionID]*session
74 stopped map[string]stoppedSession78 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
75 // creating reserves names while durable creation I/O runs without mu.89 // creating reserves names while durable creation I/O runs without mu.
76 creating map[string]struct{}90 creating map[string]struct{}
77 nextID uint6491 nextID uint64
@@ -322,7 +336,7 @@ type Daemon struct {
322 }336 }
323 337
324 type parkedAttachment struct {338 type parkedAttachment struct {
325- sess *session339+ owner attachmentOwner
326 ac *attachedClient340 ac *attachedClient
327 pickerGeneration uint64341 pickerGeneration uint64
328 timer ports.Timer342 timer ports.Timer
@@ -335,7 +349,7 @@ type parkedAttachment struct {
335 // credential. done closes when the token is published into parked or parking349 // credential. done closes when the token is published into parked or parking
336 // is abandoned.350 // is abandoned.
337 type parkingAttachment struct {351 type parkingAttachment struct {
338- sess *session352+ owner attachmentOwner
339 ac *attachedClient353 ac *attachedClient
340 done chan struct{}354 done chan struct{}
341 doneOnce sync.Once355 doneOnce sync.Once
@@ -594,28 +608,31 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
594 }608 }
595 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())609 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
596 d := &Daemon{610 d := &Daemon{
597- sessions: make(map[domain.SessionID]*session),611+ sessions: make(map[domain.SessionID]*session),
598- stopped: make(map[string]stoppedSession),612+ stopped: make(map[string]stoppedSession),
599- creating: make(map[string]struct{}),613+ remoteViews: make(map[remoteViewID]*remoteView),
600- parked: make(map[uint64]*parkedAttachment),614+ remoteViewsByKey: make(map[remoteViewKey]remoteViewID),
601- parking: make(map[uint64]*parkingAttachment),615+ remoteViewConstructions: make(map[remoteViewKey]*remoteViewConstruction),
602- paneProcessCtx: paneProcessCtx,616+ creating: make(map[string]struct{}),
603- paneProcessCancel: paneProcessCancel,617+ parked: make(map[uint64]*parkedAttachment),
604- ptys: ptys,618+ parking: make(map[uint64]*parkingAttachment),
605- clock: clock,619+ paneProcessCtx: paneProcessCtx,
606- log: log,620+ paneProcessCancel: paneProcessCancel,
607- baseEnv: os.Environ(),621+ ptys: ptys,
608- shell: shell,622+ clock: clock,
609- dirOrHome: dirOrHome,623+ log: log,
610- done: make(chan struct{}),624+ baseEnv: os.Environ(),
611- restoreDone: make(chan struct{}),625+ shell: shell,
612- animWake: make(chan struct{}, 1),626+ dirOrHome: dirOrHome,
613- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),627+ done: make(chan struct{}),
614- snapshotAdmitted: make(map[*snapshotCapture]struct{}),628+ restoreDone: make(chan struct{}),
615- snapshotWake: make(chan struct{}, 1),629+ animWake: make(chan struct{}, 1),
616- notices: newNoticeCenter(),630+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
617- remoteCatalog: newRemoteCatalogState(),631+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
618- resumeParkGrace: defaultResumeParkGrace,632+ snapshotWake: make(chan struct{}, 1),
633+ notices: newNoticeCenter(),
634+ remoteCatalog: newRemoteCatalogState(),
635+ resumeParkGrace: defaultResumeParkGrace,
619 barScripts: &barScriptState{636 barScripts: &barScriptState{
620 cfg: barConfigFromDomain(domain.Defaults().Bar),637 cfg: barConfigFromDomain(domain.Defaults().Bar),
621 outputs: make(map[domain.SessionID]barScriptOutputs),638 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -658,7 +675,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
658 state, done := initialSessionState(r)675 state, done := initialSessionState(r)
659 d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done)676 d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done)
660 } else {677 } else {
661- d.stopped[r.Name] = stoppedSessionFromRecord(r, ports.SessionStopped, nil)678+ d.stopped[r.Name] = stoppedSessionFromRecord(r, ports.SessionDown, nil)
662 }679 }
663 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {680 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
664 maxCreatedAt = r.CreatedAt681 maxCreatedAt = r.CreatedAt
@@ -796,14 +813,93 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
796 d.closing = true813 d.closing = true
797 d.purgeAllParkingLocked()814 d.purgeAllParkingLocked()
798 parkedRetirements := d.purgeAllParkedLocked()815 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)
799 snapshot := d.sessionsSnapshotLocked()827 snapshot := d.sessionsSnapshotLocked()
800 empty := len(snapshot) == 0828 empty := len(snapshot) == 0
801 d.mu.Unlock()829 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+ }
802 d.finishParkedAttachmentRetirements(parkedRetirements)898 d.finishParkedAttachmentRetirements(parkedRetirements)
803 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))899 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
804 if empty {900 if empty {
805 d.doneOnce.Do(func() { close(d.done) })901 d.doneOnce.Do(func() { close(d.done) })
806- return false902+ return checkpointIncomplete
807 }903 }
808 for _, s := range snapshot {904 for _, s := range snapshot {
809 // Cancellation and PTY closure must not wait behind a teardown owner that905 // Cancellation and PTY closure must not wait behind a teardown owner that
@@ -1023,7 +1119,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
1023 info := ports.SessionInfo{1119 info := ports.SessionInfo{
1024 SessionID: string(s.id),1120 SessionID: string(s.id),
1025 Name: s.name,1121 Name: s.name,
1026- State: ports.SessionRunning,1122+ State: ports.SessionUp,
1027 Ephemeral: s.ephemeral,1123 Ephemeral: s.ephemeral,
1028 Tabs: uint16(len(s.tabs)),1124 Tabs: uint16(len(s.tabs)),
1029 Attached: len(s.attachments) != 0,1125 Attached: len(s.attachments) != 0,
@@ -1036,7 +1132,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
1036 if _, live := liveNames[name]; live {1132 if _, live := liveNames[name]; live {
1037 continue1133 continue
1038 }1134 }
1039- state := ports.SessionStopped1135+ state := ports.SessionDown
1040 if stopped.purging || stopped.state == ports.SessionBroken {1136 if stopped.purging || stopped.state == ports.SessionBroken {
1041 // Purge is the dominant externally visible state: restoration must1137 // Purge is the dominant externally visible state: restoration must
1042 // never make a deletion-reserved record appear attachable.1138 // never make a deletion-reserved record appear attachable.
@@ -1135,8 +1231,24 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1135 return1231 return
1136 }1232 }
1137 1233
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+ }
1139 if rerr != nil {1248 if rerr != nil {
1249+ if errors.Is(rerr, errResumeTokenLifecycleRace) {
1250+ rerr = &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1251+ }
1140 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)1252 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
1141 if pe, ok := errors.AsType[*protoErr](rerr); ok {1253 if pe, ok := errors.AsType[*protoErr](rerr); ok {
1142 _ = sendHandshake(frameError(pe.code, pe.text))1254 _ = sendHandshake(frameError(pe.code, pe.text))
@@ -1146,87 +1258,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1146 _ = tr.Close()1258 _ = tr.Close()
1147 return1259 return
1148 }1260 }
1149-1261+ d.completeAttachmentHandshake(handshakeCtx, stopHandshakeTransport, finishHandshake, tr, owner, ac)
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()
1230 }1262 }
1231 1263
1232 // protoErr is a session-level rejection carrying a wire ErrorMsg code.1264 // 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,
1269 clientID: h.ClientID,1301 clientID: h.ClientID,
1270 resumeCapable: true,1302 resumeCapable: true,
1271 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1303 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1304+ renderMode: h.RenderMode,
1272 }1305 }
1273 if h.RemoteTarget != nil {1306 if h.RemoteTarget != nil {
1274 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin1307 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
M internal/usecase/daemon/durable_recovery_test.go +13 -13
@@ -304,7 +304,7 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
304 healthyEntry := d.stopped[healthy.Name]304 healthyEntry := d.stopped[healthy.Name]
305 brokenEntry := d.stopped[broken.Name]305 brokenEntry := d.stopped[broken.Name]
306 d.mu.Unlock()306 d.mu.Unlock()
307- require.Equal(t, ports.SessionStopped, healthyEntry.state)307+ require.Equal(t, ports.SessionDown, healthyEntry.state)
308 require.Equal(t, ports.SessionBroken, brokenEntry.state)308 require.Equal(t, ports.SessionBroken, brokenEntry.state)
309 for name, done := range map[string]<-chan struct{}{healthy.Name: healthyEntry.restoreDone, broken.Name: brokenEntry.restoreDone} {309 for name, done := range map[string]<-chan struct{}{healthy.Name: healthyEntry.restoreDone, broken.Name: brokenEntry.restoreDone} {
310 select {310 select {
@@ -344,14 +344,14 @@ func TestCatalogueRestoreResetsOnlyIncompatibleCheckpoint(t *testing.T) {
344 require.Empty(t, fresh.DegradedReason)344 require.Empty(t, fresh.DegradedReason)
345 require.Empty(t, fresh.TabNames)345 require.Empty(t, fresh.TabNames)
346 require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted)346 require.Equal(t, []domain.IncarnationID{incompatible.IncarnationID}, repository.deleted)
347- require.Equal(t, ports.SessionStopped, d.stopped[incompatible.Name].state)347+ require.Equal(t, ports.SessionDown, d.stopped[incompatible.Name].state)
348 require.Equal(t, fresh, d.stopped[incompatible.Name].record)348 require.Equal(t, fresh, d.stopped[incompatible.Name].record)
349 349
350 restoredSibling, ok, err := catalogue.Record(healthy.Name)350 restoredSibling, ok, err := catalogue.Record(healthy.Name)
351 require.NoError(t, err)351 require.NoError(t, err)
352 require.True(t, ok)352 require.True(t, ok)
353 require.Equal(t, healthy, restoredSibling)353 require.Equal(t, healthy, restoredSibling)
354- require.Equal(t, ports.SessionStopped, d.stopped[healthy.Name].state)354+ require.Equal(t, ports.SessionDown, d.stopped[healthy.Name].state)
355 require.Equal(t, 1, repository.loads[healthy.Name])355 require.Equal(t, 1, repository.loads[healthy.Name])
356 }356 }
357 357
@@ -381,7 +381,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te
381 d.mu.Lock()381 d.mu.Lock()
382 entry := d.stopped[record.Name]382 entry := d.stopped[record.Name]
383 d.mu.Unlock()383 d.mu.Unlock()
384- require.Equal(t, ports.SessionStopped, entry.state)384+ require.Equal(t, ports.SessionDown, entry.state)
385 require.Equal(t, fresh, entry.record)385 require.Equal(t, fresh, entry.record)
386 require.Equal(t, restoreDone, entry.restoreDone)386 require.Equal(t, restoreDone, entry.restoreDone)
387 select {387 select {
@@ -394,7 +394,7 @@ func TestIncompatibleCheckpointDeleteFailurePublishesFreshStoppedAuthority(t *te
394 d.mu.Lock()394 d.mu.Lock()
395 entry = d.stopped[record.Name]395 entry = d.stopped[record.Name]
396 d.mu.Unlock()396 d.mu.Unlock()
397- require.Equal(t, ports.SessionStopped, entry.state)397+ require.Equal(t, ports.SessionDown, entry.state)
398 require.Equal(t, fresh, entry.record)398 require.Equal(t, fresh, entry.record)
399 select {399 select {
400 case <-entry.restoreDone:400 case <-entry.restoreDone:
@@ -472,7 +472,7 @@ func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
472 472
473 require.Len(t, d.stopped, count)473 require.Len(t, d.stopped, count)
474 for _, record := range records {474 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)
476 require.Equal(t, 1, repository.loads[record.Name])476 require.Equal(t, 1, repository.loads[record.Name])
477 }477 }
478 }478 }
@@ -486,7 +486,7 @@ func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) {
486 // discovery API and therefore performs exactly one direct catalogue lookup.486 // discovery API and therefore performs exactly one direct catalogue lookup.
487 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))487 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
488 488
489- require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state)489+ require.Equal(t, ports.SessionDown, d.stopped[record.Name].state)
490 require.Equal(t, 1, repository.loads[record.Name])490 require.Equal(t, 1, repository.loads[record.Name])
491 }491 }
492 492
@@ -709,7 +709,7 @@ func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T
709 name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt,709 name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt,
710 incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq,710 incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq,
711 tabNames: append([]string(nil), record.TabNames...), record: record,711 tabNames: append([]string(nil), record.TabNames...), record: record,
712- state: ports.SessionStopped, restoreDone: make(chan struct{}),712+ state: ports.SessionDown, restoreDone: make(chan struct{}),
713 }713 }
714 }714 }
715 beforeStopped, hadStopped := d.stopped[tc.sessName]715 beforeStopped, hadStopped := d.stopped[tc.sessName]
@@ -769,7 +769,7 @@ func TestSessionRecoveryCommand(t *testing.T) {
769 769
770 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")770 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")
771 require.NoError(t, err)771 require.NoError(t, err)
772- require.Equal(t, ports.SessionStopped, d.stopped[degraded.Name].state)772+ require.Equal(t, ports.SessionDown, d.stopped[degraded.Name].state)
773 require.Equal(t, fresh, d.stopped[degraded.Name].record)773 require.Equal(t, fresh, d.stopped[degraded.Name].record)
774 }774 }
775 775
@@ -783,8 +783,8 @@ func TestListShowsDegraded(t *testing.T) {
783 783
784 listed := listSessions(t, d)784 listed := listSessions(t, d)
785 require.Equal(t, []ports.SessionInfo{785 require.Equal(t, []ports.SessionInfo{
786- {Name: fresh.Name, State: ports.SessionStopped},786+ {Name: fresh.Name, State: ports.SessionDown},
787- {Name: restoring.Name, State: ports.SessionStopped},787+ {Name: restoring.Name, State: ports.SessionDown},
788 {Name: degraded.Name, State: ports.SessionBroken},788 {Name: degraded.Name, State: ports.SessionBroken},
789 }, listed.Sessions)789 }, listed.Sessions)
790 }790 }
@@ -795,7 +795,7 @@ func TestListPurgingDominatesRestoringState(t *testing.T) {
795 d.mu.Lock()795 d.mu.Lock()
796 entry := d.stopped[record.Name]796 entry := d.stopped[record.Name]
797 entry.purging = true797 entry.purging = true
798- entry.state = ports.SessionStopped798+ entry.state = ports.SessionDown
799 d.stopped[record.Name] = entry799 d.stopped[record.Name] = entry
800 d.mu.Unlock()800 d.mu.Unlock()
801 801
@@ -935,7 +935,7 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
935 state ports.SessionState935 state ports.SessionState
936 message string936 message string
937 }{937 }{
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},
939 {name: "broken durable state", state: ports.SessionBroken, message: "session durable state is broken: " + record.Name},939 {name: "broken durable state", state: ports.SessionBroken, message: "session durable state is broken: " + record.Name},
940 }940 }
941 for _, tt := range tests {941 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
115 // by route. A fresh route-owned session is purged only after its attachment is115 // by route. A fresh route-owned session is purged only after its attachment is
116 // removed; existing sessions and their unrelated attachments are preserved.116 // removed; existing sessions and their unrelated attachments are preserved.
117 func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {117 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 {
119 if tr != nil {125 if tr != nil {
120 _ = tr.Close()126 _ = tr.Close()
121 }127 }
@@ -130,11 +136,15 @@ func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr p
130 }136 }
131 return137 return
132 }138 }
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+ }
134 if tr != nil {144 if tr != nil {
135 _ = tr.Close()145 _ = tr.Close()
136 }146 }
137- if ac.routeCreatedSession {147+ if sess := localSession(owner); sess != nil && ac.routeCreatedSession {
138 sess.mu.Lock()148 sess.mu.Lock()
139 empty := len(sess.attachments) == 0149 empty := len(sess.attachments) == 0
140 sess.mu.Unlock()150 sess.mu.Unlock()
M internal/usecase/daemon/input.go +83 -4
@@ -7,6 +7,7 @@ import (
7 "strconv"7 "strconv"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/keys"11 "github.com/bnema/vev/internal/usecase/keys"
11 "github.com/bnema/vev/internal/usecase/layout"12 "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/bnema/vev/internal/usecase/mouse"13 "github.com/bnema/vev/internal/usecase/mouse"
@@ -20,13 +21,90 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
20 d.handleInput(sess, ac, data)21 d.handleInput(sess, ac, data)
21 }22 }
22 23
23-func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {24+func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, inputSeq uint64, data []byte) {
24 if !token.attachmentEffectCurrent() {25 if !token.attachmentEffectCurrent() {
25 return26 return
26 }27 }
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+ }
27 d.handleInputForAttachment(token, data)36 d.handleInputForAttachment(token, data)
28 }37 }
29 38
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+
30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {108 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
31 ac.initOverlays()109 ac.initOverlays()
32 ac.mouseScan.Scan(data,110 ac.mouseScan.Scan(data,
@@ -383,7 +461,7 @@ type daemonKeyHandler struct {
383 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {461 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384 if h.connectionToken.ac != nil {462 if h.connectionToken.ac != nil {
385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {463 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386- return h.connectionToken.sess, effect, false464+ return h.connectionToken.localSession(), effect, false
387 }465 }
388 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)466 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
389 if h.d.afterDelayedKeyEffectAttempt != nil {467 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -397,11 +475,12 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
397 token.effect = effect475 token.effect = effect
398 h.d.afterAttachmentEffectAdmitted(token)476 h.d.afterAttachmentEffectAdmitted(token)
399 }477 }
400- if h.connectionToken.sess == nil {478+ sess := h.connectionToken.localSession()
479+ if sess == nil {
401 effect.End()480 effect.End()
402 return nil, nil, false481 return nil, nil, false
403 }482 }
404- return h.connectionToken.sess, effect, true483+ return sess, effect, true
405 }484 }
406 sess := h.ac.currentSession()485 sess := h.ac.currentSession()
407 if sess == nil {486 if sess == nil {
M internal/usecase/daemon/input_test.go +15 -15
@@ -799,8 +799,8 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
799 prepare func()799 prepare func()
800 }{800 }{
801 {name: "no target", prepare: func() {}},801 {name: "no target", prepare: func() {}},
802- {name: "stale target", prepare: func() { ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},802+ {name: "stale target", prepare: func() { ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
803- {name: "same session", prepare: func() { ac.previousSession.Set(current) }},803+ {name: "same session", prepare: func() { ac.previousOwner.Set(current) }},
804 } {804 } {
805 t.Run(tc.name, func(t *testing.T) {805 t.Run(tc.name, func(t *testing.T) {
806 ac.clearPreviousSession()806 ac.clearPreviousSession()
@@ -808,7 +808,7 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
808 runPaletteCommand(t, d, current, ac, "BSK")808 runPaletteCommand(t, d, current, ac, "BSK")
809 require.Same(t, current, ac.currentSession())809 require.Same(t, current, ac.currentSession())
810 if tc.name != "no target" {810 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")
812 }812 }
813 })813 })
814 }814 }
@@ -818,7 +818,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
818 d, current, ac, _, releases := newRecentNavigationTestSessions(t)818 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
819 defer releaseAll(releases)819 defer releaseAll(releases)
820 target := d.sessions[domain.SessionID("recent")]820 target := d.sessions[domain.SessionID("recent")]
821- ac.previousSession.Set(target)821+ ac.previousOwner.Set(target)
822 822
823 // A displaced attachment makes the previously valid handoff stale by the823 // A displaced attachment makes the previously valid handoff stale by the
824 // time switchToTarget commits it.824 // time switchToTarget commits it.
@@ -829,7 +829,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
829 d.backSession(current, ac)829 d.backSession(current, ac)
830 830
831 require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")831 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")
833 history := d.notices.history()833 history := d.notices.history()
834 require.Len(t, history, 1, "the switch failure must be reported exactly once")834 require.Len(t, history, 1, "the switch failure must be reported exactly once")
835 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)835 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
@@ -839,14 +839,14 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
839 ac := &attachedClient{}839 ac := &attachedClient{}
840 stale := &session{sessionCore: sessionCore{id: "stale"}}840 stale := &session{sessionCore: sessionCore{id: "stale"}}
841 updated := &session{sessionCore: sessionCore{id: "updated"}}841 updated := &session{sessionCore: sessionCore{id: "updated"}}
842- ac.previousSession.Set(stale)842+ ac.previousOwner.Set(stale)
843 843
844 // Model a completed hand-off between observing a stale target and clearing844 // Model a completed hand-off between observing a stale target and clearing
845 // it. The conditional clear must not erase the newer toggle destination.845 // it. The conditional clear must not erase the newer toggle destination.
846- ac.previousSession.Set(updated)846+ ac.previousOwner.Set(updated)
847- ac.clearPreviousSessionIf(stale)847+ ac.clearPreviousOwnerIf(stale)
848 848
849- require.Same(t, updated, ac.previousSession.Get())849+ require.Same(t, updated, ac.previousOwner.Get())
850 }850 }
851 851
852 // backSession's invalid-target fallback is a render producer: it must publish852 // backSession's invalid-target fallback is a render producer: it must publish
@@ -861,10 +861,10 @@ func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
861 }{861 }{
862 {name: "no previous target", prepare: func(*session, *attachedClient) {}},862 {name: "no previous target", prepare: func(*session, *attachedClient) {}},
863 {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {863 {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"}})
865 }},865 }},
866 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {866 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
867- ac.previousSession.Set(current)867+ ac.previousOwner.Set(current)
868 }},868 }},
869 } {869 } {
870 t.Run(tc.name, func(t *testing.T) {870 t.Run(tc.name, func(t *testing.T) {
@@ -940,10 +940,10 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
940 }940 }
941 require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)941 require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
942 if tc.wantPrev == "" {942 if tc.wantPrev == "" {
943- require.Nil(t, ac.previousSession.Get())943+ require.Nil(t, ac.previousOwner.Get())
944 return944 return
945 }945 }
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)
947 })947 })
948 }948 }
949 }949 }
@@ -963,10 +963,10 @@ func TestTerminalTeardownClearsPreviousSession(t *testing.T) {
963 d, current, ac, _, releases := newRecentNavigationTestSessions(t)963 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
964 defer releaseAll(releases)964 defer releaseAll(releases)
965 965
966- ac.previousSession.Set(d.sessions[domain.SessionID("recent")])966+ ac.previousOwner.Set(d.sessions[domain.SessionID("recent")])
967 tc.teardown(d, current, ac)967 tc.teardown(d, current, ac)
968 968
969- require.Nil(t, ac.previousSession.Get())969+ require.Nil(t, ac.previousOwner.Get())
970 })970 })
971 }971 }
972 }972 }
M internal/usecase/daemon/move_attachments.go +2 -2
@@ -1,8 +1,8 @@
1 package daemon1 package daemon
2 2
3 func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {3 func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {
4- return token.sess == sess && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&4+ return sameAttachmentOwner(token.owner, sess) && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5- token.ac.currentAttachmentSession() == sess && token.ac.transportSnapshotCurrent(token.transport) &&5+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), sess) && token.ac.transportSnapshotCurrent(token.transport) &&
6 attachmentRegisteredLocked(sess, token.ac)6 attachmentRegisteredLocked(sess, token.ac)
7 }7 }
8 8
M internal/usecase/daemon/move_picker.go +5 -2
@@ -161,13 +161,16 @@ type pickerRefreshOptions struct {
161 }161 }
162 162
163 func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {163 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()
165 if sess == nil {168 if sess == nil {
166 return169 return
167 }170 }
168 rt := ac.overlays171 rt := ac.overlays
169 rt.pickerMu.Lock()172 rt.pickerMu.Lock()
170- if rt.picker == nil {173+ if rt.picker == nil || rt.pickerRemoteSelection != nil {
171 rt.pickerMu.Unlock()174 rt.pickerMu.Unlock()
172 return175 return
173 }176 }
M internal/usecase/daemon/navigation_attachment_handoff_test.go +2 -2
@@ -56,7 +56,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
56 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {56 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
57 d.mu.Lock()57 d.mu.Lock()
58 delete(d.sessions, target.id)58 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}
60 d.mu.Unlock()60 d.mu.Unlock()
61 expectedCreatedAt := int64(9)61 expectedCreatedAt := int64(9)
62 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")62 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) {
149 func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {149 func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {
150 d, source, old, _ := newManualSessionWithPTYs(t, nil)150 d, source, old, _ := newManualSessionWithPTYs(t, nil)
151 d.mu.Lock()151 d.mu.Lock()
152- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}152+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionDown}
153 d.mu.Unlock()153 d.mu.Unlock()
154 154
155 rc := d.attachCoordinator(source, nil, old, true)155 rc := d.attachCoordinator(source, nil, old, true)
M internal/usecase/daemon/notify.go +30 -0
@@ -370,6 +370,29 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
370 d.notify(nil, sev, code, msg, cause)370 d.notify(nil, sev, code, msg, cause)
371 }371 }
372 372
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+
373 // publishToast durably mutates one client's toast state without repainting.396 // publishToast durably mutates one client's toast state without repainting.
374 // Routing paths call it while routingMu excludes detach, then release routingMu397 // Routing paths call it while routingMu excludes detach, then release routingMu
375 // before rendering so a render failure can safely route another notice.398 // before rendering so a render failure can safely route another notice.
@@ -491,6 +514,13 @@ func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) {
491 // repaintForNotice asks for an urgent redraw. It must be called with noticeMu514 // repaintForNotice asks for an urgent redraw. It must be called with noticeMu
492 // released: invalidateRender can paint inline, which takes sendMu.515 // released: invalidateRender can paint inline, which takes sendMu.
493 func (d *Daemon) repaintForNotice(ac *attachedClient) {516 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+ }
494 sess := ac.currentSession()524 sess := ac.currentSession()
495 if sess == nil {525 if sess == nil {
496 // Mid-handoff or torn down: whoever attaches next repaints anyway, and526 // 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
250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
251 }251 }
252 252
253-func (s *outputStateStream) ack(epoch, state uint64) {253+func (s *outputStateStream) ack(epoch, state uint64) bool {
254 if s == nil {254 if s == nil {
255- return255+ return false
256 }256 }
257 s.lockView()257 s.lockView()
258 defer s.unlockView()258 defer s.unlockView()
259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
260- return260+ return false
261 }261 }
262 s.acked = state262 s.acked = state
263 s.publishOutstanding()263 s.publishOutstanding()
264+ return true
264 }265 }
265 266
266 func (s *outputStateStream) rebase() {267 func (s *outputStateStream) rebase() {
M internal/usecase/daemon/overlay_runtime.go +5 -2
@@ -31,8 +31,11 @@ type overlayRuntime struct {
31 pickerRemotePreview picker.Preview31 pickerRemotePreview picker.Preview
32 pickerRemotePreviewCancel context.CancelFunc32 pickerRemotePreviewCancel context.CancelFunc
33 pickerPreviewGeneration uint6433 pickerPreviewGeneration uint64
34- pickerPending []byte34+ // pickerRemoteSelection fences one asynchronous remote picker activation.
35- pickerESC pendingByteTimer35+ // It is cleared before the picker accepts another remote activation.
36+ pickerRemoteSelection *remotePickerSelection
37+ pickerPending []byte
38+ pickerESC pendingByteTimer
36 39
37 // Test-only, unsynchronized lifecycle seams. Assign them before picker40 // Test-only, unsynchronized lifecycle seams. Assign them before picker
38 // publication or goroutine startup. Hooks run without pickerMu or41 // 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 {
37 37
38 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {38 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
39 // Capture daemon/session state before taking paletteMu: lock ordering forbids39 // Capture daemon/session state before taking paletteMu: lock ordering forbids
40- // holding an overlay lock while inspecting live sessions.40+ // holding an overlay lock while inspecting live sessions. A remote view is
41- recent := d.recentSessions(sess)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)
42 commands := d.paletteCommands()48 commands := d.paletteCommands()
43- results := d.paletteResults(sess, commands)49+ results := d.paletteResults(current, commands)
44 ac.overlays.paletteMu.Lock()50 ac.overlays.paletteMu.Lock()
45 ac.overlays.paletteGeneration++51 ac.overlays.paletteGeneration++
46 ac.overlays.palette = palette.New(results)52 ac.overlays.palette = palette.New(results)
@@ -49,7 +55,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
49 ac.overlays.paletteFeedback = ""55 ac.overlays.paletteFeedback = ""
50 ac.overlays.palettePending = nil56 ac.overlays.palettePending = nil
51 ac.overlays.paletteMu.Unlock()57 ac.overlays.paletteMu.Unlock()
52- d.invalidateRender(sess, ac, true, "palette.go")58+ d.invalidateAttachedOwner(ac, true, "palette.go")
53 }59 }
54 60
55 // paletteResults captures eligible named sessions before paletteMu so the61 // paletteResults captures eligible named sessions before paletteMu so the
@@ -146,7 +152,7 @@ func (d *Daemon) recordPaletteUse(code string) {
146 }152 }
147 153
148 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {154 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
149- entry := ac.currentAttachmentSession()155+ entry := ac.navigationSession()
150 if entry == nil {156 if entry == nil {
151 return157 return
152 }158 }
@@ -253,12 +259,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
253 }259 }
254 ac.overlays.paletteMu.Unlock()260 ac.overlays.paletteMu.Unlock()
255 if cancel {261 if cancel {
256- d.invalidateRender(entry, ac, true, "palette.go")262+ d.invalidateAttachedOwner(ac, true, "palette.go")
257 return263 return
258 }264 }
259 if !execute {265 if !execute {
260 if changed {266 if changed {
261- d.invalidateRender(entry, ac, true, "palette.go")267+ d.invalidateAttachedOwner(ac, true, "palette.go")
262 }268 }
263 return269 return
264 }270 }
@@ -284,11 +290,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
284 }290 }
285 if err != nil {291 if err != nil {
286 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")292 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
287- d.invalidateRender(entry, ac, true, "palette.go")293+ d.invalidateAttachedOwner(ac, true, "palette.go")
288 return294 return
289 }295 }
290 if ac.closeExecutedPalette(generation, rawQuery) {296 if ac.closeExecutedPalette(generation, rawQuery) {
291- d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")297+ d.invalidateAttachedOwner(ac, true, "palette.go")
292 }298 }
293 return299 return
294 }300 }
@@ -297,7 +303,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
297 rank, err := command.ParsePositiveDecimal(args)303 rank, err := command.ParsePositiveDecimal(args)
298 if err != nil {304 if err != nil {
299 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")305 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")
301 return307 return
302 }308 }
303 exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect}309 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 ...
306 return312 return
307 }313 }
308 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")314 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
309- d.invalidateRender(entry, ac, true, "palette.go")315+ d.invalidateAttachedOwner(ac, true, "palette.go")
310 return316 return
311 }317 }
312 if ac.closeExecutedPalette(generation, rawQuery) {318 if ac.closeExecutedPalette(generation, rawQuery) {
313 d.recordPaletteUse(cmd.Code)319 d.recordPaletteUse(cmd.Code)
314- d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")320+ d.invalidateAttachedOwner(ac, true, "palette.go")
315 }321 }
316 return322 return
317 }323 }
@@ -327,7 +333,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
327 currentToken := current.attachmentToken(ac, ac.transport())333 currentToken := current.attachmentToken(ac, ac.transport())
328 fresh, admitted := ac.beginAttachmentEffect(currentToken)334 fresh, admitted := ac.beginAttachmentEffect(currentToken)
329 if ac.closeExecutedPalette(generation, rawQuery) {335 if ac.closeExecutedPalette(generation, rawQuery) {
330- d.invalidateRender(current, ac, true, "palette.go")336+ d.invalidateAttachedOwner(ac, true, "palette.go")
331 }337 }
332 if admitted {338 if admitted {
333 fresh.End()339 fresh.End()
M internal/usecase/daemon/palette_test.go +1 -1
@@ -460,7 +460,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
460 // Establish a valid back-session target, then remove that target immediately460 // Establish a valid back-session target, then remove that target immediately
461 // after the command releases its role admission.461 // after the command releases its role admission.
462 target := d.sessions[domain.SessionID("recent")]462 target := d.sessions[domain.SessionID("recent")]
463- ac.previousSession.Set(target)463+ ac.previousOwner.Set(target)
464 invalidations := installPaletteInvalidationObserver(current)464 invalidations := installPaletteInvalidationObserver(current)
465 d.enterPalette(current, ac)465 d.enterPalette(current, ac)
466 d.handlePaletteInput(ac, []byte("BSK"))466 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.
63 rt := ac.overlays63 rt := ac.overlays
64 rt.pickerMu.Lock()64 rt.pickerMu.Lock()
65 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration65 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
66+ previousSelection := rt.pickerRemoteSelection
67+ rt.pickerRemoteSelection = nil
66 rt.pickerPreviewGeneration++68 rt.pickerPreviewGeneration++
67 if rt.pickerRemotePreviewCancel != nil {69 if rt.pickerRemotePreviewCancel != nil {
68 rt.pickerRemotePreviewCancel()70 rt.pickerRemotePreviewCancel()
@@ -80,9 +82,12 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
80 rt.pickerPending = nil82 rt.pickerPending = nil
81 rt.pickerESC.stop()83 rt.pickerESC.stop()
82 rt.pickerMu.Unlock()84 rt.pickerMu.Unlock()
85+ if previousSelection != nil {
86+ previousSelection.cancel()
87+ }
83 d.teardownPreviewSubscription(ac, previous, previousGeneration)88 d.teardownPreviewSubscription(ac, previous, previousGeneration)
84 d.registerPreviewForSelection(ac)89 d.registerPreviewForSelection(ac)
85- d.invalidateRender(sess, ac, true, "picker.go")90+ d.invalidateAttachedOwner(ac, true, "picker.go")
86 if rt.beforeRemotePickerRegistration != nil {91 if rt.beforeRemotePickerRegistration != nil {
87 rt.beforeRemotePickerRegistration()92 rt.beforeRemotePickerRegistration()
88 }93 }
@@ -247,8 +252,8 @@ func remotePickerReasonText(reason string) string {
247 return "host unreachable"252 return "host unreachable"
248 case "version_mismatch":253 case "version_mismatch":
249 return "version mismatch"254 return "version mismatch"
250- case "session_stopped":255+ case "session_down":
251- return "session stopped"256+ return "session down"
252 case "session_broken":257 case "session_broken":
253 return "session broken"258 return "session broken"
254 case "refreshing":259 case "refreshing":
@@ -264,6 +269,8 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
264 rt := ac.overlays269 rt := ac.overlays
265 var previewGeneration uint64270 var previewGeneration uint64
266 var instance remotePickerInstance271 var instance remotePickerInstance
272+ var selection *remotePickerSelection
273+ var closed bool
267 return listInputState{274 return listInputState{
268 pending: &rt.pickerPending,275 pending: &rt.pickerPending,
269 esc: &rt.pickerESC,276 esc: &rt.pickerESC,
@@ -273,6 +280,12 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
273 unlock: rt.pickerMu.Unlock,280 unlock: rt.pickerMu.Unlock,
274 active: func() bool { return rt.picker != nil },281 active: func() bool { return rt.picker != nil },
275 closeLocked: func() {282 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
276 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}289 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
277 rt.picker = nil290 rt.picker = nil
278 rt.pickerIntent = pickerNavigate291 rt.pickerIntent = pickerNavigate
@@ -280,17 +293,20 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
280 previewGeneration = rt.pickerPreviewGeneration293 previewGeneration = rt.pickerPreviewGeneration
281 },294 },
282 afterClose: func() {295 afterClose: func() {
283- d.clearPreviewGeneration(ac, previewGeneration)296+ if selection != nil {
284- d.remotePickerClosed(instance)297+ selection.cancel()
285- if sess := ac.currentAttachmentSession(); sess != nil {
286- d.invalidateRender(sess, ac, true, "picker.go")
287 }298 }
299+ if closed {
300+ d.clearPreviewGeneration(ac, previewGeneration)
301+ d.remotePickerClosed(instance)
302+ }
303+ d.invalidateAttachedOwner(ac, true, "picker.go")
288 },304 },
289 }305 }
290 }306 }
291 307
292 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {308 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
293- sess := ac.currentAttachmentSession()309+ sess := ac.navigationSession()
294 if sess == nil {310 if sess == nil {
295 return311 return
296 }312 }
@@ -333,12 +349,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
333 if result.action == 's' {349 if result.action == 's' {
334 d.togglePickerSort()350 d.togglePickerSort()
335 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})351 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
336- d.invalidateRender(sess, ac, true, "picker.go")352+ d.invalidateAttachedOwner(ac, true, "picker.go")
337 return353 return
338 }354 }
339 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {355 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
340 d.notifyRemotePickerUnavailable(sess, cursor)356 d.notifyRemotePickerUnavailable(sess, cursor)
341- d.invalidateRender(sess, ac, true, "picker.go")357+ d.invalidateAttachedOwner(ac, true, "picker.go")
342 return358 return
343 }359 }
344 if result.action == 'x' {360 if result.action == 'x' {
@@ -366,7 +382,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
366 defer fresh.End()382 defer fresh.End()
367 }383 }
368 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})384 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
369- d.invalidateRender(sess, ac, true, "picker.go")385+ d.invalidateAttachedOwner(ac, true, "picker.go")
370 return386 return
371 }387 }
372 if result.changed {388 if result.changed {
@@ -380,7 +396,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
380 if intent == pickerMovePane || intent == pickerMoveTab {396 if intent == pickerMovePane || intent == pickerMoveTab {
381 if err := d.movePickerSourceError(source); err != nil {397 if err := d.movePickerSourceError(source); err != nil {
382 d.reportAttachmentError(sess, err)398 d.reportAttachmentError(sess, err)
383- d.invalidateRender(sess, ac, true, "picker.go")399+ d.invalidateAttachedOwner(ac, true, "picker.go")
384 return400 return
385 }401 }
386 if len(effects) != 0 && effects[0] != nil {402 if len(effects) != 0 && effects[0] != nil {
@@ -390,14 +406,36 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
390 err := d.commitMovePickerSelection(intent, source, target)406 err := d.commitMovePickerSelection(intent, source, target)
391 if err != nil {407 if err != nil {
392 d.reportAttachmentError(sess, movePickerUserError(err))408 d.reportAttachmentError(sess, movePickerUserError(err))
393- d.invalidateRender(sess, ac, true, "picker.go")409+ d.invalidateAttachedOwner(ac, true, "picker.go")
394 return410 return
395 }411 }
396- d.invalidateRender(sess, ac, true, "picker.go")412+ d.invalidateAttachedOwner(ac, true, "picker.go")
397 return413 return
398 }414 }
399 var err error415 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 {
401 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")439 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
402 } else {440 } else {
403 d.closePicker(ac)441 d.closePicker(ac)
@@ -413,7 +451,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
413 return451 return
414 }452 }
415 if result.exit || result.changed {453 if result.exit || result.changed {
416- d.invalidateRender(sess, ac, true, "picker.go")454+ d.invalidateAttachedOwner(ac, true, "picker.go")
417 }455 }
418 }456 }
419 457
@@ -505,7 +543,7 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ
505 remoteTarget := *target.RemoteTarget543 remoteTarget := *target.RemoteTarget
506 message := "loading remote preview…"544 message := "loading remote preview…"
507 if remoteTarget.Stopped {545 if remoteTarget.Stopped {
508- message = "stopped session — preview unavailable"546+ message = "down session — preview unavailable"
509 }547 }
510 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)548 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)
511 if remoteTarget.Stopped {549 if remoteTarget.Stopped {
@@ -707,6 +745,8 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
707 return false745 return false
708 }746 }
709 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}747 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
748+ selection := ac.overlays.pickerRemoteSelection
749+ ac.overlays.pickerRemoteSelection = nil
710 ac.overlays.picker = nil750 ac.overlays.picker = nil
711 ac.overlays.pickerTitle = ""751 ac.overlays.pickerTitle = ""
712 ac.overlays.pickerIntent = pickerNavigate752 ac.overlays.pickerIntent = pickerNavigate
@@ -715,6 +755,9 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
715 ac.overlays.pickerESC.stop()755 ac.overlays.pickerESC.stop()
716 previewGeneration := ac.overlays.pickerPreviewGeneration756 previewGeneration := ac.overlays.pickerPreviewGeneration
717 ac.overlays.pickerMu.Unlock()757 ac.overlays.pickerMu.Unlock()
758+ if selection != nil {
759+ selection.cancel()
760+ }
718 d.clearPreviewGeneration(ac, previewGeneration)761 d.clearPreviewGeneration(ac, previewGeneration)
719 if instance.model != nil {762 if instance.model != nil {
720 d.remotePickerClosed(instance)763 d.remotePickerClosed(instance)
@@ -794,7 +837,8 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
794 }837 }
795 838
796 func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {839 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 {
798 return nil842 return nil
799 }843 }
800 d.mu.Lock()844 d.mu.Lock()
@@ -804,15 +848,15 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
804 if !token.attachmentCurrent() {848 if !token.attachmentCurrent() {
805 return nil849 return nil
806 }850 }
807- d.invalidateRender(token.sess, token.ac, true, "picker.go")851+ d.invalidateRender(source, token.ac, true, "picker.go")
808 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)852 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
809 }853 }
810- if targetSess == token.sess {854+ if targetSess == source {
811 return nil855 return nil
812 }856 }
813 857
814 transition, err := d.transitionAttachment(attachmentTransitionRequest{858 transition, err := d.transitionAttachment(attachmentTransitionRequest{
815- source: token.sess, target: targetSess, next: token.ac,859+ source: source, target: targetSess, next: token.ac,
816 860
817 expectedTransport: token.transport, sourceToken: &token, action: action,861 expectedTransport: token.transport, sourceToken: &token, action: action,
818 expectedTargetLifecycle: pickerTargetLifecycleFence(target),862 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
@@ -834,7 +878,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
834 }878 }
835 }879 }
836 d.touchMRU(targetSess)880 d.touchMRU(targetSess)
837- token.ac.recordPreviousSession(token.sess)881+ token.ac.recordPreviousSession(source)
838 d.deferAttachmentTransitionCleanups(transition)882 d.deferAttachmentTransitionCleanups(transition)
839 d.firstPaintForTransition(transition.published)883 d.firstPaintForTransition(transition.published)
840 return nil884 return nil
@@ -847,92 +891,83 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
847 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})891 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
848 }892 }
849 893
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+
850 // switchToTargetForAttachment is the sole client-originated navigation entry. The916 // switchToTargetForAttachment is the sole client-originated navigation entry. The
851 // intent captures the exact initiating capability before admission is released;917 // intent captures the exact initiating capability before admission is released;
852 // every active, stopped, and same-session target then uses transitionAttachment918 // every active, stopped, and same-session target then uses transitionAttachment
853 // for frozen, atomic source-token preflight.919 // for frozen, atomic source-token preflight.
854 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {920 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 {
856 return nil922 return nil
857 }923 }
858- if target.RemoteTarget != nil {924+ if _, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {
859- var key domain.RemoteSessionKey925+ if target.RemoteTarget != nil {
926+ return d.startRemotePickerSelection(token, target, guard, action)
927+ }
860 if target.RemoteKey != nil {928 if target.RemoteKey != nil {
861- key = *target.RemoteKey929+ return errAttachmentTransition
862 }930 }
863- return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)931+ previous := token.ac.previousOwner.Get()
864- }932+ if !retainedLocalTargetMatches(target, previous) {
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) {
885 return errAttachmentTransition933 return errAttachmentTransition
886 }934 }
887- if target.RemoteKey != nil {935+ transitioned := false
888- if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {936+ if err := d.backSessionForAttachment(token, func() {
889- return errAttachmentTransition937+ transitioned = true
938+ if guard.closePicker {
939+ d.closePicker(token.ac)
890 }940 }
941+ }); err != nil {
942+ return err
891 }943 }
892- handoff = ports.AttachTarget{944+ if !transitioned {
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) {
901 return errAttachmentTransition945 return errAttachmentTransition
902 }946 }
903- handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach}947+ return nil
904 }948 }
905- payload := ports.MarshalAttachTarget(handoff)949+ if token.localSession() == nil {
906- if payload == nil {950+ return nil
907- return errAttachmentTransition
908 }951 }
909- if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {952+ if token.effect == nil {
910- return err953+ return errAttachmentTransition
911 }954 }
912- if guard.closePicker {955+ if target.RemoteTarget != nil {
913- d.closePicker(token.ac)956+ return d.startRemotePickerSelection(token, target, guard, action)
914 }957 }
915- d.clientGoneForAttachment(token, false)958+ if target.RemoteKey != nil {
916- return nil959+ // Picker-originated remote activation requires the complete validated
917-}960+ // lifecycle/tab target. Legacy display keys must never fall back to a
918-961+ // client-owned attach handoff.
919-func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {962+ return errAttachmentTransition
920- if d == nil {
921- return false
922 }963 }
923- d.remoteCatalog.mu.Lock()964+ token.effect.bindActionEnd(d, action)
924- defer d.remoteCatalog.mu.Unlock()965+ token.effect.End()
925- for _, entry := range d.remoteCatalog.cache {966+ source := token.localSession()
926- if entry.Host != key.Host {967+ if source == nil {
927- continue968+ return errAttachmentTransition
928- }
929- for _, session := range entry.Sessions {
930- if session.Name == key.Name {
931- return d.remoteCatalog.status[key.Host] != remoteHostVersionMismatch
932- }
933- }
934 }969 }
935- return false970+ return d.switchToTargetGuardedForAttachment(source, token.ac, target, guard, &token, action)
936 }971 }
937 972
938 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {973 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
@@ -958,7 +993,7 @@ func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget
958 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {993 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
959 continue994 continue
960 }995 }
961- if target.Stopped != (session.State == "stopped") || session.State == "broken" {996+ if target.Stopped != (session.State == "down") || session.State == "broken" {
962 return false997 return false
963 }998 }
964 tabs := ports.CatalogTabs(session)999 tabs := ports.CatalogTabs(session)
M internal/usecase/daemon/picker_test.go +3 -3
@@ -741,7 +741,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
741 runtimeState ports.SessionState741 runtimeState ports.SessionState
742 }{742 }{
743 {name: "broken", broken: true, runtimeState: ports.SessionBroken},743 {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},
745 }745 }
746 746
747 for _, tt := range tests {747 for _, tt := range tests {
@@ -800,7 +800,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
800 defer release3()800 defer release3()
801 d, from, ac, sends := newManualSessionWithPTYs(t, p1)801 d, from, ac, sends := newManualSessionWithPTYs(t, p1)
802 d.ptys = newFactorySeq(t, p2, p3)802 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}
804 804
805 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})805 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
806 awaitFrame(t, sends, ports.MsgOutput)806 awaitFrame(t, sends, ports.MsgOutput)
@@ -920,7 +920,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
920 targetPane.screen.Write([]byte("TARGET"))920 targetPane.screen.Write([]byte("TARGET"))
921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
922 require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")922 require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
923- ac.previousSession.Set(target)923+ ac.previousOwner.Set(target)
924 924
925 // Exercise the user-facing previous-session route, which delegates to the925 // Exercise the user-facing previous-session route, which delegates to the
926 // real switchToTarget hand-off and immediately emits its required reset.926 // 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 {
15 mruAt uint6415 mruAt uint64
16 }16 }
17 17
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+
18 // recentSessions returns the current session's capped named-session MRU list.38 // recentSessions returns the current session's capped named-session MRU list.
19 // Callers may retain the returned values for the lifetime of an interaction.39 // Callers may retain the returned values for the lifetime of an interaction.
20 func (d *Daemon) recentSessions(current *session) []recentSession {40 func (d *Daemon) recentSessions(current *session) []recentSession {
@@ -35,6 +55,36 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
35 recent = append(recent, entry)55 recent = append(recent, entry)
36 }56 }
37 d.mu.Unlock()57 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 {
38 sort.SliceStable(recent, func(i, j int) bool {88 sort.SliceStable(recent, func(i, j int) bool {
39 if recent[i].mruAt == recent[j].mruAt {89 if recent[i].mruAt == recent[j].mruAt {
40 return recent[i].name < recent[j].name90 return recent[i].name < recent[j].name
@@ -46,3 +96,51 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
46 }96 }
47 return recent97 return recent
48 }98 }
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 {
17 return id17 return id
18 }18 }
19 19
20-func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {20+func TestRemotePickerSelectionRejectsReplacedLifecycle(t *testing.T) {
21- d := newRemotePickerDaemon(nil)21+ tests := []struct {
22- lifecycle := remoteLifecycleForTest()22+ name string
23- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{23+ run func(*testing.T)
24- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{24+ }{
25- LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},25+ {
26- }},26+ name: "replaced lifecycle",
27- }})27+ run: func(t *testing.T) {
28- d.remoteCatalog.mu.Lock()28+ d := newRemotePickerDaemon(nil)
29- d.remoteCatalog.status["arch"] = remoteHostFresh29+ lifecycle := remoteLifecycleForTest()
30- d.remoteCatalog.mu.Unlock()30+ cachedLifecycle := lifecycle
31- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")31+ cachedLifecycle[0]++
32- token := sess.attachmentToken(ac, ac.transport())32+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
33- effect, admitted := ac.beginAttachmentEffect(token)33+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
34- require.True(t, admitted)34+ LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
35- token.effect = effect35+ }},
36- defer effect.End()36+ }})
37- key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}37+ d.remoteCatalog.mu.Lock()
38- remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}38+ d.remoteCatalog.status["arch"] = remoteHostFresh
39- target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}39+ d.remoteCatalog.mu.Unlock()
40- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))40+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
41- frame := receiveRemotePicker(t, sends, "rich attach target")41+ ac.overlays.pickerMu.Lock()
42- got, err := ports.UnmarshalAttachTarget(frame.Payload)42+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
43- require.NoError(t, err)43+ ac.overlays.pickerGeneration++
44- require.NotNil(t, got.RemoteTarget)44+ ac.overlays.pickerMu.Unlock()
45- require.Equal(t, remoteTarget, *got.RemoteTarget)45+ token := sess.attachmentToken(ac, ac.transport())
46- require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy)46+ effect, admitted := ac.beginAttachmentEffect(token)
47-}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()
48 87
49-func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {88+ _, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)
50- d := newRemotePickerDaemon(nil)89+ require.ErrorIs(t, err, errAttachmentTransition)
51- lifecycle := remoteLifecycleForTest()90+ require.Same(t, source, ac.currentAttachmentSession())
52- cachedLifecycle := lifecycle91+ require.True(t, source.attachmentRegistered(ac))
53- cachedLifecycle[0]++92+ require.False(t, view.attachmentRegistered(ac))
54- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{93+ require.NoError(t, transport.Close())
55- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{94+ },
56- LifecycleID: cachedLifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},95+ },
57- }},96+ }
58- }})97+ for _, tt := range tests {
59- d.remoteCatalog.mu.Lock()98+ t.Run(tt.name, tt.run)
60- d.remoteCatalog.status["arch"] = remoteHostFresh99+ }
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)
72 }100 }
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) {
42 {42 {
43 name: "loads cached entries",43 name: "loads cached entries",
44 load: func() ([]ports.RemoteCatalogCacheEntry, error) {44 load: func() ([]ports.RemoteCatalogCacheEntry, error) {
45- return []ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}}, nil45+ return []ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}}}}, nil
46 },46 },
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"}}}},
48 wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached},48 wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached},
49 },49 },
50 {50 {
@@ -101,14 +101,14 @@ func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) {
101 }}101 }}
102 d = New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, cache, nil, ports.RemoteTransportUDP))102 d = New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, cache, nil, ports.RemoteTransportUDP))
103 d.remoteCatalog.mu.Lock()103 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"}}}
105 d.remoteCatalog.mu.Unlock()105 d.remoteCatalog.mu.Unlock()
106 106
107 done := make(chan struct{}, 2)107 done := make(chan struct{}, 2)
108 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()108 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
109 <-entered109 <-entered
110 d.remoteCatalog.mu.Lock()110 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"}}}
112 d.remoteCatalog.mu.Unlock()112 d.remoteCatalog.mu.Unlock()
113 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()113 go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
114 114
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
133 key.LifecycleID = session.LifecycleID133 key.LifecycleID = session.LifecycleID
134 key.DisplayOrigin = remoteSessionOrigin(key.Host)134 key.DisplayOrigin = remoteSessionOrigin(key.Host)
135 availability := remotePickerAvailability(status)135 availability := remotePickerAvailability(status)
136- stopped := session.State == "stopped"136+ stopped := session.State == "down"
137 broken := session.State == "broken"137 broken := session.State == "broken"
138 tabs := ports.CatalogTabs(session)138 tabs := ports.CatalogTabs(session)
139 viewTabs := make([]picker.TabEntry, 0, len(tabs))139 viewTabs := make([]picker.TabEntry, 0, len(tabs))
@@ -178,8 +178,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
178 }178 }
179 // Keep the structured session identity on rows even when one tab ID is179 // Keep the structured session identity on rows even when one tab ID is
180 // malformed or a broken session cannot be activated. Cursor navigation180 // malformed or a broken session cannot be activated. Cursor navigation
181- // must remain possible, while sendRemoteAttachTargetForAttachment will181+ // remains possible for diagnostics, but activation fails closed.
182- // fail closed on the invalid target.
183 remoteTarget = &target182 remoteTarget = &target
184 }183 }
185 reason := ""184 reason := ""
@@ -194,24 +193,21 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
194 }193 }
195 ready := status == remoteHostFresh && targetValid && !broken194 ready := status == remoteHostFresh && targetValid && !broken
196 if legacy {195 if legacy {
197- // Count-only peers cannot prove lifecycle/tab identity. Preserve the196+ // Proxied picker activation requires exact lifecycle and stable-tab
198- // historical direct-connect row for compatibility, while all current197+ // identity. A count-only cached row cannot fall back to client-owned
199- // schema rows remain gated by exact identity and fresh ownership.198+ // direct attachment under the v25 protocol contract.
200- ready = status != remoteHostVersionMismatch && !broken199+ reason = "identity_changed"
201- if status == remoteHostVersionMismatch {
202- reason = remoteReasonForStatus(status)
203- }
204 }200 }
205 detail := remotePickerStatusDetail(status, fetchedAt)201 detail := remotePickerStatusDetail(status, fetchedAt)
206 if detail == "" {202 if detail == "" {
207 switch session.State {203 switch session.State {
208- case "running":204+ case "up":
209- detail = "running"205+ detail = "up"
210 if session.Attached {206 if session.Attached {
211 detail += " · attached"207 detail += " · attached"
212 }208 }
213- case "stopped":209+ case "down":
214- detail = "stopped"210+ detail = "down"
215 case "broken":211 case "broken":
216 detail = "broken"212 detail = "broken"
217 }213 }
@@ -457,6 +453,11 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
457 d.remoteCatalog.mu.Unlock()453 d.remoteCatalog.mu.Unlock()
458 if cacheChanged {454 if cacheChanged {
459 d.persistRemoteCatalogCache()455 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 {
460 d.refreshRemoteOpenPickers()461 d.refreshRemoteOpenPickers()
461 }462 }
462 463
@@ -569,8 +570,8 @@ func (d *Daemon) refreshRemoteOpenPickers() {
569 continue570 continue
570 }571 }
571 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})572 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
572- if entry := ac.currentAttachmentSession(); entry != nil {573+ // The picker is attachment-local: catalog changes repaint the actual
573- d.invalidateRender(entry, ac, true, "remote_picker.go")574+ // owner, which can be either the retained local session or a remote view.
574- }575+ d.invalidateAttachedOwner(ac, true, "remote_picker.go")
575 }576 }
576 }577 }
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 (
6 "fmt"6 "fmt"
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9+ "strings"
9 "sync"10 "sync"
10 "testing"11 "testing"
11 "time"12 "time"
@@ -14,6 +15,7 @@ import (
14 15
15 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18+ "github.com/bnema/vev/internal/usecase/keys"
17 "github.com/bnema/vev/internal/usecase/picker"19 "github.com/bnema/vev/internal/usecase/picker"
18 )20 )
19 21
@@ -62,7 +64,7 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
62 lifecycle := remoteLifecycleForTest()64 lifecycle := remoteLifecycleForTest()
63 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{65 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
64 Host: "arch", FetchedAt: now.Add(-remotePickerAttachTTL), Sessions: []ports.RemoteCatalogSession{{66 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"}},
66 }},68 }},
67 }})69 }})
68 d.remoteCatalog.mu.Lock()70 d.remoteCatalog.mu.Lock()
@@ -83,7 +85,7 @@ func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testin
83 lifecycle := remoteLifecycleForTest()85 lifecycle := remoteLifecycleForTest()
84 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{86 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
85 Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{87 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"}},
87 }},89 }},
88 }})90 }})
89 d.remoteCatalog.mu.Lock()91 d.remoteCatalog.mu.Lock()
@@ -150,7 +152,7 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
150 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{152 Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
151 LifecycleID: lifecycle,153 LifecycleID: lifecycle,
152 Name: "vive",154 Name: "vive",
153- State: "running",155+ State: "up",
154 Tabs: test.tabs,156 Tabs: test.tabs,
155 ActiveTabID: test.activeTabID,157 ActiveTabID: test.activeTabID,
156 }},158 }},
@@ -189,7 +191,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
189 t.Run(test.name, func(t *testing.T) {191 t.Run(test.name, func(t *testing.T) {
190 d := newRemotePickerDaemon(nil)192 d := newRemotePickerDaemon(nil)
191 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{193 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"}},
193 }})195 }})
194 d.remoteCatalog.mu.Lock()196 d.remoteCatalog.mu.Lock()
195 d.remoteCatalog.status["arch"] = test.status197 d.remoteCatalog.status["arch"] = test.status
@@ -203,12 +205,36 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
203 }205 }
204 }206 }
205 207
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+
206 func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {232 func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
207 d := newRemotePickerDaemon(nil)233 d := newRemotePickerDaemon(nil)
208 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)234 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
209 failedAt := fetchedAt.Add(6 * time.Hour)235 failedAt := fetchedAt.Add(6 * time.Hour)
210 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{236 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"}},
212 }})238 }})
213 d.remoteCatalog.mu.Lock()239 d.remoteCatalog.mu.Lock()
214 d.remoteCatalog.status["arch"] = remoteHostUnreachable240 d.remoteCatalog.status["arch"] = remoteHostUnreachable
@@ -391,7 +417,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
391 ac.overlays.pickerMu.Unlock()417 ac.overlays.pickerMu.Unlock()
392 require.True(t, d.registerRemotePicker(instance))418 require.True(t, d.registerRemotePicker(instance))
393 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{419 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"}},
395 }})421 }})
396 422
397 firstGeneration := d.startRemotePickerRefresh(instance)423 firstGeneration := d.startRemotePickerRefresh(instance)
@@ -404,7 +430,7 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
404 require.Contains(t, first, "mule")430 require.Contains(t, first, "mule")
405 requireNoRemoteLockViolations(t, catalog, nil)431 requireNoRemoteLockViolations(t, catalog, nil)
406 d.remoteCatalog.mu.Lock()432 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")
408 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])434 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
409 d.remoteCatalog.mu.Unlock()435 d.remoteCatalog.mu.Unlock()
410 436
@@ -429,6 +455,55 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
429 d.remoteCatalog.mu.Unlock()455 d.remoteCatalog.mu.Unlock()
430 }456 }
431 457
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+
432 func TestRemoteRefreshLockProbesSerialized(t *testing.T) {507 func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
433 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}508 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
434 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))509 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
@@ -474,8 +549,8 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
474 hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}549 hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
475 d, _, cache := newRemoteRefreshDaemon(t, hosts, now)550 d, _, cache := newRemoteRefreshDaemon(t, hosts, now)
476 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{551 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
477- {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "running"}}},552+ {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "old", State: "up"}}},
478- {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "running"}}},553+ {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "stale", State: "up"}}},
479 })554 })
480 d.remoteCatalog.mu.Lock()555 d.remoteCatalog.mu.Lock()
481 d.remoteCatalog.refresh = 7556 d.remoteCatalog.refresh = 7
@@ -483,7 +558,7 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
483 558
484 require.False(t, d.applyRemoteRefreshResult(7, "mule", ports.RemoteCatalog{}, errors.New("offline")))559 require.False(t, d.applyRemoteRefreshResult(7, "mule", ports.RemoteCatalog{}, errors.New("offline")))
485 d.remoteCatalog.mu.Lock()560 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)
487 require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)562 require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)
488 require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])563 require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
489 d.remoteCatalog.mu.Unlock()564 d.remoteCatalog.mu.Unlock()
@@ -492,7 +567,7 @@ func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *tes
492 stored := receiveRemotePicker(t, cache.stores, "remote cache store")567 stored := receiveRemotePicker(t, cache.stores, "remote cache store")
493 require.Equal(t, []ports.RemoteCatalogCacheEntry{568 require.Equal(t, []ports.RemoteCatalogCacheEntry{
494 {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{}},569 {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"}}},
496 }, stored, "every successful write persists the newest full cache atomically")571 }, stored, "every successful write persists the newest full cache atomically")
497 requireNoRemoteLockViolations(t, nil, cache)572 requireNoRemoteLockViolations(t, nil, cache)
498 d.remoteCatalog.mu.Lock()573 d.remoteCatalog.mu.Lock()
@@ -505,15 +580,15 @@ func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
505 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}580 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
506 d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))581 d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))
507 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{582 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"}},
509 }})584 }})
510 d.remoteCatalog.mu.Lock()585 d.remoteCatalog.mu.Lock()
511 d.remoteCatalog.refresh = 9586 d.remoteCatalog.refresh = 9
512 d.remoteCatalog.mu.Unlock()587 d.remoteCatalog.mu.Unlock()
513 588
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))
515 hosts.set()590 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))
517 require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))592 require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))
518 requireNoRemoteLockViolations(t, nil, cache)593 requireNoRemoteLockViolations(t, nil, cache)
519 d.remoteCatalog.mu.Lock()594 d.remoteCatalog.mu.Lock()
@@ -529,7 +604,7 @@ func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
529 session ports.RemoteCatalogSession604 session ports.RemoteCatalogSession
530 }{605 }{
531 {name: "unknown state", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "unknown"}},606 {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"}},
533 } {608 } {
534 t.Run(test.name, func(t *testing.T) {609 t.Run(test.name, func(t *testing.T) {
535 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}610 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
@@ -547,7 +622,7 @@ func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
547 func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {622 func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
548 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}623 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
549 d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))624 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"}}}
551 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{old})626 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{old})
552 d.remoteCatalog.mu.Lock()627 d.remoteCatalog.mu.Lock()
553 d.remoteCatalog.refresh = 3628 d.remoteCatalog.refresh = 3
@@ -560,77 +635,502 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
560 d.remoteCatalog.mu.Unlock()635 d.remoteCatalog.mu.Unlock()
561 }636 }
562 637
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) {
564 d := newRemotePickerDaemon(nil)687 d := newRemotePickerDaemon(nil)
688+ target := remoteLinkTestTarget()
689+ require.NoError(t, target.Validate())
565 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{690 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+ }},
567 }})694 }})
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+ }}
568 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")708 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+
569 token := sess.attachmentToken(ac, ac.transport())715 token := sess.attachmentToken(ac, ac.transport())
570 effect, admitted := ac.beginAttachmentEffect(token)716 effect, admitted := ac.beginAttachmentEffect(token)
571 require.True(t, admitted)717 require.True(t, admitted)
572 token.effect = effect718 token.effect = effect
573- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}719+ pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
574- target := picker.Target{Session: key.ID(), RemoteKey: &key}720+ require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
575- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, 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")
576 761
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())
583 d.mu.Lock()762 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)
585 d.mu.Unlock()764 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)
586 }774 }
587 775
588-func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {776+func TestRemotePickerSelectionTransitionsBetweenRemoteViewsAndSharesAttachmentMRU(t *testing.T) {
589 d := newRemotePickerDaemon(nil)777 d := newRemotePickerDaemon(nil)
778+ first := remoteLinkTestTarget()
779+ second := first
780+ second.LifecycleID = domain.SessionLifecycleID{2}
781+ second.SessionName = "other"
782+ second.LiveTabID = "tab-2"
590 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{783 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+ },
592 }})788 }})
593- const sendErr = "remote attach send failed"789+ d.remoteCatalog.mu.Lock()
594- tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}790+ d.remoteCatalog.status[first.Endpoint] = remoteHostFresh
595- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}791+ d.remoteCatalog.mu.Unlock()
596- ac.initOverlays()792+
597- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})793+ firstTransport := newRemoteLinkTestTransport()
598- sess := &session{sessionCore: sessionCore{id: "local", name: "local", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}794+ secondTransport := newRemoteLinkTestTransport()
599- publishTiledPaneOwners(sess, tb)795+ d.remoteDialerFactory = &remoteLinkSequenceFactory{dialers: []ports.Dialer{
600- ac.setSession(sess)796+ &remoteLinkTestDialer{transport: firstTransport},
601- d.sessions[sess.id] = sess797+ &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{})
602 ac.overlays.pickerMu.Lock()896 ac.overlays.pickerMu.Lock()
603- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})897+ ac.overlays.picker = model
604 ac.overlays.pickerGeneration++898 ac.overlays.pickerGeneration++
605 ac.overlays.pickerMu.Unlock()899 ac.overlays.pickerMu.Unlock()
606 900
607- gone := make(chan struct{})901+ selectRemote := func() {
608- d.afterClientGoneDetach = func() { close(gone) }902+ // Exercise the picker input boundary without a frame effect. The daemon
609- token := sess.attachmentToken(ac, tr)903+ // must admit a short-lived local effect rather than taking the legacy
610- effect, admitted := ac.beginAttachmentEffect(token)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)
611 require.True(t, admitted)919 require.True(t, admitted)
612- token.effect = effect920+ backToken.effect = backEffect
613- defer effect.End()921+ require.NoError(t, d.backSessionForAttachment(backToken))
614- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}922+ backEffect.End()
615- target := picker.Target{Session: key.ID(), RemoteKey: &key}923+ require.Eventually(t, func() bool {
616- err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")924+ return ac.currentAttachmentSession() == sess
617- require.EqualError(t, err, sendErr)925+ }, time.Second, 5*time.Millisecond, "back navigation should return to the local source")
618- require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")926+ view.mu.Lock()
619- select {927+ require.NotNil(t, view.warm, "the detached remote view should be retained warm")
620- case <-gone:928+ view.mu.Unlock()
621- t.Fatal("failed control send reached clientGoneForAttachment")929+
622- default: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()
623 }975 }
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)
625 }1031 }
626 1032
627-type remotePickerSendErrorTransport struct {1033+func TestRemotePickerSelectionFailureKeepsSourceAndPickerOpen(t *testing.T) {
628- err error1034+ 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)
629 }1078 }
630 1079
631-func (t *remotePickerSendErrorTransport) Send(ports.Frame) error { return t.err }1080+func TestRemotePickerSelectionEscapeCancelsWithoutDetachingSource(t *testing.T) {
632-func (*remotePickerSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }1081+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
633-func (*remotePickerSendErrorTransport) Close() error { return nil }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+}
634 1134
635 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {1135 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
636 t.Helper()1136 t.Helper()
@@ -669,7 +1169,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
669 1169
670 require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{1170 require.True(t, d.applyRemoteRefreshResult(4, "arch", ports.RemoteCatalog{
671 ProtocolVersion: ports.ProtocolVersion,1171 ProtocolVersion: ports.ProtocolVersion,
672- Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 2}},1172+ Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up", Tabs: 2}},
673 }, nil))1173 }, nil))
674 receiveRemotePicker(t, cache.stores, "remote cache store")1174 receiveRemotePicker(t, cache.stores, "remote cache store")
675 requireNoRemoteLockViolations(t, catalog, cache)1175 requireNoRemoteLockViolations(t, catalog, cache)
@@ -979,15 +1479,31 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
979 ac.overlays.pickerMu.Unlock()1479 ac.overlays.pickerMu.Unlock()
980 }1480 }
981 1481
982-func TestRemoteRowsBecomeSelectableOnlyAfterOwnershipCompletion(t *testing.T) {1482+func TestRemoteRowsRequireFreshRichIdentityForPickerActivation(t *testing.T) {
983 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}1483 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
984- for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} {1484+ session := ports.RemoteCatalogSession{
985- view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, status, time.Unix(1, 0))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))
986 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()1500 _, 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)
988 }1503 }
989- mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{})1504+
990- require.False(t, mismatch.RemoteAttachReady)1505+ legacy := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostFresh, time.Unix(1, 0))
991- _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()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()
992 require.False(t, selectable)1508 require.False(t, selectable)
993 }1509 }
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 (
299 )299 )
300 300
301 func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {301 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+ }
302 // Session-owned PTY preparation remains local; attachment rendering is305 // Session-owned PTY preparation remains local; attachment rendering is
303 // captured through session.captureRenderState.306 // captured through session.captureRenderState.
304 sess := entry307 sess := entry
@@ -424,7 +427,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
424 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,427 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
425 }428 }
426 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)429 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
427- state, ok := entry.captureRenderState(ac, renderCaptureRequest{430+ captureRequest := renderCaptureRequest{
428 bars: bars,431 bars: bars,
429 overlays: capturedOverlays,432 overlays: capturedOverlays,
430 preview: preview,433 preview: preview,
@@ -433,7 +436,8 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
433 styleGeneration: applied.Generation,436 styleGeneration: applied.Generation,
434 reset: reset,437 reset: reset,
435 lease: lease,438 lease: lease,
436- })439+ }
440+ state, ok := entry.captureRenderState(ac, captureRequest)
437 endCapture(0, ok)441 endCapture(0, ok)
438 if !ok {442 if !ok {
439 ac.sendMu.Unlock()443 ac.sendMu.Unlock()
M internal/usecase/daemon/render_pipeline.go +34 -10
@@ -426,9 +426,22 @@ func commitDamageReceipts(receipts []damageReceipt) {
426 }426 }
427 }427 }
428 428
429-// emitFrame is the sole side-effecting half of the pipeline. The caller holds429+// emitFrame is the local-session compatibility entry point for the sole
430-// sendMu for the complete capture/compose/emit transaction.430+// side-effecting half of the pipeline. The caller holds sendMu for the
431+// complete capture/compose/emit transaction.
431 func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {432 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)
432 var ownedMarks runtimeMarkBatch445 var ownedMarks runtimeMarkBatch
433 var marks *runtimeMarkBatch446 var marks *runtimeMarkBatch
434 if len(batches) != 0 {447 if len(batches) != 0 {
@@ -440,22 +453,19 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
440 // follows emitFrame's release of attachment ownership.453 // follows emitFrame's release of attachment ownership.
441 defer marks.flush()454 defer marks.flush()
442 }455 }
443- if entry == nil || entry.core() == nil || ac == nil || state == nil {456+ if owner == nil || ac == nil || state == nil {
444 if ac != nil {457 if ac != nil {
445 ac.sendMu.Unlock()458 ac.sendMu.Unlock()
446 }459 }
447 return false460 return false
448 }461 }
449- entry.core().mu.Lock()462+ if !attachmentOwnerRegistered(owner, ac) || state.attachment != ac || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) {
450- _, owned := entry.core().attachments[ac]
451- entry.core().mu.Unlock()
452- if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
453 ac.sendMu.Unlock()463 ac.sendMu.Unlock()
454 return false464 return false
455 }465 }
456 if state.lease != nil {466 if state.lease != nil {
457 rc := attachmentRenderCoordinator(entry)467 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) {
459 ac.sendMu.Unlock()469 ac.sendMu.Unlock()
460 return false470 return false
461 }471 }
@@ -476,7 +486,12 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
476 endDiff(0, err == nil)486 endDiff(0, err == nil)
477 if err != nil {487 if err != nil {
478 ac.sendMu.Unlock()488 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+ }
480 // Without a coordinator reportError repaints synchronously. Suppress only495 // Without a coordinator reportError repaints synchronously. Suppress only
481 // that nested notice repaint; leave the guard before returning so a later,496 // that nested notice repaint; leave the guard before returning so a later,
482 // independent failed transaction can still notify the user.497 // independent failed transaction can still notify the user.
@@ -551,6 +566,11 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
551 // represents the captured frame. Lock panes only under sendMu and with no566 // represents the captured frame. Lock panes only under sendMu and with no
552 // session guard held.567 // session guard held.
553 commitDamageReceipts(state.receipts)568 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+ }
554 if ac.renderStages.emit != nil {574 if ac.renderStages.emit != nil {
555 ac.renderStages.emit()575 ac.renderStages.emit()
556 }576 }
@@ -561,7 +581,11 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
561 // admission first. Detachment freezes the gate and therefore cannot mutate581 // admission first. Detachment freezes the gate and therefore cannot mutate
562 // ownership until any enclosing admitted operation has also ended.582 // ownership until any enclosing admitted operation has also ended.
563 if marks.attachmentEffect == nil {583 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+ }
565 } else {589 } else {
566 // Capture the exact admitted capability, including its coordinator590 // Capture the exact admitted capability, including its coordinator
567 // lease, before End permits a new attachment publication. Reserve591 // 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 {
28 if p.connection.effect == nil {28 if p.connection.effect == nil {
29 return false29 return false
30 }30 }
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) ||
32 p.connection.ac != p.attachment || p.connection.lease != p.lease ||32 p.connection.ac != p.attachment || p.connection.lease != p.lease ||
33 p.connection.generation != p.attachment.connectionGeneration.Load() ||33 p.connection.generation != p.attachment.connectionGeneration.Load() ||
34 !p.attachment.transportSnapshotCurrent(p.connection.transport) ||34 !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 {
36 return false36 return false
37 }37 }
38 registered := attachmentRegistered(p.session, p.attachment)38 registered := attachmentRegistered(p.session, p.attachment)
M internal/usecase/daemon/resume.go +83 -45
@@ -41,47 +41,48 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
41 // it returns 0 and never creates a marker after terminal41 // it returns 0 and never creates a marker after terminal
42 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.42 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.
43 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {43 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 {
45 return 052 return 0
46 }53 }
47 d.mu.Lock()54 d.mu.Lock()
48 defer d.mu.Unlock()55 defer d.mu.Unlock()
49- if d.closing || d.sessions[sess.id] != sess {56+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) || !attachmentOwnerRegistered(owner, ac) {
50- return 0
51- }
52- sess.mu.Lock()
53- registered := attachmentRegisteredLocked(sess, ac)
54- sess.mu.Unlock()
55- if !registered {
56 return 057 return 0
57 }58 }
58 if ac.resumeToken == 0 {59 if ac.resumeToken == 0 {
59 ac.resumeToken = d.nextResumeTokenLocked()60 ac.resumeToken = d.nextResumeTokenLocked()
60 }61 }
61- d.ensureParkingInFlightLocked(sess, ac)62+ d.ensureParkingInFlightLocked(owner, ac)
62- d.log.Info("parking in flight", "session", sess.name)63+ d.log.Info("parking in flight", "session", attachmentOwnerName(owner))
63 return ac.resumeToken64 return ac.resumeToken
64 }65 }
65 66
66 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.67 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
67 // Caller holds d.mu and has verified the daemon is not closing.68 // 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) {
69 token := ac.resumeToken70 token := ac.resumeToken
70 if token == 0 {71 if token == 0 {
71 return72 return
72 }73 }
73 if existing := d.parking[token]; existing != nil {74 if existing := d.parking[token]; existing != nil {
74 if existing.ac == ac {75 if existing.ac == ac {
75- existing.sess = sess76+ existing.owner = owner
76 return77 return
77 }78 }
78 delete(d.parking, token)79 delete(d.parking, token)
79 existing.closeDone()80 existing.closeDone()
80 }81 }
81 d.parking[token] = &parkingAttachment{82 d.parking[token] = &parkingAttachment{
82- sess: sess,83+ owner: owner,
83- ac: ac,84+ ac: ac,
84- done: make(chan struct{}),85+ done: make(chan struct{}),
85 }86 }
86 }87 }
87 88
@@ -99,21 +100,16 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
99 // If another path already detached/unrouted, that path100 // If another path already detached/unrouted, that path
100 // owns park publication.101 // owns park publication.
101 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {102 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) {
102 if token == 0 || ac == nil {107 if token == 0 || ac == nil {
103 return108 return
104 }109 }
105 d.mu.Lock()110 d.mu.Lock()
106 defer d.mu.Unlock()111 defer d.mu.Unlock()
107- if d.parked[token] != nil {112+ if d.parked[token] != nil || !d.attachmentOwnerRegisteredByDaemonLocked(owner) || !attachmentOwnerRegistered(owner, ac) {
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 {
117 return113 return
118 }114 }
119 d.clearParkingInFlightLocked(token, ac)115 d.clearParkingInFlightLocked(token, ac)
@@ -143,8 +139,22 @@ func (d *Daemon) purgeAllParkingLocked() {
143 // the session is removed before park publication. Caller holds d.mu.139 // the session is removed before park publication. Caller holds d.mu.
144 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {140 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
145 for token, pending := range d.parking {141 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 {
147 delete(d.parking, token)155 delete(d.parking, token)
156+ pending.ac.resumeToken = 0
157+ pending.ac.parked = false
148 pending.closeDone()158 pending.closeDone()
149 }159 }
150 }160 }
@@ -167,7 +177,7 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
167 d.mu.Unlock()177 d.mu.Unlock()
168 return false178 return false
169 }179 }
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 {
171 d.mu.Unlock()181 d.mu.Unlock()
172 return false182 return false
173 }183 }
@@ -182,27 +192,34 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
182 }192 }
183 193
184 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {194 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 {
186 return false200 return false
187 }201 }
188 d.mu.Lock()202 d.mu.Lock()
189 defer d.mu.Unlock()203 defer d.mu.Unlock()
190- if d.closing {204+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {
191 return false205 return false
192 }206 }
193 if ac.resumeToken == 0 {207 if ac.resumeToken == 0 {
194 ac.resumeToken = d.nextResumeTokenLocked()208 ac.resumeToken = d.nextResumeTokenLocked()
195 }209 }
196 // Do not recreate parking markers here: post-detach publication races210 // Do not recreate parking markers here: post-detach publication races
197- // terminal session removal and can strand IntentResume waiters. Callers211+ // terminal owner removal and can strand IntentResume waiters. Callers that
198- // that need the detach→park gap advertised must markParkingInFlight while212+ // need the detach→park gap advertised must publish it while still exact.
199- // still the exact live owner. Direct parkAttachment still parks below.213+ d.log.Info("parking client prepared", "session", attachmentOwnerName(owner))
200- d.log.Info("parking client prepared", "session", sess.name)
201 return true214 return true
202 }215 }
203 216
204 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {217 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) {
206 return false223 return false
207 }224 }
208 // A parked attachment has no session owner. Release pane snapshots before225 // A parked attachment has no session owner. Release pane snapshots before
@@ -217,7 +234,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
217 ac.overlays.pickerMu.Unlock()234 ac.overlays.pickerMu.Unlock()
218 }235 }
219 d.mu.Lock()236 d.mu.Lock()
220- if d.closing || d.sessions[sess.id] != sess {237+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {
221 d.clearParkingInFlightLocked(ac.resumeToken, ac)238 d.clearParkingInFlightLocked(ac.resumeToken, ac)
222 d.mu.Unlock()239 d.mu.Unlock()
223 return false240 return false
@@ -237,7 +254,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
237 }254 }
238 grace := d.resumeParkGrace255 grace := d.resumeParkGrace
239 timer := d.clock.NewTimer(grace)256 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{})}
241 ac.parked = true258 ac.parked = true
242 d.parked[token] = parked259 d.parked[token] = parked
243 d.clearParkingInFlightLocked(token, ac)260 d.clearParkingInFlightLocked(token, ac)
@@ -251,7 +268,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
251 }268 }
252 oldSame.closeDone()269 oldSame.closeDone()
253 }270 }
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)
255 d.watchParkedTimer(token, parked)272 d.watchParkedTimer(token, parked)
256 return true273 return true
257 }274 }
@@ -280,7 +297,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
280 d.removeParkedLocked(token, parked)297 d.removeParkedLocked(token, parked)
281 d.mu.Unlock()298 d.mu.Unlock()
282 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)299 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))
284 return301 return
285 }302 }
286 d.mu.Unlock()303 d.mu.Unlock()
@@ -349,11 +366,16 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
349 ac.resumeToken = token366 ac.resumeToken = token
350 ac.parked = true367 ac.parked = true
351 ac.connectionGeneration.Add(1)368 ac.connectionGeneration.Add(1)
352- sess := parked.sess369+ sess := localSession(parked.owner)
370+ view, remote := normalizeAttachmentOwner(parked.owner).(*remoteView)
353 if sess != nil {371 if sess != nil {
354 sess.mu.Lock()372 sess.mu.Lock()
355 sess.unregisterAttachmentLocked(ac)373 sess.unregisterAttachmentLocked(ac)
356 sess.mu.Unlock()374 sess.mu.Unlock()
375+ } else if remote {
376+ view.mu.Lock()
377+ view.unregisterAttachmentLocked(ac)
378+ view.mu.Unlock()
357 }379 }
358 // Recreate the parked entry and its watcher instead of restoring a timer380 // Recreate the parked entry and its watcher instead of restoring a timer
359 // that may already have fired while the claim held the old entry. This also381 // 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 {
363 }385 }
364 parked.closeDone()386 parked.closeDone()
365 rearmed := &parkedAttachment{387 rearmed := &parkedAttachment{
366- sess: parked.sess,388+ owner: parked.owner,
367 ac: ac,389 ac: ac,
368 pickerGeneration: parked.pickerGeneration,390 pickerGeneration: parked.pickerGeneration,
369 timer: d.clock.NewTimer(d.resumeParkGrace),391 timer: d.clock.NewTimer(d.resumeParkGrace),
@@ -376,6 +398,9 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
376 if sess != nil {398 if sess != nil {
377 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")399 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
378 }400 }
401+ if remote {
402+ d.parkRemoteViewWarm(view)
403+ }
379 d.watchParkedTimer(token, rearmed)404 d.watchParkedTimer(token, rearmed)
380 if captured != nil {405 if captured != nil {
381 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))406 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
@@ -409,7 +434,20 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
409 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {434 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
410 var retirements []parkedAttachmentRetirement435 var retirements []parkedAttachmentRetirement
411 for token, parked := range d.parked {436 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 {
413 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))451 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
414 }452 }
415 }453 }
@@ -562,7 +600,7 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
562 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}600 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
563 }601 }
564 if d.parked[h.ResumeToken] != parked || parked.claimed {602 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 {
566 d.mu.Unlock()604 d.mu.Unlock()
567 ac.sendMu.Unlock()605 ac.sendMu.Unlock()
568 return nil, nil, false, errResumeTokenLifecycleRace606 return nil, nil, false, errResumeTokenLifecycleRace
@@ -587,11 +625,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
587 if parked == nil || h.ResumeToken == 0 {625 if parked == nil || h.ResumeToken == 0 {
588 return nil, nil, false, nil626 return nil, nil, false, nil
589 }627 }
590- sess := parked.sess628+ sess := localSession(parked.owner)
591- registered := d.sessions[sess.id] == sess629+ registered := sess != nil && d.sessions[sess.id] == sess
592 if !registered {630 if !registered {
593 d.removeParkedLocked(h.ResumeToken, parked)631 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)
595 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}633 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
596 }634 }
597 ac := parked.ac635 ac := parked.ac
M internal/usecase/daemon/resume_test.go +7 -7
@@ -533,7 +533,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
533 d.mu.Unlock()533 d.mu.Unlock()
534 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")534 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
535 require.Same(t, ac, parkingInGap.ac)535 require.Same(t, ac, parkingInGap.ac)
536- require.Same(t, sess, parkingInGap.sess)536+ require.Same(t, sess, localSession(parkingInGap.owner))
537 require.False(t, parkedInGap, "fixture: park must not have published yet")537 require.False(t, parkedInGap, "fixture: park must not have published yet")
538 538
539 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})539 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
@@ -1268,7 +1268,7 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1268 require.NoError(t, err)1268 require.NoError(t, err)
1269 1269
1270 previous := &session{sessionCore: sessionCore{id: "previous"}}1270 previous := &session{sessionCore: sessionCore{id: "previous"}}
1271- ac.previousSession.Set(previous)1271+ ac.previousOwner.Set(previous)
1272 d.clientGone(sess, ac, ac.transport(), false)1272 d.clientGone(sess, ac, ac.transport(), false)
1273 1273
1274 token := ac.resumeToken1274 token := ac.resumeToken
@@ -1276,14 +1276,14 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1276 parked := d.parked[token]1276 parked := d.parked[token]
1277 d.mu.Unlock()1277 d.mu.Unlock()
1278 require.NotNil(t, parked)1278 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")
1280 1280
1281 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))1281 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
1282 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)1282 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
1283 require.NoError(t, err)1283 require.NoError(t, err)
1284 require.Same(t, sess, resumedSess)1284 require.Same(t, sess, resumedSess)
1285 require.Same(t, ac, resumedAC)1285 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")
1287 }1287 }
1288 1288
1289 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {1289 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
@@ -1301,7 +1301,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1301 {1301 {
1302 name: "session purge",1302 name: "session purge",
1303 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {1303 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
1304- return d.purgeParkedForSessionLocked(parked.sess)1304+ return d.purgeParkedForSessionLocked(localSession(parked.owner))
1305 },1305 },
1306 },1306 },
1307 } {1307 } {
@@ -1313,7 +1313,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1313 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)1313 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1314 require.NoError(t, err)1314 require.NoError(t, err)
1315 1315
1316- ac.previousSession.Set(&session{sessionCore: sessionCore{id: "previous"}})1316+ ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "previous"}})
1317 d.clientGone(sess, ac, ac.transport(), false)1317 d.clientGone(sess, ac, ac.transport(), false)
1318 1318
1319 d.mu.Lock()1319 d.mu.Lock()
@@ -1323,7 +1323,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1323 d.mu.Unlock()1323 d.mu.Unlock()
1324 d.finishParkedAttachmentRetirements(retirements)1324 d.finishParkedAttachmentRetirements(retirements)
1325 1325
1326- require.Nil(t, ac.previousSession.Get())1326+ require.Nil(t, ac.previousOwner.Get())
1327 })1327 })
1328 }1328 }
1329 }1329 }
M internal/usecase/daemon/session.go +20 -23
@@ -467,7 +467,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
467 if err := domain.ValidateSessionName(name); err != nil {467 if err := domain.ValidateSessionName(name); err != nil {
468 return err468 return err
469 }469 }
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 {
471 return errAttachmentTransition472 return errAttachmentTransition
472 }473 }
473 token.effect.bindActionEnd(d, "create-session")474 token.effect.bindActionEnd(d, "create-session")
@@ -475,15 +476,11 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
475 476
476 var created *session477 var created *session
477 transition, err := d.transitionAttachment(attachmentTransitionRequest{478 transition, err := d.transitionAttachment(attachmentTransitionRequest{
478- source: token.sess, next: token.ac,479+ source: source, next: token.ac,
479 480
480 expectedTransport: token.transport, sourceToken: &token, action: "create-session",481 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
481 copySourceEnvironment: true, ready: true,482 copySourceEnvironment: true, ready: true,
482 createTargetLocked: func() (*session, error) {483 createTargetLocked: func() (*session, error) {
483- source := token.sess
484- if source == nil {
485- return nil, errAttachmentTransition
486- }
487 if d.closing {484 if d.closing {
488 return nil, errors.New("daemon is shutting down")485 return nil, errors.New("daemon is shutting down")
489 }486 }
@@ -506,8 +503,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
506 }503 }
507 504
508 d.touchMRU(created)505 d.touchMRU(created)
509- if token.sess != nil {506+ if source != nil {
510- token.ac.recordPreviousSession(token.sess)507+ token.ac.recordPreviousSession(source)
511 }508 }
512 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)509 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
513 d.deferAttachmentTransitionCleanups(transition)510 d.deferAttachmentTransitionCleanups(transition)
@@ -773,14 +770,15 @@ func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool
773 }770 }
774 771
775 func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {772 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 {
777 return false775 return false
778 }776 }
779 // Attachments without the session's optional primary render lease still777 // Attachments without the session's optional primary render lease still
780 // own an independent connection lifecycle and detach through the same exact778 // own an independent connection lifecycle and detach through the same exact
781 // transport/generation fence.779 // transport/generation fence.
782 if token.lease == nil {780 if token.lease == nil {
783- return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)781+ return d.detachIfCurrentTransport(sess, token.ac, token.transport)
784 }782 }
785 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)783 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
786 defer frozen.unfreeze()784 defer frozen.unfreeze()
@@ -791,8 +789,8 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
791 d.afterDetachAttachmentEffectsFrozen()789 d.afterDetachAttachmentEffectsFrozen()
792 }790 }
793 d.mu.Lock()791 d.mu.Lock()
794- core := token.sess.core()792+ core := sess.core()
795- if core == nil || d.sessions[core.id] != token.sess {793+ if core == nil || d.sessions[core.id] != sess {
796 d.mu.Unlock()794 d.mu.Unlock()
797 return false795 return false
798 }796 }
@@ -803,11 +801,11 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
803 coordinator.mu.Lock()801 coordinator.mu.Lock()
804 }802 }
805 req := attachmentTransitionRequest{803 req := attachmentTransitionRequest{
806- source: token.sess, next: token.ac, expectedTransport: token.transport,804+ source: sess, next: token.ac, expectedTransport: token.transport,
807 }805 }
808- current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)806+ current := coordinator != nil && transitionSourceTokenCurrentLocked(token, sess, coordinator, req)
809 if current {807 if current {
810- unregisterAttachmentSessionLocked(token.sess, token.ac)808+ unregisterAttachmentSessionLocked(sess, token.ac)
811 token.ac.connectionGeneration.Add(1)809 token.ac.connectionGeneration.Add(1)
812 token.ac.setSession(nil)810 token.ac.setSession(nil)
813 token.ac.invalidateFrozenAttachmentCapability()811 token.ac.invalidateFrozenAttachmentCapability()
@@ -1168,13 +1166,11 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1168 snapshot := sessionKillParticipants{target: target}1166 snapshot := sessionKillParticipants{target: target}
1169 if admission != nil {1167 if admission != nil {
1170 token := admission.token1168 token := admission.token
1171- if token.sess == nil || token.ac == nil || token.transport.transport == nil {1169+ source := token.localSession()
1172- return sessionKillParticipants{}, false1170+ if source == nil || token.ac == nil || token.transport.transport == nil {
1173- }
1174- snapshot.source = token.sess
1175- if snapshot.source == nil {
1176 return sessionKillParticipants{}, false1171 return sessionKillParticipants{}, false
1177 }1172 }
1173+ snapshot.source = source
1178 snapshot.sourceToken = &admission.token1174 snapshot.sourceToken = &admission.token
1179 }1175 }
1180 1176
@@ -1271,10 +1267,11 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
1271 return true1267 return true
1272 }1268 }
1273 token := *snapshot.sourceToken1269 token := *snapshot.sourceToken
1270+ source := token.localSession()
1274 req := attachmentTransitionRequest{1271 req := attachmentTransitionRequest{
1275- source: token.sess, next: token.ac, expectedTransport: token.transport,1272+ source: source, next: token.ac, expectedTransport: token.transport,
1276 }1273 }
1277- return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)1274+ return transitionSourceTokenCurrentLocked(token, source, sourceCoordinator, req)
1278 }1275 }
1279 1276
1280 // stopInMemoryLifecycle cancels every session producer and closes every PTY1277 // stopInMemoryLifecycle cancels every session producer and closes every PTY
@@ -1579,7 +1576,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1579 unlockSessions()1576 unlockSessions()
1580 d.notices.routingMu.Unlock()1577 d.notices.routingMu.Unlock()
1581 if !ephemeral {1578 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}
1583 d.stopped[stoppedName] = stopped1580 d.stopped[stoppedName] = stopped
1584 }1581 }
1585 empty := len(d.sessions) == 01582 empty := len(d.sessions) == 0
M internal/usecase/daemon/session_back.go +78 -17
@@ -5,15 +5,64 @@ import (
5 "github.com/bnema/vev/internal/usecase/picker"5 "github.com/bnema/vev/internal/usecase/picker"
6 )6 )
7 7
8-// backSession toggles this attachment between its current session and the8+// backSession toggles this attachment between its current local session and
9-// immediately preceding successfully activated session.9+// the immediately preceding successfully activated local session. A remote
10-func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {10+// owner is retained in the owner history but is not interpreted as a local
11- if d == nil || token.sess == nil || token.ac == nil {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 {
12 return nil14 return nil
13 }15 }
14- target := token.ac.previousSession.Get()16+ if view, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {
15- if target == nil || target == token.sess || target.core() == nil || d.sessionByID(target.core().id) != target {17+ previous := token.ac.previousOwner.Get()
16- token.ac.clearPreviousSessionIf(target)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)
17 return nil66 return nil
18 }67 }
19 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}68 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}
@@ -24,9 +73,16 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
24 if d == nil || current == nil || ac == nil {73 if d == nil || current == nil || ac == nil {
25 return74 return
26 }75 }
27- target := ac.previousSession.Get()76+ previous := ac.previousOwner.Get()
28- if target == nil || target == current || target.core() == nil || d.sessionByID(target.core().id) != target {77+ target := localSession(previous)
29- ac.clearPreviousSessionIf(target)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)
30 d.invalidateRender(current, ac, true, "session_back.go")86 d.invalidateRender(current, ac, true, "session_back.go")
31 return87 return
32 }88 }
@@ -43,24 +99,29 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
43 }99 }
44 }100 }
45 101
46-// clearPreviousSessionIf clears target only if it has not been replaced since102+// clearPreviousOwnerIf clears target only if it has not been replaced since
47 // the caller observed it. This preserves a concurrent successful hand-off.103 // 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) {
49 if ac == nil {105 if ac == nil {
50 return106 return
51 }107 }
52- ac.previousSession.With(func(previous **session) {108+ ac.previousOwner.With(func(previous *attachmentOwner) {
53- if *previous == target {109+ if sameAttachmentOwner(*previous, target) {
54 *previous = nil110 *previous = nil
55 }111 }
56 })112 })
57 }113 }
58 114
59-// recordPreviousSession is called only after a cross-session hand-off has115+// recordPreviousOwner is called only after a cross-owner hand-off has
60 // committed. Keeping this after the commit makes failed/displaced switches116 // committed. Keeping this after the commit makes failed/displaced switches
61 // leave the toggle pair intact.117 // leave the toggle pair intact.
62-func (ac *attachedClient) recordPreviousSession(origin *session) {118+func (ac *attachedClient) recordPreviousOwner(origin attachmentOwner) {
119+ origin = normalizeAttachmentOwner(origin)
63 if ac != nil && origin != nil {120 if ac != nil && origin != nil {
64- ac.previousSession.Set(origin)121+ ac.previousOwner.Set(origin)
65 }122 }
66 }123 }
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 @@
1 package daemon1 package daemon
2 2
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+)
4 7
5 // sessionGeometrySource identifies the attachment whose latest attach,8 // sessionGeometrySource identifies the attachment whose latest attach,
6 // resume, or resize claim controls shared PTY/layout geometry. claim is zero9 // resume, or resize claim controls shared PTY/layout geometry. claim is zero
@@ -62,6 +65,11 @@ func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource,
62 return domain.Size{}, sessionGeometrySource{}, false65 return domain.Size{}, sessionGeometrySource{}, false
63 }66 }
64 content := contentSize(source.size)67 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+ }
65 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true73 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true
66 }74 }
67 75
M internal/usecase/daemon/session_test.go +2 -2
@@ -779,7 +779,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
779 require.Equal(t, int64(7), stopped.createdAt)779 require.Equal(t, int64(7), stopped.createdAt)
780 require.Equal(t, incarnation, stopped.incarnation)780 require.Equal(t, incarnation, stopped.incarnation)
781 require.Equal(t, uint64(9), stopped.lastUsedSeq)781 require.Equal(t, uint64(9), stopped.lastUsedSeq)
782- require.Equal(t, ports.SessionStopped, stopped.state)782+ require.Equal(t, ports.SessionDown, stopped.state)
783 require.NotNil(t, stopped.restoreDone)783 require.NotNil(t, stopped.restoreDone)
784 require.Equal(t, uint64(9), d.mruSeq.Load())784 require.Equal(t, uint64(9), d.mruSeq.Load())
785 }785 }
@@ -1718,7 +1718,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
1718 1718
1719 alpha := d.stopped["alpha"]1719 alpha := d.stopped["alpha"]
1720 require.Equal(t, records[0], alpha.record)1720 require.Equal(t, records[0], alpha.record)
1721- require.Equal(t, ports.SessionStopped, alpha.state)1721+ require.Equal(t, ports.SessionDown, alpha.state)
1722 require.NotNil(t, alpha.restoreDone)1722 require.NotNil(t, alpha.restoreDone)
1723 select {1723 select {
1724 case <-alpha.restoreDone:1724 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")
21 21
22 func initialSessionState(record domain.CatalogueRecord) (ports.SessionState, chan struct{}) {22 func initialSessionState(record domain.CatalogueRecord) (ports.SessionState, chan struct{}) {
23 done := make(chan struct{})23 done := make(chan struct{})
24- state := ports.SessionStopped24+ state := ports.SessionDown
25 if record.DegradedReason != "" {25 if record.DegradedReason != "" {
26 state = ports.SessionBroken26 state = ports.SessionBroken
27 close(done)27 close(done)
@@ -179,7 +179,7 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
179 return nil179 return nil
180 }180 }
181 if record.Committed == nil {181 if record.Committed == nil {
182- d.setStoppedRecovery(record, ports.SessionStopped)182+ d.setStoppedRecovery(record, ports.SessionDown)
183 d.logSessionRestoreComplete(record, 0, false)183 d.logSessionRestoreComplete(record, 0, false)
184 return nil184 return nil
185 }185 }
@@ -231,7 +231,7 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
231 if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation, selected); err != nil {231 if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation, selected); err != nil {
232 return err232 return err
233 }233 }
234- d.setStoppedRecovery(record, ports.SessionStopped)234+ d.setStoppedRecovery(record, ports.SessionDown)
235 d.logSessionRestoreComplete(record, selected.Generation, false)235 d.logSessionRestoreComplete(record, selected.Generation, false)
236 return nil236 return nil
237 }237 }
@@ -252,7 +252,7 @@ func (d *Daemon) resetIncompatibleCheckpoint(ctx context.Context, record domain.
252 252
253 d.mu.Lock()253 d.mu.Lock()
254 if entry, ok := d.stopped[record.Name]; ok {254 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)
256 }256 }
257 d.mu.Unlock()257 d.mu.Unlock()
258 258
M internal/usecase/daemon/status.go +2 -2
@@ -193,7 +193,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
193 name += "*"193 name += "*"
194 }194 }
195 if ac != nil && ac.remoteOrigin != "" {195 if ac != nil && ac.remoteOrigin != "" {
196- name += " at " + ac.remoteOrigin196+ name += "@" + ac.remoteOrigin
197 }197 }
198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
199 activeIndex := 0199 activeIndex := 0
@@ -276,7 +276,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
276 if ranked != nil {276 if ranked != nil {
277 state.rankedRecent = ranked277 state.rankedRecent = ranked
278 } else if d != nil {278 } else if d != nil {
279- state.mru = d.recentSessions(cur)279+ state.mru = d.recentSessionsForAttachment(cur, ac)
280 }280 }
281 return state281 return state
282 }282 }
M internal/usecase/daemon/status_test.go +2 -1
@@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
115 remoteOrigin string115 remoteOrigin string
116 wantAttachmentBar string116 wantAttachmentBar string
117 }{117 }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"},118+ {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},
119 {name: "local attachment", wantAttachmentBar: "vive"},119 {name: "local attachment", wantAttachmentBar: "vive"},
120 }120 }
121 121
@@ -1549,4 +1549,5 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1549 releasePTY2()1549 releasePTY2()
1550 hg.Wait()1550 hg.Wait()
1551 d.sessWg.Wait()1551 d.sessWg.Wait()
1552+ d.waitNotifies()
1552 }1553 }
M internal/usecase/daemon/wire.go +73 -7
@@ -2,23 +2,89 @@
2 package daemon2 package daemon
3 3
4 import (4 import (
5+ "strconv"
6+
5 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
7 )9 )
8 10
9 func frameWelcome(s *session, ac *attachedClient) ports.Frame {11 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
10- s.mu.Lock()12+ return frameWelcomeForAttachment(s, ac)
11- w := ports.Welcome{13+}
12- SessionID: string(s.id),14+
13- SessionName: s.name,15+// frameWelcomeForAttachment emits the local-client handshake identity for one
14- Ephemeral: s.ephemeral,16+// attachment owner. A remote view is a local daemon object rather than a
15- Capabilities: ports.CapabilityResume,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{}
16 }44 }
17- s.mu.Unlock()
18 w.ResumeToken = ac.resumeToken45 w.ResumeToken = ac.resumeToken
19 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}46 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
20 }47 }
21 48
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+
22 func frameError(code uint16, text string) ports.Frame {88 func frameError(code uint16, text string) ports.Frame {
23 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}89 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
24 }90 }
M internal/usecase/picker/model.go +28 -5
@@ -267,10 +267,21 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
267 common.remote = common.hasRemoteKey || common.hasRemoteTarget || session.RemoteHost != ""267 common.remote = common.hasRemoteKey || common.hasRemoteTarget || session.RemoteHost != ""
268 header := common268 header := common
269 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1269 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+ }
270 header.selectable = header.kind.selectable(config.Mode)275 header.selectable = header.kind.selectable(config.Mode)
271 header.focusable = header.selectable276 header.focusable = header.selectable
272 if common.remote {277 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+ }
274 // Rich catalog rows use tab targets. Legacy count-only/connect-only285 // Rich catalog rows use tab targets. Legacy count-only/connect-only
275 // callers retain their header target until all peers carry the richer286 // callers retain their header target until all peers carry the richer
276 // identity contract.287 // identity contract.
@@ -585,8 +596,8 @@ func (r row) remoteKeyPointer() *domain.RemoteSessionKey {
585 // renderList draws each visible row as up to three segments: a name segment596 // renderList draws each visible row as up to three segments: a name segment
586 // (bold when styles came from a truecolor theme), a base-styled attention597 // (bold when styles came from a truecolor theme), a base-styled attention
587 // marker right after the name, and a muted detail segment (tab rows only) —598 // 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 tight599+// or a base-styled "(down)" suffix for down session headers. A tight width
589-// width ellipsizes the detail segment before eating into the name.600+// ellipsizes the detail segment before eating into the name.
590 func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {601 func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
591 if m == nil || rect.Width <= 0 || rect.Height <= 0 {602 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
592 return603 return
@@ -618,12 +629,24 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
618 if !r.kind.rendersAsHeader() {629 if !r.kind.rendersAsHeader() {
619 name = " " + name630 name = " " + name
620 }631 }
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))
622 x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)637 x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
623 638
624 if r.kind.rendersAsHeader() {639 if r.kind.rendersAsHeader() {
625 if r.stopped {640 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)
627 } else {650 } else {
628 ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)651 ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)
629 }652 }
M internal/usecase/picker/model_test.go +29 -12
@@ -2,6 +2,7 @@ package picker
2 2
3 import (3 import (
4 "reflect"4 "reflect"
5+ "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -490,14 +491,30 @@ func TestSelectedIndexReportsRawSelectedRow(t *testing.T) {
490 require.Equal(t, 1, m.SelectedIndex())491 require.Equal(t, 1, m.SelectedIndex())
491 }492 }
492 493
493-func TestStoppedSessionSelectableAndRendered(t *testing.T) {494+func TestStoppedSessionRendersDownSuffix(t *testing.T) {
494- m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})495+ for _, tt := range []struct {
495- got, ok := m.Selected()496+ name string
496- require.True(t, ok)497+ sessionID domain.SessionID
497- require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)498+ sessionName string
498- frame := m.Render(domain.Size{Cols: 24, Rows: 4}, Preview{})499+ size domain.Size
499- require.Equal(t, 'w', frame.At(0, 0).Rune)500+ }{
500- require.Equal(t, '(', frame.At(5, 0).Rune)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+ }
501 }518 }
502 519
503 func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {520 func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
@@ -510,7 +527,7 @@ func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
510 require.Equal(t, stoppedStyle, styles.Stopped)527 require.Equal(t, stoppedStyle, styles.Stopped)
511 528
512 frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})529 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.
514 require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic")531 require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic")
515 require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic")532 require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic")
516 require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style")533 require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style")
@@ -587,7 +604,7 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
587 }604 }
588 605
589 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{606 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
590- {Name: "alpha", Detail: " (running)"},607+ {Name: "alpha", Detail: " (up)"},
591 {Name: "beta", Detail: " (idle)", Attention: true},608 {Name: "beta", Detail: " (idle)", Attention: true},
592 }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})609 }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
593 610
@@ -748,9 +765,9 @@ func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) {
748 require.True(t, pickerRow.hasRemoteKey)765 require.True(t, pickerRow.hasRemoteKey)
749 require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])766 require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])
750 require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i])767 require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i])
751- wantDetail := "proxy activation pending"768+ wantDetail := "@mule (proxy activation pending)"
752 if availability[i] == RemoteStale || availability[i] == RemoteVersionMismatch {769 if availability[i] == RemoteStale || availability[i] == RemoteVersionMismatch {
753- wantDetail = "remote state detail"770+ wantDetail = "@mule (remote state detail)"
754 }771 }
755 require.Equal(t, wantDetail, pickerRow.detail)772 require.Equal(t, wantDetail, pickerRow.detail)
756 }773 }
M internal/usecase/picker/remote_parity_test.go +23 -0
@@ -6,6 +6,7 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/pkg/renderer"
9 )10 )
10 11
11 func remoteParityLifecycle(seed byte) domain.SessionLifecycleID {12 func remoteParityLifecycle(seed byte) domain.SessionLifecycleID {
@@ -14,6 +15,28 @@ func remoteParityLifecycle(seed byte) domain.SessionLifecycleID {
14 return id15 return id
15 }16 }
16 17
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+
17 func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) {40 func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) {
18 firstLifecycle := remoteParityLifecycle(1)41 firstLifecycle := remoteParityLifecycle(1)
19 secondLifecycle := remoteParityLifecycle(2)42 secondLifecycle := remoteParityLifecycle(2)
M scripts/remote-picker-harness/README.md +14 -0
@@ -18,6 +18,20 @@ Environment overrides:
18 containers (default: `ubuntu:24.04`).18 containers (default: `ubuntu:24.04`).
19 - `VEV_HARNESS_TARGET` selects the SSH target passed to the harness binary19 - `VEV_HARNESS_TARGET` selects the SSH target passed to the harness binary
20 (default: `test@remote`).20 (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.
21 35
22 The run exercises real SSH-stdio and UDP attachment, typed live catalog and36 The run exercises real SSH-stdio and UDP attachment, typed live catalog and
23 preview requests, tab creation/removal fencing, exact lifecycle fencing after37 preview requests, tab creation/removal fencing, exact lifecycle fencing after
M scripts/remote-picker-harness/main.go +168 -134
@@ -20,7 +20,6 @@ import (
20 "github.com/bnema/vev/internal/adapters/sshstdio"20 "github.com/bnema/vev/internal/adapters/sshstdio"
21 "github.com/bnema/vev/internal/domain"21 "github.com/bnema/vev/internal/domain"
22 "github.com/bnema/vev/internal/ports"22 "github.com/bnema/vev/internal/ports"
23- "github.com/bnema/vev/pkg/vt"
24 )23 )
25 24
26 const (25 const (
@@ -38,7 +37,7 @@ func main() {
38 fmt.Println("remote picker harness: PASS")37 fmt.Println("remote picker harness: PASS")
39 }38 }
40 39
41-func run() error {40+func run() (runErr error) {
42 target := os.Getenv("VEV_HARNESS_TARGET")41 target := os.Getenv("VEV_HARNESS_TARGET")
43 if target == "" {42 if target == "" {
44 target = defaultTarget43 target = defaultTarget
@@ -46,6 +45,16 @@ func run() error {
46 ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)45 ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
47 defer cancel()46 defer cancel()
48 47
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+
49 catalog := remoteadapter.NewCatalogClient()58 catalog := remoteadapter.NewCatalogClient()
50 preview := remoteadapter.NewPreviewClient()59 preview := remoteadapter.NewPreviewClient()
51 if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {60 if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {
@@ -85,7 +94,7 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
85 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 {94 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 {
86 return fmt.Errorf("direct client environment: %w", err)95 return fmt.Errorf("direct client environment: %w", err)
87 }96 }
88- live, err := waitForSession(ctx, catalog, target, sessionName, "running")97+ live, err := waitForSession(ctx, catalog, target, sessionName, "up")
89 if err != nil {98 if err != nil {
90 return fmt.Errorf("live catalog: %w", err)99 return fmt.Errorf("live catalog: %w", err)
91 }100 }
@@ -99,7 +108,7 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
99 if err := sendCommand(ctx, stdio, "new-tab"); err != nil {108 if err := sendCommand(ctx, stdio, "new-tab"); err != nil {
100 return fmt.Errorf("create second tab: %w", err)109 return fmt.Errorf("create second tab: %w", err)
101 }110 }
102- liveWithTabs, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "running", 2)111+ liveWithTabs, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "up", 2)
103 if err != nil {112 if err != nil {
104 return fmt.Errorf("multi-tab catalog: %w", err)113 return fmt.Errorf("multi-tab catalog: %w", err)
105 }114 }
@@ -107,7 +116,7 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
107 if err := sendCommand(ctx, stdio, "close-tab"); err != nil {116 if err := sendCommand(ctx, stdio, "close-tab"); err != nil {
108 return fmt.Errorf("remove tab: %w", err)117 return fmt.Errorf("remove tab: %w", err)
109 }118 }
110- after, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "running", 1)119+ after, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "up", 1)
111 if err != nil {120 if err != nil {
112 return fmt.Errorf("removed-tab catalog: %w", err)121 return fmt.Errorf("removed-tab catalog: %w", err)
113 }122 }
@@ -139,19 +148,22 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
139 148
140 // runLocalPickerUnitedPhase drives the actual local picker against the SSH149 // runLocalPickerUnitedPhase drives the actual local picker against the SSH
141 // daemon container. It validates the seam that individual catalog, preview,150 // daemon container. It validates the seam that individual catalog, preview,
142-// and attach tests cannot: catalog active-tab selection, live remote preview,151+// and attach tests cannot: unified rows before and after selection, remote
143-// emitted rich handoff, and the remote attachment's visible provenance.152+// content publication on the same local client transport, local chrome
153+// composition, and continued input forwarding.
144 func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) {154 func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) {
145 const (155 const (
146 localSession = "local-picker"156 localSession = "local-picker"
147 remoteSession = "picker"157 remoteSession = "picker"
148 previewMarker = "VEV_REMOTE_PICKER_ACTIVE"158 previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
159+ inputMarker = "VEV_REMOTE_PICKER_INPUT_OK"
149 )160 )
150 161
151 remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)162 remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
152 if err != nil {163 if err != nil {
153 return fmt.Errorf("open remote picker source: %w", err)164 return fmt.Errorf("open remote picker source: %w", err)
154 }165 }
166+ remote.probe.configure("remote-picker-source")
155 ownsRemoteSession := false167 ownsRemoteSession := false
156 defer func() {168 defer func() {
157 _ = remote.Close()169 _ = remote.Close()
@@ -180,7 +192,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
180 if err := sendInputAndAwait(ctx, remote, "printf '"+previewMarker+"\\n'", previewMarker); err != nil {192 if err := sendInputAndAwait(ctx, remote, "printf '"+previewMarker+"\\n'", previewMarker); err != nil {
181 return fmt.Errorf("seed remote picker preview: %w", err)193 return fmt.Errorf("seed remote picker preview: %w", err)
182 }194 }
183- remoteCatalog, err := waitForSessionWithTabCount(ctx, catalog, target, remoteSession, "running", 2)195+ remoteCatalog, err := waitForSessionWithTabCount(ctx, catalog, target, remoteSession, "up", 2)
184 if err != nil {196 if err != nil {
185 return fmt.Errorf("remote picker catalog: %w", err)197 return fmt.Errorf("remote picker catalog: %w", err)
186 }198 }
@@ -229,6 +241,10 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
229 if err != nil {241 if err != nil {
230 return fmt.Errorf("open local picker transport: %w", err)242 return fmt.Errorf("open local picker transport: %w", err)
231 }243 }
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")
232 defer func() { _ = local.Close() }()248 defer func() { _ = local.Close() }()
233 if err := attachAndCheck(ctx, local, ports.Hello{249 if err := attachAndCheck(ctx, local, ports.Hello{
234 Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",250 Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
@@ -248,6 +264,10 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
248 if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {264 if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
249 return fmt.Errorf("await remote picker catalog row: %w", err)265 return fmt.Errorf("await remote picker catalog row: %w", err)
250 }266 }
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+ }
251 // The local selected tab is first and the remote session header is not271 // The local selected tab is first and the remote session header is not
252 // selectable, so one down reaches the first remote tab. Derive the rest272 // selectable, so one down reaches the first remote tab. Derive the rest
253 // from the catalog's stable active tab identity instead of assuming an273 // 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
261 if err := sendRawInput(local, "\r"); err != nil {281 if err := sendRawInput(local, "\r"); err != nil {
262 return fmt.Errorf("commit remote picker target: %w", err)282 return fmt.Errorf("commit remote picker target: %w", err)
263 }283 }
264- handoffFrame, err := receiveFrame(ctx, local, ports.MsgAttachTarget)284+ if err := awaitOutputContains(ctx, local, remoteSession+" at remote"); err != nil {
265- if err != nil {285+ return fmt.Errorf("publish remote content on local transport: %w", err)
266- return fmt.Errorf("await remote picker handoff: %w", err)
267 }286 }
268- handoff, err := ports.UnmarshalAttachTarget(handoffFrame.Payload)287+ if !local.probe.contains(previewMarker) {
269- if err != nil {288+ return errors.New("published remote content omitted preview marker")
270- return fmt.Errorf("decode remote picker handoff: %w", err)
271 }289 }
272- if handoff.RemoteTarget == nil {290+ if err := assertNoDirectHandoff(local); err != nil {
273- return errors.New("remote picker handoff omitted the remote target")291+ return err
274 }292 }
275- if handoff.RemoteTarget.LiveTabID != selectedTarget.LiveTabID {293+ if err := assertNoRemoteChrome(local.probe, remoteSession); err != nil {
276- return fmt.Errorf("remote picker handoff tab = %q, want %q", handoff.RemoteTarget.LiveTabID, selectedTarget.LiveTabID)294+ return fmt.Errorf("remote content chrome: %w", err)
277 }295 }
278- if handoff.RemoteTarget.DisplayOrigin != "remote" {296+ // Split the marker so the terminal's echoed command line cannot satisfy the
279- return fmt.Errorf("remote picker handoff origin = %q, want remote", handoff.RemoteTarget.DisplayOrigin)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)
280 }300 }
281- if handoff.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {301+ if err := assertNoDirectHandoff(local); err != nil {
282- return fmt.Errorf("remote picker handoff policy = %d, want daemon-owned", handoff.EnvironmentPolicy)302+ return err
283 }303 }
284 304
285- selected, err := openTransport(ctx, ports.RemoteTransportStdio, target)305+ // Reopen the picker through the same local connection while its owner is a
286- if err != nil {306+ // remote view. The navigation shell must remain local and retain both sides
287- return fmt.Errorf("open selected remote transport: %w", err)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)
288 }310 }
289- defer func() { _ = selected.Close() }()311+ if err := awaitOutputContains(ctx, local, "Commands"); err != nil {
290- if err := attachAndCheck(ctx, selected, ports.Hello{312+ return fmt.Errorf("await reopened command palette: %w", err)
291- Intent: ports.IntentAttach, Name: handoff.Session, Env: localEnvironment(), Cwd: "/tmp/local-cwd",313+ }
292- TermEnv: "xterm-256color", TrueColor: true,314+ if err := sendRawInput(local, "SSP\r"); err != nil {
293- RemoteTarget: handoff.RemoteTarget, EnvironmentPolicy: handoff.EnvironmentPolicy,315+ return fmt.Errorf("reopen session picker from remote content: %w", err)
294- }); err != nil {316+ }
295- return fmt.Errorf("attach selected remote target: %w", err)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
296 }326 }
297- if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {327+ if err := sendRawInput(local, "\x1b"); err != nil {
298- return fmt.Errorf("remote provenance status bar: %w", err)328+ return fmt.Errorf("close reopened session picker: %w", err)
299 }329 }
300 return nil330 return nil
301 }331 }
@@ -307,7 +337,7 @@ func runRestartResumePhase(ctx context.Context, target string, catalog ports.Rem
307 if err := runRemoteCommand(ctx, target, "vev", "--daemon-launcher"); err != nil {337 if err := runRemoteCommand(ctx, target, "vev", "--daemon-launcher"); err != nil {
308 return domain.RemoteSessionTarget{}, fmt.Errorf("restart remote daemon: %w", err)338 return domain.RemoteSessionTarget{}, fmt.Errorf("restart remote daemon: %w", err)
309 }339 }
310- resumedCatalog, err := waitForSession(ctx, catalog, target, sessionName, "running")340+ resumedCatalog, err := waitForSession(ctx, catalog, target, sessionName, "up")
311 if err != nil {341 if err != nil {
312 return domain.RemoteSessionTarget{}, fmt.Errorf("resumed catalog: %w", err)342 return domain.RemoteSessionTarget{}, fmt.Errorf("resumed catalog: %w", err)
313 }343 }
@@ -433,14 +463,21 @@ type harnessFrame struct {
433 463
434 type harnessTransport struct {464 type harnessTransport struct {
435 ports.Transport465 ports.Transport
466+ probe *visualProbe
436 frames chan harnessFrame467 frames chan harnessFrame
468+ queued []harnessFrame
437 stop chan struct{}469 stop chan struct{}
438 closeOnce sync.Once470 closeOnce sync.Once
439 closeErr error471 closeErr error
440 }472 }
441 473
442 func newHarnessTransport(transport ports.Transport) *harnessTransport {474 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+ }
444 go h.receiveLoop()481 go h.receiveLoop()
445 return h482 return h
446 }483 }
@@ -484,7 +521,12 @@ func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target s
484 if err != nil {521 if err != nil {
485 return nil, err522 return nil, err
486 }523 }
487- return newHarnessTransport(transport), nil524+ 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
488 }530 }
489 531
490 const commandStderrLimit = 4 << 10532 const commandStderrLimit = 4 << 10
@@ -585,7 +627,12 @@ func openLocalTransport(ctx context.Context) (*harnessTransport, error) {
585 if err != nil {627 if err != nil {
586 return nil, err628 return nil, err
587 }629 }
588- return newHarnessTransport(transport), nil630+ 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
589 }636 }
590 637
591 func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {638 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
612 if frame.Type != ports.MsgWelcome {659 if frame.Type != ports.MsgWelcome {
613 return fmt.Errorf("welcome frame type %d", frame.Type)660 return fmt.Errorf("welcome frame type %d", frame.Type)
614 }661 }
662+ tr.probe.recordControl(probeEventWelcome)
615 if err := tr.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil {663 if err := tr.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil {
616 return fmt.Errorf("send initial theme: %w", err)664 return fmt.Errorf("send initial theme: %w", err)
617 }665 }
@@ -622,22 +670,44 @@ func sendRawInput(tr *harnessTransport, data string) error {
622 return tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})})670 return tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})})
623 }671 }
624 672
625-func acknowledgeOutput(tr *harnessTransport, output ports.Output) error {673+func processOutputFrame(tr *harnessTransport, frame ports.Frame) error {
626- if output.Epoch == 0 || output.New == 0 {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 {
627 return nil690 return nil
628 }691 }
629- payload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})692+ payload, err := ports.MarshalAck(result.Ack)
630 if err != nil {693 if err != nil {
631 return err694 return err
632 }695 }
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
634 }702 }
635 703
636 func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {704 func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {
637 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)705 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
638 defer cancel()706 defer cancel()
639- var received strings.Builder707+ visible := tr.probe.contains(want)
640- screen := vt.NewScreen(80, 24)708+ if visible {
709+ return nil
710+ }
641 for {711 for {
642 frame, err := receiveWithTimeout(deadline, tr)712 frame, err := receiveWithTimeout(deadline, tr)
643 if err != nil {713 if err != nil {
@@ -646,73 +716,44 @@ func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string)
646 if frame.Type != ports.MsgOutput {716 if frame.Type != ports.MsgOutput {
647 continue717 continue
648 }718 }
649- output, err := ports.UnmarshalOutput(frame.Payload)719+ if err := processOutputFrame(tr, frame); err != nil {
650- if err != nil {
651- return err
652- }
653- if err := acknowledgeOutput(tr, output); err != nil {
654 return err720 return err
655 }721 }
656- received.Write(output.Data)722+ nowVisible := tr.probe.contains(want)
657- screen.Write(output.Data)723+ if !visible && nowVisible {
658- if strings.Contains(received.String(), want) || strings.Contains(harnessScreenText(screen), want) {
659 return nil724 return nil
660 }725 }
661- if received.Len() > 64<<10 {726+ visible = nowVisible
662- text := received.String()
663- received.Reset()
664- received.WriteString(text[len(text)-16<<10:])
665- }
666 }727 }
667 }728 }
668 729
669 func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {730 func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {
670 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)731 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
671 defer cancel()732 defer cancel()
672- screen := vt.NewScreen(80, 24)
673- outputFrames := 0
674 sawLoading := false733 sawLoading := false
734+ markerVisible := tr.probe.contains(marker)
675 for {735 for {
676 frame, err := receiveWithTimeout(deadline, tr)736 frame, err := receiveWithTimeout(deadline, tr)
677 if err != nil {737 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)
679 }739 }
680 if frame.Type != ports.MsgOutput {740 if frame.Type != ports.MsgOutput {
681 continue741 continue
682 }742 }
683- output, err := ports.UnmarshalOutput(frame.Payload)743+ if err := processOutputFrame(tr, frame); err != nil {
684- if err != nil {
685 return err744 return err
686 }745 }
687- if err := acknowledgeOutput(tr, output); err != nil {746+ text := tr.probe.text()
688- return err747+ sawLoading = sawLoading || strings.Contains(text, "loading remote preview")
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- }
697 if strings.Contains(text, "remote preview unavailable") {748 if strings.Contains(text, "remote preview unavailable") {
698 return errors.New("remote preview unavailable")749 return errors.New("remote preview unavailable")
699 }750 }
700- }751+ nowMarkerVisible := strings.Contains(text, marker)
701-}752+ if !markerVisible && nowMarkerVisible {
702-753+ return nil
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)
712 }754 }
713- text.WriteByte('\n')755+ markerVisible = nowMarkerVisible
714 }756 }
715- return text.String()
716 }757 }
717 758
718 func remotePreviewContains(preview ports.RemotePreview, want string) bool {759 func remotePreviewContains(preview ports.RemotePreview, want string) bool {
@@ -725,64 +766,29 @@ func remotePreviewContains(preview ports.RemotePreview, want string) bool {
725 return strings.Contains(text.String(), want)766 return strings.Contains(text.String(), want)
726 }767 }
727 768
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-
751 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {769 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {
752 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {770 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
753 return err771 return err
754 }772 }
755- var received strings.Builder
756- var outputFrames int
757- var outputBytes int
758 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)773 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
759 defer cancel()774 defer cancel()
775+ visible := tr.probe.contains(want)
760 for {776 for {
761 frame, err := receiveWithTimeout(deadline, tr)777 frame, err := receiveWithTimeout(deadline, tr)
762 if err != nil {778 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)
764 }780 }
765 if frame.Type != ports.MsgOutput {781 if frame.Type != ports.MsgOutput {
766 continue782 continue
767 }783 }
768- output, err := ports.UnmarshalOutput(frame.Payload)784+ if err := processOutputFrame(tr, frame); err != nil {
769- if err != nil {785+ return fmt.Errorf("process output: %w", err)
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)
776 }786 }
777- received.Write(output.Data)787+ nowVisible := tr.probe.contains(want)
778- if strings.Contains(received.String(), want) {788+ if !visible && nowVisible {
779 return nil789 return nil
780 }790 }
781- if received.Len() > 64<<10 {791+ visible = nowVisible
782- text := received.String()
783- received.Reset()
784- received.WriteString(text[len(text)-16<<10:])
785- }
786 }792 }
787 }793 }
788 794
@@ -809,12 +815,8 @@ func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error {
809 return err815 return err
810 }816 }
811 if frame.Type == ports.MsgOutput {817 if frame.Type == ports.MsgOutput {
812- output, decodeErr := ports.UnmarshalOutput(frame.Payload)818+ if err := processOutputFrame(tr, frame); err != nil {
813- if decodeErr != nil {819+ return err
814- return decodeErr
815- }
816- if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
817- return ackErr
818 }820 }
819 continue821 continue
820 }822 }
@@ -990,11 +992,43 @@ func runRemoteCommand(ctx context.Context, target string, command ...string) err
990 return runCapturedCommand(ctx, spec.Path, spec.Args...)992 return runCapturedCommand(ctx, spec.Path, spec.Args...)
991 }993 }
992 994
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+
993 func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (ports.Frame, error) {1016 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+ }
994 select {1025 select {
995 case <-ctx.Done():1026 case <-ctx.Done():
996 return ports.Frame{}, ctx.Err()1027 return ports.Frame{}, ctx.Err()
997 case outcome := <-tr.frames:1028 case outcome := <-tr.frames:
1029+ if outcome.err == nil {
1030+ tr.probe.recordIncoming(outcome.frame)
1031+ }
998 return outcome.frame, outcome.err1032 return outcome.frame, outcome.err
999 }1033 }
1000 }1034 }
M scripts/remote-picker-harness/main_test.go +21 -0
@@ -4,6 +4,8 @@ import (
4 "errors"4 "errors"
5 "strings"5 "strings"
6 "testing"6 "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
7 )9 )
8 10
9 func TestBoundedStderrRetainsAtMostDiagnosticLimit(t *testing.T) {11 func TestBoundedStderrRetainsAtMostDiagnosticLimit(t *testing.T) {
@@ -42,3 +44,22 @@ func TestRunCapturedCommandIncludesStderr(t *testing.T) {
42 t.Fatalf("command error = %v", err)44 t.Fatalf("command error = %v", err)
43 }45 }
44 }46 }
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() {
6 Usage: scripts/remote-picker-harness/run.sh6 Usage: scripts/remote-picker-harness/run.sh
7 7
8 Builds the current vev binary into a disposable two-container environment and8 Builds the current vev binary into a disposable two-container environment and
9-runs local-picker rich-handoff plus SSH-stdio, UDP, catalog, preview,9+runs the unified same-connection local-picker proof plus SSH-stdio, UDP,
10-lifecycle-fence, and environment acceptance checks. Docker is invoked normally,10+catalog, preview, lifecycle-fence, and environment acceptance checks. Docker is
11+invoked normally,
11 so DOCKER_HOST and the active12 so DOCKER_HOST and the active
12 Docker context select the daemon; no socket path is assumed.13 Docker context select the daemon; no socket path is assumed.
13 14
@@ -16,6 +17,9 @@ Environment:
16 already select the rootless daemon.17 already select the rootless daemon.
17 VEV_HARNESS_BASE_IMAGE Base image for the disposable containers18 VEV_HARNESS_BASE_IMAGE Base image for the disposable containers
18 (default: ubuntu:24.04).19 (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.
19 USAGE23 USAGE
20 }24 }
21 25
@@ -33,6 +37,11 @@ esac
33 37
34 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"38 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
35 base_image="${VEV_HARNESS_BASE_IMAGE:-ubuntu:24.04}"39 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
36 run_id="$(date +%s)-$$"45 run_id="$(date +%s)-$$"
37 image="vev-remote-picker-harness:${run_id}"46 image="vev-remote-picker-harness:${run_id}"
38 network="vev-remote-picker-harness-${run_id}"47 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
106 fi115 fi
107 116
108 printf 'remote picker harness: running acceptance checks\n'117 printf 'remote picker harness: running acceptance checks\n'
109-docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness118+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