detached f019e0f

feat(remote): recover direct session ownership

commit
f019e0f3bbea47748614c399ab8ee3b321693d7a
author
brice · · 2026-08-08 16:49 UTC
parent
6707942
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
92 files · +1771 -9149
M .gitignore +1 -0
@@ -4,3 +4,4 @@
44 docs/superpowers/
55 .worktrees/
66 scripts/demo/out/
7+/.worktrees/
M docs/remote-resilience.md +9 -0
@@ -25,6 +25,15 @@ owning daemon without creating a local shadow session. Picker-selected remote
2525 attachments use the daemon's environment and persisted session working
2626 directory; direct CLI remote attaches retain their client-request semantics.
2727
28+Remote attachments remain direct: the selected remote daemon owns the session,
29+PTYs, rendering, input, resize, effects, and teardown. The client does not
30+proxy remote content through the local daemon. A picker-selected remote target
31+is a temporary direct handoff; the client keeps one input pump and remembers a
32+bounded home route. A remote daemon may request the home picker, and cancelling
33+that temporary local picker returns to the parked remote route, first attempting
34+resume and then falling back to a fresh attach when the resume token is stale.
35+These navigation actions and the strict handshake layout use protocol v26.
36+
2837 ## Durable record compatibility
2938
3039 The catalogue record format is currently version 4 because records now retain
M internal/app/run.go +11 -3
@@ -888,8 +888,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
888888 }
889889 seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
890890 }
891- var handoff client.AttachHandoffFunc
892- handoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
891+ pickerHandoff := remoteTarget == ""
892+ handoff := func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
893893 if err := validateRemoteAttachHandoff(target); err != nil {
894894 return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)
895895 }
@@ -915,7 +915,11 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
915915 if err != nil {
916916 return nil, client.AttachRequest{}, err
917917 }
918- return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: target.EnvironmentPolicy}, nil
918+ policy := target.EnvironmentPolicy
919+ if pickerHandoff && target.RemoteTarget == nil {
920+ policy = ports.EnvironmentPolicyDaemonOwned
921+ }
922+ return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: policy}, nil
919923 }
920924
921925 for {
@@ -941,6 +945,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
941945 }, client.AttachRequest{
942946 Intent: intent,
943947 SessionName: name,
948+ Remote: true,
944949 RemoteTarget: remoteSelection,
945950 EnvironmentPolicy: remoteEnvironmentPolicy,
946951 })
@@ -983,6 +988,9 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
983988 intent = handoffErr.Target.Intent
984989 remoteSelection = nil
985990 remoteEnvironmentPolicy = handoffErr.Target.EnvironmentPolicy
991+ if pickerHandoff && remoteEnvironmentPolicy == ports.EnvironmentPolicyClientOwned {
992+ remoteEnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
993+ }
986994 if handoffErr.Target.RemoteTarget != nil {
987995 copyTarget := *handoffErr.Target.RemoteTarget
988996 remoteSelection = &copyTarget
M internal/app/run_test.go +42 -5
@@ -575,7 +575,7 @@ func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
575575 t.Run(tt.name, func(t *testing.T) {
576576 cmd, err := parseArgs([]string{"attach", tt.arg})
577577 require.NoError(t, err)
578- connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.AttachRequest{
578+ connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{
579579 Intent: cmd.intent,
580580 SessionName: cmd.name,
581581 })
@@ -845,8 +845,8 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
845845 }
846846 gotDialer = nd.name
847847 gotRemote = deps.Remote
848- if request.Remote {
849- t.Fatal("remote carriage metadata must not be in the attach request")
848+ if !request.Remote || request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned {
849+ t.Fatal("direct CLI remote attach must preserve client-owned environment policy")
850850 }
851851 gotClipboard = deps.Clipboard
852852 if request.Intent != ports.IntentAttach || request.SessionName != "work" {
@@ -871,6 +871,29 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
871871 }
872872 }
873873
874+func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testing.T) {
875+ factory := portsmocks.NewMockRemoteDialerFactory(t)
876+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
877+ factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
878+
879+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
880+ remoteDialerFactory: factory,
881+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
882+ require.True(t, request.Remote)
883+ require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
884+ nextDialer, nextRequest, err := deps.AttachHandoff(ports.AttachTarget{
885+ Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach,
886+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
887+ })
888+ require.NoError(t, err)
889+ require.NotNil(t, nextDialer)
890+ require.Equal(t, ports.EnvironmentPolicyClientOwned, nextRequest.EnvironmentPolicy)
891+ return nil
892+ },
893+ })
894+ require.NoError(t, err)
895+}
896+
874897 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
875898 factory := portsmocks.NewMockRemoteDialerFactory(t)
876899 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
@@ -878,9 +901,11 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
878901 var calls int
879902 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
880903 remoteDialerFactory: factory,
881- runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
904+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
882905 calls++
883906 require.NotNil(t, deps.Dialer)
907+ require.True(t, request.Remote)
908+ require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy, "direct CLI remote ownership must survive a handoff")
884909 if calls == 1 {
885910 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
886911 }
@@ -934,7 +959,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
934959 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
935960 if deps.Remote {
936961 remoteCalls++
937- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked"}, request)
962+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
938963 return nil
939964 }
940965 localCalls++
@@ -954,6 +979,18 @@ func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
954979 }{
955980 {name: "invalid host", target: ports.AttachTarget{Endpoint: "remote host", Session: "work", Intent: ports.IntentAttach}},
956981 {name: "invalid session", target: ports.AttachTarget{Endpoint: "remote.example", Session: "bad name", Intent: ports.IntentAttach}},
982+ {name: "tokenless resume", target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentResume}},
983+ {
984+ name: "tokenless resume with remote target",
985+ target: ports.AttachTarget{
986+ Endpoint: "remote.example", Session: "work", Intent: ports.IntentResume,
987+ RemoteTarget: &domain.RemoteSessionTarget{
988+ Endpoint: "remote.example", DisplayOrigin: "remote.example",
989+ LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work", LiveTabID: "tab-1",
990+ },
991+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
992+ },
993+ },
957994 }
958995 for _, tt := range tests {
959996 t.Run(tt.name, func(t *testing.T) {
M internal/ports/frame.go +28 -3
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 25
15+const ProtocolVersion uint16 = 26
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -22,7 +22,7 @@ const MaxFrameLen = 16 << 20
2222 type MsgType uint8
2323
2424 // Frame message types. Client-originated messages occupy 1–13 and 15;
25-// server-originated messages occupy 16–23 and 25–26. Values 14 and 24 remain
25+// server-originated messages occupy 16–22 and 25–27. Values 14, 23–24 remain
2626 // reserved for future extensions.
2727 const (
2828 MsgHello MsgType = 1
@@ -47,9 +47,9 @@ const (
4747 MsgPong MsgType = 20
4848 MsgSessions MsgType = 21
4949 MsgCommandResult MsgType = 22
50- MsgSessionMeta MsgType = 23
5150 MsgAttachTarget MsgType = 25
5251 MsgRemotePreviewResponse MsgType = 26
52+ MsgNavigationAction MsgType = 27
5353 )
5454
5555 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -61,6 +61,31 @@ type Frame struct {
6161 Payload []byte
6262 }
6363
64+// NavigationCapabilities advertises the bounded client routes available to a
65+// directly attached daemon.
66+type NavigationCapabilities uint8
67+
68+const (
69+ NavigationCapabilityHomePicker NavigationCapabilities = 1 << iota
70+ NavigationCapabilityBack
71+)
72+
73+// StartupOverlay selects the one startup overlay opened by a navigation route.
74+type StartupOverlay uint8
75+
76+const (
77+ StartupOverlayNone StartupOverlay = iota
78+ StartupOverlaySessionPicker
79+)
80+
81+// NavigationAction is a server request for one bounded client route transition.
82+type NavigationAction uint8
83+
84+const (
85+ NavigationOpenHomePicker NavigationAction = 1
86+ NavigationBack NavigationAction = 2
87+)
88+
6489 // RemotePreviewSchemaVersion is independent from the attachment IPC version.
6590 const RemotePreviewSchemaVersion uint16 = 1
6691
M internal/ports/frame_test.go +18 -7
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 25 {
7- t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)
6+ if ProtocolVersion != 26 {
7+ t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion)
88 }
99 }
1010
@@ -18,9 +18,6 @@ func TestControlMsgTypes(t *testing.T) {
1818 if MsgOutputResetRequest != 13 {
1919 t.Fatalf("MsgOutputResetRequest = %d, want 13", MsgOutputResetRequest)
2020 }
21- if MsgSessionMeta != 23 {
22- t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta)
23- }
2421 }
2522
2623 func TestMsgTypeUnique(t *testing.T) {
@@ -35,8 +32,8 @@ func TestMsgTypeUnique(t *testing.T) {
3532 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
3633 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
3734 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
38- {"MsgCommandResult", MsgCommandResult}, {"MsgSessionMeta", MsgSessionMeta},
39- {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget},
35+ {"MsgCommandResult", MsgCommandResult},
36+ {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
4037 }
4138
4239 seen := make(map[MsgType]string, len(tests))
@@ -50,3 +47,17 @@ func TestMsgTypeUnique(t *testing.T) {
5047 t.Fatalf("expected %d distinct MsgType values, got %d", len(tests), len(seen))
5148 }
5249 }
50+
51+func TestNavigationActionFrame(t *testing.T) {
52+ frame := Frame{Type: MsgNavigationAction, Payload: MarshalNavigationAction(NavigationOpenHomePicker)}
53+ if frame.Type != MsgNavigationAction {
54+ t.Fatalf("frame type = %d, want %d", frame.Type, MsgNavigationAction)
55+ }
56+ action, err := UnmarshalNavigationAction(frame.Payload)
57+ if err != nil {
58+ t.Fatalf("UnmarshalNavigationAction() error = %v", err)
59+ }
60+ if action != NavigationOpenHomePicker {
61+ t.Fatalf("action = %d, want %d", action, NavigationOpenHomePicker)
62+ }
63+}
A internal/ports/navigation_test.go +75 -0
@@ -0,0 +1,75 @@
1+package ports
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNavigationActionWireTable(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ action NavigationAction
14+ payload []byte
15+ valid bool
16+ }{
17+ {name: "open home", action: NavigationOpenHomePicker, payload: []byte{1}, valid: true},
18+ {name: "back", action: NavigationBack, payload: []byte{2}, valid: true},
19+ {name: "zero", action: 0, valid: false},
20+ {name: "unknown", action: 3, valid: false},
21+ }
22+
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ got := MarshalNavigationAction(tt.action)
26+ if !tt.valid {
27+ require.Nil(t, got)
28+ _, err := UnmarshalNavigationAction(tt.payload)
29+ require.ErrorIs(t, err, ErrInvalidNavigation)
30+ return
31+ }
32+ require.Equal(t, tt.payload, got)
33+ decoded, err := UnmarshalNavigationAction(got)
34+ require.NoError(t, err)
35+ require.Equal(t, tt.action, decoded)
36+ _, err = UnmarshalNavigationAction(append(append([]byte(nil), got...), 0))
37+ require.ErrorIs(t, err, ErrInvalidNavigation)
38+ })
39+ }
40+}
41+
42+func TestHelloNavigationValidationTable(t *testing.T) {
43+ base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
44+ tests := []struct {
45+ name string
46+ hello Hello
47+ valid bool
48+ capability NavigationCapabilities
49+ overlay StartupOverlay
50+ }{
51+ {name: "ordinary", hello: base, valid: true},
52+ {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker},
53+ {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
54+ {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
55+ {name: "picker without back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
56+ }
57+
58+ for _, tt := range tests {
59+ t.Run(tt.name, func(t *testing.T) {
60+ err := ValidateHello(tt.hello)
61+ if tt.valid {
62+ require.NoError(t, err)
63+ payload := MarshalHello(tt.hello)
64+ require.NotNil(t, payload)
65+ decoded, decodeErr := UnmarshalHello(payload)
66+ require.NoError(t, decodeErr)
67+ require.Equal(t, tt.capability, decoded.NavigationCapabilities)
68+ require.Equal(t, tt.overlay, decoded.StartupOverlay)
69+ return
70+ }
71+ require.Error(t, err)
72+ require.Nil(t, MarshalHello(tt.hello))
73+ })
74+ }
75+}
M internal/ports/remote_target_wire_test.go +23 -43
@@ -2,7 +2,6 @@ package ports
22
33 import (
44 "bytes"
5- "reflect"
65 "testing"
76
87 "github.com/bnema/vev/internal/domain"
@@ -80,46 +79,6 @@ func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) {
8079 }
8180 }
8281
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-
12382 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
12483 target := testRemoteTarget(false)
12584 tests := []struct {
@@ -199,7 +158,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
199158 if err := tt.unmarshal(trailing); err == nil {
200159 t.Fatal("trailing garbage unexpectedly decoded")
201160 }
202- payload[len(payload)-1] = byte(EnvironmentPolicyClientOwned)
161+ payload[len(payload)-3] = byte(EnvironmentPolicyClientOwned)
203162 if err := tt.unmarshal(payload); err == nil {
204163 t.Fatal("unmarshal accepted client-owned policy for remote target")
205164 }
@@ -207,7 +166,28 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
207166 }
208167 }
209168
210-func TestTargetWireIncludesRequiredV25Section(t *testing.T) {
169+func TestAttachTargetRejectsTokenlessResume(t *testing.T) {
170+ tests := []AttachTarget{
171+ {Endpoint: "host", Session: "work", Intent: IntentResume},
172+ {
173+ Endpoint: "build@mule:2222",
174+ Session: "work",
175+ Intent: IntentResume,
176+ RemoteTarget: testRemoteTarget(false),
177+ EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
178+ },
179+ }
180+ for _, target := range tests {
181+ if err := ValidateAttachTarget(target); err != ErrInvalidAttachTarget {
182+ t.Fatalf("ValidateAttachTarget(%#v) error = %v, want %v", target, err, ErrInvalidAttachTarget)
183+ }
184+ if got := MarshalAttachTarget(target); got != nil {
185+ t.Fatalf("MarshalAttachTarget(%#v) = %x, want nil", target, got)
186+ }
187+ }
188+}
189+
190+func TestTargetWireIncludesRequiredV26Section(t *testing.T) {
211191 want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}
212192 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
213193 if !bytes.Equal(got, want) {
M internal/ports/wire.go +83 -175
@@ -49,13 +49,8 @@ var ErrInvalidAck = errors.New("ports: invalid ack")
4949 // ErrInvalidAttachTarget reports a malformed attach target.
5050 var ErrInvalidAttachTarget = errors.New("ports: invalid attach target")
5151
52-// ErrInvalidSessionMeta reports metadata that cannot be represented by the
53-// protocol's ordered uint16-indexed tab layout.
54-var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata")
55-
56-// ErrSessionMetaStringTooLong reports metadata containing a string too large
57-// for the protocol's uint16 string encoding.
58-var ErrSessionMetaStringTooLong = errors.New("ports: session metadata string too long")
52+// ErrInvalidNavigation reports an unknown or impossible navigation value.
53+var ErrInvalidNavigation = errors.New("ports: invalid navigation")
5954
6055 // Intent values carried by Hello, describing what the client wants to do.
6156 const (
@@ -80,17 +75,33 @@ func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
8075 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
8176 }
8277
83-// RenderMode determines whether the daemon emits a complete terminal view or
84-// only the private remote content surface requested by another daemon.
85-type RenderMode uint8
78+func validNavigationCapabilities(capabilities NavigationCapabilities) bool {
79+ return capabilities&^(NavigationCapabilityHomePicker|NavigationCapabilityBack) == 0
80+}
8681
87-const (
88- RenderModeFullTerminal RenderMode = iota
89- RenderModeProxiedContent
90-)
82+func validStartupOverlay(overlay StartupOverlay) bool {
83+ return overlay == StartupOverlayNone || overlay == StartupOverlaySessionPicker
84+}
85+
86+// ValidateNavigation validates route capabilities independently of the rest of
87+// the Hello payload.
88+func ValidateNavigation(capabilities NavigationCapabilities, overlay StartupOverlay, remoteTarget bool) error {
89+ if !validNavigationCapabilities(capabilities) || !validStartupOverlay(overlay) {
90+ return ErrInvalidNavigation
91+ }
92+ home := capabilities&NavigationCapabilityHomePicker != 0
93+ back := capabilities&NavigationCapabilityBack != 0
94+ if home && !remoteTarget || back != (overlay == StartupOverlaySessionPicker) {
95+ return ErrInvalidNavigation
96+ }
97+ if remoteTarget && back {
98+ return ErrInvalidNavigation
99+ }
100+ return nil
101+}
91102
92-func validRenderMode(mode RenderMode) bool {
93- return mode == RenderModeFullTerminal || mode == RenderModeProxiedContent
103+func validateHelloNavigation(h Hello) error {
104+ return ValidateNavigation(h.NavigationCapabilities, h.StartupOverlay, h.RemoteTarget != nil || h.EnvironmentPolicy == EnvironmentPolicyDaemonOwned)
94105 }
95106
96107 // Capability bits advertised in Welcome.
@@ -127,7 +138,6 @@ const (
127138 type Hello struct {
128139 Version uint16
129140 Intent uint8
130- RenderMode RenderMode
131141 ClientID [16]byte
132142 ResumeToken uint64
133143 Name string
@@ -144,8 +154,10 @@ type Hello struct {
144154 // RemoteTarget is present only for a picker-selected remote attach. It is
145155 // carried through reconnects and is validated by the owning daemon before
146156 // it creates or publishes any session resources.
147- RemoteTarget *domain.RemoteSessionTarget
148- EnvironmentPolicy EnvironmentPolicy
157+ RemoteTarget *domain.RemoteSessionTarget
158+ EnvironmentPolicy EnvironmentPolicy
159+ NavigationCapabilities NavigationCapabilities
160+ StartupOverlay StartupOverlay
149161 }
150162
151163 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -236,7 +248,6 @@ type Welcome struct {
236248 SessionName string
237249 Ephemeral bool
238250 ResumeToken uint64
239- RenderMode RenderMode
240251 Capabilities uint32
241252 }
242253
@@ -313,21 +324,6 @@ type OutputResetRequest struct{}
313324
314325 // SessionTabMeta describes one tab in a remote session's metadata snapshot.
315326 // ID is stable for the lifetime of its remote tab and ordered only for display.
316-type SessionTabMeta struct {
317- ID domain.TabStableID
318- Name string
319- Attention bool
320-}
321-
322-// SessionMeta is an authoritative, revisioned snapshot of remote tab metadata.
323-type SessionMeta struct {
324- LifecycleID domain.SessionLifecycleID
325- Revision uint64
326- SessionName string
327- ActiveTabID domain.TabStableID
328- Tabs []SessionTabMeta
329-}
330-
331327 // payloadWriter builds a message payload by appending fields in wire order.
332328 type payloadWriter struct {
333329 b []byte
@@ -384,9 +380,9 @@ func (w *payloadWriter) putLongBytes(b []byte) {
384380 w.b = append(w.b, b...)
385381 }
386382
387-// marshalRemoteTargetSection writes the required v25 target/policy section.
383+// marshalRemoteTargetSection writes the required v26 target/policy section.
388384 // 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.
385+// environment policy are never optional: v26 has no extension or legacy tail.
390386 func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {
391387 if target == nil {
392388 w.putBool(false)
@@ -699,15 +695,15 @@ func ValidateHello(h Hello) error {
699695 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
700696 return ErrInvalidHello
701697 }
702- if !validRenderMode(h.RenderMode) {
703- return ErrInvalidHello
704- }
705698 if err := ValidateSize(h.Size); err != nil {
706699 return fmt.Errorf("%w: size", ErrInvalidHello)
707700 }
708701 if !validEnvironmentPolicy(h.EnvironmentPolicy) {
709702 return ErrInvalidHello
710703 }
704+ if err := validateHelloNavigation(h); err != nil {
705+ return fmt.Errorf("%w: navigation", ErrInvalidHello)
706+ }
711707 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {
712708 return ErrInvalidHello
713709 }
@@ -717,7 +713,7 @@ func ValidateHello(h Hello) error {
717713 }
718714 }
719715 if h.RemoteTarget == nil {
720- if h.EnvironmentPolicy != EnvironmentPolicyClientOwned || h.RenderMode == RenderModeProxiedContent {
716+ if h.EnvironmentPolicy != EnvironmentPolicyClientOwned && h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
721717 return ErrInvalidHello
722718 }
723719 return nil
@@ -775,13 +771,13 @@ func ValidateAttachTarget(m AttachTarget) error {
775771 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
776772 return ErrInvalidAttachTarget
777773 }
774+ if m.Intent == IntentResume {
775+ return ErrInvalidAttachTarget
776+ }
778777 if !validEnvironmentPolicy(m.EnvironmentPolicy) {
779778 return ErrInvalidAttachTarget
780779 }
781780 if m.RemoteTarget == nil {
782- if m.EnvironmentPolicy != EnvironmentPolicyClientOwned {
783- return ErrInvalidAttachTarget
784- }
785781 return nil
786782 }
787783 if m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
@@ -814,7 +810,6 @@ func MarshalHello(h Hello) []byte {
814810 w := payloadWriter{}
815811 w.putUint16(h.Version)
816812 w.putUint8(h.Intent)
817- w.putUint8(uint8(h.RenderMode))
818813 w.putBytes(h.ClientID[:])
819814 w.putUint64(h.ResumeToken)
820815 w.putString(h.Name)
@@ -829,6 +824,8 @@ func MarshalHello(h Hello) []byte {
829824 w.putLongString(entry)
830825 }
831826 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
827+ w.putUint8(uint8(h.NavigationCapabilities))
828+ w.putUint8(uint8(h.StartupOverlay))
832829 return w.b
833830 }
834831
@@ -844,13 +841,6 @@ func preflightHello(b []byte) error {
844841 if _, err := r.getUint8(); err != nil {
845842 return err
846843 }
847- mode, err := r.getUint8()
848- if err != nil {
849- return err
850- }
851- if !validRenderMode(RenderMode(mode)) {
852- return errInvalidEnum
853- }
854844 if len(r.b) < 16 {
855845 return errShortPayload
856846 }
@@ -894,6 +884,12 @@ func preflightHello(b []byte) error {
894884 if err := skipRemoteTargetSection(&r); err != nil {
895885 return err
896886 }
887+ if _, err := r.getUint8(); err != nil {
888+ return err
889+ }
890+ if _, err := r.getUint8(); err != nil {
891+ return err
892+ }
897893 return r.done()
898894 }
899895
@@ -915,11 +911,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
915911 if h.Intent, err = r.getUint8(); err != nil {
916912 return Hello{}, err
917913 }
918- mode, err := r.getUint8()
919- if err != nil {
920- return Hello{}, err
921- }
922- h.RenderMode = RenderMode(mode)
923914 clientID, err := r.getBytes(len(h.ClientID))
924915 if err != nil {
925916 return Hello{}, err
@@ -974,6 +965,16 @@ func UnmarshalHello(b []byte) (Hello, error) {
974965 if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
975966 return Hello{}, err
976967 }
968+ capabilities, err := r.getUint8()
969+ if err != nil {
970+ return Hello{}, err
971+ }
972+ h.NavigationCapabilities = NavigationCapabilities(capabilities)
973+ overlay, err := r.getUint8()
974+ if err != nil {
975+ return Hello{}, err
976+ }
977+ h.StartupOverlay = StartupOverlay(overlay)
977978 if err := r.done(); err != nil {
978979 return Hello{}, err
979980 }
@@ -1258,9 +1259,6 @@ func UnmarshalAck(b []byte) (Ack, error) {
12581259
12591260 // MarshalWelcome encodes m into a Welcome message payload.
12601261 func MarshalWelcome(m Welcome) []byte {
1261- if !validRenderMode(m.RenderMode) {
1262- return nil
1263- }
12641262 w := payloadWriter{}
12651263 w.putString(m.SessionID)
12661264 w.putString(m.SessionName)
@@ -1270,7 +1268,6 @@ func MarshalWelcome(m Welcome) []byte {
12701268 w.putUint8(0)
12711269 }
12721270 w.putUint64(m.ResumeToken)
1273- w.putUint8(uint8(m.RenderMode))
12741271 w.putUint32(m.Capabilities)
12751272 return w.b
12761273 }
@@ -1295,14 +1292,6 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
12951292 if m.ResumeToken, err = r.getUint64(); err != nil {
12961293 return Welcome{}, err
12971294 }
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- }
13061295 if m.Capabilities, err = r.getUint32(); err != nil {
13071296 return Welcome{}, err
13081297 }
@@ -1532,111 +1521,6 @@ func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) {
15321521 return OutputResetRequest{}, nil
15331522 }
15341523
1535-// ValidateSessionMeta validates a revisioned authoritative remote metadata
1536-// snapshot. Ordered tabs remain display order, while IDs are the only identity.
1537-func ValidateSessionMeta(m SessionMeta) error {
1538- if m.LifecycleID == (domain.SessionLifecycleID{}) || m.Revision == 0 ||
1539- m.SessionName == "" || len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 {
1540- return ErrInvalidSessionMeta
1541- }
1542- if len(m.SessionName) > math.MaxUint16 || len(m.ActiveTabID) > math.MaxUint16 {
1543- return ErrSessionMetaStringTooLong
1544- }
1545- if err := domain.ValidateTabStableID(m.ActiveTabID); err != nil {
1546- return ErrInvalidSessionMeta
1547- }
1548- activeFound := false
1549- seen := make(map[domain.TabStableID]struct{}, len(m.Tabs))
1550- for _, tab := range m.Tabs {
1551- if len(tab.ID) > math.MaxUint16 || len(tab.Name) > math.MaxUint16 {
1552- return ErrSessionMetaStringTooLong
1553- }
1554- if err := domain.ValidateTabStableID(tab.ID); err != nil {
1555- return ErrInvalidSessionMeta
1556- }
1557- if _, duplicate := seen[tab.ID]; duplicate {
1558- return ErrInvalidSessionMeta
1559- }
1560- seen[tab.ID] = struct{}{}
1561- activeFound = activeFound || tab.ID == m.ActiveTabID
1562- }
1563- if !activeFound {
1564- return ErrInvalidSessionMeta
1565- }
1566- return nil
1567-}
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- }
1574- w := payloadWriter{}
1575- w.putBytes(m.LifecycleID[:])
1576- w.putUint64(m.Revision)
1577- w.putString(m.SessionName)
1578- w.putString(string(m.ActiveTabID))
1579- w.putUint16(uint16(len(m.Tabs)))
1580- for _, tab := range m.Tabs {
1581- w.putString(string(tab.ID))
1582- w.putString(tab.Name)
1583- w.putBool(tab.Attention)
1584- }
1585- return w.b, nil
1586-}
1587-
1588-// UnmarshalSessionMeta decodes a strict v25 authoritative metadata snapshot.
1589-func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
1590- r := payloadReader{b: b}
1591- var m SessionMeta
1592- lifecycle, err := r.getBytes(len(m.LifecycleID))
1593- if err != nil {
1594- return SessionMeta{}, err
1595- }
1596- copy(m.LifecycleID[:], lifecycle)
1597- if m.Revision, err = r.getUint64(); err != nil {
1598- return SessionMeta{}, err
1599- }
1600- if m.SessionName, err = r.getString(); err != nil {
1601- return SessionMeta{}, err
1602- }
1603- activeTabID, err := r.getString()
1604- if err != nil {
1605- return SessionMeta{}, err
1606- }
1607- m.ActiveTabID = domain.TabStableID(activeTabID)
1608- count, err := r.getUint16()
1609- if err != nil {
1610- return SessionMeta{}, err
1611- }
1612- if count == 0 || uint64(count) > uint64(len(r.b)/5) {
1613- return SessionMeta{}, errShortPayload
1614- }
1615- m.Tabs = make([]SessionTabMeta, 0, int(count))
1616- for range int(count) {
1617- var tab SessionTabMeta
1618- id, err := r.getString()
1619- if err != nil {
1620- return SessionMeta{}, err
1621- }
1622- tab.ID = domain.TabStableID(id)
1623- if tab.Name, err = r.getString(); err != nil {
1624- return SessionMeta{}, err
1625- }
1626- if tab.Attention, err = r.getBool(); err != nil {
1627- return SessionMeta{}, err
1628- }
1629- m.Tabs = append(m.Tabs, tab)
1630- }
1631- if err := r.done(); err != nil {
1632- return SessionMeta{}, err
1633- }
1634- if err := ValidateSessionMeta(m); err != nil {
1635- return SessionMeta{}, err
1636- }
1637- return m, nil
1638-}
1639-
16401524 // MarshalKill encodes m into a Kill message payload.
16411525 func MarshalKill(m Kill) []byte {
16421526 w := payloadWriter{}
@@ -1735,7 +1619,6 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
17351619 }
17361620 return Sessions{Sessions: sessions}, nil
17371621 }
1738-
17391622 func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) }
17401623
17411624 func getInt16(r *payloadReader) (int, error) {
@@ -2003,3 +1886,28 @@ func UnmarshalRemotePreview(data []byte) (RemotePreview, error) {
20031886 }
20041887 return p, nil
20051888 }
1889+
1890+// MarshalNavigationAction encodes one bounded navigation action.
1891+func MarshalNavigationAction(action NavigationAction) []byte {
1892+ if action != NavigationOpenHomePicker && action != NavigationBack {
1893+ return nil
1894+ }
1895+ return []byte{byte(action)}
1896+}
1897+
1898+// UnmarshalNavigationAction decodes one strict navigation action payload.
1899+func UnmarshalNavigationAction(b []byte) (NavigationAction, error) {
1900+ r := payloadReader{b: b}
1901+ value, err := r.getUint8()
1902+ if err != nil {
1903+ return 0, ErrInvalidNavigation
1904+ }
1905+ if err := r.done(); err != nil {
1906+ return 0, ErrInvalidNavigation
1907+ }
1908+ action := NavigationAction(value)
1909+ if action != NavigationOpenHomePicker && action != NavigationBack {
1910+ return 0, ErrInvalidNavigation
1911+ }
1912+ return action, nil
1913+}
M internal/ports/wire_final_test.go +4 -9
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 25 {
15- t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)
14+ if ProtocolVersion != 26 {
15+ t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 25, 2, 0}, make([]byte, 16+8)...)
30- want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
29+ want := append([]byte{0, 26, 2}, make([]byte, 16+8)...)
30+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
3333 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -197,10 +197,6 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
197197 }
198198
199199 func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
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 {
202- t.Fatal(err)
203- }
204200 tests := []struct {
205201 name string
206202 payload []byte
@@ -252,7 +248,6 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
252248 }(),
253249 decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err },
254250 },
255- {name: "session metadata boolean", payload: func() []byte { b := append([]byte(nil), meta...); b[len(b)-1] = 2; return b }(), decode: func(b []byte) error { _, err := UnmarshalSessionMeta(b); return err }},
256251 {name: "theme flags", payload: func() []byte { b := make([]byte, 57); b[0] = 0x20; return b }(), decode: func(b []byte) error { _, err := UnmarshalTheme(b); return err }},
257252 }
258253 for _, tt := range tests {
M internal/ports/wire_test.go +16 -117
@@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
7373 TrueColor: true,
7474 MaxOutputInFlight: 8,
7575 },
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},
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, 0x00, 0x00},
7777 },
7878 {
7979 name: "empty strings",
@@ -86,14 +86,15 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
8686 Cwd: "",
8787 TrueColor: false,
8888 },
89- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 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, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
9090 },
9191 }
9292
9393 for _, tt := range tests {
9494 t.Run(tt.name, func(t *testing.T) {
95+ want := append(append([]byte(nil), tt.want...), 0, 0)
9596 got := MarshalHello(tt.msg)
96- if !bytes.Equal(got, tt.want) {
97+ if !bytes.Equal(got, want) {
9798 t.Fatalf("MarshalHello() = %#v, want %#v", got, tt.want)
9899 }
99100 back, err := UnmarshalHello(got)
@@ -124,7 +125,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
124125 }
125126 got := MarshalHello(msg)
126127 want := []byte{
127- 0x00, 0x19, 0x00, 0x00, // version, intent, render mode
128+ 0x00, 0x1a, 0x00, // version, intent, first client ID byte
128129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
129130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
130131 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -138,6 +139,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
138139 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
139140 0x00, 0x00, // no remote target, client-owned environment
140141 }
142+ want = append(want, 0, 0)
141143 require.Equal(t, want, got)
142144 decoded, err := UnmarshalHello(want)
143145 require.NoError(t, err)
@@ -342,7 +344,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
342344 }
343345 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
344346 require.Equal(t, want, MarshalTheme(cleared))
345- require.Equal(t, uint16(25), ProtocolVersion)
347+ require.Equal(t, uint16(26), ProtocolVersion)
346348 }
347349
348350 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -422,12 +424,12 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
422424 {
423425 name: "ephemeral",
424426 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
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},
427+ 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},
426428 },
427429 {
428430 name: "non-ephemeral empty name",
429431 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
430- want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
432+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
431433 },
432434 }
433435
@@ -452,13 +454,6 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
452454 assertTrailingGarbageFails(t, full, UnmarshalWelcome)
453455 }
454456
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-
462457 func TestCommandErrorCodes(t *testing.T) {
463458 require.Equal(t, uint16(6), ErrUnknownCommand)
464459 require.Equal(t, uint16(7), ErrNotScriptable)
@@ -477,7 +472,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
477472 name: "minimal",
478473 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
479474 want: []byte{
480- 0x00, 0x19,
475+ 0x00, 0x1a,
481476 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
482477 0x00, // attached
483478 0x00, // self
@@ -500,7 +495,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
500495 JSON: true,
501496 },
502497 want: []byte{
503- 0x00, 0x19,
498+ 0x00, 0x1a,
504499 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
505500 0x00, // attached
506501 0x01, // self
@@ -898,9 +893,10 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
898893 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
899894 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
900895 // The empty Hello has a fixed 1x1 size before the negotiated output window.
901- want := append(make([]byte, 39), window, 0, 0, 0, 0, 0, 0)
896+ want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0)
897+ want = append(want, 0, 0)
902898 want[1] = 14
903- want[31], want[33] = 1, 1
899+ want[30], want[32] = 1, 1
904900 got := MarshalHello(hello)
905901 requireBytesEqual(t, want, got)
906902 back, err := UnmarshalHello(got)
@@ -928,7 +924,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
928924 name: "attached",
929925 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
930926 want: []byte{
931- 0x00, 0x19, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
927+ 0x00, 0x1a, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
932928 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
933929 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
934930 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -938,7 +934,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
938934 name: "control",
939935 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
940936 want: []byte{
941- 0x00, 0x19, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
937+ 0x00, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
942938 0x00, 0x02, 'l', 's', 0x00, 0x00,
943939 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
944940 },
@@ -995,100 +991,3 @@ func TestOutputResetRequestStrict(t *testing.T) {
995991 require.Equal(t, OutputResetRequest{}, back)
996992 assertTrailingGarbageFails(t, got, UnmarshalOutputResetRequest)
997993 }
998-
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}
1001- tests := []struct {
1002- name string
1003- msg SessionMeta
1004- want []byte
1005- }{
1006- {
1007- name: "one unicode tab",
1008- msg: SessionMeta{LifecycleID: lifecycle, Revision: 2, SessionName: "démo", ActiveTabID: "tab-é", Tabs: []SessionTabMeta{{ID: "tab-é", Name: "é", Attention: true}}},
1009- want: []byte{
1010- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
1011- 0, 0, 0, 0, 0, 0, 0, 2,
1012- 0, 5, 'd', 0xc3, 0xa9, 'm', 'o',
1013- 0, 6, 't', 'a', 'b', '-', 0xc3, 0xa9,
1014- 0, 1,
1015- 0, 6, 't', 'a', 'b', '-', 0xc3, 0xa9,
1016- 0, 2, 0xc3, 0xa9, 1,
1017- },
1018- },
1019- {
1020- name: "multiple tabs",
1021- msg: SessionMeta{LifecycleID: lifecycle, Revision: 7, SessionName: "work", ActiveTabID: "tab-build", Tabs: []SessionTabMeta{{ID: "tab-shell", Name: "shell"}, {ID: "tab-build", Name: "build", Attention: true}}},
1022- want: []byte{
1023- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
1024- 0, 0, 0, 0, 0, 0, 0, 7,
1025- 0, 4, 'w', 'o', 'r', 'k',
1026- 0, 9, 't', 'a', 'b', '-', 'b', 'u', 'i', 'l', 'd',
1027- 0, 2,
1028- 0, 9, 't', 'a', 'b', '-', 's', 'h', 'e', 'l', 'l', 0, 5, 's', 'h', 'e', 'l', 'l', 0,
1029- 0, 9, 't', 'a', 'b', '-', 'b', 'u', 'i', 'l', 'd', 0, 5, 'b', 'u', 'i', 'l', 'd', 1,
1030- },
1031- },
1032- }
1033- for _, tt := range tests {
1034- t.Run(tt.name, func(t *testing.T) {
1035- got, err := MarshalSessionMeta(tt.msg)
1036- require.NoError(t, err)
1037- require.Equal(t, tt.want, got)
1038- back, err := UnmarshalSessionMeta(got)
1039- require.NoError(t, err)
1040- require.Equal(t, tt.msg, back)
1041- assertAllPrefixesFail(t, got, UnmarshalSessionMeta)
1042- assertTrailingGarbageFails(t, got, UnmarshalSessionMeta)
1043- })
1044- }
1045-
1046- maxString := string(bytes.Repeat([]byte{'x'}, math.MaxUint16))
1047- payload, err := MarshalSessionMeta(SessionMeta{LifecycleID: lifecycle, Revision: 1, SessionName: maxString, ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: maxString}}})
1048- require.NoError(t, err)
1049- back, err := UnmarshalSessionMeta(payload)
1050- require.NoError(t, err)
1051- require.Equal(t, maxString, back.SessionName)
1052- require.Equal(t, maxString, back.Tabs[0].Name)
1053-
1054- validMeta := func() SessionMeta {
1055- return SessionMeta{LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}}
1056- }
1057- invalid := []SessionMeta{
1058- {},
1059- {LifecycleID: lifecycle, Revision: 1, SessionName: "work", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1060- {LifecycleID: lifecycle, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1061- {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "missing", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1062- {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}, {ID: "tab", Name: "two"}}},
1063- {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}},
1064- {LifecycleID: lifecycle, Revision: 1, SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1065- }
1066- for _, msg := range invalid {
1067- _, err := MarshalSessionMeta(msg)
1068- require.Error(t, err)
1069- }
1070-
1071- valid, err := MarshalSessionMeta(validMeta())
1072- require.NoError(t, err)
1073- for _, tt := range []struct {
1074- name string
1075- mutate func([]byte)
1076- wantError error
1077- }{
1078- {name: "zero lifecycle", mutate: func(b []byte) { clear(b[:16]) }, wantError: ErrInvalidSessionMeta},
1079- {name: "zero revision", mutate: func(b []byte) { clear(b[16:24]) }, wantError: ErrInvalidSessionMeta},
1080- {name: "invalid active stable ID", mutate: func(b []byte) { b[32] = ' ' }, wantError: ErrInvalidSessionMeta},
1081- {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }},
1082- } {
1083- t.Run(tt.name, func(t *testing.T) {
1084- malformed := append([]byte(nil), valid...)
1085- tt.mutate(malformed)
1086- _, err := UnmarshalSessionMeta(malformed)
1087- if tt.wantError != nil {
1088- require.ErrorIs(t, err, tt.wantError)
1089- return
1090- }
1091- require.Error(t, err)
1092- })
1093- }
1094-}
M internal/usecase/client/client.go +172 -46
@@ -40,10 +40,6 @@ func (e *ProtocolError) Error() string {
4040 return fmt.Sprintf("vev: %s", e.Text)
4141 }
4242
43-// errRenderModeMismatch is a permanent attach failure: retrying the same
44-// request cannot make a daemon accept a different render contract.
45-var errRenderModeMismatch = errors.New("vev: daemon accepted render mode")
46-
4743 // DetachedError reports that the daemon detached this client for a reason
4844 // other than an ordinary user-initiated detach.
4945 type DetachedError struct {
@@ -173,15 +169,22 @@ type Dependencies struct {
173169 // client-only presentation metadata. RemoteTarget is an optional exact picker
174170 // handoff and is serialized into Hello only when present.
175171 type AttachRequest struct {
176- Intent uint8
177- SessionName string
178- RenderMode ports.RenderMode
179- Remote bool
180- RemoteTarget *domain.RemoteSessionTarget
181- EnvironmentPolicy ports.EnvironmentPolicy
172+ Intent uint8
173+ SessionName string
174+ Remote bool
175+ RemoteTarget *domain.RemoteSessionTarget
176+ EnvironmentPolicy ports.EnvironmentPolicy
177+ NavigationCapabilities ports.NavigationCapabilities
178+ StartupOverlay ports.StartupOverlay
182179 }
183180
184181 // Runner owns the client lifecycle across one or more attachment attempts.
182+type attachRoute struct {
183+ dialer ports.Dialer
184+ request AttachRequest
185+ resumeToken uint64
186+}
187+
185188 type Runner struct {
186189 dialer ports.Dialer
187190 term ports.Terminal
@@ -232,33 +235,34 @@ func (r *Runner) terminalInput() *terminalInputPump {
232235 }
233236
234237 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- }
240238 switch request.Intent {
241239 case ports.IntentEphemeral:
240+ if request.SessionName != "" {
241+ return errors.New("vev: ephemeral attach cannot name a session")
242+ }
242243 case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
243244 if request.SessionName == "" {
244- return domain.ErrInvalidSessionName
245+ return errors.New("vev: session name is required")
245246 }
246247 default:
247248 return fmt.Errorf("vev: invalid session intent %d", request.Intent)
248249 }
249250 if request.SessionName != "" {
250251 if err := domain.ValidateSessionName(request.SessionName); err != nil {
251- return err
252+ return fmt.Errorf("vev: invalid session name: %w", err)
252253 }
253254 }
255+ if err := ports.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.Remote || request.RemoteTarget != nil); err != nil {
256+ return fmt.Errorf("vev: invalid navigation route: %w", err)
257+ }
254258 if request.RemoteTarget == nil {
255- if request.RenderMode == ports.RenderModeProxiedContent {
256- return errors.New("vev: proxied attach request requires remote target")
259+ if request.EnvironmentPolicy == ports.EnvironmentPolicyClientOwned {
260+ return nil
257261 }
258- if request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned {
259- return errors.New("vev: attach request without remote target requires client-owned environment")
262+ if request.Remote && request.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned {
263+ return nil
260264 }
261- return nil
265+ return errors.New("vev: attach without remote target requires a matching environment policy")
262266 }
263267 if err := request.RemoteTarget.Validate(); err != nil {
264268 return fmt.Errorf("vev: invalid remote attach target: %w", err)
@@ -270,7 +274,7 @@ func validateAttachRequest(request AttachRequest) error {
270274 return errors.New("vev: remote attach target requires daemon-owned environment")
271275 }
272276 if request.Intent != ports.IntentAttach && request.Intent != ports.IntentResume {
273- return errors.New("vev: remote attach target requires attach or resume intent")
277+ return errors.New("vev: remote attach target requires attach or resume")
274278 }
275279 return nil
276280 }
@@ -310,6 +314,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
310314 resumeToken := uint64(0)
311315 attemptRequest := request
312316 dialer := r.dialer
317+ var homeRoute *attachRoute
318+ var returnRoute *attachRoute
319+ returnResumeFallback := false
320+ homeNavigationPending := false
321+ returnNavigationPending := false
313322 backoff := defaultReconnectBackoff.initial
314323 themeState := &terminalThemeState{}
315324 var rememberOnce sync.Once
@@ -412,7 +421,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
412421 return errors.New("vev: dialer returned nil transport")
413422 }
414423 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
415- connection, err := NewSessionConnection(transport, attemptRequest)
424+ connection, err := NewSessionConnection(transport, SessionTarget{
425+ Intent: attemptRequest.Intent,
426+ SessionName: attemptRequest.SessionName,
427+ })
416428 if err != nil {
417429 stopHandshakeTransport()
418430 finishHandshake()
@@ -453,14 +465,69 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
453465 if !result.transportClosed {
454466 _ = connection.Close()
455467 }
468+ if result.action != 0 {
469+ switch result.action {
470+ case ports.NavigationOpenHomePicker:
471+ if attemptRequest.NavigationCapabilities&ports.NavigationCapabilityHomePicker == 0 || homeRoute == nil {
472+ return errors.New("vev: stale home navigation action")
473+ }
474+ returnRoute = &attachRoute{dialer: dialer, request: attemptRequest, resumeToken: result.resumeToken}
475+ homeNavigationPending = true
476+ dialer = homeRoute.dialer
477+ attemptRequest = homeRoute.request
478+ attemptRequest.Remote = false
479+ attemptRequest.Intent = ports.IntentAttach
480+ attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
481+ attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
482+ attemptRequest.RemoteTarget = nil
483+ attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
484+ remote = syncReconnectRemote(reconnect, false)
485+ resumeToken = 0
486+ backoff = defaultReconnectBackoff.initial
487+ continue
488+ case ports.NavigationBack:
489+ if attemptRequest.StartupOverlay != ports.StartupOverlaySessionPicker || returnRoute == nil || attemptRequest.NavigationCapabilities&ports.NavigationCapabilityBack == 0 {
490+ return errors.New("vev: stale return navigation action")
491+ }
492+ route := *returnRoute
493+ returnNavigationPending = true
494+ dialer = route.dialer
495+ attemptRequest = route.request
496+ attemptRequest.Intent = ports.IntentResume
497+ attemptRequest.StartupOverlay = ports.StartupOverlayNone
498+ resumeToken = route.resumeToken
499+ returnResumeFallback = true
500+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
501+ backoff = defaultReconnectBackoff.initial
502+ continue
503+ default:
504+ return errors.New("vev: unsupported navigation action")
505+ }
506+ }
456507 if result.target != nil {
457508 if r.attachHandoff == nil {
458509 return &AttachTargetError{Target: *result.target}
459510 }
511+ if result.target.RemoteTarget != nil || result.target.Endpoint != "" {
512+ if homeRoute == nil && attemptRequest.RemoteTarget == nil {
513+ routeRequest := attemptRequest
514+ routeRequest.Intent = ports.IntentAttach
515+ routeRequest.NavigationCapabilities = 0
516+ routeRequest.StartupOverlay = ports.StartupOverlayNone
517+ homeRoute = &attachRoute{dialer: dialer, request: routeRequest}
518+ }
519+ }
460520 nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target)
461521 if handoffErr != nil {
462522 return handoffErr
463523 }
524+ if homeRoute != nil {
525+ nextRequest.NavigationCapabilities |= ports.NavigationCapabilityHomePicker
526+ }
527+ if attemptRequest.StartupOverlay == ports.StartupOverlaySessionPicker {
528+ returnRoute = nil
529+ returnResumeFallback = false
530+ }
464531 nextRequest = cloneAttachRequest(nextRequest)
465532 if err := validateAttachRequest(nextRequest); err != nil {
466533 return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)
@@ -470,8 +537,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
470537 }
471538 dialer = nextDialer
472539 attemptRequest = nextRequest
473- remote = attemptRequest.Remote || r.remote
474- reconnect.remote = remote
540+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
475541 resumeToken = 0
476542 backoff = defaultReconnectBackoff.initial
477543 continue
@@ -483,11 +549,58 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
483549 attemptRequest.SessionName = result.sessionName
484550 }
485551 if result.err == nil {
552+ if result.welcomed {
553+ homeNavigationPending = false
554+ if returnNavigationPending {
555+ returnRoute = nil
556+ returnNavigationPending = false
557+ returnResumeFallback = false
558+ }
559+ }
486560 if clearErr := reconnect.clear(); clearErr != nil {
487561 return clearErr
488562 }
489563 return nil
490564 }
565+ if homeNavigationPending && returnRoute != nil {
566+ route := *returnRoute
567+ returnRoute = nil
568+ homeNavigationPending = false
569+ returnNavigationPending = false
570+ returnResumeFallback = false
571+ dialer = route.dialer
572+ attemptRequest = route.request
573+ attemptRequest.Intent = ports.IntentResume
574+ attemptRequest.StartupOverlay = ports.StartupOverlayNone
575+ resumeToken = route.resumeToken
576+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
577+ backoff = defaultReconnectBackoff.initial
578+ continue
579+ }
580+ if returnResumeFallback {
581+ var protocolErr *ProtocolError
582+ if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) {
583+ attemptRequest.Intent = ports.IntentAttach
584+ resumeToken = 0
585+ returnResumeFallback = false
586+ continue
587+ }
588+ }
589+ if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
590+ returnRoute = nil
591+ returnNavigationPending = false
592+ attemptRequest = homeRoute.request
593+ attemptRequest.Intent = ports.IntentAttach
594+ attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
595+ attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
596+ attemptRequest.RemoteTarget = nil
597+ attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
598+ attemptRequest.Remote = false
599+ resumeToken = 0
600+ remote = syncReconnectRemote(reconnect, false)
601+ backoff = defaultReconnectBackoff.initial
602+ continue
603+ }
491604 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
492605 if clearErr := reconnect.clear(); clearErr != nil {
493606 return errors.Join(result.err, clearErr)
@@ -524,6 +637,13 @@ type reconnectUI struct {
524637 stage reconnectStage
525638 }
526639
640+func syncReconnectRemote(reconnect *reconnectUI, remote bool) bool {
641+ if reconnect != nil {
642+ reconnect.remote = remote
643+ }
644+ return remote
645+}
646+
527647 func (u *reconnectUI) redraw(size domain.Size) error {
528648 if !*u.rawEntered || !u.remote {
529649 return nil
@@ -656,6 +776,7 @@ type attachResult struct {
656776 welcomed bool
657777 transportClosed bool
658778 target *ports.AttachTarget
779+ action ports.NavigationAction
659780 err error
660781 }
661782
@@ -696,11 +817,10 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
696817
697818 func (a *attachAttempt) run(ctx context.Context) attachResult {
698819 transport := a.transport
699- request := a.request
700820 if a.connection != nil {
701821 transport = a.connection.Transport()
702- request = a.connection.AttachRequest()
703822 }
823+ request := a.request
704824 term := a.runner.term
705825 clk := a.runner.clock
706826 intent := request.Intent
@@ -712,7 +832,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
712832 enterRaw := a.enterRaw
713833 reconnect := a.reconnect
714834 linkEvents := a.linkEvents
715- remote := a.remote || request.Remote
835+ remote := a.remote || a.request.Remote
716836 clipboard := a.runner.clipboard
717837 log := a.runner.logger
718838 observer := a.runner.runtimeObserver
@@ -765,20 +885,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
765885 trueColor := DetectTrueColor(termEnv, colorTerm)
766886 themeState.setTrueColor(trueColor)
767887 hello := ports.Hello{
768- Version: ports.ProtocolVersion,
769- Intent: intent,
770- ClientID: clientID,
771- ResumeToken: resumeToken,
772- Name: name,
773- RenderMode: request.RenderMode,
774- Size: size,
775- TermEnv: termEnv,
776- Cwd: cwd,
777- TrueColor: trueColor,
778- MaxOutputInFlight: requestedOutputWindow(transport),
779- Env: os.Environ(),
780- RemoteTarget: request.RemoteTarget,
781- EnvironmentPolicy: request.EnvironmentPolicy,
888+ Version: ports.ProtocolVersion,
889+ Intent: intent,
890+ ClientID: clientID,
891+ ResumeToken: resumeToken,
892+ Name: name,
893+ Size: size,
894+ TermEnv: termEnv,
895+ Cwd: cwd,
896+ TrueColor: trueColor,
897+ MaxOutputInFlight: requestedOutputWindow(transport),
898+ Env: os.Environ(),
899+ RemoteTarget: request.RemoteTarget,
900+ EnvironmentPolicy: request.EnvironmentPolicy,
901+ NavigationCapabilities: request.NavigationCapabilities,
902+ StartupOverlay: request.StartupOverlay,
782903 }
783904 if err := sendHandshake(func() error {
784905 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
@@ -805,9 +926,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
805926 if derr != nil {
806927 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
807928 }
808- if welcome.RenderMode != request.RenderMode {
809- return result(false, fmt.Errorf("%w %d, want %d", errRenderModeMismatch, welcome.RenderMode, request.RenderMode))
810- }
811929 resumeToken = welcome.ResumeToken
812930 name = welcome.SessionName
813931 ms.welcomed = true
@@ -1196,6 +1314,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
11961314 handoff := welcomedResult(nil)
11971315 handoff.target = &target
11981316 return handoff
1317+ case ports.MsgNavigationAction:
1318+ action, derr := ports.UnmarshalNavigationAction(r.frame.Payload)
1319+ if derr != nil {
1320+ return welcomedResult(fmt.Errorf("vev: decoding navigation action: %w", derr))
1321+ }
1322+ navigation := welcomedResult(nil)
1323+ navigation.action = action
1324+ return navigation
11991325 case ports.MsgDetached:
12001326 d, derr := ports.UnmarshalDetached(r.frame.Payload)
12011327 if derr != nil {
M internal/usecase/client/client_test.go +52 -235
@@ -41,44 +41,6 @@ func (r *realTimer) C() <-chan time.Time { return r.t.C }
4141 func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
4242 func (r *realTimer) Stop() bool { return r.t.Stop() }
4343
44-const runnerInitialBackoff = 100 * time.Millisecond
45-
46-type runnerTestClock struct {
47- handshake chan *runnerTestTimer
48- palette chan *runnerTestTimer
49- reconnect chan *runnerTestTimer
50-}
51-
52-type runnerTestTimer struct {
53- ch chan time.Time
54- duration time.Duration
55-}
56-
57-func newRunnerTestClock() *runnerTestClock {
58- return &runnerTestClock{
59- handshake: make(chan *runnerTestTimer, 4),
60- palette: make(chan *runnerTestTimer, 8),
61- reconnect: make(chan *runnerTestTimer, 16),
62- }
63-}
64-
65-func (*runnerTestClock) Now() time.Time { return time.Time{} }
66-func (c *runnerTestClock) NewTimer(duration time.Duration) ports.Timer {
67- timer := &runnerTestTimer{ch: make(chan time.Time, 1), duration: duration}
68- if duration == ports.HandshakeTimeout {
69- c.handshake <- timer
70- } else if duration == 200*time.Millisecond {
71- c.palette <- timer
72- } else {
73- c.reconnect <- timer
74- }
75- return timer
76-}
77-func (t *runnerTestTimer) C() <-chan time.Time { return t.ch }
78-func (*runnerTestTimer) Reset(time.Duration) bool { return false }
79-func (*runnerTestTimer) Stop() bool { return true }
80-func (t *runnerTestTimer) fire() { t.ch <- time.Time{} }
81-
8244 // recvItem is one scripted Recv result.
8345 type recvItem struct {
8446 f ports.Frame
@@ -384,6 +346,35 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
384346 }
385347 }
386348
349+func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
350+ term := newRunTerminal()
351+ defer term.in.unblock()
352+ lifecycle := domain.SessionLifecycleID{1, 2, 3}
353+ target := &domain.RemoteSessionTarget{
354+ Endpoint: "remote.example", DisplayOrigin: "remote.example", LifecycleID: lifecycle,
355+ SessionName: "work", LiveTabID: "tab-1",
356+ }
357+ request := client.AttachRequest{
358+ Intent: ports.IntentAttach, SessionName: "work", Remote: true,
359+ RemoteTarget: target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
360+ NavigationCapabilities: ports.NavigationCapabilityHomePicker,
361+ StartupOverlay: ports.StartupOverlayNone,
362+ }
363+ tr := &recordingTransport{recvs: []recvItem{
364+ {f: welcomeFrame(0)},
365+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
366+ }}
367+
368+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, term, realClock{}), request))
369+ hello := helloFromSend(t, tr)
370+ require.Equal(t, request.Intent, hello.Intent)
371+ require.Equal(t, request.SessionName, hello.Name)
372+ require.Equal(t, target, hello.RemoteTarget)
373+ require.Equal(t, request.EnvironmentPolicy, hello.EnvironmentPolicy)
374+ require.Equal(t, request.NavigationCapabilities, hello.NavigationCapabilities)
375+ require.Equal(t, request.StartupOverlay, hello.StartupOverlay)
376+}
377+
387378 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
388379 var out bytes.Buffer
389380 var restoreCount atomic.Int32
@@ -827,15 +818,11 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
827818 type recordingTransport struct {
828819 recvs []recvItem
829820 sends []ports.Frame
830- onSend func(ports.Frame)
831821 closed atomic.Int32
832822 }
833823
834824 func (t *recordingTransport) Send(f ports.Frame) error {
835825 t.sends = append(t.sends, f)
836- if t.onSend != nil {
837- t.onSend(f)
838- }
839826 return nil
840827 }
841828
@@ -888,203 +875,33 @@ func welcomeFrame(token uint64) ports.Frame {
888875 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
889876 }
890877
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- }
990- term := newRunTerminal()
991- defer term.in.unblock()
992- tr := &recordingTransport{recvs: []recvItem{
993- {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
994- SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
995- }))},
996- {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
997- }}
998- deps := testDependencies(&sequenceDialer{trs: []ports.Transport{tr}}, term, realClock{}, nil, nil)
999- err := runTestClient(context.Background(), deps, client.AttachRequest{
1000- Intent: ports.IntentAttach, SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
1001- RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1002- })
1003- require.NoError(t, err)
1004- require.Equal(t, ports.RenderModeProxiedContent, helloFromSend(t, tr).RenderMode)
1005-}
1006-
1007-func TestRunRejectsMismatchedProxiedWelcomeBeforeEnteringRawMode(t *testing.T) {
1008- lifecycle := domain.SessionLifecycleID{1}
1009- target := domain.RemoteSessionTarget{
1010- Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
1011- SessionName: "main", LiveTabID: "tab-live",
1012- }
878+func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
1013879 term := newRunTerminal()
1014880 defer term.in.unblock()
1015- tr := &recordingTransport{recvs: []recvItem{{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
1016- SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeFullTerminal,
1017- }))}}}
1018-
1019- err := runTestClient(context.Background(), testDependencies(&sequenceDialer{trs: []ports.Transport{tr}}, term, realClock{}, nil, nil), client.AttachRequest{
1020- Intent: ports.IntentAttach, SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
1021- RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1022- })
1023- require.ErrorContains(t, err, "daemon accepted render mode")
1024- require.Zero(t, term.rawCount.Load())
1025-}
881+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
882+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
883+ tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
884+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
1026885
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- }
886+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
887+ require.NoError(t, err)
888+ require.Equal(t, int32(3), d.calls.Load())
889+ require.Equal(t, int32(1), term.rawCount.Load())
890+ require.Equal(t, int32(1), term.restoreCount.Load())
1079891
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- }
892+ h1 := helloFromSend(t, tr1)
893+ h2 := helloFromSend(t, tr2)
894+ h3 := helloFromSend(t, tr3)
895+ require.Equal(t, ports.IntentAttach, h1.Intent)
896+ require.Zero(t, h1.ResumeToken)
897+ require.Equal(t, ports.IntentResume, h2.Intent)
898+ require.Equal(t, uint64(11), h2.ResumeToken)
899+ require.Equal(t, ports.IntentResume, h3.Intent)
900+ require.Equal(t, uint64(22), h3.ResumeToken)
901+ require.Equal(t, h1.ClientID, h2.ClientID)
902+ require.Equal(t, h1.ClientID, h3.ClientID)
903+ require.Contains(t, term.out.String(), "reconnecting…")
904+ require.Contains(t, term.out.String(), "\x1b[2K")
1088905 }
1089906
1090907 func TestAttachRememberRemoteHost(t *testing.T) {
A internal/usecase/client/navigation_test.go +49 -0
@@ -0,0 +1,49 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestSyncReconnectRemoteKeepsRequestAndToastClassificationTogether(t *testing.T) {
11+ reconnect := &reconnectUI{}
12+ for _, tt := range []struct {
13+ name string
14+ remote bool
15+ }{
16+ {name: "local picker", remote: false},
17+ {name: "remote handoff", remote: true},
18+ } {
19+ t.Run(tt.name, func(t *testing.T) {
20+ require.Equal(t, tt.remote, syncReconnectRemote(reconnect, tt.remote))
21+ require.Equal(t, tt.remote, reconnect.remote)
22+ })
23+ }
24+}
25+
26+func TestValidateAttachRequestNavigationTable(t *testing.T) {
27+ tests := []struct {
28+ name string
29+ request AttachRequest
30+ valid bool
31+ }{
32+ {name: "ordinary route", request: AttachRequest{}, valid: true},
33+ {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 4}, valid: false},
34+ {name: "back without startup picker", request: AttachRequest{NavigationCapabilities: ports.NavigationCapabilityBack}, valid: false},
35+ {name: "startup picker without back", request: AttachRequest{StartupOverlay: ports.StartupOverlaySessionPicker}, valid: false},
36+ }
37+
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ err := validateAttachRequest(tt.request)
41+ if tt.valid {
42+ require.NoError(t, err)
43+ return
44+ }
45+ require.Error(t, err)
46+ require.ErrorContains(t, err, "navigation")
47+ })
48+ }
49+}
M internal/usecase/client/palette_attach_generation_test.go +1 -1
@@ -35,7 +35,7 @@ type attachPaletteTimer struct {
3535 func newAttachPaletteClock() *attachPaletteClock {
3636 return &attachPaletteClock{
3737 timers: make(chan *attachPaletteTimer, 16),
38- handshakeTimers: make(chan *attachPaletteTimer, 4),
38+ handshakeTimers: make(chan *attachPaletteTimer, 1),
3939 }
4040 }
4141
M internal/usecase/client/reconnect.go +0 -3
@@ -56,9 +56,6 @@ func shouldReconnect(err error) bool {
5656 if _, ok := errors.AsType[*ProtocolError](err); ok {
5757 return false
5858 }
59- if errors.Is(err, errRenderModeMismatch) {
60- return false
61- }
6259 _, ok := errors.AsType[*DetachedError](err)
6360 return !ok
6461 }
M internal/usecase/client/remote_attach_validation_test.go +26 -0
@@ -1,6 +1,8 @@
11 package client
22
33 import (
4+ "context"
5+ "fmt"
46 "testing"
57
68 "github.com/stretchr/testify/require"
@@ -9,6 +11,30 @@ import (
911 "github.com/bnema/vev/internal/ports"
1012 )
1113
14+type validationCountingDialer struct{ calls int }
15+
16+func (d *validationCountingDialer) Dial(context.Context) (ports.Transport, error) {
17+ d.calls++
18+ return nil, nil
19+}
20+
21+func TestMalformedAttachRequestsNeverDial(t *testing.T) {
22+ tests := []AttachRequest{
23+ {Intent: ports.IntentAttach},
24+ {Intent: ports.IntentAttach, SessionName: "bad name"},
25+ {Intent: ports.IntentAttach, SessionName: "main", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned},
26+ {Intent: ports.IntentEphemeral, NavigationCapabilities: ports.NavigationCapabilityHomePicker},
27+ }
28+ for i, request := range tests {
29+ t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) {
30+ dialer := &validationCountingDialer{}
31+ err := NewRunner(Dependencies{Dialer: dialer}).Run(context.Background(), request)
32+ require.Error(t, err)
33+ require.Zero(t, dialer.calls)
34+ })
35+ }
36+}
37+
1238 func TestValidateAttachRequestRequiresDaemonOwnedEnvironmentForRemoteTarget(t *testing.T) {
1339 var lifecycle domain.SessionLifecycleID
1440 lifecycle[0] = 1
M internal/usecase/client/session_connection.go +42 -17
@@ -4,38 +4,52 @@ import (
44 "errors"
55 "fmt"
66
7+ "github.com/bnema/vev/internal/domain"
78 "github.com/bnema/vev/internal/ports"
89 )
910
1011 var errNilSessionTransport = errors.New("client: nil session transport")
1112
12-func cloneAttachRequest(request AttachRequest) AttachRequest {
13- if request.RemoteTarget != nil {
14- target := *request.RemoteTarget
15- request.RemoteTarget = &target
13+// SessionTarget is the validated daemon-facing session selection. Endpoint
14+// details are resolved before a transport is opened and never enter this type.
15+type SessionTarget struct {
16+ Intent uint8
17+ SessionName string
18+}
19+
20+func (t SessionTarget) validate() error {
21+ switch t.Intent {
22+ case ports.IntentEphemeral:
23+ if t.SessionName == "" {
24+ return nil
25+ }
26+ case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
27+ if t.SessionName == "" {
28+ return domain.ErrInvalidSessionName
29+ }
30+ default:
31+ return fmt.Errorf("invalid session intent %d", t.Intent)
1632 }
17- return request
33+ return domain.ValidateSessionName(t.SessionName)
1834 }
1935
20-// SessionConnection owns one already-open transport and the complete
21-// validated daemon-facing attach request. Local sockets and remote carriages
22-// use this same owner.
36+// SessionConnection owns one already-open transport and its daemon-facing
37+// session target. Local sockets and remote carriages use this same owner.
2338 type SessionConnection struct {
2439 transport ports.Transport
25- request AttachRequest
40+ target SessionTarget
2641 }
2742
2843 // NewSessionConnection creates a connection for an already-selected transport.
29-// Request validation happens before the connection is used for a Hello.
30-func NewSessionConnection(transport ports.Transport, request AttachRequest) (*SessionConnection, error) {
44+// Target validation happens before the connection is used for a Hello.
45+func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) {
3146 if transport == nil {
3247 return nil, errNilSessionTransport
3348 }
34- request = cloneAttachRequest(request)
35- if err := validateAttachRequest(request); err != nil {
36- return nil, fmt.Errorf("invalid attach request: %w", err)
49+ if err := target.validate(); err != nil {
50+ return nil, fmt.Errorf("invalid session target: %w", err)
3751 }
38- return &SessionConnection{transport: transport, request: request}, nil
52+ return &SessionConnection{transport: transport, target: target}, nil
3953 }
4054
4155 // Transport returns the owned carriage for this connection.
@@ -46,12 +60,23 @@ func (c *SessionConnection) Transport() ports.Transport {
4660 return c.transport
4761 }
4862
49-// AttachRequest returns the complete request retained for this connection.
63+// AttachRequest returns the common request shape used after transport
64+// selection. It contains no remote/proxy semantic.
5065 func (c *SessionConnection) AttachRequest() AttachRequest {
5166 if c == nil {
5267 return AttachRequest{}
5368 }
54- return cloneAttachRequest(c.request)
69+ return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName}
70+}
71+
72+// cloneAttachRequest keeps the immutable request independent from transport
73+// ownership while protecting the exact remote target from caller mutation.
74+func cloneAttachRequest(request AttachRequest) AttachRequest {
75+ if request.RemoteTarget != nil {
76+ target := *request.RemoteTarget
77+ request.RemoteTarget = &target
78+ }
79+ return request
5580 }
5681
5782 // Close releases the owned transport.
M internal/usecase/client/session_connection_test.go +16 -47
@@ -3,77 +3,46 @@ package client_test
33 import (
44 "testing"
55
6- "github.com/bnema/vev/internal/domain"
76 "github.com/bnema/vev/internal/ports"
87 portsmocks "github.com/bnema/vev/internal/ports/mocks"
98 "github.com/bnema/vev/internal/usecase/client"
109 "github.com/stretchr/testify/require"
1110 )
1211
13-func TestSessionConnectionOwnsCompleteAttachRequestForLocalAndRemoteTransports(t *testing.T) {
14- var lifecycle domain.SessionLifecycleID
15- lifecycle[0] = 1
16- target := &domain.RemoteSessionTarget{
17- Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
18- SessionName: "work", LiveTabID: "tab-1",
19- }
20- wantTarget := *target
21- request := client.AttachRequest{
22- Intent: ports.IntentAttach,
23- SessionName: "work",
24- RenderMode: ports.RenderModeProxiedContent,
25- Remote: true,
26- RemoteTarget: target,
27- EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
28- }
29- wantRequest := request
30- wantRequest.RemoteTarget = &wantTarget
12+func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) {
13+ target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}
3114 local := portsmocks.NewMockTransport(t)
3215 remote := portsmocks.NewMockTransport(t)
3316
34- localConnection, err := client.NewSessionConnection(local, request)
17+ localConnection, err := client.NewSessionConnection(local, target)
3518 require.NoError(t, err)
36- remoteConnection, err := client.NewSessionConnection(remote, request)
19+ remoteConnection, err := client.NewSessionConnection(remote, target)
3720 require.NoError(t, err)
3821
39- target.LiveTabID = "mutated-tab"
40- target.LifecycleID = domain.SessionLifecycleID{}
41- returned := localConnection.AttachRequest()
42- returned.RemoteTarget.LiveTabID = "mutated-returned-tab"
43-
4422 require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest())
45- require.Equal(t, wantRequest, localConnection.AttachRequest())
23+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, localConnection.AttachRequest())
4624 require.Same(t, local, localConnection.Transport())
4725 require.Same(t, remote, remoteConnection.Transport())
4826 }
4927
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- }
28+func TestSessionTargetValidation(t *testing.T) {
5629 tests := []struct {
5730 name string
58- request client.AttachRequest
31+ target client.SessionTarget
5932 wantErr bool
6033 }{
61- {name: "ephemeral", request: client.AttachRequest{Intent: ports.IntentEphemeral}},
62- {name: "new", request: client.AttachRequest{Intent: ports.IntentNew, SessionName: "work"}},
63- {name: "attach", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
64- {name: "resume", request: client.AttachRequest{Intent: ports.IntentResume, SessionName: "work"}},
65- {name: "missing named session", request: client.AttachRequest{Intent: ports.IntentAttach}, wantErr: true},
66- {name: "unsafe session", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
67- {name: "unknown intent", request: client.AttachRequest{Intent: 99, SessionName: "work"}, wantErr: true},
68- {name: "invalid render mode", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", RenderMode: ports.RenderMode(99)}, wantErr: true},
69- {name: "proxied without remote target", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", RenderMode: ports.RenderModeProxiedContent}, wantErr: true},
70- {name: "remote target with new intent", request: client.AttachRequest{Intent: ports.IntentNew, SessionName: "work", RemoteTarget: remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, wantErr: true},
71- {name: "daemon-owned without remote target", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, wantErr: true},
34+ {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}},
35+ {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}},
36+ {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}},
37+ {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}},
38+ {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true},
39+ {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
40+ {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "work"}, wantErr: true},
7241 }
7342 for _, tt := range tests {
7443 t.Run(tt.name, func(t *testing.T) {
7544 transport := portsmocks.NewMockTransport(t)
76- _, err := client.NewSessionConnection(transport, tt.request)
45+ _, err := client.NewSessionConnection(transport, tt.target)
7746 if tt.wantErr {
7847 require.Error(t, err)
7948 return
@@ -84,6 +53,6 @@ func TestSessionConnectionAttachRequestValidation(t *testing.T) {
8453 }
8554
8655 func TestSessionConnectionRequiresTransport(t *testing.T) {
87- _, err := client.NewSessionConnection(nil, client.AttachRequest{Intent: ports.IntentEphemeral})
56+ _, err := client.NewSessionConnection(nil, client.SessionTarget{Intent: ports.IntentEphemeral})
8857 require.Error(t, err)
8958 }
D internal/usecase/daemon/ansi_content_replay_phase0_test.go +0 -438
@@ -1,438 +0,0 @@
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 +32 -71
@@ -3,11 +3,11 @@ package daemon
33 import "github.com/bnema/vev/internal/ports"
44
55 // attachmentConnectionToken is the exact capability for one registered
6-// attachment connection incarnation. It is invalid as soon as the owner,
7-// attachment membership, connection generation, transport link, or
6+// attachment connection incarnation. It is invalid as soon as any of the
7+// session, attachment membership, connection generation, transport link, or
88 // coordinator lease changes.
99 type attachmentConnectionToken struct {
10- owner attachmentOwner
10+ sess *session
1111 ac *attachedClient
1212 generation uint64
1313 transport transportSnapshot
@@ -18,19 +18,11 @@ type attachmentConnectionToken struct {
1818 rebase bool
1919 }
2020
21-// attachmentToken retains the local-session constructor for the existing
22-// local route. Remote-view token construction is added with remote attachment
23-// membership in Phase 4; it must not manufacture a local-session capability.
2421 func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
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 {
22+ if entry == nil || entry.core() == nil || ac == nil || tr == nil {
3123 return attachmentConnectionToken{}
3224 }
33- // Capture one concrete link incarnation before taking the owner lock. The
25+ // Capture one concrete link incarnation before taking the session lock. The
3426 // same snapshot is revalidated below; do not take a second snapshot and
3527 // accidentally bind a replacement link.
3628 transport := ac.transportSnapshot()
@@ -40,38 +32,23 @@ func attachmentOwnerToken(owner attachmentOwner, ac *attachedClient, tr ports.Tr
4032 if ac.beforeAttachmentTokenValidation != nil {
4133 ac.beforeAttachmentTokenValidation()
4234 }
43-
44- token := attachmentConnectionToken{owner: owner, ac: ac, transport: transport}
45- switch entry := owner.(type) {
46- case *session:
47- if entry.core() == nil {
48- return attachmentConnectionToken{}
49- }
50- core := entry.core()
51- core.mu.Lock()
52- if !attachmentRegisteredLocked(entry, ac) ||
53- !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) ||
54- !ac.transportSnapshotCurrent(transport) {
55- core.mu.Unlock()
56- return attachmentConnectionToken{}
57- }
58- token.generation = ac.connectionGeneration.Load()
59- if rc := core.coordinator.Load(); rc != nil {
60- token.lease = rc.attachmentLease(ac)
61- }
35+ core := entry.core()
36+ core.mu.Lock()
37+ if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) {
6238 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:
7339 return attachmentConnectionToken{}
7440 }
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()
7552 if !token.current() {
7653 return attachmentConnectionToken{}
7754 }
@@ -83,39 +60,27 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
8360 return attachmentToken(s, ac, tr)
8461 }
8562
86-// localSession narrows a token only for explicitly local operations. It is
87-// intentionally nil for a remote owner, so local PTY/persistence paths fail
88-// closed rather than treating remote metadata as a local session.
89-func (t attachmentConnectionToken) localSession() *session {
90- return localSession(t.owner)
91-}
92-
93-// current validates every immutable identity captured by the token. The owner
94-// binding is the sole mutable authority; the local session is only a derived
95-// capability for local attachment membership and coordinator validation.
63+// current validates every immutable identity captured by the token. Membership
64+// is the sole authority; there is no owner, replacement, or compatibility role.
9665 func (t attachmentConnectionToken) current() bool {
97- return t.owner != nil && t.ac != nil &&
66+ return t.sess != nil && t.ac != nil &&
9867 t.ac.connectionGeneration.Load() == t.generation &&
99- sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) &&
68+ t.ac.currentAttachmentSession() == t.sess &&
10069 t.ac.transportSnapshotCurrent(t.transport) &&
101- attachmentOwnerRegistered(t.owner, t.ac)
70+ attachmentRegistered(t.sess, t.ac)
10271 }
10372
10473 // attachmentCurrent additionally validates the optional coordinator lease used
10574 // by render and resize effects. Attachments without that optional lease still
10675 // own their independent connection lifecycle.
10776 func (t attachmentConnectionToken) attachmentCurrent() bool {
108- if !t.current() {
77+ if !t.current() || t.ac.currentAttachmentSession() != t.sess {
10978 return false
11079 }
11180 if t.lease == nil {
11281 return true
11382 }
114- entry := t.localSession()
115- if entry == nil {
116- return false
117- }
118- rc := entry.core().coordinator.Load()
83+ rc := t.sess.core().coordinator.Load()
11984 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
12085 }
12186
@@ -143,27 +108,23 @@ func (t *attachmentConnectionToken) endAttachmentEffect() {
143108 t.effect = nil
144109 }
145110
146-// attachmentEffectCurrentSessionLocked is the same check at a local
147-// session-owned mutation boundary where sess.core().mu is already held.
111+// attachmentEffectCurrentSessionLocked is the same check at a session-owned
112+// mutation boundary where sess.core().mu is already held.
148113 func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {
149- entry := t.localSession()
150- if entry == nil {
151- return false
152- }
153114 if t.effect != nil && !t.effect.ended.Load() {
154115 return true
155116 }
156- if t.ac == nil ||
117+ if t.sess == nil || t.ac == nil ||
157118 t.ac.connectionGeneration.Load() != t.generation ||
158119 !t.ac.transportSnapshotCurrent(t.transport) ||
159- !sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) ||
160- !attachmentRegisteredLocked(entry, t.ac) {
120+ t.ac.currentAttachmentSession() != t.sess ||
121+ !attachmentRegisteredLocked(t.sess, t.ac) {
161122 return false
162123 }
163124 if t.lease == nil {
164125 return true
165126 }
166- rc := entry.core().coordinator.Load()
127+ rc := t.sess.core().coordinator.Load()
167128 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
168129 }
169130
D internal/usecase/daemon/attachment_connection_test.go +0 -35
@@ -1,35 +0,0 @@
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 +7 -15
@@ -18,7 +18,7 @@ const (
1818 // effect admission. It deliberately contains no independently mutable state:
1919 // a ticket is admitted only when every field matches the frame's token.
2020 type attachmentCapability struct {
21- owner attachmentOwner
21+ sess *session
2222 generation uint64
2323 transport transportSnapshot
2424 lease *attachmentLease
@@ -26,7 +26,7 @@ type attachmentCapability struct {
2626
2727 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
2828 return attachmentCapability{
29- owner: token.owner,
29+ sess: token.sess,
3030 generation: token.generation,
3131 transport: token.transport,
3232 lease: token.lease,
@@ -34,7 +34,7 @@ func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
3434 }
3535
3636 func (c attachmentCapability) matches(token attachmentConnectionToken) bool {
37- return token.ac != nil && sameAttachmentOwner(c.owner, token.owner) &&
37+ return token.ac != nil && c.sess == token.sess &&
3838 c.generation == token.generation && c.transport.transport == token.transport.transport &&
3939 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
4040 }
@@ -219,19 +219,11 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken)
219219 // after Welcome has completed so a replacement blocked behind that send can
220220 // publish before readiness or first paint.
221221 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
222- 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 {
222+ if ac == nil || sess == nil || tr == nil {
231223 return attachmentConnectionToken{}, nil, false
232224 }
233225 for {
234- token := attachmentOwnerToken(owner, ac, tr)
226+ token := sess.attachmentToken(ac, tr)
235227 if token.ac == nil {
236228 return token, nil, false
237229 }
@@ -279,13 +271,13 @@ func (ac *attachedClient) publishAttachmentCapability(token attachmentConnection
279271 // bootstrapAttachmentCapability supports direct/headless session construction. It
280272 // never changes an existing or frozen production publication.
281273 func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {
282- if ac == nil || token.owner == nil || token.ac != ac {
274+ if ac == nil || token.sess == nil || token.ac != ac {
283275 return
284276 }
285277 g := &ac.attachmentEffects
286278 g.mu.Lock()
287279 g.initLocked()
288- if g.phase == attachmentEffectsStable && g.capability.owner == nil {
280+ if g.phase == attachmentEffectsStable && g.capability.sess == nil {
289281 g.capability = capabilityFromToken(token)
290282 g.failedTransport = transportSnapshot{}
291283 }
M internal/usecase/daemon/attachment_effect_gate_test.go +38 -0
@@ -667,6 +667,44 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
667667 }
668668 }
669669
670+func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) {
671+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
672+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
673+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
674+ }}
675+ d.mu.Lock()
676+ d.sessions[target.id] = target
677+ d.mu.Unlock()
678+ ac.previousSession.Set(target)
679+
680+ transport := &closeTrackingTransport{}
681+ ac.replaceTransport(transport)
682+ rc := d.attachCoordinator(source, nil, ac, true)
683+ token := source.attachmentToken(ac, transport)
684+ token.lease = rc.attachmentLease(ac)
685+ ac.publishAttachmentCapability(token)
686+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
687+ Version: ports.ProtocolVersion, RequestID: 1, Slug: "back-session", Attached: true,
688+ })
689+ require.NoError(t, err)
690+
691+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
692+ require.Same(t, target, ac.currentAttachmentSession())
693+ frames := transport.Sends()
694+ require.NotEmpty(t, frames)
695+ var result ports.CommandResult
696+ for _, frame := range frames {
697+ if frame.Type != ports.MsgCommandResult {
698+ continue
699+ }
700+ result, err = ports.UnmarshalCommandResult(frame.Payload)
701+ require.NoError(t, err)
702+ break
703+ }
704+ require.True(t, result.OK, result.Text)
705+ require.False(t, transport.Closed(), "local navigation must not tear down the transitioned attachment")
706+}
707+
670708 func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
671709 p1, release1 := newBlockingPTY(t)
672710 p2, release2 := newBlockingPTY(t)
D internal/usecase/daemon/attachment_handshake.go +0 -113
@@ -1,113 +0,0 @@
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-}
D internal/usecase/daemon/attachment_owner.go +0 -281
@@ -1,281 +0,0 @@
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-}
D internal/usecase/daemon/attachment_owner_test.go +0 -564
@@ -1,564 +0,0 @@
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_registry.go +0 -19
@@ -23,13 +23,6 @@ type attachmentView struct {
2323 revision uint64
2424 }
2525
26-// attachmentViewSnapshot is the immutable value returned to callers that need
27-// to inspect attachment-local state without retaining its mutex.
28-type attachmentViewSnapshot struct {
29- attachment *attachedClient
30- view attachmentView
31-}
32-
3326 func (ac *attachedClient) viewSnapshot() attachmentView {
3427 if ac == nil {
3528 return attachmentView{}
@@ -338,18 +331,6 @@ func (s *session) snapshotAttachments() []*attachedClient {
338331 return s.snapshotAttachmentsLocked()
339332 }
340333
341-func (s *session) snapshotAttachmentViews() []attachmentViewSnapshot {
342- attachments := s.snapshotAttachments()
343- if len(attachments) == 0 {
344- return nil
345- }
346- views := make([]attachmentViewSnapshot, 0, len(attachments))
347- for _, ac := range attachments {
348- views = append(views, attachmentViewSnapshot{attachment: ac, view: ac.viewSnapshot()})
349- }
350- return views
351-}
352-
353334 // repairAttachmentViewLocked validates stable targets and chooses the nearest
354335 // surviving target when a tab or pane was removed. Caller holds s.mu.
355336 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
M internal/usecase/daemon/attachment_transition.go +3 -3
@@ -45,7 +45,7 @@ type attachmentTransitionRequest struct {
4545 }
4646
4747 func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {
48- return sameAttachmentOwner(token.owner, source) && token.ac == req.next &&
48+ return token.sess == source && token.ac == req.next &&
4949 token.generation == req.next.connectionGeneration.Load() &&
5050 token.transport.transport == req.expectedTransport.transport &&
5151 token.transport.incarnation == req.expectedTransport.incarnation
@@ -55,9 +55,9 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source
5555 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection
5656 // handoff check for client-originated navigation and lifecycle mutations.
5757 func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
58- if !sameAttachmentOwner(token.owner, source) || token.ac != req.next ||
58+ if token.sess != source || token.ac != req.next ||
5959 token.generation != req.next.connectionGeneration.Load() ||
60- token.localSession() == nil || !sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) ||
60+ token.sess == nil || token.ac.currentAttachmentSession() != source ||
6161 !attachmentRegisteredLocked(source, req.next) ||
6262 !req.next.transportSnapshotCurrent(token.transport) {
6363 return false
M internal/usecase/daemon/attachment_transition_publish.go +8 -9
@@ -29,26 +29,25 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
2929 }
3030
3131 func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {
32- sess := token.localSession()
33- if sess == nil || token.ac == nil {
32+ if token.sess == nil || token.ac == nil {
3433 return false
3534 }
3635 d.mu.Lock()
3736 defer d.mu.Unlock()
38- if d.closing || sess.core() == nil || d.sessions[sess.core().id] != sess {
37+ if d.closing || token.sess.core() == nil || d.sessions[token.sess.core().id] != token.sess {
3938 return false
4039 }
4140 d.notices.routingMu.Lock()
4241 defer d.notices.routingMu.Unlock()
43- sess.core().mu.Lock()
44- defer sess.core().mu.Unlock()
45- coordinator := sess.core().coordinator.Load()
42+ token.sess.core().mu.Lock()
43+ defer token.sess.core().mu.Unlock()
44+ coordinator := token.sess.core().coordinator.Load()
4645 if coordinator != nil {
4746 coordinator.mu.Lock()
4847 defer coordinator.mu.Unlock()
4948 }
50- req := attachmentTransitionRequest{source: sess, next: token.ac, expectedTransport: token.transport}
51- return transitionSourceTokenCurrentLocked(token, sess, coordinator, req)
49+ req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}
50+ return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
5251 }
5352
5453 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
@@ -216,7 +215,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
216215 }
217216 }
218217 result.published = attachmentConnectionToken{
219- owner: req.target, ac: req.next,
218+ sess: req.target, ac: req.next,
220219 generation: publication.nextGeneration,
221220 transport: req.expectedTransport, lease: lease,
222221 rebase: publication.source != req.target,
M internal/usecase/daemon/client.go +61 -175
@@ -41,15 +41,9 @@ type attachedClient struct {
4141 clientID [16]byte
4242 // remoteOrigin is attachment-specific presentation metadata supplied by a
4343 // validated picker handoff; it never participates in routing.
44- 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
44+ remoteOrigin string
45+ navigationCapabilities ports.NavigationCapabilities
46+ startupOverlay ports.StartupOverlay
5347 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
5448 // the linearization gate for every attachment-bound observable operation. A role
5549 // transition freezes and drains this gate before changing either generation
@@ -93,29 +87,18 @@ type attachedClient struct {
9387 // tabs and panes without changing shared session ownership.
9488 viewMu sync.Mutex
9589 view attachmentView
96- owner Guarded[attachmentOwner]
90+ sess Guarded[*session]
9791 mouseScan mouse.Scanner
9892 themeMu sync.Mutex
9993 clientTheme themeui.Theme
10094 appliedTheme appliedTheme
10195 lastCursor cursorOut
10296 renderStages renderStageHooks // optional render and handoff observability hooks
103- // previousOwner is guarded independently. It retains navigation history
104- // through temporary owner hand-offs and is cleared only on terminal teardown.
105- previousOwner Guarded[attachmentOwner]
106- // remoteRecent is attachment-local, display-only MRU state. Remote views do
107- // not enter the local session registry, so these entries must not be usable
108- // by navigation, palette, or persistence paths.
109- remoteRecent Guarded[[]remoteRecentSession]
110- linkMu sync.Mutex
111- sendMu sync.Mutex
112- // remotePaintMu coalesces asynchronous remote-view repaints to one worker
113- // per attachment. The worker always consumes the latest reset request.
114- remotePaintMu sync.Mutex
115- remotePaintView *remoteView
116- remotePaintPending bool
117- remotePaintRunning bool
118- remotePaintReset bool
97+ // previousSession is guarded independently. It is retained through temporary
98+ // setSession(nil) hand-offs and cleared only on terminal teardown.
99+ previousSession Guarded[*session]
100+ linkMu sync.Mutex
101+ sendMu sync.Mutex
119102 // routeCreatedSession marks a session created by this attachment's route.
120103 // A handshake that never commits Welcome must tear down that exact empty
121104 // session, while an attachment routed to an existing session must not.
@@ -228,50 +211,18 @@ func (ac *attachedClient) clearGeometryClaim() {
228211 ac.sizeMu.Unlock()
229212 }
230213
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-}
214+func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }
240215
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.
216+// currentSession narrows the attachment to its owning local session.
247217 func (ac *attachedClient) currentSession() *session {
248218 return ac.currentAttachmentSession()
249219 }
250220
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) }
221+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
271222
272223 func (ac *attachedClient) clearPreviousSession() {
273224 if ac != nil {
274- ac.previousOwner.Set(nil)
225+ ac.previousSession.Set(nil)
275226 }
276227 }
277228
@@ -616,11 +567,12 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
616567 }
617568
618569 type attachClientOptions struct {
619- clientID [16]byte
620- resumeCapable bool
621- maxOutputInFlight uint8
622- remoteOrigin string
623- renderMode ports.RenderMode
570+ clientID [16]byte
571+ resumeCapable bool
572+ maxOutputInFlight uint8
573+ remoteOrigin string
574+ navigationCapabilities ports.NavigationCapabilities
575+ startupOverlay ports.StartupOverlay
624576 }
625577
626578 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -661,15 +613,16 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
661613 }
662614 output := newOutputStateStream(opts.maxOutputInFlight)
663615 ac := &attachedClient{
664- tr: tr,
665- output: output,
666- size: sz,
667- view: attachmentView{windowRows: sz.Rows, windowSet: true},
668- clientID: opts.clientID,
669- remoteOrigin: opts.remoteOrigin,
670- renderMode: opts.renderMode,
671- resumeCapable: opts.resumeCapable,
672- resumeToken: resumeToken,
616+ tr: tr,
617+ output: output,
618+ size: sz,
619+ view: attachmentView{windowRows: sz.Rows, windowSet: true},
620+ clientID: opts.clientID,
621+ remoteOrigin: opts.remoteOrigin,
622+ navigationCapabilities: opts.navigationCapabilities,
623+ startupOverlay: opts.startupOverlay,
624+ resumeCapable: opts.resumeCapable,
625+ resumeToken: resumeToken,
673626 }
674627 output.attachment = ac
675628 ac.initOverlays()
@@ -812,23 +765,10 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
812765 token.ac.captureFrames = nil
813766 token.ac.sendMu.Unlock()
814767 }
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 {
768+ if token.sess == nil {
820769 return false
821770 }
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)
771+ d.firstPaintWithLease(token.sess, token.ac, token.lease, true)
832772 return true
833773 }
834774
@@ -894,31 +834,22 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
894834 d.invalidateRender(sess, ac, true, "client.go")
895835 }
896836
897-func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) bool {
837+func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) {
898838 if !token.attachmentEffectCurrent() {
899- return false
839+ return
900840 }
901841 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- }
910842 token.ac.setClientTheme(clientTheme)
911- sess := token.localSession()
843+ sess := token.sess
912844 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
913- return false
845+ return
914846 }
915847 if !token.attachmentEffectCurrent() {
916- return false
848+ return
917849 }
918- if rc := sess.renderCoordinator(); rc != nil {
919- return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
850+ if rc := token.sess.core().coordinator.Load(); rc != nil {
851+ rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
920852 }
921- return false
922853 }
923854
924855 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
@@ -954,23 +885,6 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
954885 }
955886 ac.sendMu.Unlock()
956887
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-
974888 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {
975889 // The picker preview geometry is attachment-local. A resize must
976890 // invalidate any in-flight remote request and fetch the selected row
@@ -978,7 +892,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
978892 d.registerPreviewForSelection(ac)
979893 }
980894
981- sess := token.localSession()
895+ sess := token.sess
982896 if sess == nil {
983897 return false
984898 }
@@ -1036,21 +950,11 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
1036950 d.notices.routingMu.Unlock()
1037951 return
1038952 }
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)
953+ if token.sess == nil {
1047954 d.notices.routingMu.Unlock()
1048- if repaint && token.attachmentEffectCurrent() {
1049- d.scheduleRemoteViewPaint(view, token.ac, false)
1050- }
1051955 return
1052956 }
1053- repaint := d.mutateClientNotice(sess, token.ac, notice)
957+ repaint := d.mutateClientNotice(token.sess, token.ac, notice)
1054958 d.notices.routingMu.Unlock()
1055959 if repaint && token.attachmentEffectCurrent() {
1056960 d.repaintForNotice(token.ac)
@@ -1077,52 +981,34 @@ func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, not
1077981 }
1078982
1079983 func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {
1080- if notice.Action == ports.ClientNoticeLinkConnected {
1081- return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
1082- }
1083- n, ok := d.clientNoticeNotification(notice)
1084- if !ok {
1085- return false
1086- }
1087- n.SessionID = sess.id
1088- return d.publishToast(ac, d.notices.record(n))
1089-}
1090-
1091-// mutateRemoteClientNotice keeps client-originated feedback local to the
1092-// attachment. A remoteView has no local session scope or notice history to
1093-// mutate; only its visible toast stack is part of the local composition.
1094-func (d *Daemon) mutateRemoteClientNotice(ac *attachedClient, notice ports.ClientNotice) bool {
1095- if ac == nil {
1096- return false
1097- }
1098- if notice.Action == ports.ClientNoticeLinkConnected {
1099- return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, "")
1100- }
1101- n, ok := d.clientNoticeNotification(notice)
1102- if !ok {
1103- return false
1104- }
1105- n.Count = 1
1106- return d.publishToast(ac, n)
1107-}
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) {
1114984 switch notice.Action {
1115985 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
986+ return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
1117987 case ports.ClientNoticeClipboardTooLarge:
1118- return domain.Notification{Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", Time: d.clock.Now()}, true
988+ return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
1119989 case ports.ClientNoticeLinkDegraded:
1120- return domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", Time: d.clock.Now()}, true
990+ return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
991+ case ports.ClientNoticeLinkConnected:
992+ return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
1121993 default:
1122- return domain.Notification{}, false
994+ return false
1123995 }
1124996 }
1125997
998+// recordClientNotice mutates notice history and the selected attachment while
999+// routingMu keeps another connection from changing that selection. Rendering is left
1000+// to the caller after it releases routingMu.
1001+func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
1002+ n := d.notices.record(domain.Notification{
1003+ Code: code,
1004+ Severity: sev,
1005+ Message: message,
1006+ Time: d.clock.Now(),
1007+ SessionID: sess.id,
1008+ })
1009+ return d.publishToast(ac, n)
1010+}
1011+
11261012 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
11271013 // returned value reports whether the synchronous request was accepted.
11281014 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
M internal/usecase/daemon/client_frame_routing.go +54 -48
@@ -13,24 +13,24 @@ const connectionSnapshotAttempts = 8
1313 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
1414 // handoff lands between those reads. The attachment gate still performs the final
1515 // mutation admission after this function returns.
16-func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentOwner, attachmentConnectionToken, bool) {
16+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentConnectionToken, bool) {
1717 for range connectionSnapshotAttempts {
1818 if !ac.currentTransportIs(tr) {
1919 break
2020 }
21- owner := ac.currentAttachmentOwner()
22- if owner == nil {
21+ sess := ac.currentAttachmentSession()
22+ if sess == nil {
2323 return nil, attachmentConnectionToken{}, false
2424 }
25- if sess := localSession(owner); sess != nil && d.afterConnectionSessionSnapshot != nil {
25+ if d.afterConnectionSessionSnapshot != nil {
2626 d.afterConnectionSessionSnapshot(sess)
2727 }
28- token := attachmentOwnerToken(owner, ac, tr)
29- if token.ac == nil || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) {
28+ token := attachmentToken(sess, ac, tr)
29+ if token.ac == nil || ac.currentAttachmentSession() != sess {
3030 runtime.Gosched()
3131 continue
3232 }
33- return owner, token, true
33+ return sess, token, true
3434 }
3535 return nil, attachmentConnectionToken{}, false
3636 }
@@ -44,23 +44,19 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
4444 return
4545 }
4646 for {
47- if !ac.currentTransportIs(tr) || ac.currentAttachmentOwner() == nil {
47+ if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil {
4848 return
4949 }
5050 f, err := tr.Recv()
5151 if err != nil {
5252 for range connectionSnapshotAttempts {
53- owner, token, ok := d.currentAttachmentConnection(ac, tr)
53+ sess, _, ok := d.currentAttachmentConnection(ac, tr)
5454 if !ok {
5555 return
5656 }
57- if sess := localSession(owner); sess != nil {
58- d.clientGone(sess, ac, tr, false)
59- } else if view, remote := owner.(*remoteView); remote {
60- d.clientGoneRemote(view, token, false)
61- }
62- current := ac.currentAttachmentOwner()
63- if sameAttachmentOwner(current, owner) || !ac.currentTransportIs(tr) || current == nil {
57+ d.clientGone(sess, ac, tr, false)
58+ current := ac.currentAttachmentSession()
59+ if current == sess || !ac.currentTransportIs(tr) || current == nil {
6460 return
6561 }
6662 }
@@ -135,11 +131,18 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
135131 return false
136132 }
137133 result := d.executeAttachedCommand(token, request)
138- if err := token.sendControl(frameCommandResult(result)); err != nil {
139- token.endAttachmentEffect()
140- d.detachOnAttachmentSendError(token, token.transport.transport)
134+ resultToken, ok := d.commandResultToken(token)
135+ if !ok {
136+ // A remote handoff or explicit detach deliberately retired the old
137+ // attachment; it must not be treated as a stale-result send error.
138+ return true
139+ }
140+ if err := resultToken.sendControl(frameCommandResult(result)); err != nil {
141+ resultToken.endAttachmentEffect()
142+ d.detachOnAttachmentSendError(resultToken, resultToken.transport.transport)
141143 return true
142144 }
145+ resultToken.endAttachmentEffect()
143146 case ports.MsgPing:
144147 if err := token.sendControl(framePong()); err != nil {
145148 token.endAttachmentEffect()
@@ -153,6 +156,33 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
153156 return false
154157 }
155158
159+// commandResultToken renews the attachment effect after a local navigation
160+// transition ends the command's initiating effect. A retired transport/session
161+// means the command deliberately handed off or detached and has no result to
162+// send on the old carriage.
163+func (d *Daemon) commandResultToken(token attachmentConnectionToken) (attachmentConnectionToken, bool) {
164+ if token.ac == nil || !token.ac.currentTransportIs(token.transport.transport) {
165+ return attachmentConnectionToken{}, false
166+ }
167+ sess := token.ac.currentAttachmentSession()
168+ if sess == nil {
169+ return attachmentConnectionToken{}, false
170+ }
171+ resultToken := attachmentToken(sess, token.ac, token.transport.transport)
172+ if resultToken.ac == nil {
173+ return attachmentConnectionToken{}, false
174+ }
175+ if token.effect != nil && !token.effect.ended.Load() && token.current() {
176+ return token, true
177+ }
178+ ticket, admitted := token.ac.beginAttachmentEffect(resultToken)
179+ if !admitted {
180+ return attachmentConnectionToken{}, false
181+ }
182+ resultToken.effect = ticket
183+ return resultToken, true
184+}
185+
156186 func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {
157187 result := ports.CommandResult{RequestID: request.RequestID}
158188 if request.Version != ports.ProtocolVersion {
@@ -175,17 +205,12 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
175205 result.Text = "attached commands cannot target themselves"
176206 return result
177207 }
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- }
183208 if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {
184209 result.Code = ports.ErrInvalidCommandArgs
185210 result.Text = "attached commands cannot override their active session target"
186211 return result
187212 }
188- sess := token.localSession()
213+ sess := token.sess
189214 if sess == nil || !token.attachmentEffectCurrent() {
190215 result.Code = ports.ErrNoSuchTarget
191216 result.Text = "attached session is no longer active"
@@ -243,11 +268,7 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
243268 ac.pipelineCache = composeCacheInput{}
244269 ac.pipelineScratch = composeCacheInput{}
245270 ac.sendMu.Unlock()
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- }
271+ go d.paint(token.sess, ac, true, token.lease)
251272 return true
252273 }
253274
@@ -257,25 +278,10 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
257278 return false
258279 }
259280 ac.sendMu.Lock()
260- if ac.output == nil {
261- ac.sendMu.Unlock()
262- return false
263- }
264- advanced := ac.output.ack(epoch, state)
281+ ac.output.ack(epoch, state)
265282 ac.sendMu.Unlock()
266- if !advanced {
267- return true
268- }
269- if sess := token.localSession(); sess != nil {
270- if rc := sess.renderCoordinator(); rc != nil {
271- rc.notifyAckForLease(token.lease)
272- }
273- } else if view, remote := token.owner.(*remoteView); remote {
274- // Remote views do not have a session render coordinator to consume the
275- // newly available output-window capacity. Recompose from the retained
276- // private VT so an update previously blocked by the local client window
277- // is not stranded until another remote frame arrives.
278- d.scheduleRemoteViewPaint(view, ac, false)
283+ if rc := token.sess.core().coordinator.Load(); rc != nil {
284+ rc.notifyAckForLease(token.lease)
279285 }
280286 return true
281287 }
M internal/usecase/daemon/client_transport_errors.go +4 -104
@@ -37,10 +37,7 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
3737 }
3838 token.effect.bindActionEnd(d, "detach")
3939 token.effect.End()
40- if view, remote := token.owner.(*remoteView); remote {
41- return d.clientGoneRemote(view, token, explicit)
42- }
43- sess := token.localSession()
40+ sess := token.sess
4441 if sess == nil {
4542 return false
4643 }
@@ -56,94 +53,6 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
5653 return true
5754 }
5855
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-
14756 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
14857 if sess == nil || ac == nil {
14958 return
@@ -174,7 +83,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
17483 // Explicit winners (and non-explicit park failures) must drop any same-
17584 // attachment parking marker left by a raced non-explicit teardown so
17685 // IntentResume waiters are not stranded on a never-published park.
177- d.clearParkingInFlight(ac.resumeToken, ac)
86+ d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
17887 d.closePicker(ac)
17988
18089 d.resetScreenDefaultColors(sess)
@@ -218,16 +127,7 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
218127 if failed == nil || failed != token.transport.transport {
219128 return
220129 }
221- if view, remote := token.owner.(*remoteView); remote {
222- parkingToken := d.markParkingInFlightOwner(view, token.ac)
223- if d.detachRemoteAttachmentCurrentUntil(view, token, done) {
224- d.finishRemoteClientGone(view, token.ac, failed, false)
225- return
226- }
227- d.clearParkingInFlightIfAbandonedOwner(view, token.ac, parkingToken)
228- return
229- }
230- sess := token.localSession()
130+ sess := token.sess
231131 if sess == nil {
232132 return
233133 }
@@ -270,7 +170,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
270170 d.log.Warn("parked client after send error", "session", sess.name)
271171 return
272172 }
273- d.clearParkingInFlight(ac.resumeToken, ac)
173+ d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
274174 d.closePicker(ac)
275175 d.resetScreenDefaultColors(sess)
276176 ac.clearPreviousSession()
M internal/usecase/daemon/clipboard.go +4 -12
@@ -27,14 +27,14 @@ var (
2727 )
2828
2929 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30- if token.localSession() == nil || !token.attachmentEffectCurrent() || token.ac.renderMode == ports.RenderModeProxiedContent {
30+ if !token.attachmentEffectCurrent() {
3131 return
3232 }
3333 d.handleImagePushForAttachment(token, ip)
3434 }
3535
3636 func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37- if token.localSession() == nil || len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
37+ if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
3838 return
3939 }
4040 path, err := d.writeClipboardImageForAttachment(token, ip)
@@ -85,14 +85,11 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
8585 }
8686
8787 func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
88- if token.localSession() == nil || !token.attachmentEffectCurrent() {
89- return "", errAttachmentTransition
90- }
9188 path, err := d.createClipboardImage(ip)
9289 if err != nil {
9390 return "", err
9491 }
95- sess := token.localSession()
92+ sess := token.sess
9693 if sess == nil {
9794 _ = os.Remove(path)
9895 return "", errAttachmentTransition
@@ -138,7 +135,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
138135 }
139136
140137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
141- sess := token.localSession()
138+ sess := token.sess
142139 if sess == nil || !token.attachmentEffectCurrent() {
143140 return
144141 }
@@ -239,11 +236,6 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
239236
240237 items := make([]clipboardForward, 0, len(attachments))
241238 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- }
247239 transport := ac.transport()
248240 token := sess.attachmentToken(ac, transport)
249241 if token.attachmentCurrent() {
M internal/usecase/daemon/coderabbit_regression_test.go +0 -31
@@ -8,7 +8,6 @@ import (
88 "sync"
99 "syscall"
1010 "testing"
11- "time"
1211
1312 "github.com/stretchr/testify/require"
1413
@@ -244,36 +243,6 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
244243 require.Empty(t, d.sessions)
245244 }
246245
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-
277246 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {
278247 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
279248 _, cancel := snapshotStopContext(deadline)
M internal/usecase/daemon/command_tracker.go +0 -22
@@ -135,28 +135,6 @@ func (t *CommandRequestTracker) Fail(requestID, generation uint64, err error) {
135135 t.finish(requestID, generation, CommandRequestOutcome{Err: err})
136136 }
137137
138-// FailGeneration completes every pending request for one retired transport
139-// generation. It releases the tracker lock before publishing outcomes so a
140-// caller can immediately wait or begin work on a replacement generation.
141-func (t *CommandRequestTracker) FailGeneration(generation uint64, err error) {
142- if t == nil {
143- return
144- }
145- t.mu.Lock()
146- pending := make([]commandRequestPending, 0)
147- for requestID, request := range t.pending {
148- if request.generation != generation {
149- continue
150- }
151- delete(t.pending, requestID)
152- pending = append(pending, request)
153- }
154- t.mu.Unlock()
155- for _, request := range pending {
156- request.outcome <- CommandRequestOutcome{Err: err}
157- }
158-}
159-
160138 func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {
161139 if t == nil {
162140 return
M internal/usecase/daemon/daemon.go +130 -191
@@ -45,10 +45,6 @@ const (
4545 // the in-flight send. Teardown is never gated on a client draining its socket.
4646 const detachNotifyTimeout = time.Second
4747
48-// remoteConstructionShutdownGrace bounds a non-cooperative remote dialer when
49-// durable snapshots have not installed a shared shutdown deadline.
50-const remoteConstructionShutdownGrace = ports.HandshakeTimeout
51-
5248 // maxUnackedOutputStates caps how many output states may be in flight (sent but
5349 // not yet acked by the client) before paint defers rather than composing
5450 // another diff. It bounds the daemon's paint rate to the client's ack rate, so
@@ -76,16 +72,6 @@ type Daemon struct {
7672 mu sync.Mutex
7773 sessions map[domain.SessionID]*session
7874 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
8975 // creating reserves names while durable creation I/O runs without mu.
9076 creating map[string]struct{}
9177 nextID uint64
@@ -336,7 +322,7 @@ type Daemon struct {
336322 }
337323
338324 type parkedAttachment struct {
339- owner attachmentOwner
325+ sess *session
340326 ac *attachedClient
341327 pickerGeneration uint64
342328 timer ports.Timer
@@ -349,7 +335,7 @@ type parkedAttachment struct {
349335 // credential. done closes when the token is published into parked or parking
350336 // is abandoned.
351337 type parkingAttachment struct {
352- owner attachmentOwner
338+ sess *session
353339 ac *attachedClient
354340 done chan struct{}
355341 doneOnce sync.Once
@@ -608,31 +594,28 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
608594 }
609595 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
610596 d := &Daemon{
611- sessions: make(map[domain.SessionID]*session),
612- stopped: make(map[string]stoppedSession),
613- remoteViews: make(map[remoteViewID]*remoteView),
614- remoteViewsByKey: make(map[remoteViewKey]remoteViewID),
615- remoteViewConstructions: make(map[remoteViewKey]*remoteViewConstruction),
616- creating: make(map[string]struct{}),
617- parked: make(map[uint64]*parkedAttachment),
618- parking: make(map[uint64]*parkingAttachment),
619- paneProcessCtx: paneProcessCtx,
620- paneProcessCancel: paneProcessCancel,
621- ptys: ptys,
622- clock: clock,
623- log: log,
624- baseEnv: os.Environ(),
625- shell: shell,
626- dirOrHome: dirOrHome,
627- done: make(chan struct{}),
628- restoreDone: make(chan struct{}),
629- animWake: make(chan struct{}, 1),
630- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
631- snapshotAdmitted: make(map[*snapshotCapture]struct{}),
632- snapshotWake: make(chan struct{}, 1),
633- notices: newNoticeCenter(),
634- remoteCatalog: newRemoteCatalogState(),
635- resumeParkGrace: defaultResumeParkGrace,
597+ sessions: make(map[domain.SessionID]*session),
598+ stopped: make(map[string]stoppedSession),
599+ creating: make(map[string]struct{}),
600+ parked: make(map[uint64]*parkedAttachment),
601+ parking: make(map[uint64]*parkingAttachment),
602+ paneProcessCtx: paneProcessCtx,
603+ paneProcessCancel: paneProcessCancel,
604+ ptys: ptys,
605+ clock: clock,
606+ log: log,
607+ baseEnv: os.Environ(),
608+ shell: shell,
609+ dirOrHome: dirOrHome,
610+ done: make(chan struct{}),
611+ restoreDone: make(chan struct{}),
612+ animWake: make(chan struct{}, 1),
613+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
614+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
615+ snapshotWake: make(chan struct{}, 1),
616+ notices: newNoticeCenter(),
617+ remoteCatalog: newRemoteCatalogState(),
618+ resumeParkGrace: defaultResumeParkGrace,
636619 barScripts: &barScriptState{
637620 cfg: barConfigFromDomain(domain.Defaults().Bar),
638621 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -813,93 +796,14 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
813796 d.closing = true
814797 d.purgeAllParkingLocked()
815798 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)
827799 snapshot := d.sessionsSnapshotLocked()
828800 empty := len(snapshot) == 0
829801 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- }
898802 d.finishParkedAttachmentRetirements(parkedRetirements)
899803 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
900804 if empty {
901805 d.doneOnce.Do(func() { close(d.done) })
902- return checkpointIncomplete
806+ return false
903807 }
904808 for _, s := range snapshot {
905809 // Cancellation and PTY closure must not wait behind a teardown owner that
@@ -1231,24 +1135,8 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
12311135 return
12321136 }
12331137
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- }
1138+ sess, ac, rerr := d.routeWithContext(handshakeCtx, h, tr)
12481139 if rerr != nil {
1249- if errors.Is(rerr, errResumeTokenLifecycleRace) {
1250- rerr = &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1251- }
12521140 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
12531141 if pe, ok := errors.AsType[*protoErr](rerr); ok {
12541142 _ = sendHandshake(frameError(pe.code, pe.text))
@@ -1258,7 +1146,90 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
12581146 _ = tr.Close()
12591147 return
12601148 }
1261- d.completeAttachmentHandshake(handshakeCtx, stopHandshakeTransport, finishHandshake, tr, owner, ac)
1149+
1150+ failAttachment := func() {
1151+ d.failHandshakeAttachment(sess, ac, tr)
1152+ }
1153+ if err := handshakeCtx.Err(); err != nil {
1154+ failAttachment()
1155+ return
1156+ }
1157+ rc := sess.renderCoordinator()
1158+ lease := (*attachmentLease)(nil)
1159+ if rc != nil {
1160+ lease = rc.attachmentLease(ac)
1161+ }
1162+ expected := ac.transportSnapshot()
1163+ welcomeToken := sess.attachmentToken(ac, tr)
1164+ welcomeToken.lease = lease
1165+ welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1166+ if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1167+ if admitted {
1168+ welcomeTicket.End()
1169+ }
1170+ if !d.abortResumeClaim(ac) {
1171+ d.clientGone(sess, ac, tr, false)
1172+ }
1173+ return
1174+ }
1175+ if err := handshakeCtx.Err(); err != nil {
1176+ welcomeTicket.End()
1177+ failAttachment()
1178+ return
1179+ }
1180+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1181+ return ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, d.resumeTokenSnapshot(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+ if ac.startupOverlay == ports.StartupOverlaySessionPicker {
1227+ d.enterPicker(sess, ac)
1228+ }
1229+ stopHandshakeTransport()
1230+ finishHandshake()
1231+ d.runConnLoop(ac)
1232+ _ = tr.Close()
12621233 }
12631234
12641235 // protoErr is a session-level rejection carrying a wire ErrorMsg code.
@@ -1298,10 +1269,11 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
12981269 sess.terminal = term
12991270 sess.mu.Unlock()
13001271 opts := attachClientOptions{
1301- clientID: h.ClientID,
1302- resumeCapable: true,
1303- maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1304- renderMode: h.RenderMode,
1272+ clientID: h.ClientID,
1273+ resumeCapable: true,
1274+ maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1275+ navigationCapabilities: h.NavigationCapabilities,
1276+ startupOverlay: h.StartupOverlay,
13051277 }
13061278 if h.RemoteTarget != nil {
13071279 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
@@ -1397,6 +1369,9 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
13971369 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
13981370 }
13991371 term := terminalEnv{TrueColor: h.TrueColor}
1372+ if h.Intent == ports.IntentResume && h.ResumeToken == 0 {
1373+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is required"}
1374+ }
14001375 if h.RemoteTarget != nil && h.ResumeToken == 0 {
14011376 return d.routeRemoteTargetWithContext(ctx, h, tr)
14021377 }
@@ -1420,20 +1395,12 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
14201395 }
14211396 return nil, nil, err
14221397 } else if ok {
1423- if h.RemoteTarget != nil && !d.remoteTargetMatchesSession(sess, *h.RemoteTarget) {
1424- d.clientGone(sess, ac, tr, false)
1425- return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
1426- }
14271398 return sess, ac, nil
14281399 }
14291400 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
14301401 }
14311402 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
14321403 if ok {
1433- if h.RemoteTarget != nil && !d.remoteTargetMatchesSession(sess, *h.RemoteTarget) {
1434- d.clientGone(sess, ac, tr, false)
1435- return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
1436- }
14371404 return sess, ac, nil
14381405 }
14391406 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
@@ -1451,6 +1418,10 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
14511418 return nil, nil, err
14521419 }
14531420 }
1421+ if h.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned && h.RemoteTarget == nil &&
1422+ (h.Intent == ports.IntentNew || h.Intent == ports.IntentEphemeral) {
1423+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "daemon-owned environment requires an exact remote target"}
1424+ }
14541425 d.mu.Lock()
14551426 // Shutdown/create interlock: once shutdown has begun (last session removed,
14561427 // or shutdownAll started) no new session may be created and no attach may
@@ -1461,31 +1432,6 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
14611432 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
14621433 }
14631434 switch h.Intent {
1464- case ports.IntentResume:
1465- // Resume miss/expiry falls back to normal attach semantics.
1466- sess := d.findByNameLocked(h.Name)
1467- created := false
1468- if sess == nil {
1469- stopped, ok := d.stopped[h.Name]
1470- if !ok || stopped.purging {
1471- d.mu.Unlock()
1472- return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
1473- }
1474- cwd := d.dirOrHome(stopped.cwd)
1475- var err error
1476- sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
1477- if err != nil {
1478- d.mu.Unlock()
1479- return nil, nil, err
1480- }
1481- created = true
1482- }
1483- ac, err := d.finishAttach(sess, tr, sz, term, h)
1484- if err == nil && created {
1485- ac.routeCreatedSession = true
1486- }
1487- return sess, ac, err
1488-
14891435 case ports.IntentEphemeral:
14901436 name := d.allocEphemeralNameLocked()
14911437 sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env)
@@ -1493,11 +1439,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
14931439 d.mu.Unlock()
14941440 return nil, nil, err
14951441 }
1496- ac, err := d.finishAttach(sess, tr, sz, term, h)
1497- if err == nil {
1498- ac.routeCreatedSession = true
1499- ac.routeSessionPurge = true
1500- }
1442+ ac, err := d.finishRouteAttach(sess, tr, sz, term, h, true, true)
15011443 return sess, ac, err
15021444
15031445 case ports.IntentNew:
@@ -1518,11 +1460,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
15181460 d.mu.Unlock()
15191461 return nil, nil, err
15201462 }
1521- ac, err := d.finishAttach(sess, tr, sz, term, h)
1522- if err == nil {
1523- ac.routeCreatedSession = true
1524- ac.routeSessionPurge = true
1525- }
1463+ ac, err := d.finishRouteAttach(sess, tr, sz, term, h, true, true)
15261464 return sess, ac, err
15271465
15281466 case ports.IntentAttach:
@@ -1535,18 +1473,19 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
15351473 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
15361474 }
15371475 cwd := d.dirOrHome(stopped.cwd)
1476+ env := h.Env
1477+ if h.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned {
1478+ env = copyEnvironment(d.baseEnv)
1479+ }
15381480 var err error
1539- sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
1481+ sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, env, stopped.tabNames)
15401482 if err != nil {
15411483 d.mu.Unlock()
15421484 return nil, nil, err
15431485 }
15441486 created = true
15451487 }
1546- ac, err := d.finishAttach(sess, tr, sz, term, h)
1547- if err == nil && created {
1548- ac.routeCreatedSession = true
1549- }
1488+ ac, err := d.finishRouteAttach(sess, tr, sz, term, h, created, false)
15501489 return sess, ac, err
15511490
15521491 default:
M internal/usecase/daemon/final_acceptance_test.go +23 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/layout"
89 "github.com/bnema/vev/pkg/vt"
910 "github.com/stretchr/testify/require"
1011 )
@@ -177,3 +178,25 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
177178 require.True(t, peerRegistered, "detaching one attachment removed its peer")
178179 require.Same(t, sess, second.currentSession())
179180 }
181+
182+func TestAcceptanceDirectAttachmentSupportsVerticalSplit(t *testing.T) {
183+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
184+ d.ptys = newFactory(t, newQuietPTY())
185+ rc := d.attachCoordinator(sess, nil, ac, true)
186+ token := sess.attachmentToken(ac, ac.transport())
187+ token.lease = rc.attachmentLease(ac)
188+ ac.publishAttachmentCapability(token)
189+
190+ d.enterPalette(sess, ac)
191+ d.handlePaletteInput(ac, []byte("SPD\r"))
192+
193+ sess.mu.Lock()
194+ tab := sess.tabs[0]
195+ sess.mu.Unlock()
196+ tab.mu.Lock()
197+ defer tab.mu.Unlock()
198+ require.NotNil(t, tab.tree)
199+ require.NotNil(t, tab.tree.Root)
200+ require.Equal(t, layout.Vertical, tab.tree.Root.Dir)
201+ require.Len(t, tab.panes, 2)
202+}
M internal/usecase/daemon/handshake.go +3 -13
@@ -115,13 +115,7 @@ func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fal
115115 // by route. A fresh route-owned session is purged only after its attachment is
116116 // removed; existing sessions and their unrelated attachments are preserved.
117117 func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {
118- 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 {
118+ if sess == nil || ac == nil {
125119 if tr != nil {
126120 _ = tr.Close()
127121 }
@@ -136,15 +130,11 @@ func (d *Daemon) failHandshakeAttachmentOwner(owner attachmentOwner, ac *attache
136130 }
137131 return
138132 }
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- }
133+ d.clientGone(sess, ac, tr, true)
144134 if tr != nil {
145135 _ = tr.Close()
146136 }
147- if sess := localSession(owner); sess != nil && ac.routeCreatedSession {
137+ if ac.routeCreatedSession {
148138 sess.mu.Lock()
149139 empty := len(sess.attachments) == 0
150140 sess.mu.Unlock()
M internal/usecase/daemon/input.go +4 -83
@@ -7,7 +7,6 @@ import (
77 "strconv"
88
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
1110 "github.com/bnema/vev/internal/usecase/keys"
1211 "github.com/bnema/vev/internal/usecase/layout"
1312 "github.com/bnema/vev/internal/usecase/mouse"
@@ -21,90 +20,13 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
2120 d.handleInput(sess, ac, data)
2221 }
2322
24-func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, inputSeq uint64, data []byte) {
23+func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
2524 if !token.attachmentEffectCurrent() {
2625 return
2726 }
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- }
3627 d.handleInputForAttachment(token, data)
3728 }
3829
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-
10830 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
10931 ac.initOverlays()
11032 ac.mouseScan.Scan(data,
@@ -461,7 +383,7 @@ type daemonKeyHandler struct {
461383 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
462384 if h.connectionToken.ac != nil {
463385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
464- return h.connectionToken.localSession(), effect, false
386+ return h.connectionToken.sess, effect, false
465387 }
466388 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
467389 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -475,12 +397,11 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
475397 token.effect = effect
476398 h.d.afterAttachmentEffectAdmitted(token)
477399 }
478- sess := h.connectionToken.localSession()
479- if sess == nil {
400+ if h.connectionToken.sess == nil {
480401 effect.End()
481402 return nil, nil, false
482403 }
483- return sess, effect, true
404+ return h.connectionToken.sess, effect, true
484405 }
485406 sess := h.ac.currentSession()
486407 if sess == nil {
M internal/usecase/daemon/input_test.go +15 -15
@@ -799,8 +799,8 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
799799 prepare func()
800800 }{
801801 {name: "no target", prepare: func() {}},
802- {name: "stale target", prepare: func() { ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
803- {name: "same session", prepare: func() { ac.previousOwner.Set(current) }},
802+ {name: "stale target", prepare: func() { ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
803+ {name: "same session", prepare: func() { ac.previousSession.Set(current) }},
804804 } {
805805 t.Run(tc.name, func(t *testing.T) {
806806 ac.clearPreviousSession()
@@ -808,7 +808,7 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
808808 runPaletteCommand(t, d, current, ac, "BSK")
809809 require.Same(t, current, ac.currentSession())
810810 if tc.name != "no target" {
811- require.Nil(t, ac.previousOwner.Get(), "invalid target must not remain toggleable")
811+ require.Nil(t, ac.previousSession.Get(), "invalid target must not remain toggleable")
812812 }
813813 })
814814 }
@@ -818,7 +818,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
818818 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
819819 defer releaseAll(releases)
820820 target := d.sessions[domain.SessionID("recent")]
821- ac.previousOwner.Set(target)
821+ ac.previousSession.Set(target)
822822
823823 // A displaced attachment makes the previously valid handoff stale by the
824824 // time switchToTarget commits it.
@@ -829,7 +829,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
829829 d.backSession(current, ac)
830830
831831 require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")
832- require.Same(t, target, ac.previousOwner.Get(), "a failed handoff remains retryable")
832+ require.Same(t, target, ac.previousSession.Get(), "a failed handoff remains retryable")
833833 history := d.notices.history()
834834 require.Len(t, history, 1, "the switch failure must be reported exactly once")
835835 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
@@ -839,14 +839,14 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
839839 ac := &attachedClient{}
840840 stale := &session{sessionCore: sessionCore{id: "stale"}}
841841 updated := &session{sessionCore: sessionCore{id: "updated"}}
842- ac.previousOwner.Set(stale)
842+ ac.previousSession.Set(stale)
843843
844844 // Model a completed hand-off between observing a stale target and clearing
845845 // it. The conditional clear must not erase the newer toggle destination.
846- ac.previousOwner.Set(updated)
847- ac.clearPreviousOwnerIf(stale)
846+ ac.previousSession.Set(updated)
847+ ac.clearPreviousSessionIf(stale)
848848
849- require.Same(t, updated, ac.previousOwner.Get())
849+ require.Same(t, updated, ac.previousSession.Get())
850850 }
851851
852852 // backSession's invalid-target fallback is a render producer: it must publish
@@ -861,10 +861,10 @@ func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
861861 }{
862862 {name: "no previous target", prepare: func(*session, *attachedClient) {}},
863863 {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
864- ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}})
864+ ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}})
865865 }},
866866 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
867- ac.previousOwner.Set(current)
867+ ac.previousSession.Set(current)
868868 }},
869869 } {
870870 t.Run(tc.name, func(t *testing.T) {
@@ -940,10 +940,10 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
940940 }
941941 require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
942942 if tc.wantPrev == "" {
943- require.Nil(t, ac.previousOwner.Get())
943+ require.Nil(t, ac.previousSession.Get())
944944 return
945945 }
946- require.Equal(t, domain.SessionID(tc.wantPrev), localSession(ac.previousOwner.Get()).core().id)
946+ require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().core().id)
947947 })
948948 }
949949 }
@@ -963,10 +963,10 @@ func TestTerminalTeardownClearsPreviousSession(t *testing.T) {
963963 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
964964 defer releaseAll(releases)
965965
966- ac.previousOwner.Set(d.sessions[domain.SessionID("recent")])
966+ ac.previousSession.Set(d.sessions[domain.SessionID("recent")])
967967 tc.teardown(d, current, ac)
968968
969- require.Nil(t, ac.previousOwner.Get())
969+ require.Nil(t, ac.previousSession.Get())
970970 })
971971 }
972972 }
M internal/usecase/daemon/move_attachments.go +2 -2
@@ -1,8 +1,8 @@
11 package daemon
22
33 func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {
4- return sameAttachmentOwner(token.owner, sess) && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5- sameAttachmentOwner(token.ac.currentAttachmentOwner(), sess) && token.ac.transportSnapshotCurrent(token.transport) &&
4+ return token.sess == sess && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5+ token.ac.currentAttachmentSession() == sess && token.ac.transportSnapshotCurrent(token.transport) &&
66 attachmentRegisteredLocked(sess, token.ac)
77 }
88
M internal/usecase/daemon/move_picker.go +2 -5
@@ -161,16 +161,13 @@ type pickerRefreshOptions struct {
161161 }
162162
163163 func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
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()
164+ sess := ac.currentSession()
168165 if sess == nil {
169166 return
170167 }
171168 rt := ac.overlays
172169 rt.pickerMu.Lock()
173- if rt.picker == nil || rt.pickerRemoteSelection != nil {
170+ if rt.picker == nil {
174171 rt.pickerMu.Unlock()
175172 return
176173 }
M internal/usecase/daemon/notify.go +0 -30
@@ -370,29 +370,6 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
370370 d.notify(nil, sev, code, msg, cause)
371371 }
372372
373-// notifyAttachment publishes a notice to one exact attachment. Remote views do
374-// not belong to the local-session attachment registry, so a remote selection
375-// failure must not fall back to daemon-global delivery.
376-func (d *Daemon) notifyAttachment(ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
377- if d == nil || ac == nil {
378- return
379- }
380- n := d.notices.record(domain.Notification{
381- Code: code,
382- Severity: sev,
383- Message: msg,
384- Details: noticeDetails(cause),
385- Time: d.clock.Now(),
386- })
387- d.log.Log(d.serveCtx, slogLevelFor(sev), "attachment notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
388- d.notices.routingMu.Lock()
389- published := d.publishToast(ac, n)
390- d.notices.routingMu.Unlock()
391- if published {
392- d.repaintForNotice(ac)
393- }
394-}
395-
396373 // publishToast durably mutates one client's toast state without repainting.
397374 // Routing paths call it while routingMu excludes detach, then release routingMu
398375 // before rendering so a render failure can safely route another notice.
@@ -514,13 +491,6 @@ func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) {
514491 // repaintForNotice asks for an urgent redraw. It must be called with noticeMu
515492 // released: invalidateRender can paint inline, which takes sendMu.
516493 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- }
524494 sess := ac.currentSession()
525495 if sess == nil {
526496 // Mid-handoff or torn down: whoever attaches next repaints anyway, and
M internal/usecase/daemon/output_state_stream.go +3 -4
@@ -250,18 +250,17 @@ func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports
250250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
251251 }
252252
253-func (s *outputStateStream) ack(epoch, state uint64) bool {
253+func (s *outputStateStream) ack(epoch, state uint64) {
254254 if s == nil {
255- return false
255+ return
256256 }
257257 s.lockView()
258258 defer s.unlockView()
259259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
260- return false
260+ return
261261 }
262262 s.acked = state
263263 s.publishOutstanding()
264- return true
265264 }
266265
267266 func (s *outputStateStream) rebase() {
M internal/usecase/daemon/overlay_runtime.go +2 -5
@@ -31,11 +31,8 @@ type overlayRuntime struct {
3131 pickerRemotePreview picker.Preview
3232 pickerRemotePreviewCancel context.CancelFunc
3333 pickerPreviewGeneration uint64
34- // pickerRemoteSelection fences one asynchronous remote picker activation.
35- // It is cleared before the picker accepts another remote activation.
36- pickerRemoteSelection *remotePickerSelection
37- pickerPending []byte
38- pickerESC pendingByteTimer
34+ pickerPending []byte
35+ pickerESC pendingByteTimer
3936
4037 // Test-only, unsynchronized lifecycle seams. Assign them before picker
4138 // publication or goroutine startup. Hooks run without pickerMu or
M internal/usecase/daemon/palette.go +17 -19
@@ -6,6 +6,7 @@ import (
66 "time"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
910 "github.com/bnema/vev/internal/usecase/command"
1011 "github.com/bnema/vev/internal/usecase/layout"
1112 "github.com/bnema/vev/internal/usecase/palette"
@@ -37,16 +38,10 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
3738
3839 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
3940 // Capture daemon/session state before taking paletteMu: lock ordering forbids
40- // holding an overlay lock while inspecting live sessions. A remote view is
41- // not a local session, so its retained local source must remain offered as a
42- // palette result and JRS candidate.
43- current := sess
44- if _, remote := normalizeAttachmentOwner(ac.currentAttachmentOwner()).(*remoteView); remote {
45- current = nil
46- }
47- recent := d.recentSessions(current)
41+ // holding an overlay lock while inspecting live sessions.
42+ recent := d.recentSessions(sess)
4843 commands := d.paletteCommands()
49- results := d.paletteResults(current, commands)
44+ results := d.paletteResults(sess, commands)
5045 ac.overlays.paletteMu.Lock()
5146 ac.overlays.paletteGeneration++
5247 ac.overlays.palette = palette.New(results)
@@ -55,7 +50,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
5550 ac.overlays.paletteFeedback = ""
5651 ac.overlays.palettePending = nil
5752 ac.overlays.paletteMu.Unlock()
58- d.invalidateAttachedOwner(ac, true, "palette.go")
53+ d.invalidateRender(sess, ac, true, "palette.go")
5954 }
6055
6156 // paletteResults captures eligible named sessions before paletteMu so the
@@ -152,7 +147,7 @@ func (d *Daemon) recordPaletteUse(code string) {
152147 }
153148
154149 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
155- entry := ac.navigationSession()
150+ entry := ac.currentAttachmentSession()
156151 if entry == nil {
157152 return
158153 }
@@ -259,12 +254,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
259254 }
260255 ac.overlays.paletteMu.Unlock()
261256 if cancel {
262- d.invalidateAttachedOwner(ac, true, "palette.go")
257+ d.invalidateRender(entry, ac, true, "palette.go")
263258 return
264259 }
265260 if !execute {
266261 if changed {
267- d.invalidateAttachedOwner(ac, true, "palette.go")
262+ d.invalidateRender(entry, ac, true, "palette.go")
268263 }
269264 return
270265 }
@@ -290,11 +285,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
290285 }
291286 if err != nil {
292287 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
293- d.invalidateAttachedOwner(ac, true, "palette.go")
288+ d.invalidateRender(entry, ac, true, "palette.go")
294289 return
295290 }
296291 if ac.closeExecutedPalette(generation, rawQuery) {
297- d.invalidateAttachedOwner(ac, true, "palette.go")
292+ d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")
298293 }
299294 return
300295 }
@@ -303,7 +298,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
303298 rank, err := command.ParsePositiveDecimal(args)
304299 if err != nil {
305300 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
306- d.invalidateAttachedOwner(ac, true, "palette.go")
301+ d.invalidateRender(entry, ac, true, "palette.go")
307302 return
308303 }
309304 exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect}
@@ -312,12 +307,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
312307 return
313308 }
314309 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
315- d.invalidateAttachedOwner(ac, true, "palette.go")
310+ d.invalidateRender(entry, ac, true, "palette.go")
316311 return
317312 }
318313 if ac.closeExecutedPalette(generation, rawQuery) {
319314 d.recordPaletteUse(cmd.Code)
320- d.invalidateAttachedOwner(ac, true, "palette.go")
315+ d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")
321316 }
322317 return
323318 }
@@ -333,7 +328,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
333328 currentToken := current.attachmentToken(ac, ac.transport())
334329 fresh, admitted := ac.beginAttachmentEffect(currentToken)
335330 if ac.closeExecutedPalette(generation, rawQuery) {
336- d.invalidateAttachedOwner(ac, true, "palette.go")
331+ d.invalidateRender(current, ac, true, "palette.go")
337332 }
338333 if admitted {
339334 fresh.End()
@@ -604,6 +599,9 @@ func (e paletteExec) RenameTab() error {
604599 }
605600
606601 func (e paletteExec) OpenSessionPicker() error {
602+ if e.ac != nil && e.ac.navigationCapabilities&ports.NavigationCapabilityHomePicker != 0 && e.effect != nil {
603+ return e.d.sendNavigationActionForAttachment(e.effect.connectionToken(), ports.NavigationOpenHomePicker)
604+ }
607605 e.d.enterPicker(e.sess, e.ac)
608606 return nil
609607 }
M internal/usecase/daemon/palette_test.go +1 -1
@@ -460,7 +460,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
460460 // Establish a valid back-session target, then remove that target immediately
461461 // after the command releases its role admission.
462462 target := d.sessions[domain.SessionID("recent")]
463- ac.previousOwner.Set(target)
463+ ac.previousSession.Set(target)
464464 invalidations := installPaletteInvalidationObserver(current)
465465 d.enterPalette(current, ac)
466466 d.handlePaletteInput(ac, []byte("BSK"))
M internal/usecase/daemon/picker.go +96 -123
@@ -63,8 +63,6 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
6363 rt := ac.overlays
6464 rt.pickerMu.Lock()
6565 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
66- previousSelection := rt.pickerRemoteSelection
67- rt.pickerRemoteSelection = nil
6866 rt.pickerPreviewGeneration++
6967 if rt.pickerRemotePreviewCancel != nil {
7068 rt.pickerRemotePreviewCancel()
@@ -82,12 +80,9 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
8280 rt.pickerPending = nil
8381 rt.pickerESC.stop()
8482 rt.pickerMu.Unlock()
85- if previousSelection != nil {
86- previousSelection.cancel()
87- }
8883 d.teardownPreviewSubscription(ac, previous, previousGeneration)
8984 d.registerPreviewForSelection(ac)
90- d.invalidateAttachedOwner(ac, true, "picker.go")
85+ d.invalidateRender(sess, ac, true, "picker.go")
9186 if rt.beforeRemotePickerRegistration != nil {
9287 rt.beforeRemotePickerRegistration()
9388 }
@@ -252,8 +247,8 @@ func remotePickerReasonText(reason string) string {
252247 return "host unreachable"
253248 case "version_mismatch":
254249 return "version mismatch"
255- case "session_down":
256- return "session down"
250+ case "session_stopped":
251+ return "session stopped"
257252 case "session_broken":
258253 return "session broken"
259254 case "refreshing":
@@ -269,8 +264,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
269264 rt := ac.overlays
270265 var previewGeneration uint64
271266 var instance remotePickerInstance
272- var selection *remotePickerSelection
273- var closed bool
274267 return listInputState{
275268 pending: &rt.pickerPending,
276269 esc: &rt.pickerESC,
@@ -280,12 +273,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
280273 unlock: rt.pickerMu.Unlock,
281274 active: func() bool { return rt.picker != nil },
282275 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
289276 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
290277 rt.picker = nil
291278 rt.pickerIntent = pickerNavigate
@@ -293,20 +280,17 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
293280 previewGeneration = rt.pickerPreviewGeneration
294281 },
295282 afterClose: func() {
296- if selection != nil {
297- selection.cancel()
298- }
299- if closed {
300- d.clearPreviewGeneration(ac, previewGeneration)
301- d.remotePickerClosed(instance)
283+ d.clearPreviewGeneration(ac, previewGeneration)
284+ d.remotePickerClosed(instance)
285+ if sess := ac.currentAttachmentSession(); sess != nil {
286+ d.invalidateRender(sess, ac, true, "picker.go")
302287 }
303- d.invalidateAttachedOwner(ac, true, "picker.go")
304288 },
305289 }
306290 }
307291
308292 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
309- sess := ac.navigationSession()
293+ sess := ac.currentAttachmentSession()
310294 if sess == nil {
311295 return
312296 }
@@ -349,12 +333,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
349333 if result.action == 's' {
350334 d.togglePickerSort()
351335 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
352- d.invalidateAttachedOwner(ac, true, "picker.go")
336+ d.invalidateRender(sess, ac, true, "picker.go")
353337 return
354338 }
355339 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
356340 d.notifyRemotePickerUnavailable(sess, cursor)
357- d.invalidateAttachedOwner(ac, true, "picker.go")
341+ d.invalidateRender(sess, ac, true, "picker.go")
358342 return
359343 }
360344 if result.action == 'x' {
@@ -382,7 +366,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
382366 defer fresh.End()
383367 }
384368 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
385- d.invalidateAttachedOwner(ac, true, "picker.go")
369+ d.invalidateRender(sess, ac, true, "picker.go")
386370 return
387371 }
388372 if result.changed {
@@ -390,13 +374,21 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
390374 }
391375 committing := (result.action == '\r' || result.action == '\n') && ok
392376 if result.exit && !committing {
377+ back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0
378+ if back && len(effects) != 0 && effects[0] != nil {
379+ if err := d.sendNavigationActionForAttachment(effects[0].connectionToken(), ports.NavigationBack); err != nil {
380+ d.reportAttachmentError(sess, err)
381+ d.invalidateRender(sess, ac, true, "picker.go")
382+ return
383+ }
384+ }
393385 d.closePicker(ac)
394386 }
395387 if committing {
396388 if intent == pickerMovePane || intent == pickerMoveTab {
397389 if err := d.movePickerSourceError(source); err != nil {
398390 d.reportAttachmentError(sess, err)
399- d.invalidateAttachedOwner(ac, true, "picker.go")
391+ d.invalidateRender(sess, ac, true, "picker.go")
400392 return
401393 }
402394 if len(effects) != 0 && effects[0] != nil {
@@ -406,36 +398,14 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
406398 err := d.commitMovePickerSelection(intent, source, target)
407399 if err != nil {
408400 d.reportAttachmentError(sess, movePickerUserError(err))
409- d.invalidateAttachedOwner(ac, true, "picker.go")
401+ d.invalidateRender(sess, ac, true, "picker.go")
410402 return
411403 }
412- d.invalidateAttachedOwner(ac, true, "picker.go")
404+ d.invalidateRender(sess, ac, true, "picker.go")
413405 return
414406 }
415407 var err error
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 {
408+ if len(effects) != 0 && effects[0] != nil {
439409 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
440410 } else {
441411 d.closePicker(ac)
@@ -451,7 +421,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
451421 return
452422 }
453423 if result.exit || result.changed {
454- d.invalidateAttachedOwner(ac, true, "picker.go")
424+ d.invalidateRender(sess, ac, true, "picker.go")
455425 }
456426 }
457427
@@ -543,7 +513,7 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ
543513 remoteTarget := *target.RemoteTarget
544514 message := "loading remote preview…"
545515 if remoteTarget.Stopped {
546- message = "down session — preview unavailable"
516+ message = "stopped session — preview unavailable"
547517 }
548518 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)
549519 if remoteTarget.Stopped {
@@ -745,8 +715,6 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
745715 return false
746716 }
747717 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
748- selection := ac.overlays.pickerRemoteSelection
749- ac.overlays.pickerRemoteSelection = nil
750718 ac.overlays.picker = nil
751719 ac.overlays.pickerTitle = ""
752720 ac.overlays.pickerIntent = pickerNavigate
@@ -755,9 +723,6 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
755723 ac.overlays.pickerESC.stop()
756724 previewGeneration := ac.overlays.pickerPreviewGeneration
757725 ac.overlays.pickerMu.Unlock()
758- if selection != nil {
759- selection.cancel()
760- }
761726 d.clearPreviewGeneration(ac, previewGeneration)
762727 if instance.model != nil {
763728 d.remotePickerClosed(instance)
@@ -837,8 +802,7 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
837802 }
838803
839804 func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
840- source := token.localSession()
841- if source == nil || token.ac == nil {
805+ if token.sess == nil || token.ac == nil {
842806 return nil
843807 }
844808 d.mu.Lock()
@@ -848,15 +812,15 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
848812 if !token.attachmentCurrent() {
849813 return nil
850814 }
851- d.invalidateRender(source, token.ac, true, "picker.go")
815+ d.invalidateRender(token.sess, token.ac, true, "picker.go")
852816 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
853817 }
854- if targetSess == source {
818+ if targetSess == token.sess {
855819 return nil
856820 }
857821
858822 transition, err := d.transitionAttachment(attachmentTransitionRequest{
859- source: source, target: targetSess, next: token.ac,
823+ source: token.sess, target: targetSess, next: token.ac,
860824
861825 expectedTransport: token.transport, sourceToken: &token, action: action,
862826 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
@@ -878,7 +842,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
878842 }
879843 }
880844 d.touchMRU(targetSess)
881- token.ac.recordPreviousSession(source)
845+ token.ac.recordPreviousSession(token.sess)
882846 d.deferAttachmentTransitionCleanups(transition)
883847 d.firstPaintForTransition(transition.published)
884848 return nil
@@ -891,83 +855,92 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
891855 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
892856 }
893857
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-
916858 // switchToTargetForAttachment is the sole client-originated navigation entry. The
917859 // intent captures the exact initiating capability before admission is released;
918860 // every active, stopped, and same-session target then uses transitionAttachment
919861 // for frozen, atomic source-token preflight.
920862 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
921- if token.ac == nil {
863+ if token.sess == nil || token.ac == nil || token.effect == nil {
922864 return nil
923865 }
924- if _, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {
925- if target.RemoteTarget != nil {
926- return d.startRemotePickerSelection(token, target, guard, action)
927- }
866+ if target.RemoteTarget != nil {
867+ var key domain.RemoteSessionKey
928868 if target.RemoteKey != nil {
929- return errAttachmentTransition
869+ key = *target.RemoteKey
930870 }
931- previous := token.ac.previousOwner.Get()
932- if !retainedLocalTargetMatches(target, previous) {
871+ return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
872+ }
873+ if target.RemoteKey != nil {
874+ return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
875+ }
876+ token.effect.bindActionEnd(d, action)
877+ token.effect.End()
878+ if token.sess == nil {
879+ return errAttachmentTransition
880+ }
881+ return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
882+}
883+
884+// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
885+// endpoint to the thin client. The daemon owns no remote session shadow: after
886+// the target is sent, the local attachment is detached and the client opens a
887+// fresh connection to the owning daemon.
888+func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
889+ var handoff ports.AttachTarget
890+ if target.RemoteTarget != nil {
891+ remoteTarget := *target.RemoteTarget
892+ if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
933893 return errAttachmentTransition
934894 }
935- transitioned := false
936- if err := d.backSessionForAttachment(token, func() {
937- transitioned = true
938- if guard.closePicker {
939- d.closePicker(token.ac)
895+ if target.RemoteKey != nil {
896+ if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
897+ return errAttachmentTransition
940898 }
941- }); err != nil {
942- return err
943899 }
944- if !transitioned {
900+ handoff = ports.AttachTarget{
901+ Endpoint: remoteTarget.Endpoint,
902+ Session: remoteTarget.SessionName,
903+ Intent: ports.IntentAttach,
904+ RemoteTarget: &remoteTarget,
905+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
906+ }
907+ } else {
908+ if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
945909 return errAttachmentTransition
946910 }
947- return nil
948- }
949- if token.localSession() == nil {
950- return nil
911+ handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
951912 }
952- if token.effect == nil {
913+ payload := ports.MarshalAttachTarget(handoff)
914+ if payload == nil {
953915 return errAttachmentTransition
954916 }
955- if target.RemoteTarget != nil {
956- return d.startRemotePickerSelection(token, target, guard, action)
917+ if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
918+ return err
957919 }
958- if target.RemoteKey != nil {
959- // Picker-originated remote activation requires the complete validated
960- // lifecycle/tab target. Legacy display keys must never fall back to a
961- // client-owned attach handoff.
962- return errAttachmentTransition
920+ if guard.closePicker {
921+ d.closePicker(token.ac)
963922 }
964- token.effect.bindActionEnd(d, action)
965- token.effect.End()
966- source := token.localSession()
967- if source == nil {
968- return errAttachmentTransition
923+ d.clientGoneForAttachment(token, false)
924+ return nil
925+}
926+
927+func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
928+ if d == nil {
929+ return false
969930 }
970- return d.switchToTargetGuardedForAttachment(source, token.ac, target, guard, &token, action)
931+ d.remoteCatalog.mu.Lock()
932+ defer d.remoteCatalog.mu.Unlock()
933+ for _, entry := range d.remoteCatalog.cache {
934+ if entry.Host != key.Host {
935+ continue
936+ }
937+ for _, session := range entry.Sessions {
938+ if session.Name == key.Name {
939+ return d.remoteCatalog.status[key.Host] != remoteHostVersionMismatch
940+ }
941+ }
942+ }
943+ return false
971944 }
972945
973946 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
@@ -993,7 +966,7 @@ func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget
993966 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
994967 continue
995968 }
996- if target.Stopped != (session.State == "down") || session.State == "broken" {
969+ if target.Stopped != remoteSessionStateStopped(session.State) || session.State == "broken" {
997970 return false
998971 }
999972 tabs := ports.CatalogTabs(session)
M internal/usecase/daemon/picker_test.go +31 -1
@@ -878,6 +878,36 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
878878 }
879879 }
880880
881+func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) {
882+ d, sess, ac, _, releases := newManualTabSession(t, 1)
883+ defer releases[0]()
884+ const sendErr = "navigation back send failed"
885+ tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}
886+ ac.replaceTransport(tr)
887+ ac.startupOverlay = ports.StartupOverlaySessionPicker
888+ ac.navigationCapabilities = ports.NavigationCapabilityBack
889+ ac.overlays.pickerMu.Lock()
890+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
891+ ac.overlays.pickerGeneration++
892+ ac.overlays.pickerMu.Unlock()
893+
894+ rc := d.attachCoordinator(sess, nil, ac, true)
895+ token := sess.attachmentToken(ac, tr)
896+ token.lease = rc.attachmentLease(ac)
897+ ac.publishAttachmentCapability(token)
898+ effect, admitted := ac.beginAttachmentEffect(token)
899+ require.True(t, admitted)
900+ defer effect.End()
901+ token.effect = effect
902+
903+ d.handlePickerInput(ac, []byte("\r"), effect)
904+ require.True(t, ac.overlays.pickerActive(), "failed navigation send must leave the picker open")
905+ require.Same(t, sess, ac.currentAttachmentSession())
906+ history := d.notices.history()
907+ require.NotEmpty(t, history)
908+ require.Contains(t, history[len(history)-1].Details, sendErr)
909+}
910+
881911 func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
882912 d, sess, ac, sends, releases := newManualTabSession(t, 1)
883913 defer releases[0]()
@@ -920,7 +950,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
920950 targetPane.screen.Write([]byte("TARGET"))
921951 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
922952 require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
923- ac.previousOwner.Set(target)
953+ ac.previousSession.Set(target)
924954
925955 // Exercise the user-facing previous-session route, which delegates to the
926956 // real switchToTarget hand-off and immediately emits its required reset.
D internal/usecase/daemon/proxied_handshake_test.go +0 -251
@@ -1,251 +0,0 @@
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-}
D internal/usecase/daemon/proxied_render.go +0 -151
@@ -1,151 +0,0 @@
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 +0 -98
@@ -15,26 +15,6 @@ type recentSession struct {
1515 mruAt uint64
1616 }
1717
18-// remoteRecentSession retains display-only recency for a remote view. It
19-// stores value identity rather than a view pointer so a retained warm view
20-// cannot gain navigation authority through the local status bar.
21-type remoteRecentSession struct {
22- key remoteViewKey
23- displayOrigin string
24- mruAt uint64
25-}
26-
27-func (r remoteRecentSession) name() string {
28- origin := r.displayOrigin
29- if origin == "" {
30- origin = r.key.endpoint
31- }
32- if origin == "" {
33- return r.key.sessionName
34- }
35- return r.key.sessionName + "@" + origin
36-}
37-
3818 // recentSessions returns the current session's capped named-session MRU list.
3919 // Callers may retain the returned values for the lifetime of an interaction.
4020 func (d *Daemon) recentSessions(current *session) []recentSession {
@@ -55,36 +35,6 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
5535 recent = append(recent, entry)
5636 }
5737 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 {
8838 sort.SliceStable(recent, func(i, j int) bool {
8939 if recent[i].mruAt == recent[j].mruAt {
9040 return recent[i].name < recent[j].name
@@ -96,51 +46,3 @@ func trimRecentSessions(recent []recentSession) []recentSession {
9646 }
9747 return recent
9848 }
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.go +31 -14
@@ -48,7 +48,7 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
4848 d.mu.Unlock()
4949 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
5050 }
51- ac, err := d.finishAttach(live, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h)
51+ ac, err := d.finishRouteAttach(live, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h, false, false)
5252 return live, ac, err
5353 }
5454
@@ -76,25 +76,42 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
7676 d.mu.Unlock()
7777 return nil, nil, err
7878 }
79- ac, err := d.finishAttach(sess, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h)
80- if err != nil {
81- // The route-created session has no published client if finishAttach
82- // fails. Retire it immediately rather than leaving a shadow live row.
83- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
84- return nil, nil, err
79+ ac, err := d.finishRouteAttach(sess, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h, true, false)
80+ return sess, ac, err
81+}
82+
83+func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
84+ payload := ports.MarshalNavigationAction(action)
85+ if payload == nil {
86+ return errAttachmentTransition
8587 }
86- ac.routeCreatedSession = true
87- return sess, ac, nil
88+ return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload})
8889 }
8990
90-func (d *Daemon) remoteTargetMatchesSession(sess *session, target domain.RemoteSessionTarget) bool {
91- if sess == nil {
91+func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
92+ ac, err := d.finishAttach(sess, tr, sz, term, h)
93+ if err != nil && routeCreated {
94+ if cleanupErr := d.killSession(sess, ports.ReasonSessionKilled, purge); cleanupErr != nil {
95+ err = errors.Join(err, cleanupErr)
96+ }
97+ return nil, err
98+ }
99+ if err == nil && routeCreated && ac != nil {
100+ ac.routeCreatedSession = true
101+ ac.routeSessionPurge = purge
102+ }
103+ return ac, err
104+}
105+
106+// remoteTargetMatchesSessionLocked validates the exact lifecycle and tab
107+// selector while the daemon registry lock is held. Callers use this before
108+// changing resume ownership so a stale target cannot partially claim a session.
109+func (d *Daemon) remoteTargetMatchesSessionLocked(sess *session, target domain.RemoteSessionTarget) bool {
110+ if sess == nil || d.sessions[sess.id] != sess {
92111 return false
93112 }
94- d.mu.Lock()
95- defer d.mu.Unlock()
96113 sess.mu.Lock()
97- matches := d.sessions[sess.id] == sess && sess.incarnation == target.LifecycleID && sess.name == target.SessionName
114+ matches := sess.incarnation == target.LifecycleID && sess.name == target.SessionName
98115 sess.mu.Unlock()
99116 if !matches {
100117 return false
M internal/usecase/daemon/remote_attach_test.go +51 -79
@@ -17,84 +17,56 @@ func remoteLifecycleForTest() domain.SessionLifecycleID {
1717 return id
1818 }
1919
20-func TestRemotePickerSelectionRejectsReplacedLifecycle(t *testing.T) {
21- tests := []struct {
22- name string
23- run func(*testing.T)
24- }{
25- {
26- name: "replaced lifecycle",
27- run: func(t *testing.T) {
28- d := newRemotePickerDaemon(nil)
29- lifecycle := remoteLifecycleForTest()
30- cachedLifecycle := lifecycle
31- cachedLifecycle[0]++
32- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
33- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
34- LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
35- }},
36- }})
37- d.remoteCatalog.mu.Lock()
38- d.remoteCatalog.status["arch"] = remoteHostFresh
39- d.remoteCatalog.mu.Unlock()
40- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
41- ac.overlays.pickerMu.Lock()
42- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
43- ac.overlays.pickerGeneration++
44- ac.overlays.pickerMu.Unlock()
45- token := sess.attachmentToken(ac, ac.transport())
46- effect, admitted := ac.beginAttachmentEffect(token)
47- require.True(t, admitted)
48- token.effect = effect
49- defer effect.End()
50- key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
51- remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
52- target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
53-
54- require.ErrorIs(t, d.switchToTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select"), errAttachmentTransition)
55- require.Same(t, sess, ac.currentAttachmentSession())
56- require.True(t, ac.overlays.pickerActive())
57- select {
58- case frame := <-sends:
59- require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
60- default:
61- }
62- },
63- },
64- {
65- name: "replaced picker instance",
66- run: func(t *testing.T) {
67- d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
68- view := &remoteView{key: remoteViewKey{endpoint: "remote.example", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
69- transport := newRemoteLinkTestTransport()
70- link := &remoteLink{view: view, generation: 1, transport: transport, active: true, commands: NewCommandRequestTracker()}
71- view.link = link
72- view.linkGeneration = 1
73- d.mu.Lock()
74- require.NoError(t, d.registerRemoteViewLocked(view))
75- d.mu.Unlock()
76- model := picker.New(nil, picker.SelectionConfig{})
77- ac.overlays.pickerMu.Lock()
78- ac.overlays.picker = model
79- ac.overlays.pickerGeneration++
80- generation := ac.overlays.pickerGeneration
81- ac.overlays.pickerMu.Unlock()
82- selection := &remotePickerSelection{model: model, generation: generation, token: source.attachmentToken(ac, ac.transport())}
83- ac.overlays.pickerMu.Lock()
84- ac.overlays.pickerRemoteSelection = selection
85- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
86- ac.overlays.pickerMu.Unlock()
20+func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
21+ d := newRemotePickerDaemon(nil)
22+ lifecycle := remoteLifecycleForTest()
23+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
24+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
25+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
26+ }},
27+ }})
28+ d.remoteCatalog.mu.Lock()
29+ d.remoteCatalog.status["arch"] = remoteHostFresh
30+ d.remoteCatalog.mu.Unlock()
31+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
32+ token := sess.attachmentToken(ac, ac.transport())
33+ effect, admitted := ac.beginAttachmentEffect(token)
34+ require.True(t, admitted)
35+ token.effect = effect
36+ defer effect.End()
37+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
38+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
39+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
40+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
41+ frame := receiveRemotePicker(t, sends, "rich attach target")
42+ got, err := ports.UnmarshalAttachTarget(frame.Payload)
43+ require.NoError(t, err)
44+ require.NotNil(t, got.RemoteTarget)
45+ require.Equal(t, remoteTarget, *got.RemoteTarget)
46+ require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy)
47+}
8748
88- _, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)
89- require.ErrorIs(t, err, errAttachmentTransition)
90- require.Same(t, source, ac.currentAttachmentSession())
91- require.True(t, source.attachmentRegistered(ac))
92- require.False(t, view.attachmentRegistered(ac))
93- require.NoError(t, transport.Close())
94- },
95- },
96- }
97- for _, tt := range tests {
98- t.Run(tt.name, tt.run)
99- }
49+func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
50+ d := newRemotePickerDaemon(nil)
51+ lifecycle := remoteLifecycleForTest()
52+ cachedLifecycle := lifecycle
53+ cachedLifecycle[0]++
54+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
55+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
56+ LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
57+ }},
58+ }})
59+ d.remoteCatalog.mu.Lock()
60+ d.remoteCatalog.status["arch"] = remoteHostFresh
61+ d.remoteCatalog.mu.Unlock()
62+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
63+ token := sess.attachmentToken(ac, ac.transport())
64+ effect, admitted := ac.beginAttachmentEffect(token)
65+ require.True(t, admitted)
66+ token.effect = effect
67+ defer effect.End()
68+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
69+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
70+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
71+ require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
10072 }
D internal/usecase/daemon/remote_attachment_geometry_test.go +0 -110
@@ -1,110 +0,0 @@
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-}
D internal/usecase/daemon/remote_attachment_transition.go +0 -300
@@ -1,300 +0,0 @@
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-}
D internal/usecase/daemon/remote_attachment_transition_back_test.go +0 -149
@@ -1,149 +0,0 @@
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-}
D internal/usecase/daemon/remote_attachment_transition_picker_test.go +0 -63
@@ -1,63 +0,0 @@
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-}
D internal/usecase/daemon/remote_frame_routing_test.go +0 -360
@@ -1,360 +0,0 @@
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-}
D internal/usecase/daemon/remote_link.go +0 -993
@@ -1,993 +0,0 @@
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-}
D internal/usecase/daemon/remote_link_test.go +0 -1131
@@ -1,1131 +0,0 @@
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 +21 -18
@@ -129,11 +129,15 @@ func remoteSessionOrigin(host string) string {
129129 return host
130130 }
131131
132+func remoteSessionStateStopped(state string) bool {
133+ return state == "down" || state == "stopped"
134+}
135+
132136 func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
133137 key.LifecycleID = session.LifecycleID
134138 key.DisplayOrigin = remoteSessionOrigin(key.Host)
135139 availability := remotePickerAvailability(status)
136- stopped := session.State == "down"
140+ stopped := remoteSessionStateStopped(session.State)
137141 broken := session.State == "broken"
138142 tabs := ports.CatalogTabs(session)
139143 viewTabs := make([]picker.TabEntry, 0, len(tabs))
@@ -178,7 +182,8 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
178182 }
179183 // Keep the structured session identity on rows even when one tab ID is
180184 // malformed or a broken session cannot be activated. Cursor navigation
181- // remains possible for diagnostics, but activation fails closed.
185+ // must remain possible, while sendRemoteAttachTargetForAttachment will
186+ // fail closed on the invalid target.
182187 remoteTarget = &target
183188 }
184189 reason := ""
@@ -193,21 +198,24 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
193198 }
194199 ready := status == remoteHostFresh && targetValid && !broken
195200 if legacy {
196- // Proxied picker activation requires exact lifecycle and stable-tab
197- // identity. A count-only cached row cannot fall back to client-owned
198- // direct attachment under the v25 protocol contract.
199- reason = "identity_changed"
201+ // Count-only peers cannot prove lifecycle/tab identity. Preserve the
202+ // historical row for compatibility, but make it read-only until a fresh
203+ // catalog response authorizes a name attach.
204+ ready = status == remoteHostFresh && !broken && !stopped
205+ if status == remoteHostVersionMismatch {
206+ reason = remoteReasonForStatus(status)
207+ }
200208 }
201209 detail := remotePickerStatusDetail(status, fetchedAt)
202210 if detail == "" {
203211 switch session.State {
204212 case "up":
205- detail = "up"
213+ detail = "running"
206214 if session.Attached {
207215 detail += " · attached"
208216 }
209- case "down":
210- detail = "down"
217+ case "down", "stopped":
218+ detail = "stopped"
211219 case "broken":
212220 detail = "broken"
213221 }
@@ -454,12 +462,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
454462 if cacheChanged {
455463 d.persistRemoteCatalogCache()
456464 }
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 {
461- d.refreshRemoteOpenPickers()
462- }
465+ d.refreshRemoteOpenPickers()
463466
464467 for _, host := range hosts {
465468 d.sessWg.Go(func() {
@@ -570,8 +573,8 @@ func (d *Daemon) refreshRemoteOpenPickers() {
570573 continue
571574 }
572575 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
573- // The picker is attachment-local: catalog changes repaint the actual
574- // owner, which can be either the retained local session or a remote view.
575- d.invalidateAttachedOwner(ac, true, "remote_picker.go")
576+ if entry := ac.currentAttachmentSession(); entry != nil {
577+ d.invalidateRender(entry, ac, true, "remote_picker.go")
578+ }
576579 }
577580 }
D internal/usecase/daemon/remote_picker_selection.go +0 -154
@@ -1,154 +0,0 @@
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 +125 -564
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "io"
88 "log/slog"
9- "strings"
109 "sync"
1110 "testing"
1211 "time"
@@ -15,7 +14,6 @@ import (
1514
1615 "github.com/bnema/vev/internal/domain"
1716 "github.com/bnema/vev/internal/ports"
18- "github.com/bnema/vev/internal/usecase/keys"
1917 "github.com/bnema/vev/internal/usecase/picker"
2018 )
2119
@@ -57,9 +55,30 @@ func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
5755 return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
5856 }
5957
58+func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) {
59+ lifecycle := remoteLifecycleForTest()
60+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle}
61+ session := ports.RemoteCatalogSession{
62+ LifecycleID: lifecycle, Name: "work", State: "down",
63+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
64+ }
65+ live := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0))
66+ require.True(t, live.Stopped)
67+ require.NotNil(t, live.RemoteTarget)
68+ require.True(t, live.RemoteTarget.Stopped)
69+ require.True(t, live.RemoteAttachReady, "structured stopped rows have an explicit safe restore target")
70+
71+ legacy := session
72+ legacy.LifecycleID = domain.SessionLifecycleID{}
73+ legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0))
74+ require.True(t, legacyView.Stopped)
75+ require.True(t, legacyView.ConnectOnly)
76+ require.False(t, legacyView.RemoteAttachReady, "legacy stopped rows cannot be selected without lifecycle identity")
77+}
78+
6079 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
6180 now := time.Unix(100, 0)
62- clock := &remotePreviewTestClock{now: now}
81+ clock := &remotePickerClock{now: now}
6382 d := newTestDaemon(t, nil, clock)
6483 lifecycle := remoteLifecycleForTest()
6584 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
@@ -80,7 +99,7 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
8099
81100 func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) {
82101 now := time.Unix(100, 0)
83- clock := &remotePreviewTestClock{now: now}
102+ clock := &remotePickerClock{now: now}
84103 d := newTestDaemon(t, nil, clock)
85104 lifecycle := remoteLifecycleForTest()
86105 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
@@ -205,30 +224,6 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
205224 }
206225 }
207226
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-
232227 func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
233228 d := newRemotePickerDaemon(nil)
234229 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
@@ -381,6 +376,12 @@ type fixedRemoteRefreshClock struct{ now time.Time }
381376 func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
382377 func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
383378
379+type remotePickerClock struct{ now time.Time }
380+
381+func (c *remotePickerClock) Now() time.Time { return c.now }
382+func (*remotePickerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
383+func (c *remotePickerClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
384+
384385 func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {
385386 t.Helper()
386387 if catalog != nil {
@@ -416,8 +417,12 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
416417 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
417418 ac.overlays.pickerMu.Unlock()
418419 require.True(t, d.registerRemotePicker(instance))
420+ lifecycle := remoteLifecycleForTest()
419421 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
420- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "cached", State: "up"}},
422+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
423+ LifecycleID: lifecycle, Name: "cached", State: "up",
424+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
425+ }},
421426 }})
422427
423428 firstGeneration := d.startRemotePickerRefresh(instance)
@@ -430,9 +435,17 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
430435 require.Contains(t, first, "mule")
431436 requireNoRemoteLockViolations(t, catalog, nil)
432437 d.remoteCatalog.mu.Lock()
433- require.Equal(t, []ports.RemoteCatalogSession{{Name: "cached", State: "up"}}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
438+ require.Equal(t, []ports.RemoteCatalogSession{{
439+ LifecycleID: lifecycle, Name: "cached", State: "up",
440+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
441+ }}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
434442 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
435443 d.remoteCatalog.mu.Unlock()
444+ ac.overlays.pickerMu.Lock()
445+ refreshedModel := ac.overlays.picker
446+ ac.overlays.pickerMu.Unlock()
447+ _, selectable := refreshedModel.Selected()
448+ require.False(t, selectable, "refreshing status must gate picker activation")
436449
437450 secondGeneration := d.startRemotePickerRefresh(instance)
438451 require.Greater(t, secondGeneration, firstGeneration)
@@ -455,55 +468,6 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
455468 d.remoteCatalog.mu.Unlock()
456469 }
457470
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-
507471 func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
508472 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
509473 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
@@ -635,503 +599,105 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
635599 d.remoteCatalog.mu.Unlock()
636600 }
637601
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) {
602+func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
687603 d := newRemotePickerDaemon(nil)
688- target := remoteLinkTestTarget()
689- require.NoError(t, target.Validate())
690604 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
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- }},
605+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
694606 }})
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- }}
708607 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-
715608 token := sess.attachmentToken(ac, ac.transport())
716609 effect, admitted := ac.beginAttachmentEffect(token)
717610 require.True(t, admitted)
718611 token.effect = effect
719- pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
720- require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
721- effect.End()
722-
723- receiveRemotePickerClose(t, remoteDialStarted, "remote picker dial")
724- require.Same(t, sess, ac.currentAttachmentSession(), "the source remains attached while the remote handshake is pending")
725- require.True(t, ac.overlays.pickerActive(), "the local picker stays available until publication")
726- require.Never(t, func() bool {
727- select {
728- case frame := <-sends:
729- return frame.Type == ports.MsgAttachTarget
730- default:
731- return false
732- }
733- }, 100*time.Millisecond, 5*time.Millisecond, "picker selection must not hand the client to the remote daemon")
734-
735- enqueueRemoteLinkHandshake(t, remoteTransport, target, ac.sizeSnapshot())
736- close(remoteDialRelease)
737- require.Eventually(t, func() bool {
738- _, remote := ac.currentAttachmentOwner().(*remoteView)
739- return remote
740- }, time.Second, 5*time.Millisecond, "the ready remote surface is atomically published to the same client")
741- require.False(t, ac.overlays.pickerActive())
742- require.Same(t, sess, ac.previousOwner.Get(), "return navigation retains the local source owner")
743- view, ok := ac.currentAttachmentOwner().(*remoteView)
744- require.True(t, ok)
745- remoteToken := attachmentOwnerToken(view, ac, ac.transport())
746- require.True(t, remoteToken.current())
747- d.handleAttachmentClientFrame(remoteToken, frameInput([]byte{keys.ESC, ' '}))
748- require.True(t, ac.overlays.paletteActive(), "the same local connection can reopen its local palette")
749- d.handleAttachmentClientFrame(remoteToken, frameInput([]byte("SSP\r")))
750- ac.overlays.pickerMu.Lock()
751- model := ac.overlays.picker.Clone()
752- ac.overlays.pickerMu.Unlock()
753- require.NotNil(t, model)
754- frame := model.Render(domain.Size{Cols: 80, Rows: 20}, picker.Preview{})
755- var rendered strings.Builder
756- for y := range frame.Height {
757- rendered.WriteString(rowText(frame.Row(y)))
758- }
759- require.Contains(t, rendered.String(), sess.name)
760- require.Contains(t, rendered.String(), target.SessionName, "the reopened picker retains local and remote rows")
612+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
613+ target := picker.Target{Session: key.ID(), RemoteKey: &key}
614+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
761615
616+ frame := receiveRemotePicker(t, sends, "attach target")
617+ require.Equal(t, ports.MsgAttachTarget, frame.Type)
618+ got, err := ports.UnmarshalAttachTarget(frame.Payload)
619+ require.NoError(t, err)
620+ require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, got)
621+ require.Nil(t, ac.currentAttachmentSession())
762622 d.mu.Lock()
763- require.Len(t, d.remoteViews, 1)
623+ require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow")
764624 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)
774-}
775-
776-func TestRemotePickerSelectionTransitionsBetweenRemoteViewsAndSharesAttachmentMRU(t *testing.T) {
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"
783- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
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- },
788- }})
789- d.remoteCatalog.mu.Lock()
790- d.remoteCatalog.status[first.Endpoint] = remoteHostFresh
791- d.remoteCatalog.mu.Unlock()
792-
793- firstTransport := newRemoteLinkTestTransport()
794- secondTransport := newRemoteLinkTestTransport()
795- d.remoteDialerFactory = &remoteLinkSequenceFactory{dialers: []ports.Dialer{
796- &remoteLinkTestDialer{transport: firstTransport},
797- &remoteLinkTestDialer{transport: secondTransport},
798- }}
799- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
800- openPicker := func() {
801- ac.overlays.pickerMu.Lock()
802- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
803- ac.overlays.pickerGeneration++
804- ac.overlays.pickerMu.Unlock()
805- }
806- selectTarget := func(token attachmentConnectionToken, target domain.RemoteSessionTarget) {
807- effect, admitted := ac.beginAttachmentEffect(token)
808- require.True(t, admitted)
809- token.effect = effect
810- key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
811- require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &target}, sessionHandoffGuard{closePicker: true}, "picker-select"))
812- effect.End()
813- }
814-
815- openPicker()
816- selectTarget(sess.attachmentToken(ac, ac.transport()), first)
817- enqueueRemoteLinkHandshake(t, firstTransport, first, ac.sizeSnapshot())
818- require.Eventually(t, func() bool {
819- _, remote := ac.currentAttachmentOwner().(*remoteView)
820- return remote
821- }, time.Second, 5*time.Millisecond, "the first remote picker selection should publish its view")
822- firstView, ok := ac.currentAttachmentOwner().(*remoteView)
823- require.True(t, ok)
824-
825- openPicker()
826- selectTarget(attachmentOwnerToken(firstView, ac, ac.transport()), second)
827- enqueueRemoteLinkHandshake(t, secondTransport, second, ac.sizeSnapshot())
828- require.Eventually(t, func() bool {
829- view, remote := ac.currentAttachmentOwner().(*remoteView)
830- return remote && view != firstView && view.key == remoteViewKey{endpoint: second.Endpoint, lifecycleID: second.LifecycleID, sessionName: second.SessionName}
831- }, time.Second, 5*time.Millisecond, "the selected remote view should replace the current remote owner")
832- secondView, ok := ac.currentAttachmentOwner().(*remoteView)
833- require.True(t, ok)
834- require.False(t, firstView.attachmentRegistered(ac))
835- require.True(t, secondView.attachmentRegistered(ac))
836- require.Same(t, sess, ac.previousOwner.Get(), "remote-to-remote keeps the retained local return owner")
837- firstView.mu.Lock()
838- require.NotNil(t, firstView.warm, "the replaced remote view is retained warm")
839- firstView.mu.Unlock()
840-
841- remoteRecent := d.recentSessionsForRemoteAttachment(secondView, ac)
842- remoteNames := make([]string, 0, len(remoteRecent))
843- for _, entry := range remoteRecent {
844- remoteNames = append(remoteNames, entry.name)
845- }
846- require.Contains(t, remoteNames, "remote@remote.example")
847- require.NotContains(t, remoteNames, "other@remote.example", "the remote status bar excludes its current view")
848- select {
849- case frame := <-sends:
850- require.NotEqual(t, ports.MsgAttachTarget, frame.Type)
851- default:
852- }
853-
854- backToken := attachmentOwnerToken(secondView, ac, ac.transport())
855- backEffect, admitted := ac.beginAttachmentEffect(backToken)
856- require.True(t, admitted)
857- backToken.effect = backEffect
858- require.NoError(t, d.backSessionForAttachment(backToken))
859- backEffect.End()
860- require.Same(t, sess, ac.currentAttachmentSession())
861- localState := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, nil)
862- localNames := make([]string, 0, len(localState.mru))
863- for _, entry := range localState.mru {
864- localNames = append(localNames, entry.name)
865- }
866- require.Equal(t, []string{"other@remote.example", "remote@remote.example"}, localNames)
867- d.shutdownAll(ports.ReasonServerShutdown)
868625 }
869626
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{})
896- ac.overlays.pickerMu.Lock()
897- ac.overlays.picker = model
898- ac.overlays.pickerGeneration++
899- ac.overlays.pickerMu.Unlock()
900-
901- selectRemote := func() {
902- // Exercise the picker input boundary without a frame effect. The daemon
903- // must admit a short-lived local effect rather than taking the legacy
904- // client handoff path or closing the picker before the async selection.
905- d.handlePickerInput(ac, []byte("\r"))
627+func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
628+ tests := []struct {
629+ name string
630+ action ports.NavigationAction
631+ }{
632+ {name: "home picker", action: ports.NavigationOpenHomePicker},
633+ {name: "back", action: ports.NavigationBack},
906634 }
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)
919- require.True(t, admitted)
920- backToken.effect = backEffect
921- require.NoError(t, d.backSessionForAttachment(backToken))
922- backEffect.End()
923- require.Eventually(t, func() bool {
924- return ac.currentAttachmentSession() == sess
925- }, time.Second, 5*time.Millisecond, "back navigation should return to the local source")
926- view.mu.Lock()
927- require.NotNil(t, view.warm, "the detached remote view should be retained warm")
928- view.mu.Unlock()
929-
930- ac.overlays.pickerMu.Lock()
931- ac.overlays.picker = model
932- ac.overlays.pickerGeneration++
933- ac.overlays.pickerMu.Unlock()
934- selectRemote()
935- require.Eventually(t, func() bool {
936- owner, remote := ac.currentAttachmentOwner().(*remoteView)
937- return remote && owner == view
938- }, time.Second, 5*time.Millisecond, "reselecting should publish the warm remote view")
939- require.Equal(t, int32(1), factory.calls.Load(), "reselecting a warm remote view must not dial again")
940- view.mu.Lock()
941- require.Nil(t, view.warm, "publishing the warm view should cancel its retention timer")
942- view.mu.Unlock()
943- d.shutdownAll(ports.ReasonServerShutdown)
944-}
945635
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()
975- }
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()
636+ for _, tt := range tests {
637+ t.Run(tt.name, func(t *testing.T) {
638+ d := newRemotePickerDaemon(nil)
639+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
640+ token := sess.attachmentToken(ac, ac.transport())
641+ effect, admitted := ac.beginAttachmentEffect(token)
642+ require.True(t, admitted)
643+ token.effect = effect
644+ require.NoError(t, d.sendNavigationActionForAttachment(token, tt.action))
645+ frame := receiveRemotePicker(t, sends, "navigation action")
646+ require.Equal(t, ports.MsgNavigationAction, frame.Type)
647+ got, err := ports.UnmarshalNavigationAction(frame.Payload)
648+ require.NoError(t, err)
649+ require.Equal(t, tt.action, got)
650+ })
983651 }
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)
1031652 }
1032653
1033-func TestRemotePickerSelectionFailureKeepsSourceAndPickerOpen(t *testing.T) {
654+func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
1034655 d := newRemotePickerDaemon(nil)
1035- target := remoteLinkTestTarget()
1036656 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- }},
657+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
1040658 }})
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")
659+ const sendErr = "remote attach send failed"
660+ tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}
661+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
662+ ac.initOverlays()
663+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
664+ sess := &session{sessionCore: sessionCore{id: "local", name: "local", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}
665+ publishTiledPaneOwners(sess, tb)
666+ ac.setSession(sess)
667+ d.sessions[sess.id] = sess
1047668 ac.overlays.pickerMu.Lock()
1048669 ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
1049670 ac.overlays.pickerGeneration++
1050671 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()
1058672
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)
1078-}
1079-
1080-func TestRemotePickerSelectionEscapeCancelsWithoutDetachingSource(t *testing.T) {
1081- clock := &signalClock{timers: make(chan *signalTimer, 2)}
1082- d := newRemotePickerDaemon(nil)
1083- d.clock = clock
1084- target := remoteLinkTestTarget()
1085- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
1086- Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
1087- LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
1088- }},
1089- }})
1090- d.remoteCatalog.mu.Lock()
1091- d.remoteCatalog.status[target.Endpoint] = remoteHostFresh
1092- d.remoteCatalog.mu.Unlock()
1093- remoteTransport := newRemoteLinkTestTransport()
1094- dialStarted := make(chan struct{})
1095- d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: remoteTransport, started: dialStarted, release: make(chan struct{})}}
1096-
1097- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
1098- ac.overlays.pickerMu.Lock()
1099- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
1100- ac.overlays.pickerGeneration++
1101- ac.overlays.pickerMu.Unlock()
1102- token := sess.attachmentToken(ac, ac.transport())
673+ gone := make(chan struct{})
674+ d.afterClientGoneDetach = func() { close(gone) }
675+ token := sess.attachmentToken(ac, tr)
1103676 effect, admitted := ac.beginAttachmentEffect(token)
1104677 require.True(t, admitted)
1105678 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")
679+ defer effect.End()
680+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
681+ target := picker.Target{Session: key.ID(), RemoteKey: &key}
682+ err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")
683+ require.EqualError(t, err, sendErr)
684+ require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")
685+ select {
686+ case <-gone:
687+ t.Fatal("failed control send reached clientGoneForAttachment")
688+ default:
689+ }
1120690 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)
1133691 }
1134692
693+type remotePickerSendErrorTransport struct {
694+ err error
695+}
696+
697+func (t *remotePickerSendErrorTransport) Send(ports.Frame) error { return t.err }
698+func (*remotePickerSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
699+func (*remotePickerSendErrorTransport) Close() error { return nil }
700+
1135701 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
1136702 t.Helper()
1137703 tr, sends := newCapturingTransport(t)
@@ -1479,31 +1045,26 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
14791045 ac.overlays.pickerMu.Unlock()
14801046 }
14811047
1482-func TestRemoteRowsRequireFreshRichIdentityForPickerActivation(t *testing.T) {
1048+func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) {
14831049 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1484- session := ports.RemoteCatalogSession{
1485- LifecycleID: remoteLifecycleForTest(), Name: key.Name, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Name: "main"}},
1486- }
1487- for _, test := range []struct {
1050+ for _, tt := range []struct {
14881051 status remoteHostStatus
1489- want bool
1052+ ready bool
14901053 }{
1491- {status: remoteHostCached},
1492- {status: remoteHostRefreshing},
1493- {status: remoteHostFresh, want: true},
1494- {status: remoteHostStale},
1495- {status: remoteHostUnreachable},
1496- {status: remoteHostMalformed},
1497- {status: remoteHostVersionMismatch},
1054+ {status: remoteHostCached, ready: false},
1055+ {status: remoteHostRefreshing, ready: false},
1056+ {status: remoteHostStale, ready: false},
1057+ {status: remoteHostMalformed, ready: false},
1058+ {status: remoteHostFresh, ready: true},
1059+ {status: remoteHostUnreachable, ready: false},
14981060 } {
1499- view := remotePickerView(key, session, test.status, time.Unix(1, 0))
1061+ view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, tt.status, time.Unix(1, 0))
1062+ require.Equal(t, tt.ready, view.RemoteAttachReady, "status %d readiness", tt.status)
15001063 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1501- require.Equal(t, test.want, selectable, "status %d", test.status)
1502- require.Equal(t, test.want, view.RemoteAttachReady, "status %d", test.status)
1064+ require.Equal(t, tt.ready, selectable, "status %d selection", tt.status)
15031065 }
1504-
1505- legacy := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostFresh, time.Unix(1, 0))
1506- require.False(t, legacy.RemoteAttachReady, "count-only cached rows cannot initiate proxied picker activation")
1507- _, selectable := picker.New([]picker.SessionView{legacy}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1066+ mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{})
1067+ require.False(t, mismatch.RemoteAttachReady)
1068+ _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
15081069 require.False(t, selectable)
15091070 }
D internal/usecase/daemon/remote_reconnect.go +0 -78
@@ -1,78 +0,0 @@
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-}
D internal/usecase/daemon/remote_render.go +0 -287
@@ -1,287 +0,0 @@
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-}
D internal/usecase/daemon/remote_resume.go +0 -217
@@ -1,217 +0,0 @@
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-}
M internal/usecase/daemon/remote_route_test.go +22 -0
@@ -26,6 +26,28 @@ func TestRouteRemoteTargetSelectsExactLiveTab(t *testing.T) {
2626 d.clientGone(sess, ac, tr, false)
2727 }
2828
29+func TestFinishRouteAttachRollsBackCreatedSession(t *testing.T) {
30+ pty, release := newBlockingPTY(t)
31+ defer release()
32+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
33+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", defaultSize, terminalEnv{}, d.baseEnv)
34+ require.NoError(t, err)
35+ target := domain.RemoteSessionTarget{LifecycleID: sess.incarnation, SessionName: "work", LiveTabID: "missing-tab"}
36+ hello := ports.Hello{
37+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize,
38+ RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
39+ }
40+ d.mu.Lock()
41+ _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, terminalEnv{}, hello, true, true)
42+ var protocol *protoErr
43+ require.ErrorAs(t, err, &protocol)
44+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
45+ d.mu.Lock()
46+ _, retained := d.sessions[sess.id]
47+ d.mu.Unlock()
48+ require.False(t, retained, "failed route attach must remove its newly created session")
49+}
50+
2951 func TestRouteRemoteTargetRejectsSameNameReplacement(t *testing.T) {
3052 d := newTestDaemon(t, nil, stubClock{})
3153 sess := addControlSession(d, "work", "tab-new", "pane-new")
M internal/usecase/daemon/remote_stopped_route_test.go +37 -0
@@ -10,6 +10,43 @@ import (
1010 "github.com/bnema/vev/internal/ports"
1111 )
1212
13+func TestDaemonOwnedNoExactTargetRejectsNewAndEphemeral(t *testing.T) {
14+ d := newTestDaemon(t, nil, stubClock{})
15+ for _, intent := range []uint8{ports.IntentNew, ports.IntentEphemeral} {
16+ hello := ports.Hello{
17+ Version: ports.ProtocolVersion, Intent: intent, Name: "work",
18+ Size: domain.Size{Cols: 80, Rows: 24}, Cwd: "/untrusted/cwd",
19+ Env: []string{"UNTRUSTED=client"}, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
20+ }
21+ _, _, err := d.route(hello, &closeTrackingTransport{})
22+ var protocol *protoErr
23+ require.ErrorAs(t, err, &protocol)
24+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
25+ }
26+ require.Empty(t, d.sessions)
27+}
28+
29+func TestLegacyDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) {
30+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
31+ d.baseEnv = []string{"DAEMON=owned"}
32+ d.mu.Lock()
33+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/remote/work", tabNames: []string{"main"}, state: ports.SessionDown}
34+ d.mu.Unlock()
35+
36+ hello := ports.Hello{
37+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
38+ Size: domain.Size{Cols: 80, Rows: 24}, Cwd: "/untrusted/cwd",
39+ Env: []string{"UNTRUSTED=client"}, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
40+ }
41+ sess, ac, err := d.routeWithContext(context.Background(), hello, &closeTrackingTransport{})
42+ require.NoError(t, err)
43+ require.NotNil(t, ac)
44+ sess.mu.Lock()
45+ require.Equal(t, []string{"DAEMON=owned"}, sess.env)
46+ sess.mu.Unlock()
47+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
48+}
49+
1350 func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) {
1451 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
1552 lifecycle := remoteLifecycleForTest()
D internal/usecase/daemon/remote_terminal.go +0 -50
@@ -1,50 +0,0 @@
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-}
D internal/usecase/daemon/remote_view_tabs.go +0 -221
@@ -1,221 +0,0 @@
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-}
D internal/usecase/daemon/remote_view_tabs_test.go +0 -96
@@ -1,96 +0,0 @@
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-}
D internal/usecase/daemon/remote_warm.go +0 -168
@@ -1,168 +0,0 @@
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-}
D internal/usecase/daemon/remote_warm_test.go +0 -115
@@ -1,115 +0,0 @@
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 +2 -6
@@ -299,9 +299,6 @@ const (
299299 )
300300
301301 func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
302- if ac != nil && ac.renderMode == ports.RenderModeProxiedContent {
303- return d.paintProxiedContent(entry, ac, reset, lease)
304- }
305302 // Session-owned PTY preparation remains local; attachment rendering is
306303 // captured through session.captureRenderState.
307304 sess := entry
@@ -427,7 +424,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
427424 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
428425 }
429426 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
430- captureRequest := renderCaptureRequest{
427+ state, ok := entry.captureRenderState(ac, renderCaptureRequest{
431428 bars: bars,
432429 overlays: capturedOverlays,
433430 preview: preview,
@@ -436,8 +433,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
436433 styleGeneration: applied.Generation,
437434 reset: reset,
438435 lease: lease,
439- }
440- state, ok := entry.captureRenderState(ac, captureRequest)
436+ })
441437 endCapture(0, ok)
442438 if !ok {
443439 ac.sendMu.Unlock()
M internal/usecase/daemon/render_pipeline.go +10 -34
@@ -426,22 +426,9 @@ func commitDamageReceipts(receipts []damageReceipt) {
426426 }
427427 }
428428
429-// emitFrame is the local-session compatibility entry point for the sole
430-// side-effecting half of the pipeline. The caller holds sendMu for the
431-// complete capture/compose/emit transaction.
429+// emitFrame is the sole side-effecting half of the pipeline. The caller holds
430+// sendMu for the complete capture/compose/emit transaction.
432431 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)
445432 var ownedMarks runtimeMarkBatch
446433 var marks *runtimeMarkBatch
447434 if len(batches) != 0 {
@@ -453,19 +440,22 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
453440 // follows emitFrame's release of attachment ownership.
454441 defer marks.flush()
455442 }
456- if owner == nil || ac == nil || state == nil {
443+ if entry == nil || entry.core() == nil || ac == nil || state == nil {
457444 if ac != nil {
458445 ac.sendMu.Unlock()
459446 }
460447 return false
461448 }
462- if !attachmentOwnerRegistered(owner, ac) || state.attachment != ac || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) {
449+ entry.core().mu.Lock()
450+ _, owned := entry.core().attachments[ac]
451+ entry.core().mu.Unlock()
452+ if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
463453 ac.sendMu.Unlock()
464454 return false
465455 }
466456 if state.lease != nil {
467457 rc := attachmentRenderCoordinator(entry)
468- if entry == nil || rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
458+ if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
469459 ac.sendMu.Unlock()
470460 return false
471461 }
@@ -486,12 +476,7 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
486476 endDiff(0, err == nil)
487477 if err != nil {
488478 ac.sendMu.Unlock()
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- }
479+ d.log.Error("render draw failed", "err", err, "session", entry.core().name)
495480 // Without a coordinator reportError repaints synchronously. Suppress only
496481 // that nested notice repaint; leave the guard before returning so a later,
497482 // independent failed transaction can still notify the user.
@@ -566,11 +551,6 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
566551 // represents the captured frame. Lock panes only under sendMu and with no
567552 // session guard held.
568553 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- }
574554 if ac.renderStages.emit != nil {
575555 ac.renderStages.emit()
576556 }
@@ -581,11 +561,7 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
581561 // admission first. Detachment freezes the gate and therefore cannot mutate
582562 // ownership until any enclosing admitted operation has also ended.
583563 if marks.attachmentEffect == nil {
584- if entry != nil {
585- d.detachOnSendError(entry, ac, sendTr)
586- } else if remote {
587- d.clientGoneRemote(view, attachmentOwnerToken(view, ac, sendTr), false)
588- }
564+ d.detachOnSendError(entry, ac, sendTr)
589565 } else {
590566 // Capture the exact admitted capability, including its coordinator
591567 // lease, before End permits a new attachment publication. Reserve
M internal/usecase/daemon/resize_publication.go +2 -2
@@ -28,11 +28,11 @@ func (p resizeCommitPublication) current() bool {
2828 if p.connection.effect == nil {
2929 return false
3030 }
31- if p.connection.effect.ended.Load() || !sameAttachmentOwner(p.connection.owner, p.session) ||
31+ if p.connection.effect.ended.Load() || p.connection.sess != p.session ||
3232 p.connection.ac != p.attachment || p.connection.lease != p.lease ||
3333 p.connection.generation != p.attachment.connectionGeneration.Load() ||
3434 !p.attachment.transportSnapshotCurrent(p.connection.transport) ||
35- !sameAttachmentOwner(p.attachment.currentAttachmentOwner(), p.session) || p.coordinator == nil {
35+ p.attachment.currentSession() != p.session || p.coordinator == nil {
3636 return false
3737 }
3838 registered := attachmentRegistered(p.session, p.attachment)
M internal/usecase/daemon/resume.go +67 -85
@@ -11,6 +11,17 @@ import (
1111
1212 var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race")
1313
14+// resumeTokenSnapshot reads the credential under the same daemon lock used by
15+// its lifecycle writers. Callers must not hold d.mu.
16+func (d *Daemon) resumeTokenSnapshot(ac *attachedClient) uint64 {
17+ if ac == nil {
18+ return 0
19+ }
20+ d.mu.Lock()
21+ defer d.mu.Unlock()
22+ return ac.resumeToken
23+}
24+
1425 func newResumeToken() uint64 {
1526 var b [8]byte
1627 if _, err := rand.Read(b[:]); err != nil {
@@ -41,48 +52,47 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
4152 // it returns 0 and never creates a marker after terminal
4253 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.
4354 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
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 {
55+ if sess == nil || ac == nil || !ac.resumeCapable {
5256 return 0
5357 }
5458 d.mu.Lock()
5559 defer d.mu.Unlock()
56- if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) || !attachmentOwnerRegistered(owner, ac) {
60+ if d.closing || d.sessions[sess.id] != sess {
61+ return 0
62+ }
63+ sess.mu.Lock()
64+ registered := attachmentRegisteredLocked(sess, ac)
65+ sess.mu.Unlock()
66+ if !registered {
5767 return 0
5868 }
5969 if ac.resumeToken == 0 {
6070 ac.resumeToken = d.nextResumeTokenLocked()
6171 }
62- d.ensureParkingInFlightLocked(owner, ac)
63- d.log.Info("parking in flight", "session", attachmentOwnerName(owner))
72+ d.ensureParkingInFlightLocked(sess, ac)
73+ d.log.Info("parking in flight", "session", sess.name)
6474 return ac.resumeToken
6575 }
6676
6777 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
6878 // Caller holds d.mu and has verified the daemon is not closing.
69-func (d *Daemon) ensureParkingInFlightLocked(owner attachmentOwner, ac *attachedClient) {
79+func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) {
7080 token := ac.resumeToken
7181 if token == 0 {
7282 return
7383 }
7484 if existing := d.parking[token]; existing != nil {
7585 if existing.ac == ac {
76- existing.owner = owner
86+ existing.sess = sess
7787 return
7888 }
7989 delete(d.parking, token)
8090 existing.closeDone()
8191 }
8292 d.parking[token] = &parkingAttachment{
83- owner: owner,
84- ac: ac,
85- done: make(chan struct{}),
93+ sess: sess,
94+ ac: ac,
95+ done: make(chan struct{}),
8696 }
8797 }
8898
@@ -100,16 +110,21 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
100110 // If another path already detached/unrouted, that path
101111 // owns park publication.
102112 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) {
107113 if token == 0 || ac == nil {
108114 return
109115 }
110116 d.mu.Lock()
111117 defer d.mu.Unlock()
112- if d.parked[token] != nil || !d.attachmentOwnerRegisteredByDaemonLocked(owner) || !attachmentOwnerRegistered(owner, ac) {
118+ if d.parked[token] != nil {
119+ return
120+ }
121+ stillOwner := false
122+ if sess != nil {
123+ sess.mu.Lock()
124+ stillOwner = attachmentRegisteredLocked(sess, ac)
125+ sess.mu.Unlock()
126+ }
127+ if !stillOwner {
113128 return
114129 }
115130 d.clearParkingInFlightLocked(token, ac)
@@ -139,27 +154,13 @@ func (d *Daemon) purgeAllParkingLocked() {
139154 // the session is removed before park publication. Caller holds d.mu.
140155 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
141156 for token, pending := range d.parking {
142- if localSession(pending.owner) == sess {
157+ if pending.sess == sess {
143158 delete(d.parking, token)
144159 pending.closeDone()
145160 }
146161 }
147162 }
148163
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 {
155- delete(d.parking, token)
156- pending.ac.resumeToken = 0
157- pending.ac.parked = false
158- pending.closeDone()
159- }
160- }
161-}
162-
163164 // waitParkingInFlight waits for an identified same-client parking publication
164165 // to finish. Returns true when a matching in-flight entry was observed.
165166 // Unknown and mismatched credentials return false without waiting.
@@ -177,7 +178,7 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
177178 d.mu.Unlock()
178179 return false
179180 }
180- if sess := localSession(pending.owner); h.Name != "" && sess != nil && sess.name != h.Name {
181+ if h.Name != "" && pending.sess != nil && pending.sess.name != h.Name {
181182 d.mu.Unlock()
182183 return false
183184 }
@@ -192,34 +193,27 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
192193 }
193194
194195 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
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 {
196+ if !ac.resumeCapable {
200197 return false
201198 }
202199 d.mu.Lock()
203200 defer d.mu.Unlock()
204- if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {
201+ if d.closing {
205202 return false
206203 }
207204 if ac.resumeToken == 0 {
208205 ac.resumeToken = d.nextResumeTokenLocked()
209206 }
210207 // Do not recreate parking markers here: post-detach publication races
211- // terminal owner removal and can strand IntentResume waiters. Callers that
212- // need the detach→park gap advertised must publish it while still exact.
213- d.log.Info("parking client prepared", "session", attachmentOwnerName(owner))
208+ // terminal session removal and can strand IntentResume waiters. Callers
209+ // that need the detach→park gap advertised must markParkingInFlight while
210+ // still the exact live owner. Direct parkAttachment still parks below.
211+ d.log.Info("parking client prepared", "session", sess.name)
214212 return true
215213 }
216214
217215 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
218- return d.parkAttachmentOwner(sess, ac)
219-}
220-
221-func (d *Daemon) parkAttachmentOwner(owner attachmentOwner, ac *attachedClient) bool {
222- if !d.prepareParkAttachmentOwner(owner, ac) {
216+ if !d.prepareParkAttachment(sess, ac) {
223217 return false
224218 }
225219 // A parked attachment has no session owner. Release pane snapshots before
@@ -234,7 +228,7 @@ func (d *Daemon) parkAttachmentOwner(owner attachmentOwner, ac *attachedClient)
234228 ac.overlays.pickerMu.Unlock()
235229 }
236230 d.mu.Lock()
237- if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {
231+ if d.closing || d.sessions[sess.id] != sess {
238232 d.clearParkingInFlightLocked(ac.resumeToken, ac)
239233 d.mu.Unlock()
240234 return false
@@ -254,7 +248,7 @@ func (d *Daemon) parkAttachmentOwner(owner attachmentOwner, ac *attachedClient)
254248 }
255249 grace := d.resumeParkGrace
256250 timer := d.clock.NewTimer(grace)
257- parked := &parkedAttachment{owner: owner, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
251+ parked := &parkedAttachment{sess: sess, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
258252 ac.parked = true
259253 d.parked[token] = parked
260254 d.clearParkingInFlightLocked(token, ac)
@@ -268,7 +262,7 @@ func (d *Daemon) parkAttachmentOwner(owner attachmentOwner, ac *attachedClient)
268262 }
269263 oldSame.closeDone()
270264 }
271- d.log.Info("client parked for resume", "session", attachmentOwnerName(owner), "grace", grace)
265+ d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
272266 d.watchParkedTimer(token, parked)
273267 return true
274268 }
@@ -297,7 +291,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
297291 d.removeParkedLocked(token, parked)
298292 d.mu.Unlock()
299293 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
300- d.log.Warn("parked client expired", "session", attachmentOwnerName(parked.owner))
294+ d.log.Warn("parked client expired", "session", parked.sess.name)
301295 return
302296 }
303297 d.mu.Unlock()
@@ -366,16 +360,11 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
366360 ac.resumeToken = token
367361 ac.parked = true
368362 ac.connectionGeneration.Add(1)
369- sess := localSession(parked.owner)
370- view, remote := normalizeAttachmentOwner(parked.owner).(*remoteView)
363+ sess := parked.sess
371364 if sess != nil {
372365 sess.mu.Lock()
373366 sess.unregisterAttachmentLocked(ac)
374367 sess.mu.Unlock()
375- } else if remote {
376- view.mu.Lock()
377- view.unregisterAttachmentLocked(ac)
378- view.mu.Unlock()
379368 }
380369 // Recreate the parked entry and its watcher instead of restoring a timer
381370 // that may already have fired while the claim held the old entry. This also
@@ -385,7 +374,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
385374 }
386375 parked.closeDone()
387376 rearmed := &parkedAttachment{
388- owner: parked.owner,
377+ sess: parked.sess,
389378 ac: ac,
390379 pickerGeneration: parked.pickerGeneration,
391380 timer: d.clock.NewTimer(d.resumeParkGrace),
@@ -398,9 +387,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
398387 if sess != nil {
399388 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
400389 }
401- if remote {
402- d.parkRemoteViewWarm(view)
403- }
404390 d.watchParkedTimer(token, rearmed)
405391 if captured != nil {
406392 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
@@ -434,20 +420,7 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
434420 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
435421 var retirements []parkedAttachmentRetirement
436422 for token, parked := range d.parked {
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 {
423+ if parked.sess == sess {
451424 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
452425 }
453426 }
@@ -520,6 +493,10 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
520493 d.mu.Unlock()
521494 return d.resumeParked(h, tr, sz)
522495 }
496+ if credentialMatch && h.RemoteTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.RemoteTarget) {
497+ d.mu.Unlock()
498+ return nil, nil, false, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
499+ }
523500 d.mu.Unlock()
524501
525502 if clientMismatch {
@@ -559,7 +536,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
559536 if !d.parkAttachment(sess, ac) {
560537 // Detach already published; retire the captured link exactly once so
561538 // shutdown cannot leave an orphaned open transport with no owner.
562- d.clearParkingInFlight(ac.resumeToken, ac)
539+ d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
563540 d.resetScreenDefaultColors(sess)
564541 ac.clearPreviousSession()
565542 _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))
@@ -600,7 +577,7 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
600577 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
601578 }
602579 if d.parked[h.ResumeToken] != parked || parked.claimed {
603- if sess := localSession(parked.owner); sess != nil && d.sessions[sess.id] == sess {
580+ if d.sessions[parked.sess.id] == parked.sess {
604581 d.mu.Unlock()
605582 ac.sendMu.Unlock()
606583 return nil, nil, false, errResumeTokenLifecycleRace
@@ -625,11 +602,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
625602 if parked == nil || h.ResumeToken == 0 {
626603 return nil, nil, false, nil
627604 }
628- sess := localSession(parked.owner)
629- registered := sess != nil && d.sessions[sess.id] == sess
605+ sess := parked.sess
606+ registered := d.sessions[sess.id] == sess
630607 if !registered {
631608 d.removeParkedLocked(h.ResumeToken, parked)
632- d.log.Warn("resume rejected", "session", attachmentOwnerName(parked.owner), "registered", false)
609+ d.log.Warn("resume rejected", "session", sess.name, "registered", false)
633610 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
634611 }
635612 ac := parked.ac
@@ -641,6 +618,9 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
641618 d.log.Warn("resume rejected", "session", sess.name, "err", reason)
642619 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
643620 }
621+ if h.RemoteTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.RemoteTarget) {
622+ return nil, nil, false, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
623+ }
644624 // Claim the attachment while retaining the old credential until Welcome is
645625 // accepted. A failed handshake can therefore restore this exact parked
646626 // entry; another resume sees claimed and loses atomically.
@@ -659,7 +639,9 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
659639 // The resumed session's snapshot is the sole source for future PTY children.
660640 // Existing PTYs retain the environment they were started with.
661641 sess.mu.Lock()
662- sess.env = copyEnvironment(h.Env)
642+ if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
643+ sess.env = copyEnvironment(h.Env)
644+ }
663645 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
664646 sess.mu.Unlock()
665647 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
M internal/usecase/daemon/resume_test.go +102 -7
@@ -97,6 +97,73 @@ func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
9797 }
9898 }
9999
100+func TestTokenlessResumeIsRejectedBeforeNameRouting(t *testing.T) {
101+ d := newTestDaemon(t, nil, stubClock{})
102+ tr, _ := newCapturingTransport(t)
103+ _, _, err := d.route(helloResumeCapable(ports.IntentResume, "work", 0), tr)
104+ var protocol *protoErr
105+ require.ErrorAs(t, err, &protocol)
106+ require.Equal(t, ports.ErrNoSuchSession, protocol.code)
107+ require.Empty(t, d.sessions)
108+}
109+
110+func TestResumeRejectsMismatchedRemoteTargetBeforeOwnershipMutation(t *testing.T) {
111+ pty, release := newBlockingPTY(t)
112+ defer release()
113+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
114+ oldTransport := &closeTrackingTransport{}
115+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
116+ require.NoError(t, err)
117+ token := ac.resumeToken
118+
119+ target := domain.RemoteSessionTarget{
120+ LifecycleID: sess.incarnation, SessionName: sess.name, LiveTabID: "missing-tab",
121+ }
122+ resume := helloResumeCapable(ports.IntentResume, "work", token)
123+ resume.RemoteTarget = &target
124+ resume.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
125+ _, _, err = d.route(resume, &closeTrackingTransport{})
126+ var protocol *protoErr
127+ require.ErrorAs(t, err, &protocol)
128+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
129+ require.Same(t, oldTransport, ac.transport())
130+ d.mu.Lock()
131+ _, parked := d.parked[token]
132+ d.mu.Unlock()
133+ require.False(t, parked, "mismatched target must not park the live attachment")
134+
135+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
136+}
137+
138+func TestParkedResumeRejectsMismatchedRemoteTargetBeforeClaim(t *testing.T) {
139+ pty, release := newBlockingPTY(t)
140+ defer release()
141+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
142+ oldTransport := &closeTrackingTransport{}
143+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
144+ require.NoError(t, err)
145+ token := ac.resumeToken
146+ d.clientGone(sess, ac, oldTransport, false)
147+
148+ target := domain.RemoteSessionTarget{
149+ LifecycleID: sess.incarnation, SessionName: sess.name, LiveTabID: "missing-tab",
150+ }
151+ resume := helloResumeCapable(ports.IntentResume, "work", token)
152+ resume.RemoteTarget = &target
153+ resume.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
154+ _, _, err = d.route(resume, &closeTrackingTransport{})
155+ var protocol *protoErr
156+ require.ErrorAs(t, err, &protocol)
157+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
158+ d.mu.Lock()
159+ parked := d.parked[token]
160+ d.mu.Unlock()
161+ require.NotNil(t, parked)
162+ require.False(t, parked.claimed, "mismatched target must not claim the parked attachment")
163+
164+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
165+}
166+
100167 func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
101168 clock := &signalClock{timers: make(chan *signalTimer, 8)}
102169 pty, release := newBlockingPTY(t)
@@ -533,7 +600,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
533600 d.mu.Unlock()
534601 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
535602 require.Same(t, ac, parkingInGap.ac)
536- require.Same(t, sess, localSession(parkingInGap.owner))
603+ require.Same(t, sess, parkingInGap.sess)
537604 require.False(t, parkedInGap, "fixture: park must not have published yet")
538605
539606 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
@@ -1268,7 +1335,7 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
12681335 require.NoError(t, err)
12691336
12701337 previous := &session{sessionCore: sessionCore{id: "previous"}}
1271- ac.previousOwner.Set(previous)
1338+ ac.previousSession.Set(previous)
12721339 d.clientGone(sess, ac, ac.transport(), false)
12731340
12741341 token := ac.resumeToken
@@ -1276,14 +1343,14 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
12761343 parked := d.parked[token]
12771344 d.mu.Unlock()
12781345 require.NotNil(t, parked)
1279- require.Same(t, previous, ac.previousOwner.Get(), "a live parked attachment keeps its toggle")
1346+ require.Same(t, previous, ac.previousSession.Get(), "a live parked attachment keeps its toggle")
12801347
12811348 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
12821349 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
12831350 require.NoError(t, err)
12841351 require.Same(t, sess, resumedSess)
12851352 require.Same(t, ac, resumedAC)
1286- require.Same(t, previous, resumedAC.previousOwner.Get(), "resume keeps the live attachment toggle")
1353+ require.Same(t, previous, resumedAC.previousSession.Get(), "resume keeps the live attachment toggle")
12871354 }
12881355
12891356 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
@@ -1301,7 +1368,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13011368 {
13021369 name: "session purge",
13031370 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
1304- return d.purgeParkedForSessionLocked(localSession(parked.owner))
1371+ return d.purgeParkedForSessionLocked(parked.sess)
13051372 },
13061373 },
13071374 } {
@@ -1313,7 +1380,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13131380 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
13141381 require.NoError(t, err)
13151382
1316- ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "previous"}})
1383+ ac.previousSession.Set(&session{sessionCore: sessionCore{id: "previous"}})
13171384 d.clientGone(sess, ac, ac.transport(), false)
13181385
13191386 d.mu.Lock()
@@ -1323,7 +1390,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13231390 d.mu.Unlock()
13241391 d.finishParkedAttachmentRetirements(retirements)
13251392
1326- require.Nil(t, ac.previousOwner.Get())
1393+ require.Nil(t, ac.previousSession.Get())
13271394 })
13281395 }
13291396 }
@@ -1548,6 +1615,34 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
15481615 require.Equal(t, []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])
15491616 }
15501617
1618+func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
1619+ pty, release := newBlockingPTY(t)
1620+ defer release()
1621+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1622+ d.baseEnv = []string{"DAEMON=owned"}
1623+ oldTransport := &closeTrackingTransport{}
1624+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
1625+ require.NoError(t, err)
1626+ sess.mu.Lock()
1627+ sess.env = copyEnvironment(d.baseEnv)
1628+ sess.mu.Unlock()
1629+ token := ac.resumeToken
1630+ require.True(t, sess.detachIfCurrent(ac))
1631+ require.True(t, d.parkAttachment(sess, ac))
1632+
1633+ resume := helloResumeCapable(ports.IntentResume, "work", token)
1634+ resume.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
1635+ resume.Env = []string{"UNTRUSTED=client"}
1636+ _, _, ok, err := d.resumeParked(resume, &closeTrackingTransport{}, defaultSize)
1637+ require.NoError(t, err)
1638+ require.True(t, ok)
1639+ sess.mu.Lock()
1640+ require.Equal(t, []string{"DAEMON=owned"}, sess.env)
1641+ sess.mu.Unlock()
1642+
1643+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
1644+}
1645+
15511646 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {
15521647 pty, release := newBlockingPTY(t)
15531648 defer release()
M internal/usecase/daemon/session.go +22 -19
@@ -467,8 +467,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
467467 if err := domain.ValidateSessionName(name); err != nil {
468468 return err
469469 }
470- source := token.localSession()
471- if source == nil || token.ac == nil || token.effect == nil {
470+ if token.sess == nil || token.ac == nil || token.effect == nil {
472471 return errAttachmentTransition
473472 }
474473 token.effect.bindActionEnd(d, "create-session")
@@ -476,11 +475,15 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
476475
477476 var created *session
478477 transition, err := d.transitionAttachment(attachmentTransitionRequest{
479- source: source, next: token.ac,
478+ source: token.sess, next: token.ac,
480479
481480 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
482481 copySourceEnvironment: true, ready: true,
483482 createTargetLocked: func() (*session, error) {
483+ source := token.sess
484+ if source == nil {
485+ return nil, errAttachmentTransition
486+ }
484487 if d.closing {
485488 return nil, errors.New("daemon is shutting down")
486489 }
@@ -503,8 +506,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
503506 }
504507
505508 d.touchMRU(created)
506- if source != nil {
507- token.ac.recordPreviousSession(source)
509+ if token.sess != nil {
510+ token.ac.recordPreviousSession(token.sess)
508511 }
509512 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
510513 d.deferAttachmentTransitionCleanups(transition)
@@ -770,15 +773,14 @@ func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool
770773 }
771774
772775 func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
773- sess := token.localSession()
774- if sess == nil || token.ac == nil || token.transport.transport == nil {
776+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
775777 return false
776778 }
777779 // Attachments without the session's optional primary render lease still
778780 // own an independent connection lifecycle and detach through the same exact
779781 // transport/generation fence.
780782 if token.lease == nil {
781- return d.detachIfCurrentTransport(sess, token.ac, token.transport)
783+ return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
782784 }
783785 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
784786 defer frozen.unfreeze()
@@ -789,8 +791,8 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
789791 d.afterDetachAttachmentEffectsFrozen()
790792 }
791793 d.mu.Lock()
792- core := sess.core()
793- if core == nil || d.sessions[core.id] != sess {
794+ core := token.sess.core()
795+ if core == nil || d.sessions[core.id] != token.sess {
794796 d.mu.Unlock()
795797 return false
796798 }
@@ -801,11 +803,11 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
801803 coordinator.mu.Lock()
802804 }
803805 req := attachmentTransitionRequest{
804- source: sess, next: token.ac, expectedTransport: token.transport,
806+ source: token.sess, next: token.ac, expectedTransport: token.transport,
805807 }
806- current := coordinator != nil && transitionSourceTokenCurrentLocked(token, sess, coordinator, req)
808+ current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
807809 if current {
808- unregisterAttachmentSessionLocked(sess, token.ac)
810+ unregisterAttachmentSessionLocked(token.sess, token.ac)
809811 token.ac.connectionGeneration.Add(1)
810812 token.ac.setSession(nil)
811813 token.ac.invalidateFrozenAttachmentCapability()
@@ -1166,11 +1168,13 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
11661168 snapshot := sessionKillParticipants{target: target}
11671169 if admission != nil {
11681170 token := admission.token
1169- source := token.localSession()
1170- if source == nil || token.ac == nil || token.transport.transport == nil {
1171+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1172+ return sessionKillParticipants{}, false
1173+ }
1174+ snapshot.source = token.sess
1175+ if snapshot.source == nil {
11711176 return sessionKillParticipants{}, false
11721177 }
1173- snapshot.source = source
11741178 snapshot.sourceToken = &admission.token
11751179 }
11761180
@@ -1267,11 +1271,10 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
12671271 return true
12681272 }
12691273 token := *snapshot.sourceToken
1270- source := token.localSession()
12711274 req := attachmentTransitionRequest{
1272- source: source, next: token.ac, expectedTransport: token.transport,
1275+ source: token.sess, next: token.ac, expectedTransport: token.transport,
12731276 }
1274- return transitionSourceTokenCurrentLocked(token, source, sourceCoordinator, req)
1277+ return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
12751278 }
12761279
12771280 // stopInMemoryLifecycle cancels every session producer and closes every PTY
M internal/usecase/daemon/session_back.go +17 -78
@@ -5,64 +5,15 @@ import (
55 "github.com/bnema/vev/internal/usecase/picker"
66 )
77
8-// backSession toggles this attachment between its current local session and
9-// the immediately preceding successfully activated local session. A remote
10-// owner is retained in the owner history but is not interpreted as a local
11-// session; remote back navigation is published with remote transitions.
12-func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken, afterRemoteTransition ...func()) error {
13- if d == nil || token.ac == nil {
8+// backSession toggles this attachment between its current session and the
9+// immediately preceding successfully activated session.
10+func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
11+ if d == nil || token.sess == nil || token.ac == nil {
1412 return nil
1513 }
16- if view, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {
17- previous := token.ac.previousOwner.Get()
18- target := localSession(previous)
19- if target == nil {
20- // A non-local predecessor is not a valid reverse target. Keep it as
21- // stable history for the owner-specific transition that can interpret it.
22- return nil
23- }
24- if target.core() == nil || d.sessionByID(target.core().id) != target {
25- token.ac.clearPreviousOwnerIf(previous)
26- return nil
27- }
28- published, err := d.transitionFromRemoteView(token, view, target)
29- if err != nil {
30- if !token.current() {
31- return nil
32- }
33- return err
34- }
35- if len(afterRemoteTransition) != 0 && afterRemoteTransition[0] != nil {
36- afterRemoteTransition[0]()
37- }
38- d.touchMRU(target)
39- d.firstPaintForTransition(published)
40- return nil
41- }
42-
43- current := token.localSession()
44- if current == nil {
45- return nil
46- }
47- previous := token.ac.previousOwner.Get()
48- if view, remote := normalizeAttachmentOwner(previous).(*remoteView); remote {
49- published, err := d.transitionToRemoteView(token, view)
50- if err != nil {
51- if !token.current() {
52- return nil
53- }
54- token.ac.clearPreviousOwnerIf(previous)
55- return err
56- }
57- d.firstPaintForTransition(published)
58- return nil
59- }
60- target := localSession(previous)
61- if target == nil {
62- return nil
63- }
64- if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
65- token.ac.clearPreviousOwnerIf(previous)
14+ target := token.ac.previousSession.Get()
15+ if target == nil || target == token.sess || target.core() == nil || d.sessionByID(target.core().id) != target {
16+ token.ac.clearPreviousSessionIf(target)
6617 return nil
6718 }
6819 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}
@@ -73,16 +24,9 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
7324 if d == nil || current == nil || ac == nil {
7425 return
7526 }
76- previous := ac.previousOwner.Get()
77- target := localSession(previous)
78- if target == nil {
79- if previous == nil {
80- d.invalidateRender(current, ac, true, "session_back.go")
81- }
82- return
83- }
84- if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
85- ac.clearPreviousOwnerIf(previous)
27+ target := ac.previousSession.Get()
28+ if target == nil || target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
29+ ac.clearPreviousSessionIf(target)
8630 d.invalidateRender(current, ac, true, "session_back.go")
8731 return
8832 }
@@ -99,29 +43,24 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
9943 }
10044 }
10145
102-// clearPreviousOwnerIf clears target only if it has not been replaced since
46+// clearPreviousSessionIf clears target only if it has not been replaced since
10347 // the caller observed it. This preserves a concurrent successful hand-off.
104-func (ac *attachedClient) clearPreviousOwnerIf(target attachmentOwner) {
48+func (ac *attachedClient) clearPreviousSessionIf(target *session) {
10549 if ac == nil {
10650 return
10751 }
108- ac.previousOwner.With(func(previous *attachmentOwner) {
109- if sameAttachmentOwner(*previous, target) {
52+ ac.previousSession.With(func(previous **session) {
53+ if *previous == target {
11054 *previous = nil
11155 }
11256 })
11357 }
11458
115-// recordPreviousOwner is called only after a cross-owner hand-off has
59+// recordPreviousSession is called only after a cross-session hand-off has
11660 // committed. Keeping this after the commit makes failed/displaced switches
11761 // leave the toggle pair intact.
118-func (ac *attachedClient) recordPreviousOwner(origin attachmentOwner) {
119- origin = normalizeAttachmentOwner(origin)
62+func (ac *attachedClient) recordPreviousSession(origin *session) {
12063 if ac != nil && origin != nil {
121- ac.previousOwner.Set(origin)
64+ ac.previousSession.Set(origin)
12265 }
12366 }
124-
125-func (ac *attachedClient) recordPreviousSession(origin *session) {
126- ac.recordPreviousOwner(origin)
127-}
M internal/usecase/daemon/session_geometry.go +1 -9
@@ -1,9 +1,6 @@
11 package daemon
22
3-import (
4- "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/internal/ports"
6-)
3+import "github.com/bnema/vev/internal/domain"
74
85 // sessionGeometrySource identifies the attachment whose latest attach,
96 // resume, or resize claim controls shared PTY/layout geometry. claim is zero
@@ -65,11 +62,6 @@ func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource,
6562 return domain.Size{}, sessionGeometrySource{}, false
6663 }
6764 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- }
7365 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true
7466 }
7567
M internal/usecase/daemon/status.go +2 -2
@@ -193,7 +193,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
193193 name += "*"
194194 }
195195 if ac != nil && ac.remoteOrigin != "" {
196- name += "@" + ac.remoteOrigin
196+ name += " at " + ac.remoteOrigin
197197 }
198198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
199199 activeIndex := 0
@@ -276,7 +276,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
276276 if ranked != nil {
277277 state.rankedRecent = ranked
278278 } else if d != nil {
279- state.mru = d.recentSessionsForAttachment(cur, ac)
279+ state.mru = d.recentSessions(cur)
280280 }
281281 return state
282282 }
M internal/usecase/daemon/status_test.go +1 -2
@@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
115115 remoteOrigin string
116116 wantAttachmentBar string
117117 }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},
118+ {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"},
119119 {name: "local attachment", wantAttachmentBar: "vive"},
120120 }
121121
@@ -1549,5 +1549,4 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
15491549 releasePTY2()
15501550 hg.Wait()
15511551 d.sessWg.Wait()
1552- d.waitNotifies()
15531552 }
M internal/usecase/daemon/wire.go +8 -74
@@ -2,87 +2,21 @@
22 package daemon
33
44 import (
5- "strconv"
6-
75 "github.com/bnema/vev/internal/domain"
86 "github.com/bnema/vev/internal/ports"
97 )
108
11-func frameWelcome(s *session, ac *attachedClient) ports.Frame {
12- return frameWelcomeForAttachment(s, ac)
13-}
14-
15-// frameWelcomeForAttachment emits the local-client handshake identity for one
16-// attachment owner. A remote view is a local daemon object rather than a
17-// synthetic session, so its opaque local view ID is used only for this wire
18-// identity while its remote session name remains presentation data.
19-func frameWelcomeForAttachment(owner attachmentOwner, ac *attachedClient) ports.Frame {
20- if ac == nil {
21- return ports.Frame{}
22- }
23- var w ports.Welcome
24- switch owner := normalizeAttachmentOwner(owner).(type) {
25- case *session:
26- owner.mu.Lock()
27- w = ports.Welcome{
28- SessionID: string(owner.id),
29- SessionName: owner.name,
30- Ephemeral: owner.ephemeral,
31- RenderMode: ac.renderMode,
32- Capabilities: ports.CapabilityResume,
33- }
34- owner.mu.Unlock()
35- case *remoteView:
36- w = ports.Welcome{
37- SessionID: "remote-view-" + strconv.FormatUint(uint64(owner.id), 10),
38- SessionName: owner.key.sessionName,
39- RenderMode: ac.renderMode,
40- Capabilities: ports.CapabilityResume,
41- }
42- default:
43- return ports.Frame{}
44- }
45- w.ResumeToken = ac.resumeToken
46- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
47-}
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.
9+func frameWelcome(s *session, resumeToken uint64) ports.Frame {
6010 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
11+ w := ports.Welcome{
12+ SessionID: string(s.id),
13+ SessionName: s.name,
14+ Ephemeral: s.ephemeral,
15+ Capabilities: ports.CapabilityResume,
7316 }
74- if len(meta.Tabs) == 0 {
75- active = ""
76- } else if !activePresent {
77- active = meta.Tabs[0].ID
78- }
79- meta.ActiveTabID = active
8017 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
18+ w.ResumeToken = resumeToken
19+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
8620 }
8721
8822 func frameError(code uint16, text string) ports.Frame {
M internal/usecase/daemon/wire_metadata_test.go +1 -1
@@ -23,7 +23,7 @@ func TestFrameWelcomeSnapshotsSessionMetadataUnderLock(t *testing.T) {
2323 })
2424
2525 for range 1000 {
26- frame := frameWelcome(sess, ac)
26+ frame := frameWelcome(sess, ac.resumeToken)
2727 welcome, err := ports.UnmarshalWelcome(frame.Payload)
2828 if err != nil {
2929 t.Fatalf("UnmarshalWelcome() error = %v", err)
M internal/usecase/palette/fuzzy_test.go +10 -10
@@ -99,9 +99,9 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
9999 NewCommandResult(cmd("ZZZ", "", "work tools")),
100100 },
101101 query: "work",
102- wantText: []string{"WORK", "work", "work", "WQORRK", "ZZZ"},
103- wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand},
104- wantPositions: [][]int{{0, 1, 2, 3}, {0, 1, 2, 3}, {0, 1, 2, 3}, {0, 2, 3, 5}, nil},
102+ wantText: []string{"WORK", "Resume session work", "WQORRK", "Switch to session work", "ZZZ"},
103+ wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindCommand, ResultKindActiveSession, ResultKindCommand},
104+ wantPositions: [][]int{{0, 1, 2, 3}, {15, 16, 17, 18}, {0, 2, 3, 5}, {1, 8, 20, 21}, nil},
105105 },
106106 {
107107 name: "equivalent sessions sort by normalized text",
@@ -110,12 +110,12 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
110110 NewStoppedSessionResult("aAlpha", time.Time{}),
111111 },
112112 query: "a",
113- wantText: []string{"aAlpha", "aBravo"},
113+ wantText: []string{"Resume session aAlpha", "Resume session aBravo"},
114114 wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession},
115- wantPositions: [][]int{{0}, {0}},
115+ wantPositions: [][]int{{15}, {15}},
116116 },
117117 {
118- name: "kind then normalized text then stable order",
118+ name: "display prefix positions sort before later active names",
119119 results: []Result{
120120 NewStoppedSessionResult("aBravo", time.Time{}),
121121 NewStoppedSessionResult("aAlpha", time.Time{}),
@@ -125,10 +125,10 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
125125 NewCommandResult(cmd("AX", "", "")),
126126 },
127127 query: "a",
128- wantText: []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"},
129- wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession},
130- wantPositions: [][]int{{0}, {0}, {0}, {0}, {0}, {0}},
131- wantActiveIDs: map[int]domain.SessionID{1: "e", 2: "e2", 3: "z"},
128+ wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"},
129+ wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession},
130+ wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}},
131+ wantActiveIDs: map[int]domain.SessionID{3: "e", 4: "e2", 5: "z"},
132132 },
133133 }
134134
M internal/usecase/palette/model.go +1 -2
@@ -332,10 +332,9 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
332332
333333 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
334334 x, nextHighlight := 0, 0
335- prefixWidth := utf8.RuneCountInString(match.Result.sessionDisplayPrefix())
336335 for _, r := range match.Result.DisplayText() {
337336 cellStyle := style
338- if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x {
337+ if nextHighlight < len(match.Positions) && match.Positions[nextHighlight] == x {
339338 cellStyle = selection
340339 cellStyle.Bold = true
341340 nextHighlight++
M internal/usecase/palette/model_test.go +3 -3
@@ -465,11 +465,11 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
465465 m := New(results)
466466 results[1] = NewActiveSessionResult("changed", created, "changed-id")
467467
468- for _, r := range "work" {
468+ for _, r := range "switch" {
469469 m.Insert(r)
470470 }
471471 matches := m.Matches()
472- require.Len(t, matches, 2)
472+ require.Len(t, matches, 1)
473473 require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
474474 matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
475475 require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
@@ -481,7 +481,7 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
481481 require.True(t, active)
482482 require.Equal(t, domain.SessionID("work-id"), id)
483483 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
484- require.Equal(t, "work", m.Query())
484+ require.Equal(t, "switch", m.Query())
485485 _, argument := m.ArgumentCommand()
486486 require.False(t, argument)
487487
M internal/usecase/palette/result.go +1 -4
@@ -68,10 +68,7 @@ func (r Result) sessionDisplayPrefix() string {
6868 }
6969
7070 func (r Result) SearchText() string {
71- if r.kind == ResultKindCommand {
72- return r.command.Code
73- }
74- return r.session.name
71+ return r.DisplayText()
7572 }
7673
7774 // Command returns the command payload only for command results.
M internal/usecase/palette/result_test.go +26 -1
@@ -30,7 +30,7 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
3030 require.True(t, ok)
3131 require.Equal(t, created, createdAt)
3232 require.Equal(t, "Switch to session work", active.DisplayText())
33- require.Equal(t, "work", active.SearchText())
33+ require.Equal(t, "Switch to session work", active.SearchText())
3434 id, ok := active.SessionID()
3535 require.True(t, ok)
3636 require.Equal(t, domain.SessionID("session-work"), id)
@@ -43,6 +43,31 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
4343 require.True(t, ok)
4444 require.Equal(t, created, createdAt)
4545 require.Equal(t, "Resume session archive", stopped.DisplayText())
46+ require.Equal(t, "Resume session archive", stopped.SearchText())
4647 _, ok = stopped.SessionID()
4748 require.False(t, ok)
4849 }
50+
51+func TestSessionResultsSearchDisplayedActionText(t *testing.T) {
52+ created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
53+ tests := []struct {
54+ name string
55+ result Result
56+ query string
57+ positions []int
58+ }{
59+ {name: "active prefix", result: NewActiveSessionResult("work", created, "work-id"), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}},
60+ {name: "active name", result: NewActiveSessionResult("work", created, "work-id"), query: "work", positions: []int{1, 8, 20, 21}},
61+ {name: "stopped prefix", result: NewStoppedSessionResult("archive", created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}},
62+ {name: "stopped name", result: NewStoppedSessionResult("archive", created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}},
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ matches := Fuzzy([]Result{tt.result}, tt.query)
68+ require.Len(t, matches, 1)
69+ require.Equal(t, tt.result.DisplayText(), tt.result.SearchText())
70+ require.Equal(t, tt.positions, matches[0].Positions)
71+ })
72+ }
73+}

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