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 @@
4 docs/superpowers/4 docs/superpowers/
5 .worktrees/5 .worktrees/
6 scripts/demo/out/6 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
25 attachments use the daemon's environment and persisted session working25 attachments use the daemon's environment and persisted session working
26 directory; direct CLI remote attaches retain their client-request semantics.26 directory; direct CLI remote attaches retain their client-request semantics.
27 27
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+
28 ## Durable record compatibility37 ## Durable record compatibility
29 38
30 The catalogue record format is currently version 4 because records now retain39 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
888 }888 }
889 seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}889 seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
890 }890 }
891- var handoff client.AttachHandoffFunc891+ pickerHandoff := remoteTarget == ""
892- handoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {892+ handoff := func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
893 if err := validateRemoteAttachHandoff(target); err != nil {893 if err := validateRemoteAttachHandoff(target); err != nil {
894 return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)894 return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)
895 }895 }
@@ -915,7 +915,11 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
915 if err != nil {915 if err != nil {
916 return nil, client.AttachRequest{}, err916 return nil, client.AttachRequest{}, err
917 }917 }
918- return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: target.EnvironmentPolicy}, nil918+ 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
919 }923 }
920 924
921 for {925 for {
@@ -941,6 +945,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
941 }, client.AttachRequest{945 }, client.AttachRequest{
942 Intent: intent,946 Intent: intent,
943 SessionName: name,947 SessionName: name,
948+ Remote: true,
944 RemoteTarget: remoteSelection,949 RemoteTarget: remoteSelection,
945 EnvironmentPolicy: remoteEnvironmentPolicy,950 EnvironmentPolicy: remoteEnvironmentPolicy,
946 })951 })
@@ -983,6 +988,9 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
983 intent = handoffErr.Target.Intent988 intent = handoffErr.Target.Intent
984 remoteSelection = nil989 remoteSelection = nil
985 remoteEnvironmentPolicy = handoffErr.Target.EnvironmentPolicy990 remoteEnvironmentPolicy = handoffErr.Target.EnvironmentPolicy
991+ if pickerHandoff && remoteEnvironmentPolicy == ports.EnvironmentPolicyClientOwned {
992+ remoteEnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
993+ }
986 if handoffErr.Target.RemoteTarget != nil {994 if handoffErr.Target.RemoteTarget != nil {
987 copyTarget := *handoffErr.Target.RemoteTarget995 copyTarget := *handoffErr.Target.RemoteTarget
988 remoteSelection = &copyTarget996 remoteSelection = &copyTarget
M internal/app/run_test.go +42 -5
@@ -575,7 +575,7 @@ func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
575 t.Run(tt.name, func(t *testing.T) {575 t.Run(tt.name, func(t *testing.T) {
576 cmd, err := parseArgs([]string{"attach", tt.arg})576 cmd, err := parseArgs([]string{"attach", tt.arg})
577 require.NoError(t, err)577 require.NoError(t, err)
578- connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.AttachRequest{578+ connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{
579 Intent: cmd.intent,579 Intent: cmd.intent,
580 SessionName: cmd.name,580 SessionName: cmd.name,
581 })581 })
@@ -845,8 +845,8 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
845 }845 }
846 gotDialer = nd.name846 gotDialer = nd.name
847 gotRemote = deps.Remote847 gotRemote = deps.Remote
848- if request.Remote {848+ if !request.Remote || request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned {
849- t.Fatal("remote carriage metadata must not be in the attach request")849+ t.Fatal("direct CLI remote attach must preserve client-owned environment policy")
850 }850 }
851 gotClipboard = deps.Clipboard851 gotClipboard = deps.Clipboard
852 if request.Intent != ports.IntentAttach || request.SessionName != "work" {852 if request.Intent != ports.IntentAttach || request.SessionName != "work" {
@@ -871,6 +871,29 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
871 }871 }
872 }872 }
873 873
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+
874 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {897 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
875 factory := portsmocks.NewMockRemoteDialerFactory(t)898 factory := portsmocks.NewMockRemoteDialerFactory(t)
876 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()899 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.
878 var calls int901 var calls int
879 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{902 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
880 remoteDialerFactory: factory,903 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 {
882 calls++905 calls++
883 require.NotNil(t, deps.Dialer)906 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")
884 if calls == 1 {909 if calls == 1 {
885 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}910 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
886 }911 }
@@ -934,7 +959,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
934 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {959 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
935 if deps.Remote {960 if deps.Remote {
936 remoteCalls++961 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)
938 return nil963 return nil
939 }964 }
940 localCalls++965 localCalls++
@@ -954,6 +979,18 @@ func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
954 }{979 }{
955 {name: "invalid host", target: ports.AttachTarget{Endpoint: "remote host", Session: "work", Intent: ports.IntentAttach}},980 {name: "invalid host", target: ports.AttachTarget{Endpoint: "remote host", Session: "work", Intent: ports.IntentAttach}},
956 {name: "invalid session", target: ports.AttachTarget{Endpoint: "remote.example", Session: "bad name", Intent: ports.IntentAttach}},981 {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+ },
957 }994 }
958 for _, tt := range tests {995 for _, tt := range tests {
959 t.Run(tt.name, func(t *testing.T) {996 t.Run(tt.name, func(t *testing.T) {
M internal/ports/frame.go +28 -3
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 2515+const ProtocolVersion uint16 = 26
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
@@ -22,7 +22,7 @@ const MaxFrameLen = 16 << 20
22 type MsgType uint822 type MsgType uint8
23 23
24 // Frame message types. Client-originated messages occupy 1–13 and 15;24 // 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 remain25+// server-originated messages occupy 16–22 and 25–27. Values 14, 23–24 remain
26 // reserved for future extensions.26 // reserved for future extensions.
27 const (27 const (
28 MsgHello MsgType = 128 MsgHello MsgType = 1
@@ -47,9 +47,9 @@ const (
47 MsgPong MsgType = 2047 MsgPong MsgType = 20
48 MsgSessions MsgType = 2148 MsgSessions MsgType = 21
49 MsgCommandResult MsgType = 2249 MsgCommandResult MsgType = 22
50- MsgSessionMeta MsgType = 23
51 MsgAttachTarget MsgType = 2550 MsgAttachTarget MsgType = 25
52 MsgRemotePreviewResponse MsgType = 2651 MsgRemotePreviewResponse MsgType = 26
52+ MsgNavigationAction MsgType = 27
53 )53 )
54 54
55 // Frame is the unit of exchange over a Transport: a typed, length-delimited55 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -61,6 +61,31 @@ type Frame struct {
61 Payload []byte61 Payload []byte
62 }62 }
63 63
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+
64 // RemotePreviewSchemaVersion is independent from the attachment IPC version.89 // RemotePreviewSchemaVersion is independent from the attachment IPC version.
65 const RemotePreviewSchemaVersion uint16 = 190 const RemotePreviewSchemaVersion uint16 = 1
66 91
M internal/ports/frame_test.go +18 -7
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 25 {6+ if ProtocolVersion != 26 {
7- t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -18,9 +18,6 @@ func TestControlMsgTypes(t *testing.T) {
18 if MsgOutputResetRequest != 13 {18 if MsgOutputResetRequest != 13 {
19 t.Fatalf("MsgOutputResetRequest = %d, want 13", MsgOutputResetRequest)19 t.Fatalf("MsgOutputResetRequest = %d, want 13", MsgOutputResetRequest)
20 }20 }
21- if MsgSessionMeta != 23 {
22- t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta)
23- }
24 }21 }
25 22
26 func TestMsgTypeUnique(t *testing.T) {23 func TestMsgTypeUnique(t *testing.T) {
@@ -35,8 +32,8 @@ func TestMsgTypeUnique(t *testing.T) {
35 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},32 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
36 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},33 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
37 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},34 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
38- {"MsgCommandResult", MsgCommandResult}, {"MsgSessionMeta", MsgSessionMeta},35+ {"MsgCommandResult", MsgCommandResult},
39- {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget},36+ {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
40 }37 }
41 38
42 seen := make(map[MsgType]string, len(tests))39 seen := make(map[MsgType]string, len(tests))
@@ -50,3 +47,17 @@ func TestMsgTypeUnique(t *testing.T) {
50 t.Fatalf("expected %d distinct MsgType values, got %d", len(tests), len(seen))47 t.Fatalf("expected %d distinct MsgType values, got %d", len(tests), len(seen))
51 }48 }
52 }49 }
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
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5- "reflect"
6 "testing"5 "testing"
7 6
8 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
@@ -80,46 +79,6 @@ func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) {
80 }79 }
81 }80 }
82 81
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-
123 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {82 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
124 target := testRemoteTarget(false)83 target := testRemoteTarget(false)
125 tests := []struct {84 tests := []struct {
@@ -199,7 +158,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
199 if err := tt.unmarshal(trailing); err == nil {158 if err := tt.unmarshal(trailing); err == nil {
200 t.Fatal("trailing garbage unexpectedly decoded")159 t.Fatal("trailing garbage unexpectedly decoded")
201 }160 }
202- payload[len(payload)-1] = byte(EnvironmentPolicyClientOwned)161+ payload[len(payload)-3] = byte(EnvironmentPolicyClientOwned)
203 if err := tt.unmarshal(payload); err == nil {162 if err := tt.unmarshal(payload); err == nil {
204 t.Fatal("unmarshal accepted client-owned policy for remote target")163 t.Fatal("unmarshal accepted client-owned policy for remote target")
205 }164 }
@@ -207,7 +166,28 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
207 }166 }
208 }167 }
209 168
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) {
211 want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}191 want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}
212 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})192 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
213 if !bytes.Equal(got, want) {193 if !bytes.Equal(got, want) {
M internal/ports/wire.go +83 -175
@@ -49,13 +49,8 @@ var ErrInvalidAck = errors.New("ports: invalid ack")
49 // ErrInvalidAttachTarget reports a malformed attach target.49 // ErrInvalidAttachTarget reports a malformed attach target.
50 var ErrInvalidAttachTarget = errors.New("ports: invalid attach target")50 var ErrInvalidAttachTarget = errors.New("ports: invalid attach target")
51 51
52-// ErrInvalidSessionMeta reports metadata that cannot be represented by the52+// ErrInvalidNavigation reports an unknown or impossible navigation value.
53-// protocol's ordered uint16-indexed tab layout.53+var ErrInvalidNavigation = errors.New("ports: invalid navigation")
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")
59 54
60 // Intent values carried by Hello, describing what the client wants to do.55 // Intent values carried by Hello, describing what the client wants to do.
61 const (56 const (
@@ -80,17 +75,33 @@ func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
80 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned75 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
81 }76 }
82 77
83-// RenderMode determines whether the daemon emits a complete terminal view or78+func validNavigationCapabilities(capabilities NavigationCapabilities) bool {
84-// only the private remote content surface requested by another daemon.79+ return capabilities&^(NavigationCapabilityHomePicker|NavigationCapabilityBack) == 0
85-type RenderMode uint880+}
86 81
87-const (82+func validStartupOverlay(overlay StartupOverlay) bool {
88- RenderModeFullTerminal RenderMode = iota83+ return overlay == StartupOverlayNone || overlay == StartupOverlaySessionPicker
89- RenderModeProxiedContent84+}
90-)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+}
91 102
92-func validRenderMode(mode RenderMode) bool {103+func validateHelloNavigation(h Hello) error {
93- return mode == RenderModeFullTerminal || mode == RenderModeProxiedContent104+ return ValidateNavigation(h.NavigationCapabilities, h.StartupOverlay, h.RemoteTarget != nil || h.EnvironmentPolicy == EnvironmentPolicyDaemonOwned)
94 }105 }
95 106
96 // Capability bits advertised in Welcome.107 // Capability bits advertised in Welcome.
@@ -127,7 +138,6 @@ const (
127 type Hello struct {138 type Hello struct {
128 Version uint16139 Version uint16
129 Intent uint8140 Intent uint8
130- RenderMode RenderMode
131 ClientID [16]byte141 ClientID [16]byte
132 ResumeToken uint64142 ResumeToken uint64
133 Name string143 Name string
@@ -144,8 +154,10 @@ type Hello struct {
144 // RemoteTarget is present only for a picker-selected remote attach. It is154 // RemoteTarget is present only for a picker-selected remote attach. It is
145 // carried through reconnects and is validated by the owning daemon before155 // carried through reconnects and is validated by the owning daemon before
146 // it creates or publishes any session resources.156 // it creates or publishes any session resources.
147- RemoteTarget *domain.RemoteSessionTarget157+ RemoteTarget *domain.RemoteSessionTarget
148- EnvironmentPolicy EnvironmentPolicy158+ EnvironmentPolicy EnvironmentPolicy
159+ NavigationCapabilities NavigationCapabilities
160+ StartupOverlay StartupOverlay
149 }161 }
150 162
151 // Input carries raw bytes typed/pasted by the client, destined for the PTY.163 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -236,7 +248,6 @@ type Welcome struct {
236 SessionName string248 SessionName string
237 Ephemeral bool249 Ephemeral bool
238 ResumeToken uint64250 ResumeToken uint64
239- RenderMode RenderMode
240 Capabilities uint32251 Capabilities uint32
241 }252 }
242 253
@@ -313,21 +324,6 @@ type OutputResetRequest struct{}
313 324
314 // SessionTabMeta describes one tab in a remote session's metadata snapshot.325 // SessionTabMeta describes one tab in a remote session's metadata snapshot.
315 // ID is stable for the lifetime of its remote tab and ordered only for display.326 // 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-
331 // payloadWriter builds a message payload by appending fields in wire order.327 // payloadWriter builds a message payload by appending fields in wire order.
332 type payloadWriter struct {328 type payloadWriter struct {
333 b []byte329 b []byte
@@ -384,9 +380,9 @@ func (w *payloadWriter) putLongBytes(b []byte) {
384 w.b = append(w.b, b...)380 w.b = append(w.b, b...)
385 }381 }
386 382
387-// marshalRemoteTargetSection writes the required v25 target/policy section.383+// marshalRemoteTargetSection writes the required v26 target/policy section.
388 // A target may be absent for direct local attaches, but its presence marker and384 // 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.
390 func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {386 func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {
391 if target == nil {387 if target == nil {
392 w.putBool(false)388 w.putBool(false)
@@ -699,15 +695,15 @@ func ValidateHello(h Hello) error {
699 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {695 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
700 return ErrInvalidHello696 return ErrInvalidHello
701 }697 }
702- if !validRenderMode(h.RenderMode) {
703- return ErrInvalidHello
704- }
705 if err := ValidateSize(h.Size); err != nil {698 if err := ValidateSize(h.Size); err != nil {
706 return fmt.Errorf("%w: size", ErrInvalidHello)699 return fmt.Errorf("%w: size", ErrInvalidHello)
707 }700 }
708 if !validEnvironmentPolicy(h.EnvironmentPolicy) {701 if !validEnvironmentPolicy(h.EnvironmentPolicy) {
709 return ErrInvalidHello702 return ErrInvalidHello
710 }703 }
704+ if err := validateHelloNavigation(h); err != nil {
705+ return fmt.Errorf("%w: navigation", ErrInvalidHello)
706+ }
711 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {707 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {
712 return ErrInvalidHello708 return ErrInvalidHello
713 }709 }
@@ -717,7 +713,7 @@ func ValidateHello(h Hello) error {
717 }713 }
718 }714 }
719 if h.RemoteTarget == nil {715 if h.RemoteTarget == nil {
720- if h.EnvironmentPolicy != EnvironmentPolicyClientOwned || h.RenderMode == RenderModeProxiedContent {716+ if h.EnvironmentPolicy != EnvironmentPolicyClientOwned && h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
721 return ErrInvalidHello717 return ErrInvalidHello
722 }718 }
723 return nil719 return nil
@@ -775,13 +771,13 @@ func ValidateAttachTarget(m AttachTarget) error {
775 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {771 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
776 return ErrInvalidAttachTarget772 return ErrInvalidAttachTarget
777 }773 }
774+ if m.Intent == IntentResume {
775+ return ErrInvalidAttachTarget
776+ }
778 if !validEnvironmentPolicy(m.EnvironmentPolicy) {777 if !validEnvironmentPolicy(m.EnvironmentPolicy) {
779 return ErrInvalidAttachTarget778 return ErrInvalidAttachTarget
780 }779 }
781 if m.RemoteTarget == nil {780 if m.RemoteTarget == nil {
782- if m.EnvironmentPolicy != EnvironmentPolicyClientOwned {
783- return ErrInvalidAttachTarget
784- }
785 return nil781 return nil
786 }782 }
787 if m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {783 if m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
@@ -814,7 +810,6 @@ func MarshalHello(h Hello) []byte {
814 w := payloadWriter{}810 w := payloadWriter{}
815 w.putUint16(h.Version)811 w.putUint16(h.Version)
816 w.putUint8(h.Intent)812 w.putUint8(h.Intent)
817- w.putUint8(uint8(h.RenderMode))
818 w.putBytes(h.ClientID[:])813 w.putBytes(h.ClientID[:])
819 w.putUint64(h.ResumeToken)814 w.putUint64(h.ResumeToken)
820 w.putString(h.Name)815 w.putString(h.Name)
@@ -829,6 +824,8 @@ func MarshalHello(h Hello) []byte {
829 w.putLongString(entry)824 w.putLongString(entry)
830 }825 }
831 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)826 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
827+ w.putUint8(uint8(h.NavigationCapabilities))
828+ w.putUint8(uint8(h.StartupOverlay))
832 return w.b829 return w.b
833 }830 }
834 831
@@ -844,13 +841,6 @@ func preflightHello(b []byte) error {
844 if _, err := r.getUint8(); err != nil {841 if _, err := r.getUint8(); err != nil {
845 return err842 return err
846 }843 }
847- mode, err := r.getUint8()
848- if err != nil {
849- return err
850- }
851- if !validRenderMode(RenderMode(mode)) {
852- return errInvalidEnum
853- }
854 if len(r.b) < 16 {844 if len(r.b) < 16 {
855 return errShortPayload845 return errShortPayload
856 }846 }
@@ -894,6 +884,12 @@ func preflightHello(b []byte) error {
894 if err := skipRemoteTargetSection(&r); err != nil {884 if err := skipRemoteTargetSection(&r); err != nil {
895 return err885 return err
896 }886 }
887+ if _, err := r.getUint8(); err != nil {
888+ return err
889+ }
890+ if _, err := r.getUint8(); err != nil {
891+ return err
892+ }
897 return r.done()893 return r.done()
898 }894 }
899 895
@@ -915,11 +911,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
915 if h.Intent, err = r.getUint8(); err != nil {911 if h.Intent, err = r.getUint8(); err != nil {
916 return Hello{}, err912 return Hello{}, err
917 }913 }
918- mode, err := r.getUint8()
919- if err != nil {
920- return Hello{}, err
921- }
922- h.RenderMode = RenderMode(mode)
923 clientID, err := r.getBytes(len(h.ClientID))914 clientID, err := r.getBytes(len(h.ClientID))
924 if err != nil {915 if err != nil {
925 return Hello{}, err916 return Hello{}, err
@@ -974,6 +965,16 @@ func UnmarshalHello(b []byte) (Hello, error) {
974 if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {965 if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
975 return Hello{}, err966 return Hello{}, err
976 }967 }
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)
977 if err := r.done(); err != nil {978 if err := r.done(); err != nil {
978 return Hello{}, err979 return Hello{}, err
979 }980 }
@@ -1258,9 +1259,6 @@ func UnmarshalAck(b []byte) (Ack, error) {
1258 1259
1259 // MarshalWelcome encodes m into a Welcome message payload.1260 // MarshalWelcome encodes m into a Welcome message payload.
1260 func MarshalWelcome(m Welcome) []byte {1261 func MarshalWelcome(m Welcome) []byte {
1261- if !validRenderMode(m.RenderMode) {
1262- return nil
1263- }
1264 w := payloadWriter{}1262 w := payloadWriter{}
1265 w.putString(m.SessionID)1263 w.putString(m.SessionID)
1266 w.putString(m.SessionName)1264 w.putString(m.SessionName)
@@ -1270,7 +1268,6 @@ func MarshalWelcome(m Welcome) []byte {
1270 w.putUint8(0)1268 w.putUint8(0)
1271 }1269 }
1272 w.putUint64(m.ResumeToken)1270 w.putUint64(m.ResumeToken)
1273- w.putUint8(uint8(m.RenderMode))
1274 w.putUint32(m.Capabilities)1271 w.putUint32(m.Capabilities)
1275 return w.b1272 return w.b
1276 }1273 }
@@ -1295,14 +1292,6 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
1295 if m.ResumeToken, err = r.getUint64(); err != nil {1292 if m.ResumeToken, err = r.getUint64(); err != nil {
1296 return Welcome{}, err1293 return Welcome{}, err
1297 }1294 }
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- }
1306 if m.Capabilities, err = r.getUint32(); err != nil {1295 if m.Capabilities, err = r.getUint32(); err != nil {
1307 return Welcome{}, err1296 return Welcome{}, err
1308 }1297 }
@@ -1532,111 +1521,6 @@ func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) {
1532 return OutputResetRequest{}, nil1521 return OutputResetRequest{}, nil
1533 }1522 }
1534 1523
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-
1640 // MarshalKill encodes m into a Kill message payload.1524 // MarshalKill encodes m into a Kill message payload.
1641 func MarshalKill(m Kill) []byte {1525 func MarshalKill(m Kill) []byte {
1642 w := payloadWriter{}1526 w := payloadWriter{}
@@ -1735,7 +1619,6 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
1735 }1619 }
1736 return Sessions{Sessions: sessions}, nil1620 return Sessions{Sessions: sessions}, nil
1737 }1621 }
1738-
1739 func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) }1622 func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) }
1740 1623
1741 func getInt16(r *payloadReader) (int, error) {1624 func getInt16(r *payloadReader) (int, error) {
@@ -2003,3 +1886,28 @@ func UnmarshalRemotePreview(data []byte) (RemotePreview, error) {
2003 }1886 }
2004 return p, nil1887 return p, nil
2005 }1888 }
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 (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 25 {14+ if ProtocolVersion != 26 {
15- t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 25, 2, 0}, make([]byte, 16+8)...)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)30+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
33 t.Fatalf("Hello bytes = %x, want %x", got, want)33 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -197,10 +197,6 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
197 }197 }
198 198
199 func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {199 func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
200- meta, err := MarshalSessionMeta(SessionMeta{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- }
204 tests := []struct {200 tests := []struct {
205 name string201 name string
206 payload []byte202 payload []byte
@@ -252,7 +248,6 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
252 }(),248 }(),
253 decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err },249 decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err },
254 },250 },
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 }},
256 {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 }},251 {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 }},
257 }252 }
258 for _, tt := range tests {253 for _, tt := range tests {
M internal/ports/wire_test.go +16 -117
@@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
73 TrueColor: true,73 TrueColor: true,
74 MaxOutputInFlight: 8,74 MaxOutputInFlight: 8,
75 },75 },
76- want: []byte{0x00, 0x01, 0x01, 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},
77 },77 },
78 {78 {
79 name: "empty strings",79 name: "empty strings",
@@ -86,14 +86,15 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
86 Cwd: "",86 Cwd: "",
87 TrueColor: false,87 TrueColor: false,
88 },88 },
89- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 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},
90 },90 },
91 }91 }
92 92
93 for _, tt := range tests {93 for _, tt := range tests {
94 t.Run(tt.name, func(t *testing.T) {94 t.Run(tt.name, func(t *testing.T) {
95+ want := append(append([]byte(nil), tt.want...), 0, 0)
95 got := MarshalHello(tt.msg)96 got := MarshalHello(tt.msg)
96- if !bytes.Equal(got, tt.want) {97+ if !bytes.Equal(got, want) {
97 t.Fatalf("MarshalHello() = %#v, want %#v", got, tt.want)98 t.Fatalf("MarshalHello() = %#v, want %#v", got, tt.want)
98 }99 }
99 back, err := UnmarshalHello(got)100 back, err := UnmarshalHello(got)
@@ -124,7 +125,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
124 }125 }
125 got := MarshalHello(msg)126 got := MarshalHello(msg)
126 want := []byte{127 want := []byte{
127- 0x00, 0x19, 0x00, 0x00, // version, intent, render mode128+ 0x00, 0x1a, 0x00, // version, intent, first client ID byte
128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token131 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -138,6 +139,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',139 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
139 0x00, 0x00, // no remote target, client-owned environment140 0x00, 0x00, // no remote target, client-owned environment
140 }141 }
142+ want = append(want, 0, 0)
141 require.Equal(t, want, got)143 require.Equal(t, want, got)
142 decoded, err := UnmarshalHello(want)144 decoded, err := UnmarshalHello(want)
143 require.NoError(t, err)145 require.NoError(t, err)
@@ -342,7 +344,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
342 }344 }
343 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)345 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
344 require.Equal(t, want, MarshalTheme(cleared))346 require.Equal(t, want, MarshalTheme(cleared))
345- require.Equal(t, uint16(25), ProtocolVersion)347+ require.Equal(t, uint16(26), ProtocolVersion)
346 }348 }
347 349
348 func TestResizeGoldenAndRoundTrip(t *testing.T) {350 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -422,12 +424,12 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
422 {424 {
423 name: "ephemeral",425 name: "ephemeral",
424 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},426 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},
426 },428 },
427 {429 {
428 name: "non-ephemeral empty name",430 name: "non-ephemeral empty name",
429 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},431 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},
431 },433 },
432 }434 }
433 435
@@ -452,13 +454,6 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
452 assertTrailingGarbageFails(t, full, UnmarshalWelcome)454 assertTrailingGarbageFails(t, full, UnmarshalWelcome)
453 }455 }
454 456
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-
462 func TestCommandErrorCodes(t *testing.T) {457 func TestCommandErrorCodes(t *testing.T) {
463 require.Equal(t, uint16(6), ErrUnknownCommand)458 require.Equal(t, uint16(6), ErrUnknownCommand)
464 require.Equal(t, uint16(7), ErrNotScriptable)459 require.Equal(t, uint16(7), ErrNotScriptable)
@@ -477,7 +472,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
477 name: "minimal",472 name: "minimal",
478 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},473 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
479 want: []byte{474 want: []byte{
480- 0x00, 0x19,475+ 0x00, 0x1a,
481 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID476 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
482 0x00, // attached477 0x00, // attached
483 0x00, // self478 0x00, // self
@@ -500,7 +495,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
500 JSON: true,495 JSON: true,
501 },496 },
502 want: []byte{497 want: []byte{
503- 0x00, 0x19,498+ 0x00, 0x1a,
504 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID499 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
505 0x00, // attached500 0x00, // attached
506 0x01, // self501 0x01, // self
@@ -898,9 +893,10 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
898 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {893 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
899 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}894 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
900 // The empty Hello has a fixed 1x1 size before the negotiated output window.895 // 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)
902 want[1] = 14898 want[1] = 14
903- want[31], want[33] = 1, 1899+ want[30], want[32] = 1, 1
904 got := MarshalHello(hello)900 got := MarshalHello(hello)
905 requireBytesEqual(t, want, got)901 requireBytesEqual(t, want, got)
906 back, err := UnmarshalHello(got)902 back, err := UnmarshalHello(got)
@@ -928,7 +924,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
928 name: "attached",924 name: "attached",
929 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},925 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
930 want: []byte{926 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,
932 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',928 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
933 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',929 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
934 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,930 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -938,7 +934,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
938 name: "control",934 name: "control",
939 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},935 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
940 want: []byte{936 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,
942 0x00, 0x02, 'l', 's', 0x00, 0x00,938 0x00, 0x02, 'l', 's', 0x00, 0x00,
943 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,939 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
944 },940 },
@@ -995,100 +991,3 @@ func TestOutputResetRequestStrict(t *testing.T) {
995 require.Equal(t, OutputResetRequest{}, back)991 require.Equal(t, OutputResetRequest{}, back)
996 assertTrailingGarbageFails(t, got, UnmarshalOutputResetRequest)992 assertTrailingGarbageFails(t, got, UnmarshalOutputResetRequest)
997 }993 }
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 {
40 return fmt.Sprintf("vev: %s", e.Text)40 return fmt.Sprintf("vev: %s", e.Text)
41 }41 }
42 42
43-// errRenderModeMismatch is a permanent attach failure: retrying the same
44-// request cannot make a daemon accept a different render contract.
45-var errRenderModeMismatch = errors.New("vev: daemon accepted render mode")
46-
47 // DetachedError reports that the daemon detached this client for a reason43 // DetachedError reports that the daemon detached this client for a reason
48 // other than an ordinary user-initiated detach.44 // other than an ordinary user-initiated detach.
49 type DetachedError struct {45 type DetachedError struct {
@@ -173,15 +169,22 @@ type Dependencies struct {
173 // client-only presentation metadata. RemoteTarget is an optional exact picker169 // client-only presentation metadata. RemoteTarget is an optional exact picker
174 // handoff and is serialized into Hello only when present.170 // handoff and is serialized into Hello only when present.
175 type AttachRequest struct {171 type AttachRequest struct {
176- Intent uint8172+ Intent uint8
177- SessionName string173+ SessionName string
178- RenderMode ports.RenderMode174+ Remote bool
179- Remote bool175+ RemoteTarget *domain.RemoteSessionTarget
180- RemoteTarget *domain.RemoteSessionTarget176+ EnvironmentPolicy ports.EnvironmentPolicy
181- EnvironmentPolicy ports.EnvironmentPolicy177+ NavigationCapabilities ports.NavigationCapabilities
178+ StartupOverlay ports.StartupOverlay
182 }179 }
183 180
184 // Runner owns the client lifecycle across one or more attachment attempts.181 // 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+
185 type Runner struct {188 type Runner struct {
186 dialer ports.Dialer189 dialer ports.Dialer
187 term ports.Terminal190 term ports.Terminal
@@ -232,33 +235,34 @@ func (r *Runner) terminalInput() *terminalInputPump {
232 }235 }
233 236
234 func validateAttachRequest(request AttachRequest) error {237 func validateAttachRequest(request AttachRequest) error {
235- switch request.RenderMode {
236- case ports.RenderModeFullTerminal, ports.RenderModeProxiedContent:
237- default:
238- return fmt.Errorf("vev: invalid render mode %d", request.RenderMode)
239- }
240 switch request.Intent {238 switch request.Intent {
241 case ports.IntentEphemeral:239 case ports.IntentEphemeral:
240+ if request.SessionName != "" {
241+ return errors.New("vev: ephemeral attach cannot name a session")
242+ }
242 case ports.IntentNew, ports.IntentAttach, ports.IntentResume:243 case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
243 if request.SessionName == "" {244 if request.SessionName == "" {
244- return domain.ErrInvalidSessionName245+ return errors.New("vev: session name is required")
245 }246 }
246 default:247 default:
247 return fmt.Errorf("vev: invalid session intent %d", request.Intent)248 return fmt.Errorf("vev: invalid session intent %d", request.Intent)
248 }249 }
249 if request.SessionName != "" {250 if request.SessionName != "" {
250 if err := domain.ValidateSessionName(request.SessionName); err != nil {251 if err := domain.ValidateSessionName(request.SessionName); err != nil {
251- return err252+ return fmt.Errorf("vev: invalid session name: %w", err)
252 }253 }
253 }254 }
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+ }
254 if request.RemoteTarget == nil {258 if request.RemoteTarget == nil {
255- if request.RenderMode == ports.RenderModeProxiedContent {259+ if request.EnvironmentPolicy == ports.EnvironmentPolicyClientOwned {
256- return errors.New("vev: proxied attach request requires remote target")260+ return nil
257 }261 }
258- if request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned {262+ if request.Remote && request.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned {
259- return errors.New("vev: attach request without remote target requires client-owned environment")263+ return nil
260 }264 }
261- return nil265+ return errors.New("vev: attach without remote target requires a matching environment policy")
262 }266 }
263 if err := request.RemoteTarget.Validate(); err != nil {267 if err := request.RemoteTarget.Validate(); err != nil {
264 return fmt.Errorf("vev: invalid remote attach target: %w", err)268 return fmt.Errorf("vev: invalid remote attach target: %w", err)
@@ -270,7 +274,7 @@ func validateAttachRequest(request AttachRequest) error {
270 return errors.New("vev: remote attach target requires daemon-owned environment")274 return errors.New("vev: remote attach target requires daemon-owned environment")
271 }275 }
272 if request.Intent != ports.IntentAttach && request.Intent != ports.IntentResume {276 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")
274 }278 }
275 return nil279 return nil
276 }280 }
@@ -310,6 +314,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
310 resumeToken := uint64(0)314 resumeToken := uint64(0)
311 attemptRequest := request315 attemptRequest := request
312 dialer := r.dialer316 dialer := r.dialer
317+ var homeRoute *attachRoute
318+ var returnRoute *attachRoute
319+ returnResumeFallback := false
320+ homeNavigationPending := false
321+ returnNavigationPending := false
313 backoff := defaultReconnectBackoff.initial322 backoff := defaultReconnectBackoff.initial
314 themeState := &terminalThemeState{}323 themeState := &terminalThemeState{}
315 var rememberOnce sync.Once324 var rememberOnce sync.Once
@@ -412,7 +421,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
412 return errors.New("vev: dialer returned nil transport")421 return errors.New("vev: dialer returned nil transport")
413 }422 }
414 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)423 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+ })
416 if err != nil {428 if err != nil {
417 stopHandshakeTransport()429 stopHandshakeTransport()
418 finishHandshake()430 finishHandshake()
@@ -453,14 +465,69 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
453 if !result.transportClosed {465 if !result.transportClosed {
454 _ = connection.Close()466 _ = connection.Close()
455 }467 }
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+ }
456 if result.target != nil {507 if result.target != nil {
457 if r.attachHandoff == nil {508 if r.attachHandoff == nil {
458 return &AttachTargetError{Target: *result.target}509 return &AttachTargetError{Target: *result.target}
459 }510 }
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+ }
460 nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target)520 nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target)
461 if handoffErr != nil {521 if handoffErr != nil {
462 return handoffErr522 return handoffErr
463 }523 }
524+ if homeRoute != nil {
525+ nextRequest.NavigationCapabilities |= ports.NavigationCapabilityHomePicker
526+ }
527+ if attemptRequest.StartupOverlay == ports.StartupOverlaySessionPicker {
528+ returnRoute = nil
529+ returnResumeFallback = false
530+ }
464 nextRequest = cloneAttachRequest(nextRequest)531 nextRequest = cloneAttachRequest(nextRequest)
465 if err := validateAttachRequest(nextRequest); err != nil {532 if err := validateAttachRequest(nextRequest); err != nil {
466 return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)533 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)
470 }537 }
471 dialer = nextDialer538 dialer = nextDialer
472 attemptRequest = nextRequest539 attemptRequest = nextRequest
473- remote = attemptRequest.Remote || r.remote540+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
474- reconnect.remote = remote
475 resumeToken = 0541 resumeToken = 0
476 backoff = defaultReconnectBackoff.initial542 backoff = defaultReconnectBackoff.initial
477 continue543 continue
@@ -483,11 +549,58 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
483 attemptRequest.SessionName = result.sessionName549 attemptRequest.SessionName = result.sessionName
484 }550 }
485 if result.err == nil {551 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+ }
486 if clearErr := reconnect.clear(); clearErr != nil {560 if clearErr := reconnect.clear(); clearErr != nil {
487 return clearErr561 return clearErr
488 }562 }
489 return nil563 return nil
490 }564 }
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+ }
491 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {604 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
492 if clearErr := reconnect.clear(); clearErr != nil {605 if clearErr := reconnect.clear(); clearErr != nil {
493 return errors.Join(result.err, clearErr)606 return errors.Join(result.err, clearErr)
@@ -524,6 +637,13 @@ type reconnectUI struct {
524 stage reconnectStage637 stage reconnectStage
525 }638 }
526 639
640+func syncReconnectRemote(reconnect *reconnectUI, remote bool) bool {
641+ if reconnect != nil {
642+ reconnect.remote = remote
643+ }
644+ return remote
645+}
646+
527 func (u *reconnectUI) redraw(size domain.Size) error {647 func (u *reconnectUI) redraw(size domain.Size) error {
528 if !*u.rawEntered || !u.remote {648 if !*u.rawEntered || !u.remote {
529 return nil649 return nil
@@ -656,6 +776,7 @@ type attachResult struct {
656 welcomed bool776 welcomed bool
657 transportClosed bool777 transportClosed bool
658 target *ports.AttachTarget778 target *ports.AttachTarget
779+ action ports.NavigationAction
659 err error780 err error
660 }781 }
661 782
@@ -696,11 +817,10 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
696 817
697 func (a *attachAttempt) run(ctx context.Context) attachResult {818 func (a *attachAttempt) run(ctx context.Context) attachResult {
698 transport := a.transport819 transport := a.transport
699- request := a.request
700 if a.connection != nil {820 if a.connection != nil {
701 transport = a.connection.Transport()821 transport = a.connection.Transport()
702- request = a.connection.AttachRequest()
703 }822 }
823+ request := a.request
704 term := a.runner.term824 term := a.runner.term
705 clk := a.runner.clock825 clk := a.runner.clock
706 intent := request.Intent826 intent := request.Intent
@@ -712,7 +832,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
712 enterRaw := a.enterRaw832 enterRaw := a.enterRaw
713 reconnect := a.reconnect833 reconnect := a.reconnect
714 linkEvents := a.linkEvents834 linkEvents := a.linkEvents
715- remote := a.remote || request.Remote835+ remote := a.remote || a.request.Remote
716 clipboard := a.runner.clipboard836 clipboard := a.runner.clipboard
717 log := a.runner.logger837 log := a.runner.logger
718 observer := a.runner.runtimeObserver838 observer := a.runner.runtimeObserver
@@ -765,20 +885,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
765 trueColor := DetectTrueColor(termEnv, colorTerm)885 trueColor := DetectTrueColor(termEnv, colorTerm)
766 themeState.setTrueColor(trueColor)886 themeState.setTrueColor(trueColor)
767 hello := ports.Hello{887 hello := ports.Hello{
768- Version: ports.ProtocolVersion,888+ Version: ports.ProtocolVersion,
769- Intent: intent,889+ Intent: intent,
770- ClientID: clientID,890+ ClientID: clientID,
771- ResumeToken: resumeToken,891+ ResumeToken: resumeToken,
772- Name: name,892+ Name: name,
773- RenderMode: request.RenderMode,893+ Size: size,
774- Size: size,894+ TermEnv: termEnv,
775- TermEnv: termEnv,895+ Cwd: cwd,
776- Cwd: cwd,896+ TrueColor: trueColor,
777- TrueColor: trueColor,897+ MaxOutputInFlight: requestedOutputWindow(transport),
778- MaxOutputInFlight: requestedOutputWindow(transport),898+ Env: os.Environ(),
779- Env: os.Environ(),899+ RemoteTarget: request.RemoteTarget,
780- RemoteTarget: request.RemoteTarget,900+ EnvironmentPolicy: request.EnvironmentPolicy,
781- EnvironmentPolicy: request.EnvironmentPolicy,901+ NavigationCapabilities: request.NavigationCapabilities,
902+ StartupOverlay: request.StartupOverlay,
782 }903 }
783 if err := sendHandshake(func() error {904 if err := sendHandshake(func() error {
784 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})905 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
@@ -805,9 +926,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
805 if derr != nil {926 if derr != nil {
806 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))927 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
807 }928 }
808- if welcome.RenderMode != request.RenderMode {
809- return result(false, fmt.Errorf("%w %d, want %d", errRenderModeMismatch, welcome.RenderMode, request.RenderMode))
810- }
811 resumeToken = welcome.ResumeToken929 resumeToken = welcome.ResumeToken
812 name = welcome.SessionName930 name = welcome.SessionName
813 ms.welcomed = true931 ms.welcomed = true
@@ -1196,6 +1314,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1196 handoff := welcomedResult(nil)1314 handoff := welcomedResult(nil)
1197 handoff.target = &target1315 handoff.target = &target
1198 return handoff1316 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
1199 case ports.MsgDetached:1325 case ports.MsgDetached:
1200 d, derr := ports.UnmarshalDetached(r.frame.Payload)1326 d, derr := ports.UnmarshalDetached(r.frame.Payload)
1201 if derr != nil {1327 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 }
41 func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }41 func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
42 func (r *realTimer) Stop() bool { return r.t.Stop() }42 func (r *realTimer) Stop() bool { return r.t.Stop() }
43 43
44-const runnerInitialBackoff = 100 * time.Millisecond
45-
46-type runnerTestClock struct {
47- handshake chan *runnerTestTimer
48- palette chan *runnerTestTimer
49- reconnect chan *runnerTestTimer
50-}
51-
52-type runnerTestTimer struct {
53- ch chan time.Time
54- duration time.Duration
55-}
56-
57-func newRunnerTestClock() *runnerTestClock {
58- return &runnerTestClock{
59- handshake: make(chan *runnerTestTimer, 4),
60- palette: make(chan *runnerTestTimer, 8),
61- reconnect: make(chan *runnerTestTimer, 16),
62- }
63-}
64-
65-func (*runnerTestClock) Now() time.Time { return time.Time{} }
66-func (c *runnerTestClock) NewTimer(duration time.Duration) ports.Timer {
67- timer := &runnerTestTimer{ch: make(chan time.Time, 1), duration: duration}
68- if duration == ports.HandshakeTimeout {
69- c.handshake <- timer
70- } else if duration == 200*time.Millisecond {
71- c.palette <- timer
72- } else {
73- c.reconnect <- timer
74- }
75- return timer
76-}
77-func (t *runnerTestTimer) C() <-chan time.Time { return t.ch }
78-func (*runnerTestTimer) Reset(time.Duration) bool { return false }
79-func (*runnerTestTimer) Stop() bool { return true }
80-func (t *runnerTestTimer) fire() { t.ch <- time.Time{} }
81-
82 // recvItem is one scripted Recv result.44 // recvItem is one scripted Recv result.
83 type recvItem struct {45 type recvItem struct {
84 f ports.Frame46 f ports.Frame
@@ -384,6 +346,35 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
384 }346 }
385 }347 }
386 348
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+
387 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {378 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
388 var out bytes.Buffer379 var out bytes.Buffer
389 var restoreCount atomic.Int32380 var restoreCount atomic.Int32
@@ -827,15 +818,11 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
827 type recordingTransport struct {818 type recordingTransport struct {
828 recvs []recvItem819 recvs []recvItem
829 sends []ports.Frame820 sends []ports.Frame
830- onSend func(ports.Frame)
831 closed atomic.Int32821 closed atomic.Int32
832 }822 }
833 823
834 func (t *recordingTransport) Send(f ports.Frame) error {824 func (t *recordingTransport) Send(f ports.Frame) error {
835 t.sends = append(t.sends, f)825 t.sends = append(t.sends, f)
836- if t.onSend != nil {
837- t.onSend(f)
838- }
839 return nil826 return nil
840 }827 }
841 828
@@ -888,203 +875,33 @@ func welcomeFrame(token uint64) ports.Frame {
888 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))875 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
889 }876 }
890 877
891-func TestRunReconnectsPreservesRichRemoteTarget(t *testing.T) {878+func TestRunReconnectsWithRotatedTokenAndSameClientID(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- }
1013 term := newRunTerminal()879 term := newRunTerminal()
1014 defer term.in.unblock()880 defer term.in.unblock()
1015- tr := &recordingTransport{recvs: []recvItem{{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{881+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
1016- SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeFullTerminal,882+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
1017- }))}}}883+ tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
1018-884+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
1019- err := runTestClient(context.Background(), testDependencies(&sequenceDialer{trs: []ports.Transport{tr}}, term, realClock{}, nil, nil), client.AttachRequest{
1020- Intent: ports.IntentAttach, SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
1021- RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1022- })
1023- require.ErrorContains(t, err, "daemon accepted render mode")
1024- require.Zero(t, term.rawCount.Load())
1025-}
1026 885
1027-func TestRunnerRejectsInvalidAttachRequestBeforeHello(t *testing.T) {886+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
1028- lifecycle := domain.SessionLifecycleID{}887+ require.NoError(t, err)
1029- lifecycle[0] = 1888+ require.Equal(t, int32(3), d.calls.Load())
1030- validTarget := domain.RemoteSessionTarget{889+ require.Equal(t, int32(1), term.rawCount.Load())
1031- Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,890+ require.Equal(t, int32(1), term.restoreCount.Load())
1032- SessionName: "main", LiveTabID: "tab-live",
1033- }
1034- tests := []struct {
1035- name string
1036- request client.AttachRequest
1037- wantErr string
1038- }{
1039- {
1040- name: "invalid render mode",
1041- request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", RenderMode: ports.RenderMode(99)},
1042- wantErr: "vev: invalid render mode",
1043- },
1044- {
1045- name: "invalid session intent",
1046- request: client.AttachRequest{Intent: 99, SessionName: "main"},
1047- wantErr: "vev: invalid session intent",
1048- },
1049- {
1050- name: "missing daemon-owned policy",
1051- request: client.AttachRequest{
1052- Intent: ports.IntentAttach,
1053- SessionName: "main",
1054- RemoteTarget: &validTarget,
1055- },
1056- wantErr: "remote attach target requires daemon-owned environment",
1057- },
1058- {
1059- name: "target session mismatch",
1060- request: client.AttachRequest{
1061- Intent: ports.IntentAttach,
1062- SessionName: "other",
1063- RemoteTarget: &validTarget,
1064- EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1065- },
1066- wantErr: "remote attach target session mismatch",
1067- },
1068- {
1069- name: "malformed target",
1070- request: client.AttachRequest{
1071- Intent: ports.IntentAttach,
1072- SessionName: "main",
1073- RemoteTarget: &domain.RemoteSessionTarget{SessionName: "main", LiveTabID: "tab-live"},
1074- EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1075- },
1076- wantErr: "invalid remote attach target",
1077- },
1078- }
1079 891
1080- for _, tt := range tests {892+ h1 := helloFromSend(t, tr1)
1081- t.Run(tt.name, func(t *testing.T) {893+ h2 := helloFromSend(t, tr2)
1082- dialer := &sequenceDialer{}894+ h3 := helloFromSend(t, tr3)
1083- err := client.NewRunner(client.Dependencies{Dialer: dialer}).Run(context.Background(), tt.request)895+ require.Equal(t, ports.IntentAttach, h1.Intent)
1084- require.ErrorContains(t, err, tt.wantErr)896+ require.Zero(t, h1.ResumeToken)
1085- require.Zero(t, dialer.calls.Load(), "invalid request must be rejected before dialing and Hello")897+ require.Equal(t, ports.IntentResume, h2.Intent)
1086- })898+ require.Equal(t, uint64(11), h2.ResumeToken)
1087- }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")
1088 }905 }
1089 906
1090 func TestAttachRememberRemoteHost(t *testing.T) {907 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 {
35 func newAttachPaletteClock() *attachPaletteClock {35 func newAttachPaletteClock() *attachPaletteClock {
36 return &attachPaletteClock{36 return &attachPaletteClock{
37 timers: make(chan *attachPaletteTimer, 16),37 timers: make(chan *attachPaletteTimer, 16),
38- handshakeTimers: make(chan *attachPaletteTimer, 4),38+ handshakeTimers: make(chan *attachPaletteTimer, 1),
39 }39 }
40 }40 }
41 41
M internal/usecase/client/reconnect.go +0 -3
@@ -56,9 +56,6 @@ func shouldReconnect(err error) bool {
56 if _, ok := errors.AsType[*ProtocolError](err); ok {56 if _, ok := errors.AsType[*ProtocolError](err); ok {
57 return false57 return false
58 }58 }
59- if errors.Is(err, errRenderModeMismatch) {
60- return false
61- }
62 _, ok := errors.AsType[*DetachedError](err)59 _, ok := errors.AsType[*DetachedError](err)
63 return !ok60 return !ok
64 }61 }
M internal/usecase/client/remote_attach_validation_test.go +26 -0
@@ -1,6 +1,8 @@
1 package client1 package client
2 2
3 import (3 import (
4+ "context"
5+ "fmt"
4 "testing"6 "testing"
5 7
6 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
@@ -9,6 +11,30 @@ import (
9 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
10 )12 )
11 13
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+
12 func TestValidateAttachRequestRequiresDaemonOwnedEnvironmentForRemoteTarget(t *testing.T) {38 func TestValidateAttachRequestRequiresDaemonOwnedEnvironmentForRemoteTarget(t *testing.T) {
13 var lifecycle domain.SessionLifecycleID39 var lifecycle domain.SessionLifecycleID
14 lifecycle[0] = 140 lifecycle[0] = 1
M internal/usecase/client/session_connection.go +42 -17
@@ -4,38 +4,52 @@ import (
4 "errors"4 "errors"
5 "fmt"5 "fmt"
6 6
7+ "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 )9 )
9 10
10 var errNilSessionTransport = errors.New("client: nil session transport")11 var errNilSessionTransport = errors.New("client: nil session transport")
11 12
12-func cloneAttachRequest(request AttachRequest) AttachRequest {13+// SessionTarget is the validated daemon-facing session selection. Endpoint
13- if request.RemoteTarget != nil {14+// details are resolved before a transport is opened and never enter this type.
14- target := *request.RemoteTarget15+type SessionTarget struct {
15- request.RemoteTarget = &target16+ 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)
16 }32 }
17- return request33+ return domain.ValidateSessionName(t.SessionName)
18 }34 }
19 35
20-// SessionConnection owns one already-open transport and the complete36+// SessionConnection owns one already-open transport and its daemon-facing
21-// validated daemon-facing attach request. Local sockets and remote carriages37+// session target. Local sockets and remote carriages use this same owner.
22-// use this same owner.
23 type SessionConnection struct {38 type SessionConnection struct {
24 transport ports.Transport39 transport ports.Transport
25- request AttachRequest40+ target SessionTarget
26 }41 }
27 42
28 // NewSessionConnection creates a connection for an already-selected transport.43 // NewSessionConnection creates a connection for an already-selected transport.
29-// Request validation happens before the connection is used for a Hello.44+// Target validation happens before the connection is used for a Hello.
30-func NewSessionConnection(transport ports.Transport, request AttachRequest) (*SessionConnection, error) {45+func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) {
31 if transport == nil {46 if transport == nil {
32 return nil, errNilSessionTransport47 return nil, errNilSessionTransport
33 }48 }
34- request = cloneAttachRequest(request)49+ if err := target.validate(); err != nil {
35- if err := validateAttachRequest(request); err != nil {50+ return nil, fmt.Errorf("invalid session target: %w", err)
36- return nil, fmt.Errorf("invalid attach request: %w", err)
37 }51 }
38- return &SessionConnection{transport: transport, request: request}, nil52+ return &SessionConnection{transport: transport, target: target}, nil
39 }53 }
40 54
41 // Transport returns the owned carriage for this connection.55 // Transport returns the owned carriage for this connection.
@@ -46,12 +60,23 @@ func (c *SessionConnection) Transport() ports.Transport {
46 return c.transport60 return c.transport
47 }61 }
48 62
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.
50 func (c *SessionConnection) AttachRequest() AttachRequest {65 func (c *SessionConnection) AttachRequest() AttachRequest {
51 if c == nil {66 if c == nil {
52 return AttachRequest{}67 return AttachRequest{}
53 }68 }
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
55 }80 }
56 81
57 // Close releases the owned transport.82 // Close releases the owned transport.
M internal/usecase/client/session_connection_test.go +16 -47
@@ -3,77 +3,46 @@ package client_test
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
8 portsmocks "github.com/bnema/vev/internal/ports/mocks"7 portsmocks "github.com/bnema/vev/internal/ports/mocks"
9 "github.com/bnema/vev/internal/usecase/client"8 "github.com/bnema/vev/internal/usecase/client"
10 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
11 )10 )
12 11
13-func TestSessionConnectionOwnsCompleteAttachRequestForLocalAndRemoteTransports(t *testing.T) {12+func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) {
14- var lifecycle domain.SessionLifecycleID13+ target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}
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
31 local := portsmocks.NewMockTransport(t)14 local := portsmocks.NewMockTransport(t)
32 remote := portsmocks.NewMockTransport(t)15 remote := portsmocks.NewMockTransport(t)
33 16
34- localConnection, err := client.NewSessionConnection(local, request)17+ localConnection, err := client.NewSessionConnection(local, target)
35 require.NoError(t, err)18 require.NoError(t, err)
36- remoteConnection, err := client.NewSessionConnection(remote, request)19+ remoteConnection, err := client.NewSessionConnection(remote, target)
37 require.NoError(t, err)20 require.NoError(t, err)
38 21
39- target.LiveTabID = "mutated-tab"
40- target.LifecycleID = domain.SessionLifecycleID{}
41- returned := localConnection.AttachRequest()
42- returned.RemoteTarget.LiveTabID = "mutated-returned-tab"
43-
44 require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest())22 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())
46 require.Same(t, local, localConnection.Transport())24 require.Same(t, local, localConnection.Transport())
47 require.Same(t, remote, remoteConnection.Transport())25 require.Same(t, remote, remoteConnection.Transport())
48 }26 }
49 27
50-func TestSessionConnectionAttachRequestValidation(t *testing.T) {28+func TestSessionTargetValidation(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- }
56 tests := []struct {29 tests := []struct {
57 name string30 name string
58- request client.AttachRequest31+ target client.SessionTarget
59 wantErr bool32 wantErr bool
60 }{33 }{
61- {name: "ephemeral", request: client.AttachRequest{Intent: ports.IntentEphemeral}},34+ {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}},
62- {name: "new", request: client.AttachRequest{Intent: ports.IntentNew, SessionName: "work"}},35+ {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}},
63- {name: "attach", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},36+ {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}},
64- {name: "resume", request: client.AttachRequest{Intent: ports.IntentResume, SessionName: "work"}},37+ {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}},
65- {name: "missing named session", request: client.AttachRequest{Intent: ports.IntentAttach}, wantErr: true},38+ {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true},
66- {name: "unsafe session", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},39+ {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
67- {name: "unknown intent", request: client.AttachRequest{Intent: 99, SessionName: "work"}, wantErr: true},40+ {name: "unknown intent", target: client.SessionTarget{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},
72 }41 }
73 for _, tt := range tests {42 for _, tt := range tests {
74 t.Run(tt.name, func(t *testing.T) {43 t.Run(tt.name, func(t *testing.T) {
75 transport := portsmocks.NewMockTransport(t)44 transport := portsmocks.NewMockTransport(t)
76- _, err := client.NewSessionConnection(transport, tt.request)45+ _, err := client.NewSessionConnection(transport, tt.target)
77 if tt.wantErr {46 if tt.wantErr {
78 require.Error(t, err)47 require.Error(t, err)
79 return48 return
@@ -84,6 +53,6 @@ func TestSessionConnectionAttachRequestValidation(t *testing.T) {
84 }53 }
85 54
86 func TestSessionConnectionRequiresTransport(t *testing.T) {55 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})
88 require.Error(t, err)57 require.Error(t, err)
89 }58 }
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
3 import "github.com/bnema/vev/internal/ports"3 import "github.com/bnema/vev/internal/ports"
4 4
5 // attachmentConnectionToken is the exact capability for one registered5 // attachmentConnectionToken is the exact capability for one registered
6-// attachment connection incarnation. It is invalid as soon as the owner,6+// attachment connection incarnation. It is invalid as soon as any of the
7-// attachment membership, connection generation, transport link, or7+// session, attachment membership, connection generation, transport link, or
8 // coordinator lease changes.8 // coordinator lease changes.
9 type attachmentConnectionToken struct {9 type attachmentConnectionToken struct {
10- owner attachmentOwner10+ sess *session
11 ac *attachedClient11 ac *attachedClient
12 generation uint6412 generation uint64
13 transport transportSnapshot13 transport transportSnapshot
@@ -18,19 +18,11 @@ type attachmentConnectionToken struct {
18 rebase bool18 rebase bool
19 }19 }
20 20
21-// attachmentToken retains the local-session constructor for the existing
22-// local route. Remote-view token construction is added with remote attachment
23-// membership in Phase 4; it must not manufacture a local-session capability.
24 func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {21 func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
25- return attachmentOwnerToken(entry, ac, tr)22+ if entry == nil || entry.core() == nil || ac == nil || tr == nil {
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 {
31 return attachmentConnectionToken{}23 return attachmentConnectionToken{}
32 }24 }
33- // Capture one concrete link incarnation before taking the owner lock. The25+ // Capture one concrete link incarnation before taking the session lock. The
34 // same snapshot is revalidated below; do not take a second snapshot and26 // same snapshot is revalidated below; do not take a second snapshot and
35 // accidentally bind a replacement link.27 // accidentally bind a replacement link.
36 transport := ac.transportSnapshot()28 transport := ac.transportSnapshot()
@@ -40,38 +32,23 @@ func attachmentOwnerToken(owner attachmentOwner, ac *attachedClient, tr ports.Tr
40 if ac.beforeAttachmentTokenValidation != nil {32 if ac.beforeAttachmentTokenValidation != nil {
41 ac.beforeAttachmentTokenValidation()33 ac.beforeAttachmentTokenValidation()
42 }34 }
43-35+ core := entry.core()
44- token := attachmentConnectionToken{owner: owner, ac: ac, transport: transport}36+ core.mu.Lock()
45- switch entry := owner.(type) {37+ if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) {
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- }
62 core.mu.Unlock()38 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:
73 return attachmentConnectionToken{}39 return attachmentConnectionToken{}
74 }40 }
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()
75 if !token.current() {52 if !token.current() {
76 return attachmentConnectionToken{}53 return attachmentConnectionToken{}
77 }54 }
@@ -83,39 +60,27 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
83 return attachmentToken(s, ac, tr)60 return attachmentToken(s, ac, tr)
84 }61 }
85 62
86-// localSession narrows a token only for explicitly local operations. It is63+// current validates every immutable identity captured by the token. Membership
87-// intentionally nil for a remote owner, so local PTY/persistence paths fail64+// is the sole authority; there is no owner, replacement, or compatibility role.
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.
96 func (t attachmentConnectionToken) current() bool {65 func (t attachmentConnectionToken) current() bool {
97- return t.owner != nil && t.ac != nil &&66+ return t.sess != nil && t.ac != nil &&
98 t.ac.connectionGeneration.Load() == t.generation &&67 t.ac.connectionGeneration.Load() == t.generation &&
99- sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) &&68+ t.ac.currentAttachmentSession() == t.sess &&
100 t.ac.transportSnapshotCurrent(t.transport) &&69 t.ac.transportSnapshotCurrent(t.transport) &&
101- attachmentOwnerRegistered(t.owner, t.ac)70+ attachmentRegistered(t.sess, t.ac)
102 }71 }
103 72
104 // attachmentCurrent additionally validates the optional coordinator lease used73 // attachmentCurrent additionally validates the optional coordinator lease used
105 // by render and resize effects. Attachments without that optional lease still74 // by render and resize effects. Attachments without that optional lease still
106 // own their independent connection lifecycle.75 // own their independent connection lifecycle.
107 func (t attachmentConnectionToken) attachmentCurrent() bool {76 func (t attachmentConnectionToken) attachmentCurrent() bool {
108- if !t.current() {77+ if !t.current() || t.ac.currentAttachmentSession() != t.sess {
109 return false78 return false
110 }79 }
111 if t.lease == nil {80 if t.lease == nil {
112 return true81 return true
113 }82 }
114- entry := t.localSession()83+ rc := t.sess.core().coordinator.Load()
115- if entry == nil {
116- return false
117- }
118- rc := entry.core().coordinator.Load()
119 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)84 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
120 }85 }
121 86
@@ -143,27 +108,23 @@ func (t *attachmentConnectionToken) endAttachmentEffect() {
143 t.effect = nil108 t.effect = nil
144 }109 }
145 110
146-// attachmentEffectCurrentSessionLocked is the same check at a local111+// attachmentEffectCurrentSessionLocked is the same check at a session-owned
147-// session-owned mutation boundary where sess.core().mu is already held.112+// mutation boundary where sess.core().mu is already held.
148 func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {113 func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {
149- entry := t.localSession()
150- if entry == nil {
151- return false
152- }
153 if t.effect != nil && !t.effect.ended.Load() {114 if t.effect != nil && !t.effect.ended.Load() {
154 return true115 return true
155 }116 }
156- if t.ac == nil ||117+ if t.sess == nil || t.ac == nil ||
157 t.ac.connectionGeneration.Load() != t.generation ||118 t.ac.connectionGeneration.Load() != t.generation ||
158 !t.ac.transportSnapshotCurrent(t.transport) ||119 !t.ac.transportSnapshotCurrent(t.transport) ||
159- !sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) ||120+ t.ac.currentAttachmentSession() != t.sess ||
160- !attachmentRegisteredLocked(entry, t.ac) {121+ !attachmentRegisteredLocked(t.sess, t.ac) {
161 return false122 return false
162 }123 }
163 if t.lease == nil {124 if t.lease == nil {
164 return true125 return true
165 }126 }
166- rc := entry.core().coordinator.Load()127+ rc := t.sess.core().coordinator.Load()
167 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)128 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
168 }129 }
169 130
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 (
18 // effect admission. It deliberately contains no independently mutable state:18 // effect admission. It deliberately contains no independently mutable state:
19 // a ticket is admitted only when every field matches the frame's token.19 // a ticket is admitted only when every field matches the frame's token.
20 type attachmentCapability struct {20 type attachmentCapability struct {
21- owner attachmentOwner21+ sess *session
22 generation uint6422 generation uint64
23 transport transportSnapshot23 transport transportSnapshot
24 lease *attachmentLease24 lease *attachmentLease
@@ -26,7 +26,7 @@ type attachmentCapability struct {
26 26
27 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {27 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
28 return attachmentCapability{28 return attachmentCapability{
29- owner: token.owner,29+ sess: token.sess,
30 generation: token.generation,30 generation: token.generation,
31 transport: token.transport,31 transport: token.transport,
32 lease: token.lease,32 lease: token.lease,
@@ -34,7 +34,7 @@ func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
34 }34 }
35 35
36 func (c attachmentCapability) matches(token attachmentConnectionToken) bool {36 func (c attachmentCapability) matches(token attachmentConnectionToken) bool {
37- return token.ac != nil && sameAttachmentOwner(c.owner, token.owner) &&37+ return token.ac != nil && c.sess == token.sess &&
38 c.generation == token.generation && c.transport.transport == token.transport.transport &&38 c.generation == token.generation && c.transport.transport == token.transport.transport &&
39 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease39 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
40 }40 }
@@ -219,19 +219,11 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken)
219 // after Welcome has completed so a replacement blocked behind that send can219 // after Welcome has completed so a replacement blocked behind that send can
220 // publish before readiness or first paint.220 // publish before readiness or first paint.
221 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {221 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
222- return ac.beginCurrentAttachmentOwnerEffect(sess, tr)222+ if ac == nil || sess == nil || tr == nil {
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 {
231 return attachmentConnectionToken{}, nil, false223 return attachmentConnectionToken{}, nil, false
232 }224 }
233 for {225 for {
234- token := attachmentOwnerToken(owner, ac, tr)226+ token := sess.attachmentToken(ac, tr)
235 if token.ac == nil {227 if token.ac == nil {
236 return token, nil, false228 return token, nil, false
237 }229 }
@@ -279,13 +271,13 @@ func (ac *attachedClient) publishAttachmentCapability(token attachmentConnection
279 // bootstrapAttachmentCapability supports direct/headless session construction. It271 // bootstrapAttachmentCapability supports direct/headless session construction. It
280 // never changes an existing or frozen production publication.272 // never changes an existing or frozen production publication.
281 func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {273 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 {
283 return275 return
284 }276 }
285 g := &ac.attachmentEffects277 g := &ac.attachmentEffects
286 g.mu.Lock()278 g.mu.Lock()
287 g.initLocked()279 g.initLocked()
288- if g.phase == attachmentEffectsStable && g.capability.owner == nil {280+ if g.phase == attachmentEffectsStable && g.capability.sess == nil {
289 g.capability = capabilityFromToken(token)281 g.capability = capabilityFromToken(token)
290 g.failedTransport = transportSnapshot{}282 g.failedTransport = transportSnapshot{}
291 }283 }
M internal/usecase/daemon/attachment_effect_gate_test.go +38 -0
@@ -667,6 +667,44 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
667 }667 }
668 }668 }
669 669
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+
670 func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {708 func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
671 p1, release1 := newBlockingPTY(t)709 p1, release1 := newBlockingPTY(t)
672 p2, release2 := newBlockingPTY(t)710 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 {
23 revision uint6423 revision uint64
24 }24 }
25 25
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-
33 func (ac *attachedClient) viewSnapshot() attachmentView {26 func (ac *attachedClient) viewSnapshot() attachmentView {
34 if ac == nil {27 if ac == nil {
35 return attachmentView{}28 return attachmentView{}
@@ -338,18 +331,6 @@ func (s *session) snapshotAttachments() []*attachedClient {
338 return s.snapshotAttachmentsLocked()331 return s.snapshotAttachmentsLocked()
339 }332 }
340 333
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-
353 // repairAttachmentViewLocked validates stable targets and chooses the nearest334 // repairAttachmentViewLocked validates stable targets and chooses the nearest
354 // surviving target when a tab or pane was removed. Caller holds s.mu.335 // surviving target when a tab or pane was removed. Caller holds s.mu.
355 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {336 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 {
45 }45 }
46 46
47 func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {47 func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {
48- return sameAttachmentOwner(token.owner, source) && token.ac == req.next &&48+ return token.sess == source && token.ac == req.next &&
49 token.generation == req.next.connectionGeneration.Load() &&49 token.generation == req.next.connectionGeneration.Load() &&
50 token.transport.transport == req.expectedTransport.transport &&50 token.transport.transport == req.expectedTransport.transport &&
51 token.transport.incarnation == req.expectedTransport.incarnation51 token.transport.incarnation == req.expectedTransport.incarnation
@@ -55,9 +55,9 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source
55 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection55 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection
56 // handoff check for client-originated navigation and lifecycle mutations.56 // handoff check for client-originated navigation and lifecycle mutations.
57 func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {57 func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
58- if !sameAttachmentOwner(token.owner, source) || token.ac != req.next ||58+ if token.sess != source || token.ac != req.next ||
59 token.generation != req.next.connectionGeneration.Load() ||59 token.generation != req.next.connectionGeneration.Load() ||
60- token.localSession() == nil || !sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) ||60+ token.sess == nil || token.ac.currentAttachmentSession() != source ||
61 !attachmentRegisteredLocked(source, req.next) ||61 !attachmentRegisteredLocked(source, req.next) ||
62 !req.next.transportSnapshotCurrent(token.transport) {62 !req.next.transportSnapshotCurrent(token.transport) {
63 return false63 return false
M internal/usecase/daemon/attachment_transition_publish.go +8 -9
@@ -29,26 +29,25 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
29 }29 }
30 30
31 func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {31 func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {
32- sess := token.localSession()32+ if token.sess == nil || token.ac == nil {
33- if sess == nil || token.ac == nil {
34 return false33 return false
35 }34 }
36 d.mu.Lock()35 d.mu.Lock()
37 defer d.mu.Unlock()36 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 {
39 return false38 return false
40 }39 }
41 d.notices.routingMu.Lock()40 d.notices.routingMu.Lock()
42 defer d.notices.routingMu.Unlock()41 defer d.notices.routingMu.Unlock()
43- sess.core().mu.Lock()42+ token.sess.core().mu.Lock()
44- defer sess.core().mu.Unlock()43+ defer token.sess.core().mu.Unlock()
45- coordinator := sess.core().coordinator.Load()44+ coordinator := token.sess.core().coordinator.Load()
46 if coordinator != nil {45 if coordinator != nil {
47 coordinator.mu.Lock()46 coordinator.mu.Lock()
48 defer coordinator.mu.Unlock()47 defer coordinator.mu.Unlock()
49 }48 }
50- req := attachmentTransitionRequest{source: sess, next: token.ac, expectedTransport: token.transport}49+ req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}
51- return transitionSourceTokenCurrentLocked(token, sess, coordinator, req)50+ return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
52 }51 }
53 52
54 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {53 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
@@ -216,7 +215,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
216 }215 }
217 }216 }
218 result.published = attachmentConnectionToken{217 result.published = attachmentConnectionToken{
219- owner: req.target, ac: req.next,218+ sess: req.target, ac: req.next,
220 generation: publication.nextGeneration,219 generation: publication.nextGeneration,
221 transport: req.expectedTransport, lease: lease,220 transport: req.expectedTransport, lease: lease,
222 rebase: publication.source != req.target,221 rebase: publication.source != req.target,
M internal/usecase/daemon/client.go +61 -175
@@ -41,15 +41,9 @@ type attachedClient struct {
41 clientID [16]byte41 clientID [16]byte
42 // remoteOrigin is attachment-specific presentation metadata supplied by a42 // remoteOrigin is attachment-specific presentation metadata supplied by a
43 // validated picker handoff; it never participates in routing.43 // validated picker handoff; it never participates in routing.
44- remoteOrigin string44+ remoteOrigin string
45- // renderMode is fixed by the accepted Hello and controls the remote daemon's45+ navigationCapabilities ports.NavigationCapabilities
46- // publication contract for this transport. It is immutable for one46+ startupOverlay ports.StartupOverlay
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
53 // connectionGeneration is the wire-facing capability generation. attachmentEffects is47 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
54 // the linearization gate for every attachment-bound observable operation. A role48 // the linearization gate for every attachment-bound observable operation. A role
55 // transition freezes and drains this gate before changing either generation49 // transition freezes and drains this gate before changing either generation
@@ -93,29 +87,18 @@ type attachedClient struct {
93 // tabs and panes without changing shared session ownership.87 // tabs and panes without changing shared session ownership.
94 viewMu sync.Mutex88 viewMu sync.Mutex
95 view attachmentView89 view attachmentView
96- owner Guarded[attachmentOwner]90+ sess Guarded[*session]
97 mouseScan mouse.Scanner91 mouseScan mouse.Scanner
98 themeMu sync.Mutex92 themeMu sync.Mutex
99 clientTheme themeui.Theme93 clientTheme themeui.Theme
100 appliedTheme appliedTheme94 appliedTheme appliedTheme
101 lastCursor cursorOut95 lastCursor cursorOut
102 renderStages renderStageHooks // optional render and handoff observability hooks96 renderStages renderStageHooks // optional render and handoff observability hooks
103- // previousOwner is guarded independently. It retains navigation history97+ // previousSession is guarded independently. It is retained through temporary
104- // through temporary owner hand-offs and is cleared only on terminal teardown.98+ // setSession(nil) hand-offs and cleared only on terminal teardown.
105- previousOwner Guarded[attachmentOwner]99+ previousSession Guarded[*session]
106- // remoteRecent is attachment-local, display-only MRU state. Remote views do100+ linkMu sync.Mutex
107- // not enter the local session registry, so these entries must not be usable101+ sendMu sync.Mutex
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
119 // routeCreatedSession marks a session created by this attachment's route.102 // routeCreatedSession marks a session created by this attachment's route.
120 // A handshake that never commits Welcome must tear down that exact empty103 // A handshake that never commits Welcome must tear down that exact empty
121 // session, while an attachment routed to an existing session must not.104 // session, while an attachment routed to an existing session must not.
@@ -228,50 +211,18 @@ func (ac *attachedClient) clearGeometryClaim() {
228 ac.sizeMu.Unlock()211 ac.sizeMu.Unlock()
229 }212 }
230 213
231-// currentAttachmentOwner returns the one authoritative binding for this214+func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }
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-}
240 215
241-func (ac *attachedClient) currentAttachmentSession() *session {216+// currentSession narrows the attachment to its owning local 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.
247 func (ac *attachedClient) currentSession() *session {217 func (ac *attachedClient) currentSession() *session {
248 return ac.currentAttachmentSession()218 return ac.currentAttachmentSession()
249 }219 }
250 220
251-// navigationSession returns the local session that owns local navigation UI.221+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
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) }
271 222
272 func (ac *attachedClient) clearPreviousSession() {223 func (ac *attachedClient) clearPreviousSession() {
273 if ac != nil {224 if ac != nil {
274- ac.previousOwner.Set(nil)225+ ac.previousSession.Set(nil)
275 }226 }
276 }227 }
277 228
@@ -616,11 +567,12 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
616 }567 }
617 568
618 type attachClientOptions struct {569 type attachClientOptions struct {
619- clientID [16]byte570+ clientID [16]byte
620- resumeCapable bool571+ resumeCapable bool
621- maxOutputInFlight uint8572+ maxOutputInFlight uint8
622- remoteOrigin string573+ remoteOrigin string
623- renderMode ports.RenderMode574+ navigationCapabilities ports.NavigationCapabilities
575+ startupOverlay ports.StartupOverlay
624 }576 }
625 577
626 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {578 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,
661 }613 }
662 output := newOutputStateStream(opts.maxOutputInFlight)614 output := newOutputStateStream(opts.maxOutputInFlight)
663 ac := &attachedClient{615 ac := &attachedClient{
664- tr: tr,616+ tr: tr,
665- output: output,617+ output: output,
666- size: sz,618+ size: sz,
667- view: attachmentView{windowRows: sz.Rows, windowSet: true},619+ view: attachmentView{windowRows: sz.Rows, windowSet: true},
668- clientID: opts.clientID,620+ clientID: opts.clientID,
669- remoteOrigin: opts.remoteOrigin,621+ remoteOrigin: opts.remoteOrigin,
670- renderMode: opts.renderMode,622+ navigationCapabilities: opts.navigationCapabilities,
671- resumeCapable: opts.resumeCapable,623+ startupOverlay: opts.startupOverlay,
672- resumeToken: resumeToken,624+ resumeCapable: opts.resumeCapable,
625+ resumeToken: resumeToken,
673 }626 }
674 output.attachment = ac627 output.attachment = ac
675 ac.initOverlays()628 ac.initOverlays()
@@ -812,23 +765,10 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
812 token.ac.captureFrames = nil765 token.ac.captureFrames = nil
813 token.ac.sendMu.Unlock()766 token.ac.sendMu.Unlock()
814 }767 }
815- if view, remote := token.owner.(*remoteView); remote {768+ if token.sess == nil {
816- return d.paintRemoteView(view, token.ac, true, token) == paintEmitted
817- }
818- sess := token.localSession()
819- if sess == nil {
820 return false769 return false
821 }770 }
822- if token.ac.renderMode == ports.RenderModeProxiedContent {771+ d.firstPaintWithLease(token.sess, token.ac, token.lease, true)
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)
832 return true772 return true
833 }773 }
834 774
@@ -894,31 +834,22 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
894 d.invalidateRender(sess, ac, true, "client.go")834 d.invalidateRender(sess, ac, true, "client.go")
895 }835 }
896 836
897-func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) bool {837+func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) {
898 if !token.attachmentEffectCurrent() {838 if !token.attachmentEffectCurrent() {
899- return false839+ return
900 }840 }
901 clientTheme := themeFromMessage(msg)841 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- }
910 token.ac.setClientTheme(clientTheme)842 token.ac.setClientTheme(clientTheme)
911- sess := token.localSession()843+ sess := token.sess
912 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {844 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
913- return false845+ return
914 }846 }
915 if !token.attachmentEffectCurrent() {847 if !token.attachmentEffectCurrent() {
916- return false848+ return
917 }849 }
918- if rc := sess.renderCoordinator(); rc != nil {850+ if rc := token.sess.core().coordinator.Load(); rc != nil {
919- return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})851+ rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
920 }852 }
921- return false
922 }853 }
923 854
924 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {855 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
@@ -954,23 +885,6 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
954 }885 }
955 ac.sendMu.Unlock()886 ac.sendMu.Unlock()
956 887
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-
974 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {888 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {
975 // The picker preview geometry is attachment-local. A resize must889 // The picker preview geometry is attachment-local. A resize must
976 // invalidate any in-flight remote request and fetch the selected row890 // invalidate any in-flight remote request and fetch the selected row
@@ -978,7 +892,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
978 d.registerPreviewForSelection(ac)892 d.registerPreviewForSelection(ac)
979 }893 }
980 894
981- sess := token.localSession()895+ sess := token.sess
982 if sess == nil {896 if sess == nil {
983 return false897 return false
984 }898 }
@@ -1036,21 +950,11 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
1036 d.notices.routingMu.Unlock()950 d.notices.routingMu.Unlock()
1037 return951 return
1038 }952 }
1039- sess := token.localSession()953+ if token.sess == nil {
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)
1047 d.notices.routingMu.Unlock()954 d.notices.routingMu.Unlock()
1048- if repaint && token.attachmentEffectCurrent() {
1049- d.scheduleRemoteViewPaint(view, token.ac, false)
1050- }
1051 return955 return
1052 }956 }
1053- repaint := d.mutateClientNotice(sess, token.ac, notice)957+ repaint := d.mutateClientNotice(token.sess, token.ac, notice)
1054 d.notices.routingMu.Unlock()958 d.notices.routingMu.Unlock()
1055 if repaint && token.attachmentEffectCurrent() {959 if repaint && token.attachmentEffectCurrent() {
1056 d.repaintForNotice(token.ac)960 d.repaintForNotice(token.ac)
@@ -1077,52 +981,34 @@ func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, not
1077 }981 }
1078 982
1079 func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {983 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) {
1114 switch notice.Action {984 switch notice.Action {
1115 case ports.ClientNoticeClipboardFallback:985 case ports.ClientNoticeClipboardFallback:
1116- return domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", Time: d.clock.Now()}, true986+ return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
1117 case ports.ClientNoticeClipboardTooLarge:987 case ports.ClientNoticeClipboardTooLarge:
1118- return domain.Notification{Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", Time: d.clock.Now()}, true988+ return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
1119 case ports.ClientNoticeLinkDegraded:989 case ports.ClientNoticeLinkDegraded:
1120- return domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", Time: d.clock.Now()}, true990+ return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
991+ case ports.ClientNoticeLinkConnected:
992+ return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
1121 default:993 default:
1122- return domain.Notification{}, false994+ return false
1123 }995 }
1124 }996 }
1125 997
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+
1126 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The1012 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
1127 // returned value reports whether the synchronous request was accepted.1013 // returned value reports whether the synchronous request was accepted.
1128 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {1014 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
13 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a13 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
14 // handoff lands between those reads. The attachment gate still performs the final14 // handoff lands between those reads. The attachment gate still performs the final
15 // mutation admission after this function returns.15 // mutation admission after this function returns.
16-func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentOwner, attachmentConnectionToken, bool) {16+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentConnectionToken, bool) {
17 for range connectionSnapshotAttempts {17 for range connectionSnapshotAttempts {
18 if !ac.currentTransportIs(tr) {18 if !ac.currentTransportIs(tr) {
19 break19 break
20 }20 }
21- owner := ac.currentAttachmentOwner()21+ sess := ac.currentAttachmentSession()
22- if owner == nil {22+ if sess == nil {
23 return nil, attachmentConnectionToken{}, false23 return nil, attachmentConnectionToken{}, false
24 }24 }
25- if sess := localSession(owner); sess != nil && d.afterConnectionSessionSnapshot != nil {25+ if d.afterConnectionSessionSnapshot != nil {
26 d.afterConnectionSessionSnapshot(sess)26 d.afterConnectionSessionSnapshot(sess)
27 }27 }
28- token := attachmentOwnerToken(owner, ac, tr)28+ token := attachmentToken(sess, ac, tr)
29- if token.ac == nil || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) {29+ if token.ac == nil || ac.currentAttachmentSession() != sess {
30 runtime.Gosched()30 runtime.Gosched()
31 continue31 continue
32 }32 }
33- return owner, token, true33+ return sess, token, true
34 }34 }
35 return nil, attachmentConnectionToken{}, false35 return nil, attachmentConnectionToken{}, false
36 }36 }
@@ -44,23 +44,19 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
44 return44 return
45 }45 }
46 for {46 for {
47- if !ac.currentTransportIs(tr) || ac.currentAttachmentOwner() == nil {47+ if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil {
48 return48 return
49 }49 }
50 f, err := tr.Recv()50 f, err := tr.Recv()
51 if err != nil {51 if err != nil {
52 for range connectionSnapshotAttempts {52 for range connectionSnapshotAttempts {
53- owner, token, ok := d.currentAttachmentConnection(ac, tr)53+ sess, _, ok := d.currentAttachmentConnection(ac, tr)
54 if !ok {54 if !ok {
55 return55 return
56 }56 }
57- if sess := localSession(owner); sess != nil {57+ d.clientGone(sess, ac, tr, false)
58- d.clientGone(sess, ac, tr, false)58+ current := ac.currentAttachmentSession()
59- } else if view, remote := owner.(*remoteView); remote {59+ if current == sess || !ac.currentTransportIs(tr) || current == nil {
60- d.clientGoneRemote(view, token, false)
61- }
62- current := ac.currentAttachmentOwner()
63- if sameAttachmentOwner(current, owner) || !ac.currentTransportIs(tr) || current == nil {
64 return60 return
65 }61 }
66 }62 }
@@ -135,11 +131,18 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
135 return false131 return false
136 }132 }
137 result := d.executeAttachedCommand(token, request)133 result := d.executeAttachedCommand(token, request)
138- if err := token.sendControl(frameCommandResult(result)); err != nil {134+ resultToken, ok := d.commandResultToken(token)
139- token.endAttachmentEffect()135+ if !ok {
140- d.detachOnAttachmentSendError(token, token.transport.transport)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)
141 return true143 return true
142 }144 }
145+ resultToken.endAttachmentEffect()
143 case ports.MsgPing:146 case ports.MsgPing:
144 if err := token.sendControl(framePong()); err != nil {147 if err := token.sendControl(framePong()); err != nil {
145 token.endAttachmentEffect()148 token.endAttachmentEffect()
@@ -153,6 +156,33 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
153 return false156 return false
154 }157 }
155 158
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+
156 func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {186 func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {
157 result := ports.CommandResult{RequestID: request.RequestID}187 result := ports.CommandResult{RequestID: request.RequestID}
158 if request.Version != ports.ProtocolVersion {188 if request.Version != ports.ProtocolVersion {
@@ -175,17 +205,12 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
175 result.Text = "attached commands cannot target themselves"205 result.Text = "attached commands cannot target themselves"
176 return result206 return result
177 }207 }
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- }
183 if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {208 if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {
184 result.Code = ports.ErrInvalidCommandArgs209 result.Code = ports.ErrInvalidCommandArgs
185 result.Text = "attached commands cannot override their active session target"210 result.Text = "attached commands cannot override their active session target"
186 return result211 return result
187 }212 }
188- sess := token.localSession()213+ sess := token.sess
189 if sess == nil || !token.attachmentEffectCurrent() {214 if sess == nil || !token.attachmentEffectCurrent() {
190 result.Code = ports.ErrNoSuchTarget215 result.Code = ports.ErrNoSuchTarget
191 result.Text = "attached session is no longer active"216 result.Text = "attached session is no longer active"
@@ -243,11 +268,7 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
243 ac.pipelineCache = composeCacheInput{}268 ac.pipelineCache = composeCacheInput{}
244 ac.pipelineScratch = composeCacheInput{}269 ac.pipelineScratch = composeCacheInput{}
245 ac.sendMu.Unlock()270 ac.sendMu.Unlock()
246- if view, remote := token.owner.(*remoteView); remote {271+ go d.paint(token.sess, ac, true, token.lease)
247- d.scheduleRemoteViewPaint(view, ac, true)
248- } else if sess := token.localSession(); sess != nil {
249- go d.paint(sess, ac, true, token.lease)
250- }
251 return true272 return true
252 }273 }
253 274
@@ -257,25 +278,10 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
257 return false278 return false
258 }279 }
259 ac.sendMu.Lock()280 ac.sendMu.Lock()
260- if ac.output == nil {281+ ac.output.ack(epoch, state)
261- ac.sendMu.Unlock()
262- return false
263- }
264- advanced := ac.output.ack(epoch, state)
265 ac.sendMu.Unlock()282 ac.sendMu.Unlock()
266- if !advanced {283+ if rc := token.sess.core().coordinator.Load(); rc != nil {
267- return true284+ rc.notifyAckForLease(token.lease)
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)
279 }285 }
280 return true286 return true
281 }287 }
M internal/usecase/daemon/client_transport_errors.go +4 -104
@@ -37,10 +37,7 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
37 }37 }
38 token.effect.bindActionEnd(d, "detach")38 token.effect.bindActionEnd(d, "detach")
39 token.effect.End()39 token.effect.End()
40- if view, remote := token.owner.(*remoteView); remote {40+ sess := token.sess
41- return d.clientGoneRemote(view, token, explicit)
42- }
43- sess := token.localSession()
44 if sess == nil {41 if sess == nil {
45 return false42 return false
46 }43 }
@@ -56,94 +53,6 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
56 return true53 return true
57 }54 }
58 55
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-
147 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {56 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
148 if sess == nil || ac == nil {57 if sess == nil || ac == nil {
149 return58 return
@@ -174,7 +83,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
174 // Explicit winners (and non-explicit park failures) must drop any same-83 // Explicit winners (and non-explicit park failures) must drop any same-
175 // attachment parking marker left by a raced non-explicit teardown so84 // attachment parking marker left by a raced non-explicit teardown so
176 // IntentResume waiters are not stranded on a never-published park.85 // IntentResume waiters are not stranded on a never-published park.
177- d.clearParkingInFlight(ac.resumeToken, ac)86+ d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
178 d.closePicker(ac)87 d.closePicker(ac)
179 88
180 d.resetScreenDefaultColors(sess)89 d.resetScreenDefaultColors(sess)
@@ -218,16 +127,7 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
218 if failed == nil || failed != token.transport.transport {127 if failed == nil || failed != token.transport.transport {
219 return128 return
220 }129 }
221- if view, remote := token.owner.(*remoteView); remote {130+ sess := token.sess
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()
231 if sess == nil {131 if sess == nil {
232 return132 return
233 }133 }
@@ -270,7 +170,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
270 d.log.Warn("parked client after send error", "session", sess.name)170 d.log.Warn("parked client after send error", "session", sess.name)
271 return171 return
272 }172 }
273- d.clearParkingInFlight(ac.resumeToken, ac)173+ d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
274 d.closePicker(ac)174 d.closePicker(ac)
275 d.resetScreenDefaultColors(sess)175 d.resetScreenDefaultColors(sess)
276 ac.clearPreviousSession()176 ac.clearPreviousSession()
M internal/usecase/daemon/clipboard.go +4 -12
@@ -27,14 +27,14 @@ var (
27 )27 )
28 28
29 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {29 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30- if token.localSession() == nil || !token.attachmentEffectCurrent() || token.ac.renderMode == ports.RenderModeProxiedContent {30+ if !token.attachmentEffectCurrent() {
31 return31 return
32 }32 }
33 d.handleImagePushForAttachment(token, ip)33 d.handleImagePushForAttachment(token, ip)
34 }34 }
35 35
36 func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {36 func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37- if 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() {
38 return38 return
39 }39 }
40 path, err := d.writeClipboardImageForAttachment(token, ip)40 path, err := d.writeClipboardImageForAttachment(token, ip)
@@ -85,14 +85,11 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
85 }85 }
86 86
87 func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {87 func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
88- if token.localSession() == nil || !token.attachmentEffectCurrent() {
89- return "", errAttachmentTransition
90- }
91 path, err := d.createClipboardImage(ip)88 path, err := d.createClipboardImage(ip)
92 if err != nil {89 if err != nil {
93 return "", err90 return "", err
94 }91 }
95- sess := token.localSession()92+ sess := token.sess
96 if sess == nil {93 if sess == nil {
97 _ = os.Remove(path)94 _ = os.Remove(path)
98 return "", errAttachmentTransition95 return "", errAttachmentTransition
@@ -138,7 +135,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
138 }135 }
139 136
140 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
141- sess := token.localSession()138+ sess := token.sess
142 if sess == nil || !token.attachmentEffectCurrent() {139 if sess == nil || !token.attachmentEffectCurrent() {
143 return140 return
144 }141 }
@@ -239,11 +236,6 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
239 236
240 items := make([]clipboardForward, 0, len(attachments))237 items := make([]clipboardForward, 0, len(attachments))
241 for _, ac := range attachments {238 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- }
247 transport := ac.transport()239 transport := ac.transport()
248 token := sess.attachmentToken(ac, transport)240 token := sess.attachmentToken(ac, transport)
249 if token.attachmentCurrent() {241 if token.attachmentCurrent() {
M internal/usecase/daemon/coderabbit_regression_test.go +0 -31
@@ -8,7 +8,6 @@ import (
8 "sync"8 "sync"
9 "syscall"9 "syscall"
10 "testing"10 "testing"
11- "time"
12 11
13 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
14 13
@@ -244,36 +243,6 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
244 require.Empty(t, d.sessions)243 require.Empty(t, d.sessions)
245 }244 }
246 245
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-
277 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {246 func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {
278 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}247 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
279 _, cancel := snapshotStopContext(deadline)248 _, 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) {
135 t.finish(requestID, generation, CommandRequestOutcome{Err: err})135 t.finish(requestID, generation, CommandRequestOutcome{Err: err})
136 }136 }
137 137
138-// FailGeneration completes every pending request for one retired transport
139-// generation. It releases the tracker lock before publishing outcomes so a
140-// caller can immediately wait or begin work on a replacement generation.
141-func (t *CommandRequestTracker) FailGeneration(generation uint64, err error) {
142- if t == nil {
143- return
144- }
145- t.mu.Lock()
146- pending := make([]commandRequestPending, 0)
147- for requestID, request := range t.pending {
148- if request.generation != generation {
149- continue
150- }
151- delete(t.pending, requestID)
152- pending = append(pending, request)
153- }
154- t.mu.Unlock()
155- for _, request := range pending {
156- request.outcome <- CommandRequestOutcome{Err: err}
157- }
158-}
159-
160 func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {138 func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {
161 if t == nil {139 if t == nil {
162 return140 return
M internal/usecase/daemon/daemon.go +130 -191
@@ -45,10 +45,6 @@ const (
45 // the in-flight send. Teardown is never gated on a client draining its socket.45 // the in-flight send. Teardown is never gated on a client draining its socket.
46 const detachNotifyTimeout = time.Second46 const detachNotifyTimeout = time.Second
47 47
48-// remoteConstructionShutdownGrace bounds a non-cooperative remote dialer when
49-// durable snapshots have not installed a shared shutdown deadline.
50-const remoteConstructionShutdownGrace = ports.HandshakeTimeout
51-
52 // maxUnackedOutputStates caps how many output states may be in flight (sent but48 // maxUnackedOutputStates caps how many output states may be in flight (sent but
53 // not yet acked by the client) before paint defers rather than composing49 // not yet acked by the client) before paint defers rather than composing
54 // another diff. It bounds the daemon's paint rate to the client's ack rate, so50 // another diff. It bounds the daemon's paint rate to the client's ack rate, so
@@ -76,16 +72,6 @@ type Daemon struct {
76 mu sync.Mutex72 mu sync.Mutex
77 sessions map[domain.SessionID]*session73 sessions map[domain.SessionID]*session
78 stopped map[string]stoppedSession74 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
89 // creating reserves names while durable creation I/O runs without mu.75 // creating reserves names while durable creation I/O runs without mu.
90 creating map[string]struct{}76 creating map[string]struct{}
91 nextID uint6477 nextID uint64
@@ -336,7 +322,7 @@ type Daemon struct {
336 }322 }
337 323
338 type parkedAttachment struct {324 type parkedAttachment struct {
339- owner attachmentOwner325+ sess *session
340 ac *attachedClient326 ac *attachedClient
341 pickerGeneration uint64327 pickerGeneration uint64
342 timer ports.Timer328 timer ports.Timer
@@ -349,7 +335,7 @@ type parkedAttachment struct {
349 // credential. done closes when the token is published into parked or parking335 // credential. done closes when the token is published into parked or parking
350 // is abandoned.336 // is abandoned.
351 type parkingAttachment struct {337 type parkingAttachment struct {
352- owner attachmentOwner338+ sess *session
353 ac *attachedClient339 ac *attachedClient
354 done chan struct{}340 done chan struct{}
355 doneOnce sync.Once341 doneOnce sync.Once
@@ -608,31 +594,28 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
608 }594 }
609 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())595 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
610 d := &Daemon{596 d := &Daemon{
611- sessions: make(map[domain.SessionID]*session),597+ sessions: make(map[domain.SessionID]*session),
612- stopped: make(map[string]stoppedSession),598+ stopped: make(map[string]stoppedSession),
613- remoteViews: make(map[remoteViewID]*remoteView),599+ creating: make(map[string]struct{}),
614- remoteViewsByKey: make(map[remoteViewKey]remoteViewID),600+ parked: make(map[uint64]*parkedAttachment),
615- remoteViewConstructions: make(map[remoteViewKey]*remoteViewConstruction),601+ parking: make(map[uint64]*parkingAttachment),
616- creating: make(map[string]struct{}),602+ paneProcessCtx: paneProcessCtx,
617- parked: make(map[uint64]*parkedAttachment),603+ paneProcessCancel: paneProcessCancel,
618- parking: make(map[uint64]*parkingAttachment),604+ ptys: ptys,
619- paneProcessCtx: paneProcessCtx,605+ clock: clock,
620- paneProcessCancel: paneProcessCancel,606+ log: log,
621- ptys: ptys,607+ baseEnv: os.Environ(),
622- clock: clock,608+ shell: shell,
623- log: log,609+ dirOrHome: dirOrHome,
624- baseEnv: os.Environ(),610+ done: make(chan struct{}),
625- shell: shell,611+ restoreDone: make(chan struct{}),
626- dirOrHome: dirOrHome,612+ animWake: make(chan struct{}, 1),
627- done: make(chan struct{}),613+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
628- restoreDone: make(chan struct{}),614+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
629- animWake: make(chan struct{}, 1),615+ snapshotWake: make(chan struct{}, 1),
630- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),616+ notices: newNoticeCenter(),
631- snapshotAdmitted: make(map[*snapshotCapture]struct{}),617+ remoteCatalog: newRemoteCatalogState(),
632- snapshotWake: make(chan struct{}, 1),618+ resumeParkGrace: defaultResumeParkGrace,
633- notices: newNoticeCenter(),
634- remoteCatalog: newRemoteCatalogState(),
635- resumeParkGrace: defaultResumeParkGrace,
636 barScripts: &barScriptState{619 barScripts: &barScriptState{
637 cfg: barConfigFromDomain(domain.Defaults().Bar),620 cfg: barConfigFromDomain(domain.Defaults().Bar),
638 outputs: make(map[domain.SessionID]barScriptOutputs),621 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -813,93 +796,14 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
813 d.closing = true796 d.closing = true
814 d.purgeAllParkingLocked()797 d.purgeAllParkingLocked()
815 parkedRetirements := d.purgeAllParkedLocked()798 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)
827 snapshot := d.sessionsSnapshotLocked()799 snapshot := d.sessionsSnapshotLocked()
828 empty := len(snapshot) == 0800 empty := len(snapshot) == 0
829 d.mu.Unlock()801 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- }
898 d.finishParkedAttachmentRetirements(parkedRetirements)802 d.finishParkedAttachmentRetirements(parkedRetirements)
899 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))803 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
900 if empty {804 if empty {
901 d.doneOnce.Do(func() { close(d.done) })805 d.doneOnce.Do(func() { close(d.done) })
902- return checkpointIncomplete806+ return false
903 }807 }
904 for _, s := range snapshot {808 for _, s := range snapshot {
905 // Cancellation and PTY closure must not wait behind a teardown owner that809 // Cancellation and PTY closure must not wait behind a teardown owner that
@@ -1231,24 +1135,8 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1231 return1135 return
1232 }1136 }
1233 1137
1234- view, ac, remoteResume, rerr := d.resumeParkedRemoteView(h, tr)1138+ sess, ac, rerr := d.routeWithContext(handshakeCtx, 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- }
1248 if rerr != nil {1139 if rerr != nil {
1249- if errors.Is(rerr, errResumeTokenLifecycleRace) {
1250- rerr = &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1251- }
1252 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)1140 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
1253 if pe, ok := errors.AsType[*protoErr](rerr); ok {1141 if pe, ok := errors.AsType[*protoErr](rerr); ok {
1254 _ = sendHandshake(frameError(pe.code, pe.text))1142 _ = sendHandshake(frameError(pe.code, pe.text))
@@ -1258,7 +1146,90 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1258 _ = tr.Close()1146 _ = tr.Close()
1259 return1147 return
1260 }1148 }
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()
1262 }1233 }
1263 1234
1264 // protoErr is a session-level rejection carrying a wire ErrorMsg code.1235 // 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,
1298 sess.terminal = term1269 sess.terminal = term
1299 sess.mu.Unlock()1270 sess.mu.Unlock()
1300 opts := attachClientOptions{1271 opts := attachClientOptions{
1301- clientID: h.ClientID,1272+ clientID: h.ClientID,
1302- resumeCapable: true,1273+ resumeCapable: true,
1303- maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1274+ maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1304- renderMode: h.RenderMode,1275+ navigationCapabilities: h.NavigationCapabilities,
1276+ startupOverlay: h.StartupOverlay,
1305 }1277 }
1306 if h.RemoteTarget != nil {1278 if h.RemoteTarget != nil {
1307 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin1279 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
@@ -1397,6 +1369,9 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1397 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}1369 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
1398 }1370 }
1399 term := terminalEnv{TrueColor: h.TrueColor}1371 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+ }
1400 if h.RemoteTarget != nil && h.ResumeToken == 0 {1375 if h.RemoteTarget != nil && h.ResumeToken == 0 {
1401 return d.routeRemoteTargetWithContext(ctx, h, tr)1376 return d.routeRemoteTargetWithContext(ctx, h, tr)
1402 }1377 }
@@ -1420,20 +1395,12 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1420 }1395 }
1421 return nil, nil, err1396 return nil, nil, err
1422 } else if ok {1397 } 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- }
1427 return sess, ac, nil1398 return sess, ac, nil
1428 }1399 }
1429 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}1400 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1430 }1401 }
1431 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {1402 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
1432 if ok {1403 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- }
1437 return sess, ac, nil1404 return sess, ac, nil
1438 }1405 }
1439 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}1406 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
1451 return nil, nil, err1418 return nil, nil, err
1452 }1419 }
1453 }1420 }
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+ }
1454 d.mu.Lock()1425 d.mu.Lock()
1455 // Shutdown/create interlock: once shutdown has begun (last session removed,1426 // Shutdown/create interlock: once shutdown has begun (last session removed,
1456 // or shutdownAll started) no new session may be created and no attach may1427 // 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
1461 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}1432 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
1462 }1433 }
1463 switch h.Intent {1434 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-
1489 case ports.IntentEphemeral:1435 case ports.IntentEphemeral:
1490 name := d.allocEphemeralNameLocked()1436 name := d.allocEphemeralNameLocked()
1491 sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env)1437 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
1493 d.mu.Unlock()1439 d.mu.Unlock()
1494 return nil, nil, err1440 return nil, nil, err
1495 }1441 }
1496- ac, err := d.finishAttach(sess, tr, sz, term, h)1442+ ac, err := d.finishRouteAttach(sess, tr, sz, term, h, true, true)
1497- if err == nil {
1498- ac.routeCreatedSession = true
1499- ac.routeSessionPurge = true
1500- }
1501 return sess, ac, err1443 return sess, ac, err
1502 1444
1503 case ports.IntentNew:1445 case ports.IntentNew:
@@ -1518,11 +1460,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1518 d.mu.Unlock()1460 d.mu.Unlock()
1519 return nil, nil, err1461 return nil, nil, err
1520 }1462 }
1521- ac, err := d.finishAttach(sess, tr, sz, term, h)1463+ ac, err := d.finishRouteAttach(sess, tr, sz, term, h, true, true)
1522- if err == nil {
1523- ac.routeCreatedSession = true
1524- ac.routeSessionPurge = true
1525- }
1526 return sess, ac, err1464 return sess, ac, err
1527 1465
1528 case ports.IntentAttach:1466 case ports.IntentAttach:
@@ -1535,18 +1473,19 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1535 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}1473 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
1536 }1474 }
1537 cwd := d.dirOrHome(stopped.cwd)1475 cwd := d.dirOrHome(stopped.cwd)
1476+ env := h.Env
1477+ if h.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned {
1478+ env = copyEnvironment(d.baseEnv)
1479+ }
1538 var err error1480 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)
1540 if err != nil {1482 if err != nil {
1541 d.mu.Unlock()1483 d.mu.Unlock()
1542 return nil, nil, err1484 return nil, nil, err
1543 }1485 }
1544 created = true1486 created = true
1545 }1487 }
1546- ac, err := d.finishAttach(sess, tr, sz, term, h)1488+ ac, err := d.finishRouteAttach(sess, tr, sz, term, h, created, false)
1547- if err == nil && created {
1548- ac.routeCreatedSession = true
1549- }
1550 return sess, ac, err1489 return sess, ac, err
1551 1490
1552 default:1491 default:
M internal/usecase/daemon/final_acceptance_test.go +23 -0
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/layout"
8 "github.com/bnema/vev/pkg/vt"9 "github.com/bnema/vev/pkg/vt"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
@@ -177,3 +178,25 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
177 require.True(t, peerRegistered, "detaching one attachment removed its peer")178 require.True(t, peerRegistered, "detaching one attachment removed its peer")
178 require.Same(t, sess, second.currentSession())179 require.Same(t, sess, second.currentSession())
179 }180 }
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
115 // by route. A fresh route-owned session is purged only after its attachment is115 // by route. A fresh route-owned session is purged only after its attachment is
116 // removed; existing sessions and their unrelated attachments are preserved.116 // removed; existing sessions and their unrelated attachments are preserved.
117 func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {117 func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {
118- d.failHandshakeAttachmentOwner(sess, ac, tr)118+ if sess == nil || ac == nil {
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 {
125 if tr != nil {119 if tr != nil {
126 _ = tr.Close()120 _ = tr.Close()
127 }121 }
@@ -136,15 +130,11 @@ func (d *Daemon) failHandshakeAttachmentOwner(owner attachmentOwner, ac *attache
136 }130 }
137 return131 return
138 }132 }
139- if view, remote := owner.(*remoteView); remote {133+ d.clientGone(sess, ac, tr, true)
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- }
144 if tr != nil {134 if tr != nil {
145 _ = tr.Close()135 _ = tr.Close()
146 }136 }
147- if sess := localSession(owner); sess != nil && ac.routeCreatedSession {137+ if ac.routeCreatedSession {
148 sess.mu.Lock()138 sess.mu.Lock()
149 empty := len(sess.attachments) == 0139 empty := len(sess.attachments) == 0
150 sess.mu.Unlock()140 sess.mu.Unlock()
M internal/usecase/daemon/input.go +4 -83
@@ -7,7 +7,6 @@ import (
7 "strconv"7 "strconv"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/keys"10 "github.com/bnema/vev/internal/usecase/keys"
12 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
13 "github.com/bnema/vev/internal/usecase/mouse"12 "github.com/bnema/vev/internal/usecase/mouse"
@@ -21,90 +20,13 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
21 d.handleInput(sess, ac, data)20 d.handleInput(sess, ac, data)
22 }21 }
23 22
24-func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, inputSeq uint64, data []byte) {23+func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
25 if !token.attachmentEffectCurrent() {24 if !token.attachmentEffectCurrent() {
26 return25 return
27 }26 }
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- }
36 d.handleInputForAttachment(token, data)27 d.handleInputForAttachment(token, data)
37 }28 }
38 29
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-
108 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
109 ac.initOverlays()31 ac.initOverlays()
110 ac.mouseScan.Scan(data,32 ac.mouseScan.Scan(data,
@@ -461,7 +383,7 @@ type daemonKeyHandler struct {
461 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {383 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
462 if h.connectionToken.ac != nil {384 if h.connectionToken.ac != nil {
463 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
464- return h.connectionToken.localSession(), effect, false386+ return h.connectionToken.sess, effect, false
465 }387 }
466 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)388 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
467 if h.d.afterDelayedKeyEffectAttempt != nil {389 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -475,12 +397,11 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
475 token.effect = effect397 token.effect = effect
476 h.d.afterAttachmentEffectAdmitted(token)398 h.d.afterAttachmentEffectAdmitted(token)
477 }399 }
478- sess := h.connectionToken.localSession()400+ if h.connectionToken.sess == nil {
479- if sess == nil {
480 effect.End()401 effect.End()
481 return nil, nil, false402 return nil, nil, false
482 }403 }
483- return sess, effect, true404+ return h.connectionToken.sess, effect, true
484 }405 }
485 sess := h.ac.currentSession()406 sess := h.ac.currentSession()
486 if sess == nil {407 if sess == nil {
M internal/usecase/daemon/input_test.go +15 -15
@@ -799,8 +799,8 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
799 prepare func()799 prepare func()
800 }{800 }{
801 {name: "no target", prepare: func() {}},801 {name: "no target", prepare: func() {}},
802- {name: "stale target", prepare: func() { ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},802+ {name: "stale target", prepare: func() { ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
803- {name: "same session", prepare: func() { ac.previousOwner.Set(current) }},803+ {name: "same session", prepare: func() { ac.previousSession.Set(current) }},
804 } {804 } {
805 t.Run(tc.name, func(t *testing.T) {805 t.Run(tc.name, func(t *testing.T) {
806 ac.clearPreviousSession()806 ac.clearPreviousSession()
@@ -808,7 +808,7 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
808 runPaletteCommand(t, d, current, ac, "BSK")808 runPaletteCommand(t, d, current, ac, "BSK")
809 require.Same(t, current, ac.currentSession())809 require.Same(t, current, ac.currentSession())
810 if tc.name != "no target" {810 if tc.name != "no target" {
811- require.Nil(t, ac.previousOwner.Get(), "invalid target must not remain toggleable")811+ require.Nil(t, ac.previousSession.Get(), "invalid target must not remain toggleable")
812 }812 }
813 })813 })
814 }814 }
@@ -818,7 +818,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
818 d, current, ac, _, releases := newRecentNavigationTestSessions(t)818 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
819 defer releaseAll(releases)819 defer releaseAll(releases)
820 target := d.sessions[domain.SessionID("recent")]820 target := d.sessions[domain.SessionID("recent")]
821- ac.previousOwner.Set(target)821+ ac.previousSession.Set(target)
822 822
823 // A displaced attachment makes the previously valid handoff stale by the823 // A displaced attachment makes the previously valid handoff stale by the
824 // time switchToTarget commits it.824 // time switchToTarget commits it.
@@ -829,7 +829,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
829 d.backSession(current, ac)829 d.backSession(current, ac)
830 830
831 require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")831 require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")
832- require.Same(t, target, ac.previousOwner.Get(), "a failed handoff remains retryable")832+ require.Same(t, target, ac.previousSession.Get(), "a failed handoff remains retryable")
833 history := d.notices.history()833 history := d.notices.history()
834 require.Len(t, history, 1, "the switch failure must be reported exactly once")834 require.Len(t, history, 1, "the switch failure must be reported exactly once")
835 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)835 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
@@ -839,14 +839,14 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
839 ac := &attachedClient{}839 ac := &attachedClient{}
840 stale := &session{sessionCore: sessionCore{id: "stale"}}840 stale := &session{sessionCore: sessionCore{id: "stale"}}
841 updated := &session{sessionCore: sessionCore{id: "updated"}}841 updated := &session{sessionCore: sessionCore{id: "updated"}}
842- ac.previousOwner.Set(stale)842+ ac.previousSession.Set(stale)
843 843
844 // Model a completed hand-off between observing a stale target and clearing844 // Model a completed hand-off between observing a stale target and clearing
845 // it. The conditional clear must not erase the newer toggle destination.845 // it. The conditional clear must not erase the newer toggle destination.
846- ac.previousOwner.Set(updated)846+ ac.previousSession.Set(updated)
847- ac.clearPreviousOwnerIf(stale)847+ ac.clearPreviousSessionIf(stale)
848 848
849- require.Same(t, updated, ac.previousOwner.Get())849+ require.Same(t, updated, ac.previousSession.Get())
850 }850 }
851 851
852 // backSession's invalid-target fallback is a render producer: it must publish852 // backSession's invalid-target fallback is a render producer: it must publish
@@ -861,10 +861,10 @@ func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
861 }{861 }{
862 {name: "no previous target", prepare: func(*session, *attachedClient) {}},862 {name: "no previous target", prepare: func(*session, *attachedClient) {}},
863 {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {863 {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
864- ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}})864+ ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}})
865 }},865 }},
866 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {866 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
867- ac.previousOwner.Set(current)867+ ac.previousSession.Set(current)
868 }},868 }},
869 } {869 } {
870 t.Run(tc.name, func(t *testing.T) {870 t.Run(tc.name, func(t *testing.T) {
@@ -940,10 +940,10 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
940 }940 }
941 require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)941 require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
942 if tc.wantPrev == "" {942 if tc.wantPrev == "" {
943- require.Nil(t, ac.previousOwner.Get())943+ require.Nil(t, ac.previousSession.Get())
944 return944 return
945 }945 }
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)
947 })947 })
948 }948 }
949 }949 }
@@ -963,10 +963,10 @@ func TestTerminalTeardownClearsPreviousSession(t *testing.T) {
963 d, current, ac, _, releases := newRecentNavigationTestSessions(t)963 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
964 defer releaseAll(releases)964 defer releaseAll(releases)
965 965
966- ac.previousOwner.Set(d.sessions[domain.SessionID("recent")])966+ ac.previousSession.Set(d.sessions[domain.SessionID("recent")])
967 tc.teardown(d, current, ac)967 tc.teardown(d, current, ac)
968 968
969- require.Nil(t, ac.previousOwner.Get())969+ require.Nil(t, ac.previousSession.Get())
970 })970 })
971 }971 }
972 }972 }
M internal/usecase/daemon/move_attachments.go +2 -2
@@ -1,8 +1,8 @@
1 package daemon1 package daemon
2 2
3 func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {3 func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {
4- return sameAttachmentOwner(token.owner, sess) && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&4+ return token.sess == sess && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5- sameAttachmentOwner(token.ac.currentAttachmentOwner(), sess) && token.ac.transportSnapshotCurrent(token.transport) &&5+ token.ac.currentAttachmentSession() == sess && token.ac.transportSnapshotCurrent(token.transport) &&
6 attachmentRegisteredLocked(sess, token.ac)6 attachmentRegisteredLocked(sess, token.ac)
7 }7 }
8 8
M internal/usecase/daemon/move_picker.go +2 -5
@@ -161,16 +161,13 @@ type pickerRefreshOptions struct {
161 }161 }
162 162
163 func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {163 func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
164- // A remote view retains its local navigation predecessor for picker state.164+ sess := ac.currentSession()
165- // Rebuild against that session so remote catalog completions update the
166- // picker while its attachment remains remotely owned.
167- sess := ac.navigationSession()
168 if sess == nil {165 if sess == nil {
169 return166 return
170 }167 }
171 rt := ac.overlays168 rt := ac.overlays
172 rt.pickerMu.Lock()169 rt.pickerMu.Lock()
173- if rt.picker == nil || rt.pickerRemoteSelection != nil {170+ if rt.picker == nil {
174 rt.pickerMu.Unlock()171 rt.pickerMu.Unlock()
175 return172 return
176 }173 }
M internal/usecase/daemon/notify.go +0 -30
@@ -370,29 +370,6 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode,
370 d.notify(nil, sev, code, msg, cause)370 d.notify(nil, sev, code, msg, cause)
371 }371 }
372 372
373-// notifyAttachment publishes a notice to one exact attachment. Remote views do
374-// not belong to the local-session attachment registry, so a remote selection
375-// failure must not fall back to daemon-global delivery.
376-func (d *Daemon) notifyAttachment(ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
377- if d == nil || ac == nil {
378- return
379- }
380- n := d.notices.record(domain.Notification{
381- Code: code,
382- Severity: sev,
383- Message: msg,
384- Details: noticeDetails(cause),
385- Time: d.clock.Now(),
386- })
387- d.log.Log(d.serveCtx, slogLevelFor(sev), "attachment notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
388- d.notices.routingMu.Lock()
389- published := d.publishToast(ac, n)
390- d.notices.routingMu.Unlock()
391- if published {
392- d.repaintForNotice(ac)
393- }
394-}
395-
396 // publishToast durably mutates one client's toast state without repainting.373 // publishToast durably mutates one client's toast state without repainting.
397 // Routing paths call it while routingMu excludes detach, then release routingMu374 // Routing paths call it while routingMu excludes detach, then release routingMu
398 // before rendering so a render failure can safely route another notice.375 // before rendering so a render failure can safely route another notice.
@@ -514,13 +491,6 @@ func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) {
514 // repaintForNotice asks for an urgent redraw. It must be called with noticeMu491 // repaintForNotice asks for an urgent redraw. It must be called with noticeMu
515 // released: invalidateRender can paint inline, which takes sendMu.492 // released: invalidateRender can paint inline, which takes sendMu.
516 func (d *Daemon) repaintForNotice(ac *attachedClient) {493 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- }
524 sess := ac.currentSession()494 sess := ac.currentSession()
525 if sess == nil {495 if sess == nil {
526 // Mid-handoff or torn down: whoever attaches next repaints anyway, and496 // 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
250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
251 }251 }
252 252
253-func (s *outputStateStream) ack(epoch, state uint64) bool {253+func (s *outputStateStream) ack(epoch, state uint64) {
254 if s == nil {254 if s == nil {
255- return false255+ return
256 }256 }
257 s.lockView()257 s.lockView()
258 defer s.unlockView()258 defer s.unlockView()
259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
260- return false260+ return
261 }261 }
262 s.acked = state262 s.acked = state
263 s.publishOutstanding()263 s.publishOutstanding()
264- return true
265 }264 }
266 265
267 func (s *outputStateStream) rebase() {266 func (s *outputStateStream) rebase() {
M internal/usecase/daemon/overlay_runtime.go +2 -5
@@ -31,11 +31,8 @@ type overlayRuntime struct {
31 pickerRemotePreview picker.Preview31 pickerRemotePreview picker.Preview
32 pickerRemotePreviewCancel context.CancelFunc32 pickerRemotePreviewCancel context.CancelFunc
33 pickerPreviewGeneration uint6433 pickerPreviewGeneration uint64
34- // pickerRemoteSelection fences one asynchronous remote picker activation.34+ pickerPending []byte
35- // It is cleared before the picker accepts another remote activation.35+ pickerESC pendingByteTimer
36- pickerRemoteSelection *remotePickerSelection
37- pickerPending []byte
38- pickerESC pendingByteTimer
39 36
40 // Test-only, unsynchronized lifecycle seams. Assign them before picker37 // Test-only, unsynchronized lifecycle seams. Assign them before picker
41 // publication or goroutine startup. Hooks run without pickerMu or38 // publication or goroutine startup. Hooks run without pickerMu or
M internal/usecase/daemon/palette.go +17 -19
@@ -6,6 +6,7 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/command"10 "github.com/bnema/vev/internal/usecase/command"
10 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/bnema/vev/internal/usecase/palette"12 "github.com/bnema/vev/internal/usecase/palette"
@@ -37,16 +38,10 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
37 38
38 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {39 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
39 // Capture daemon/session state before taking paletteMu: lock ordering forbids40 // Capture daemon/session state before taking paletteMu: lock ordering forbids
40- // holding an overlay lock while inspecting live sessions. A remote view is41+ // holding an overlay lock while inspecting live sessions.
41- // not a local session, so its retained local source must remain offered as a42+ recent := d.recentSessions(sess)
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)
48 commands := d.paletteCommands()43 commands := d.paletteCommands()
49- results := d.paletteResults(current, commands)44+ results := d.paletteResults(sess, commands)
50 ac.overlays.paletteMu.Lock()45 ac.overlays.paletteMu.Lock()
51 ac.overlays.paletteGeneration++46 ac.overlays.paletteGeneration++
52 ac.overlays.palette = palette.New(results)47 ac.overlays.palette = palette.New(results)
@@ -55,7 +50,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
55 ac.overlays.paletteFeedback = ""50 ac.overlays.paletteFeedback = ""
56 ac.overlays.palettePending = nil51 ac.overlays.palettePending = nil
57 ac.overlays.paletteMu.Unlock()52 ac.overlays.paletteMu.Unlock()
58- d.invalidateAttachedOwner(ac, true, "palette.go")53+ d.invalidateRender(sess, ac, true, "palette.go")
59 }54 }
60 55
61 // paletteResults captures eligible named sessions before paletteMu so the56 // paletteResults captures eligible named sessions before paletteMu so the
@@ -152,7 +147,7 @@ func (d *Daemon) recordPaletteUse(code string) {
152 }147 }
153 148
154 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {149 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
155- entry := ac.navigationSession()150+ entry := ac.currentAttachmentSession()
156 if entry == nil {151 if entry == nil {
157 return152 return
158 }153 }
@@ -259,12 +254,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
259 }254 }
260 ac.overlays.paletteMu.Unlock()255 ac.overlays.paletteMu.Unlock()
261 if cancel {256 if cancel {
262- d.invalidateAttachedOwner(ac, true, "palette.go")257+ d.invalidateRender(entry, ac, true, "palette.go")
263 return258 return
264 }259 }
265 if !execute {260 if !execute {
266 if changed {261 if changed {
267- d.invalidateAttachedOwner(ac, true, "palette.go")262+ d.invalidateRender(entry, ac, true, "palette.go")
268 }263 }
269 return264 return
270 }265 }
@@ -290,11 +285,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
290 }285 }
291 if err != nil {286 if err != nil {
292 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")287 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
293- d.invalidateAttachedOwner(ac, true, "palette.go")288+ d.invalidateRender(entry, ac, true, "palette.go")
294 return289 return
295 }290 }
296 if ac.closeExecutedPalette(generation, rawQuery) {291 if ac.closeExecutedPalette(generation, rawQuery) {
297- d.invalidateAttachedOwner(ac, true, "palette.go")292+ d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")
298 }293 }
299 return294 return
300 }295 }
@@ -303,7 +298,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
303 rank, err := command.ParsePositiveDecimal(args)298 rank, err := command.ParsePositiveDecimal(args)
304 if err != nil {299 if err != nil {
305 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")300 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")
307 return302 return
308 }303 }
309 exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect}304 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 ...
312 return307 return
313 }308 }
314 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")309 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
315- d.invalidateAttachedOwner(ac, true, "palette.go")310+ d.invalidateRender(entry, ac, true, "palette.go")
316 return311 return
317 }312 }
318 if ac.closeExecutedPalette(generation, rawQuery) {313 if ac.closeExecutedPalette(generation, rawQuery) {
319 d.recordPaletteUse(cmd.Code)314 d.recordPaletteUse(cmd.Code)
320- d.invalidateAttachedOwner(ac, true, "palette.go")315+ d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")
321 }316 }
322 return317 return
323 }318 }
@@ -333,7 +328,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
333 currentToken := current.attachmentToken(ac, ac.transport())328 currentToken := current.attachmentToken(ac, ac.transport())
334 fresh, admitted := ac.beginAttachmentEffect(currentToken)329 fresh, admitted := ac.beginAttachmentEffect(currentToken)
335 if ac.closeExecutedPalette(generation, rawQuery) {330 if ac.closeExecutedPalette(generation, rawQuery) {
336- d.invalidateAttachedOwner(ac, true, "palette.go")331+ d.invalidateRender(current, ac, true, "palette.go")
337 }332 }
338 if admitted {333 if admitted {
339 fresh.End()334 fresh.End()
@@ -604,6 +599,9 @@ func (e paletteExec) RenameTab() error {
604 }599 }
605 600
606 func (e paletteExec) OpenSessionPicker() error {601 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+ }
607 e.d.enterPicker(e.sess, e.ac)605 e.d.enterPicker(e.sess, e.ac)
608 return nil606 return nil
609 }607 }
M internal/usecase/daemon/palette_test.go +1 -1
@@ -460,7 +460,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
460 // Establish a valid back-session target, then remove that target immediately460 // Establish a valid back-session target, then remove that target immediately
461 // after the command releases its role admission.461 // after the command releases its role admission.
462 target := d.sessions[domain.SessionID("recent")]462 target := d.sessions[domain.SessionID("recent")]
463- ac.previousOwner.Set(target)463+ ac.previousSession.Set(target)
464 invalidations := installPaletteInvalidationObserver(current)464 invalidations := installPaletteInvalidationObserver(current)
465 d.enterPalette(current, ac)465 d.enterPalette(current, ac)
466 d.handlePaletteInput(ac, []byte("BSK"))466 d.handlePaletteInput(ac, []byte("BSK"))
M internal/usecase/daemon/picker.go +96 -123
@@ -63,8 +63,6 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
63 rt := ac.overlays63 rt := ac.overlays
64 rt.pickerMu.Lock()64 rt.pickerMu.Lock()
65 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration65 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
66- previousSelection := rt.pickerRemoteSelection
67- rt.pickerRemoteSelection = nil
68 rt.pickerPreviewGeneration++66 rt.pickerPreviewGeneration++
69 if rt.pickerRemotePreviewCancel != nil {67 if rt.pickerRemotePreviewCancel != nil {
70 rt.pickerRemotePreviewCancel()68 rt.pickerRemotePreviewCancel()
@@ -82,12 +80,9 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
82 rt.pickerPending = nil80 rt.pickerPending = nil
83 rt.pickerESC.stop()81 rt.pickerESC.stop()
84 rt.pickerMu.Unlock()82 rt.pickerMu.Unlock()
85- if previousSelection != nil {
86- previousSelection.cancel()
87- }
88 d.teardownPreviewSubscription(ac, previous, previousGeneration)83 d.teardownPreviewSubscription(ac, previous, previousGeneration)
89 d.registerPreviewForSelection(ac)84 d.registerPreviewForSelection(ac)
90- d.invalidateAttachedOwner(ac, true, "picker.go")85+ d.invalidateRender(sess, ac, true, "picker.go")
91 if rt.beforeRemotePickerRegistration != nil {86 if rt.beforeRemotePickerRegistration != nil {
92 rt.beforeRemotePickerRegistration()87 rt.beforeRemotePickerRegistration()
93 }88 }
@@ -252,8 +247,8 @@ func remotePickerReasonText(reason string) string {
252 return "host unreachable"247 return "host unreachable"
253 case "version_mismatch":248 case "version_mismatch":
254 return "version mismatch"249 return "version mismatch"
255- case "session_down":250+ case "session_stopped":
256- return "session down"251+ return "session stopped"
257 case "session_broken":252 case "session_broken":
258 return "session broken"253 return "session broken"
259 case "refreshing":254 case "refreshing":
@@ -269,8 +264,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
269 rt := ac.overlays264 rt := ac.overlays
270 var previewGeneration uint64265 var previewGeneration uint64
271 var instance remotePickerInstance266 var instance remotePickerInstance
272- var selection *remotePickerSelection
273- var closed bool
274 return listInputState{267 return listInputState{
275 pending: &rt.pickerPending,268 pending: &rt.pickerPending,
276 esc: &rt.pickerESC,269 esc: &rt.pickerESC,
@@ -280,12 +273,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
280 unlock: rt.pickerMu.Unlock,273 unlock: rt.pickerMu.Unlock,
281 active: func() bool { return rt.picker != nil },274 active: func() bool { return rt.picker != nil },
282 closeLocked: func() {275 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
289 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}276 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
290 rt.picker = nil277 rt.picker = nil
291 rt.pickerIntent = pickerNavigate278 rt.pickerIntent = pickerNavigate
@@ -293,20 +280,17 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
293 previewGeneration = rt.pickerPreviewGeneration280 previewGeneration = rt.pickerPreviewGeneration
294 },281 },
295 afterClose: func() {282 afterClose: func() {
296- if selection != nil {283+ d.clearPreviewGeneration(ac, previewGeneration)
297- selection.cancel()284+ d.remotePickerClosed(instance)
298- }285+ if sess := ac.currentAttachmentSession(); sess != nil {
299- if closed {286+ d.invalidateRender(sess, ac, true, "picker.go")
300- d.clearPreviewGeneration(ac, previewGeneration)
301- d.remotePickerClosed(instance)
302 }287 }
303- d.invalidateAttachedOwner(ac, true, "picker.go")
304 },288 },
305 }289 }
306 }290 }
307 291
308 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {292 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
309- sess := ac.navigationSession()293+ sess := ac.currentAttachmentSession()
310 if sess == nil {294 if sess == nil {
311 return295 return
312 }296 }
@@ -349,12 +333,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
349 if result.action == 's' {333 if result.action == 's' {
350 d.togglePickerSort()334 d.togglePickerSort()
351 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})335 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
352- d.invalidateAttachedOwner(ac, true, "picker.go")336+ d.invalidateRender(sess, ac, true, "picker.go")
353 return337 return
354 }338 }
355 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {339 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
356 d.notifyRemotePickerUnavailable(sess, cursor)340 d.notifyRemotePickerUnavailable(sess, cursor)
357- d.invalidateAttachedOwner(ac, true, "picker.go")341+ d.invalidateRender(sess, ac, true, "picker.go")
358 return342 return
359 }343 }
360 if result.action == 'x' {344 if result.action == 'x' {
@@ -382,7 +366,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
382 defer fresh.End()366 defer fresh.End()
383 }367 }
384 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})368 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
385- d.invalidateAttachedOwner(ac, true, "picker.go")369+ d.invalidateRender(sess, ac, true, "picker.go")
386 return370 return
387 }371 }
388 if result.changed {372 if result.changed {
@@ -390,13 +374,21 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
390 }374 }
391 committing := (result.action == '\r' || result.action == '\n') && ok375 committing := (result.action == '\r' || result.action == '\n') && ok
392 if result.exit && !committing {376 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+ }
393 d.closePicker(ac)385 d.closePicker(ac)
394 }386 }
395 if committing {387 if committing {
396 if intent == pickerMovePane || intent == pickerMoveTab {388 if intent == pickerMovePane || intent == pickerMoveTab {
397 if err := d.movePickerSourceError(source); err != nil {389 if err := d.movePickerSourceError(source); err != nil {
398 d.reportAttachmentError(sess, err)390 d.reportAttachmentError(sess, err)
399- d.invalidateAttachedOwner(ac, true, "picker.go")391+ d.invalidateRender(sess, ac, true, "picker.go")
400 return392 return
401 }393 }
402 if len(effects) != 0 && effects[0] != nil {394 if len(effects) != 0 && effects[0] != nil {
@@ -406,36 +398,14 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
406 err := d.commitMovePickerSelection(intent, source, target)398 err := d.commitMovePickerSelection(intent, source, target)
407 if err != nil {399 if err != nil {
408 d.reportAttachmentError(sess, movePickerUserError(err))400 d.reportAttachmentError(sess, movePickerUserError(err))
409- d.invalidateAttachedOwner(ac, true, "picker.go")401+ d.invalidateRender(sess, ac, true, "picker.go")
410 return402 return
411 }403 }
412- d.invalidateAttachedOwner(ac, true, "picker.go")404+ d.invalidateRender(sess, ac, true, "picker.go")
413 return405 return
414 }406 }
415 var err error407 var err error
416- if target.RemoteTarget != nil {408+ if len(effects) != 0 && effects[0] != 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 {
439 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")409 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
440 } else {410 } else {
441 d.closePicker(ac)411 d.closePicker(ac)
@@ -451,7 +421,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
451 return421 return
452 }422 }
453 if result.exit || result.changed {423 if result.exit || result.changed {
454- d.invalidateAttachedOwner(ac, true, "picker.go")424+ d.invalidateRender(sess, ac, true, "picker.go")
455 }425 }
456 }426 }
457 427
@@ -543,7 +513,7 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ
543 remoteTarget := *target.RemoteTarget513 remoteTarget := *target.RemoteTarget
544 message := "loading remote preview…"514 message := "loading remote preview…"
545 if remoteTarget.Stopped {515 if remoteTarget.Stopped {
546- message = "down session — preview unavailable"516+ message = "stopped session — preview unavailable"
547 }517 }
548 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)518 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)
549 if remoteTarget.Stopped {519 if remoteTarget.Stopped {
@@ -745,8 +715,6 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
745 return false715 return false
746 }716 }
747 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}717 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
748- selection := ac.overlays.pickerRemoteSelection
749- ac.overlays.pickerRemoteSelection = nil
750 ac.overlays.picker = nil718 ac.overlays.picker = nil
751 ac.overlays.pickerTitle = ""719 ac.overlays.pickerTitle = ""
752 ac.overlays.pickerIntent = pickerNavigate720 ac.overlays.pickerIntent = pickerNavigate
@@ -755,9 +723,6 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
755 ac.overlays.pickerESC.stop()723 ac.overlays.pickerESC.stop()
756 previewGeneration := ac.overlays.pickerPreviewGeneration724 previewGeneration := ac.overlays.pickerPreviewGeneration
757 ac.overlays.pickerMu.Unlock()725 ac.overlays.pickerMu.Unlock()
758- if selection != nil {
759- selection.cancel()
760- }
761 d.clearPreviewGeneration(ac, previewGeneration)726 d.clearPreviewGeneration(ac, previewGeneration)
762 if instance.model != nil {727 if instance.model != nil {
763 d.remotePickerClosed(instance)728 d.remotePickerClosed(instance)
@@ -837,8 +802,7 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
837 }802 }
838 803
839 func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {804 func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
840- source := token.localSession()805+ if token.sess == nil || token.ac == nil {
841- if source == nil || token.ac == nil {
842 return nil806 return nil
843 }807 }
844 d.mu.Lock()808 d.mu.Lock()
@@ -848,15 +812,15 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
848 if !token.attachmentCurrent() {812 if !token.attachmentCurrent() {
849 return nil813 return nil
850 }814 }
851- d.invalidateRender(source, token.ac, true, "picker.go")815+ d.invalidateRender(token.sess, token.ac, true, "picker.go")
852 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)816 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
853 }817 }
854- if targetSess == source {818+ if targetSess == token.sess {
855 return nil819 return nil
856 }820 }
857 821
858 transition, err := d.transitionAttachment(attachmentTransitionRequest{822 transition, err := d.transitionAttachment(attachmentTransitionRequest{
859- source: source, target: targetSess, next: token.ac,823+ source: token.sess, target: targetSess, next: token.ac,
860 824
861 expectedTransport: token.transport, sourceToken: &token, action: action,825 expectedTransport: token.transport, sourceToken: &token, action: action,
862 expectedTargetLifecycle: pickerTargetLifecycleFence(target),826 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
@@ -878,7 +842,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
878 }842 }
879 }843 }
880 d.touchMRU(targetSess)844 d.touchMRU(targetSess)
881- token.ac.recordPreviousSession(source)845+ token.ac.recordPreviousSession(token.sess)
882 d.deferAttachmentTransitionCleanups(transition)846 d.deferAttachmentTransitionCleanups(transition)
883 d.firstPaintForTransition(transition.published)847 d.firstPaintForTransition(transition.published)
884 return nil848 return nil
@@ -891,83 +855,92 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
891 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})855 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
892 }856 }
893 857
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-
916 // switchToTargetForAttachment is the sole client-originated navigation entry. The858 // switchToTargetForAttachment is the sole client-originated navigation entry. The
917 // intent captures the exact initiating capability before admission is released;859 // intent captures the exact initiating capability before admission is released;
918 // every active, stopped, and same-session target then uses transitionAttachment860 // every active, stopped, and same-session target then uses transitionAttachment
919 // for frozen, atomic source-token preflight.861 // for frozen, atomic source-token preflight.
920 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {862 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 {
922 return nil864 return nil
923 }865 }
924- if _, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {866+ if target.RemoteTarget != nil {
925- if target.RemoteTarget != nil {867+ var key domain.RemoteSessionKey
926- return d.startRemotePickerSelection(token, target, guard, action)
927- }
928 if target.RemoteKey != nil {868 if target.RemoteKey != nil {
929- return errAttachmentTransition869+ key = *target.RemoteKey
930 }870 }
931- previous := token.ac.previousOwner.Get()871+ return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
932- if !retainedLocalTargetMatches(target, previous) {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) {
933 return errAttachmentTransition893 return errAttachmentTransition
934 }894 }
935- transitioned := false895+ if target.RemoteKey != nil {
936- if err := d.backSessionForAttachment(token, func() {896+ if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
937- transitioned = true897+ return errAttachmentTransition
938- if guard.closePicker {
939- d.closePicker(token.ac)
940 }898 }
941- }); err != nil {
942- return err
943 }899 }
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) {
945 return errAttachmentTransition909 return errAttachmentTransition
946 }910 }
947- return nil911+ handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
948- }
949- if token.localSession() == nil {
950- return nil
951 }912 }
952- if token.effect == nil {913+ payload := ports.MarshalAttachTarget(handoff)
914+ if payload == nil {
953 return errAttachmentTransition915 return errAttachmentTransition
954 }916 }
955- if target.RemoteTarget != nil {917+ if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
956- return d.startRemotePickerSelection(token, target, guard, action)918+ return err
957 }919 }
958- if target.RemoteKey != nil {920+ if guard.closePicker {
959- // Picker-originated remote activation requires the complete validated921+ d.closePicker(token.ac)
960- // lifecycle/tab target. Legacy display keys must never fall back to a
961- // client-owned attach handoff.
962- return errAttachmentTransition
963 }922 }
964- token.effect.bindActionEnd(d, action)923+ d.clientGoneForAttachment(token, false)
965- token.effect.End()924+ return nil
966- source := token.localSession()925+}
967- if source == nil {926+
968- return errAttachmentTransition927+func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
928+ if d == nil {
929+ return false
969 }930 }
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
971 }944 }
972 945
973 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {946 func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
@@ -993,7 +966,7 @@ func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget
993 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {966 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
994 continue967 continue
995 }968 }
996- if target.Stopped != (session.State == "down") || session.State == "broken" {969+ if target.Stopped != remoteSessionStateStopped(session.State) || session.State == "broken" {
997 return false970 return false
998 }971 }
999 tabs := ports.CatalogTabs(session)972 tabs := ports.CatalogTabs(session)
M internal/usecase/daemon/picker_test.go +31 -1
@@ -878,6 +878,36 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
878 }878 }
879 }879 }
880 880
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+
881 func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {911 func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
882 d, sess, ac, sends, releases := newManualTabSession(t, 1)912 d, sess, ac, sends, releases := newManualTabSession(t, 1)
883 defer releases[0]()913 defer releases[0]()
@@ -920,7 +950,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
920 targetPane.screen.Write([]byte("TARGET"))950 targetPane.screen.Write([]byte("TARGET"))
921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.951 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
922 require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")952 require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
923- ac.previousOwner.Set(target)953+ ac.previousSession.Set(target)
924 954
925 // Exercise the user-facing previous-session route, which delegates to the955 // Exercise the user-facing previous-session route, which delegates to the
926 // real switchToTarget hand-off and immediately emits its required reset.956 // 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 {
15 mruAt uint6415 mruAt uint64
16 }16 }
17 17
18-// remoteRecentSession retains display-only recency for a remote view. It
19-// stores value identity rather than a view pointer so a retained warm view
20-// cannot gain navigation authority through the local status bar.
21-type remoteRecentSession struct {
22- key remoteViewKey
23- displayOrigin string
24- mruAt uint64
25-}
26-
27-func (r remoteRecentSession) name() string {
28- origin := r.displayOrigin
29- if origin == "" {
30- origin = r.key.endpoint
31- }
32- if origin == "" {
33- return r.key.sessionName
34- }
35- return r.key.sessionName + "@" + origin
36-}
37-
38 // recentSessions returns the current session's capped named-session MRU list.18 // recentSessions returns the current session's capped named-session MRU list.
39 // Callers may retain the returned values for the lifetime of an interaction.19 // Callers may retain the returned values for the lifetime of an interaction.
40 func (d *Daemon) recentSessions(current *session) []recentSession {20 func (d *Daemon) recentSessions(current *session) []recentSession {
@@ -55,36 +35,6 @@ func (d *Daemon) recentSessions(current *session) []recentSession {
55 recent = append(recent, entry)35 recent = append(recent, entry)
56 }36 }
57 d.mu.Unlock()37 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 {
88 sort.SliceStable(recent, func(i, j int) bool {38 sort.SliceStable(recent, func(i, j int) bool {
89 if recent[i].mruAt == recent[j].mruAt {39 if recent[i].mruAt == recent[j].mruAt {
90 return recent[i].name < recent[j].name40 return recent[i].name < recent[j].name
@@ -96,51 +46,3 @@ func trimRecentSessions(recent []recentSession) []recentSession {
96 }46 }
97 return recent47 return recent
98 }48 }
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
48 d.mu.Unlock()48 d.mu.Unlock()
49 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}49 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
50 }50 }
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)
52 return live, ac, err52 return live, ac, err
53 }53 }
54 54
@@ -76,25 +76,42 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
76 d.mu.Unlock()76 d.mu.Unlock()
77 return nil, nil, err77 return nil, nil, err
78 }78 }
79- ac, err := d.finishAttach(sess, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h)79+ ac, err := d.finishRouteAttach(sess, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h, true, false)
80- if err != nil {80+ return sess, ac, err
81- // The route-created session has no published client if finishAttach81+}
82- // fails. Retire it immediately rather than leaving a shadow live row.82+
83- _ = d.killSession(sess, ports.ReasonSessionKilled, false)83+func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
84- return nil, nil, err84+ payload := ports.MarshalNavigationAction(action)
85+ if payload == nil {
86+ return errAttachmentTransition
85 }87 }
86- ac.routeCreatedSession = true88+ return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload})
87- return sess, ac, nil
88 }89 }
89 90
90-func (d *Daemon) remoteTargetMatchesSession(sess *session, target domain.RemoteSessionTarget) bool {91+func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
91- if sess == nil {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 {
92 return false111 return false
93 }112 }
94- d.mu.Lock()
95- defer d.mu.Unlock()
96 sess.mu.Lock()113 sess.mu.Lock()
97- matches := d.sessions[sess.id] == sess && sess.incarnation == target.LifecycleID && sess.name == target.SessionName114+ matches := sess.incarnation == target.LifecycleID && sess.name == target.SessionName
98 sess.mu.Unlock()115 sess.mu.Unlock()
99 if !matches {116 if !matches {
100 return false117 return false
M internal/usecase/daemon/remote_attach_test.go +51 -79
@@ -17,84 +17,56 @@ func remoteLifecycleForTest() domain.SessionLifecycleID {
17 return id17 return id
18 }18 }
19 19
20-func TestRemotePickerSelectionRejectsReplacedLifecycle(t *testing.T) {20+func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
21- tests := []struct {21+ d := newRemotePickerDaemon(nil)
22- name string22+ lifecycle := remoteLifecycleForTest()
23- run func(*testing.T)23+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
24- }{24+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
25- {25+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
26- name: "replaced lifecycle",26+ }},
27- run: func(t *testing.T) {27+ }})
28- d := newRemotePickerDaemon(nil)28+ d.remoteCatalog.mu.Lock()
29- lifecycle := remoteLifecycleForTest()29+ d.remoteCatalog.status["arch"] = remoteHostFresh
30- cachedLifecycle := lifecycle30+ d.remoteCatalog.mu.Unlock()
31- cachedLifecycle[0]++31+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
32- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{32+ token := sess.attachmentToken(ac, ac.transport())
33- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{33+ effect, admitted := ac.beginAttachmentEffect(token)
34- LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},34+ require.True(t, admitted)
35- }},35+ token.effect = effect
36- }})36+ defer effect.End()
37- d.remoteCatalog.mu.Lock()37+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
38- d.remoteCatalog.status["arch"] = remoteHostFresh38+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
39- d.remoteCatalog.mu.Unlock()39+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
40- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")40+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
41- ac.overlays.pickerMu.Lock()41+ frame := receiveRemotePicker(t, sends, "rich attach target")
42- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})42+ got, err := ports.UnmarshalAttachTarget(frame.Payload)
43- ac.overlays.pickerGeneration++43+ require.NoError(t, err)
44- ac.overlays.pickerMu.Unlock()44+ require.NotNil(t, got.RemoteTarget)
45- token := sess.attachmentToken(ac, ac.transport())45+ require.Equal(t, remoteTarget, *got.RemoteTarget)
46- effect, admitted := ac.beginAttachmentEffect(token)46+ require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy)
47- require.True(t, admitted)47+}
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()
87 48
88- _, err := d.transitionToRemoteViewForPicker(selection.token, view, selection)49+func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
89- require.ErrorIs(t, err, errAttachmentTransition)50+ d := newRemotePickerDaemon(nil)
90- require.Same(t, source, ac.currentAttachmentSession())51+ lifecycle := remoteLifecycleForTest()
91- require.True(t, source.attachmentRegistered(ac))52+ cachedLifecycle := lifecycle
92- require.False(t, view.attachmentRegistered(ac))53+ cachedLifecycle[0]++
93- require.NoError(t, transport.Close())54+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
94- },55+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
95- },56+ LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
96- }57+ }},
97- for _, tt := range tests {58+ }})
98- t.Run(tt.name, tt.run)59+ d.remoteCatalog.mu.Lock()
99- }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)
100 }72 }
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 {
129 return host129 return host
130 }130 }
131 131
132+func remoteSessionStateStopped(state string) bool {
133+ return state == "down" || state == "stopped"
134+}
135+
132 func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {136 func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
133 key.LifecycleID = session.LifecycleID137 key.LifecycleID = session.LifecycleID
134 key.DisplayOrigin = remoteSessionOrigin(key.Host)138 key.DisplayOrigin = remoteSessionOrigin(key.Host)
135 availability := remotePickerAvailability(status)139 availability := remotePickerAvailability(status)
136- stopped := session.State == "down"140+ stopped := remoteSessionStateStopped(session.State)
137 broken := session.State == "broken"141 broken := session.State == "broken"
138 tabs := ports.CatalogTabs(session)142 tabs := ports.CatalogTabs(session)
139 viewTabs := make([]picker.TabEntry, 0, len(tabs))143 viewTabs := make([]picker.TabEntry, 0, len(tabs))
@@ -178,7 +182,8 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
178 }182 }
179 // Keep the structured session identity on rows even when one tab ID is183 // Keep the structured session identity on rows even when one tab ID is
180 // malformed or a broken session cannot be activated. Cursor navigation184 // 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.
182 remoteTarget = &target187 remoteTarget = &target
183 }188 }
184 reason := ""189 reason := ""
@@ -193,21 +198,24 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
193 }198 }
194 ready := status == remoteHostFresh && targetValid && !broken199 ready := status == remoteHostFresh && targetValid && !broken
195 if legacy {200 if legacy {
196- // Proxied picker activation requires exact lifecycle and stable-tab201+ // Count-only peers cannot prove lifecycle/tab identity. Preserve the
197- // identity. A count-only cached row cannot fall back to client-owned202+ // historical row for compatibility, but make it read-only until a fresh
198- // direct attachment under the v25 protocol contract.203+ // catalog response authorizes a name attach.
199- reason = "identity_changed"204+ ready = status == remoteHostFresh && !broken && !stopped
205+ if status == remoteHostVersionMismatch {
206+ reason = remoteReasonForStatus(status)
207+ }
200 }208 }
201 detail := remotePickerStatusDetail(status, fetchedAt)209 detail := remotePickerStatusDetail(status, fetchedAt)
202 if detail == "" {210 if detail == "" {
203 switch session.State {211 switch session.State {
204 case "up":212 case "up":
205- detail = "up"213+ detail = "running"
206 if session.Attached {214 if session.Attached {
207 detail += " · attached"215 detail += " · attached"
208 }216 }
209- case "down":217+ case "down", "stopped":
210- detail = "down"218+ detail = "stopped"
211 case "broken":219 case "broken":
212 detail = "broken"220 detail = "broken"
213 }221 }
@@ -454,12 +462,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
454 if cacheChanged {462 if cacheChanged {
455 d.persistRemoteCatalogCache()463 d.persistRemoteCatalogCache()
456 }464 }
457- // A picker model may have been built from a fresh cached entry just before465+ d.refreshRemoteOpenPickers()
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- }
463 466
464 for _, host := range hosts {467 for _, host := range hosts {
465 d.sessWg.Go(func() {468 d.sessWg.Go(func() {
@@ -570,8 +573,8 @@ func (d *Daemon) refreshRemoteOpenPickers() {
570 continue573 continue
571 }574 }
572 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})575 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
573- // The picker is attachment-local: catalog changes repaint the actual576+ if entry := ac.currentAttachmentSession(); entry != nil {
574- // owner, which can be either the retained local session or a remote view.577+ d.invalidateRender(entry, ac, true, "remote_picker.go")
575- d.invalidateAttachedOwner(ac, true, "remote_picker.go")578+ }
576 }579 }
577 }580 }
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 (
6 "fmt"6 "fmt"
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9- "strings"
10 "sync"9 "sync"
11 "testing"10 "testing"
12 "time"11 "time"
@@ -15,7 +14,6 @@ import (
15 14
16 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
18- "github.com/bnema/vev/internal/usecase/keys"
19 "github.com/bnema/vev/internal/usecase/picker"17 "github.com/bnema/vev/internal/usecase/picker"
20 )18 )
21 19
@@ -57,9 +55,30 @@ func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
57 return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))55 return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
58 }56 }
59 57
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+
60 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {79 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
61 now := time.Unix(100, 0)80 now := time.Unix(100, 0)
62- clock := &remotePreviewTestClock{now: now}81+ clock := &remotePickerClock{now: now}
63 d := newTestDaemon(t, nil, clock)82 d := newTestDaemon(t, nil, clock)
64 lifecycle := remoteLifecycleForTest()83 lifecycle := remoteLifecycleForTest()
65 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{84 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
@@ -80,7 +99,7 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
80 99
81 func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) {100 func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) {
82 now := time.Unix(100, 0)101 now := time.Unix(100, 0)
83- clock := &remotePreviewTestClock{now: now}102+ clock := &remotePickerClock{now: now}
84 d := newTestDaemon(t, nil, clock)103 d := newTestDaemon(t, nil, clock)
85 lifecycle := remoteLifecycleForTest()104 lifecycle := remoteLifecycleForTest()
86 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{105 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
@@ -205,30 +224,6 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
205 }224 }
206 }225 }
207 226
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-
232 func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {227 func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
233 d := newRemotePickerDaemon(nil)228 d := newRemotePickerDaemon(nil)
234 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)229 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
@@ -381,6 +376,12 @@ type fixedRemoteRefreshClock struct{ now time.Time }
381 func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }376 func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
382 func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }377 func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
383 378
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+
384 func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {385 func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {
385 t.Helper()386 t.Helper()
386 if catalog != nil {387 if catalog != nil {
@@ -416,8 +417,12 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
416 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}417 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
417 ac.overlays.pickerMu.Unlock()418 ac.overlays.pickerMu.Unlock()
418 require.True(t, d.registerRemotePicker(instance))419 require.True(t, d.registerRemotePicker(instance))
420+ lifecycle := remoteLifecycleForTest()
419 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{421 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+ }},
421 }})426 }})
422 427
423 firstGeneration := d.startRemotePickerRefresh(instance)428 firstGeneration := d.startRemotePickerRefresh(instance)
@@ -430,9 +435,17 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
430 require.Contains(t, first, "mule")435 require.Contains(t, first, "mule")
431 requireNoRemoteLockViolations(t, catalog, nil)436 requireNoRemoteLockViolations(t, catalog, nil)
432 d.remoteCatalog.mu.Lock()437 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")
434 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])442 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
435 d.remoteCatalog.mu.Unlock()443 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")
436 449
437 secondGeneration := d.startRemotePickerRefresh(instance)450 secondGeneration := d.startRemotePickerRefresh(instance)
438 require.Greater(t, secondGeneration, firstGeneration)451 require.Greater(t, secondGeneration, firstGeneration)
@@ -455,55 +468,6 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
455 d.remoteCatalog.mu.Unlock()468 d.remoteCatalog.mu.Unlock()
456 }469 }
457 470
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-
507 func TestRemoteRefreshLockProbesSerialized(t *testing.T) {471 func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
508 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}472 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
509 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))473 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
@@ -635,503 +599,105 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
635 d.remoteCatalog.mu.Unlock()599 d.remoteCatalog.mu.Unlock()
636 }600 }
637 601
638-func TestRemoteCatalogRefreshUpdatesRemoteOwnerPicker(t *testing.T) {602+func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(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) {
687 d := newRemotePickerDaemon(nil)603 d := newRemotePickerDaemon(nil)
688- target := remoteLinkTestTarget()
689- require.NoError(t, target.Validate())
690 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{604 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
691- Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{605+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
692- LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
693- }},
694 }})606 }})
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- }}
708 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")607 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-
715 token := sess.attachmentToken(ac, ac.transport())608 token := sess.attachmentToken(ac, ac.transport())
716 effect, admitted := ac.beginAttachmentEffect(token)609 effect, admitted := ac.beginAttachmentEffect(token)
717 require.True(t, admitted)610 require.True(t, admitted)
718 token.effect = effect611 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}612+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
720- require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))613+ target := picker.Target{Session: key.ID(), RemoteKey: &key}
721- effect.End()614+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
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")
761 615
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())
762 d.mu.Lock()622 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")
764 d.mu.Unlock()624 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)
868 }625 }
869 626
870-func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) {627+func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
871- d := newRemotePickerDaemon(nil)628+ tests := []struct {
872- target := remoteLinkTestTarget()629+ name string
873- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{630+ action ports.NavigationAction
874- Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{631+ }{
875- LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},632+ {name: "home picker", action: ports.NavigationOpenHomePicker},
876- }},633+ {name: "back", action: ports.NavigationBack},
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"))
906 }634 }
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-}
945 635
946-func TestRemotePickerWarmReselectActivatesSelectedAlternateTabWithoutSecondDial(t *testing.T) {636+ for _, tt := range tests {
947- d := newRemotePickerDaemon(nil)637+ t.Run(tt.name, func(t *testing.T) {
948- target := remoteLinkTestTarget()638+ d := newRemotePickerDaemon(nil)
949- alternate := target639+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
950- alternate.LiveTabID = "tab-2"640+ token := sess.attachmentToken(ac, ac.transport())
951- const alternateName = "alternate"641+ effect, admitted := ac.beginAttachmentEffect(token)
952- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{642+ require.True(t, admitted)
953- Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{643+ token.effect = effect
954- LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up",644+ require.NoError(t, d.sendNavigationActionForAttachment(token, tt.action))
955- Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}, {ID: string(alternate.LiveTabID), Index: 1, Name: alternateName}},645+ frame := receiveRemotePicker(t, sends, "navigation action")
956- }},646+ require.Equal(t, ports.MsgNavigationAction, frame.Type)
957- }})647+ got, err := ports.UnmarshalNavigationAction(frame.Payload)
958- d.remoteCatalog.mu.Lock()648+ require.NoError(t, err)
959- d.remoteCatalog.status[target.Endpoint] = remoteHostFresh649+ require.Equal(t, tt.action, got)
960- d.remoteCatalog.mu.Unlock()650+ })
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()
983 }651 }
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)
1031 }652 }
1032 653
1033-func TestRemotePickerSelectionFailureKeepsSourceAndPickerOpen(t *testing.T) {654+func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
1034 d := newRemotePickerDaemon(nil)655 d := newRemotePickerDaemon(nil)
1035- target := remoteLinkTestTarget()
1036 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{656 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
1037- Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{657+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "up"}},
1038- LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},
1039- }},
1040 }})658 }})
1041- d.remoteCatalog.mu.Lock()659+ const sendErr = "remote attach send failed"
1042- d.remoteCatalog.status[target.Endpoint] = remoteHostFresh660+ tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}
1043- d.remoteCatalog.mu.Unlock()661+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
1044- d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{}}662+ ac.initOverlays()
1045-663+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1046- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")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
1047 ac.overlays.pickerMu.Lock()668 ac.overlays.pickerMu.Lock()
1048 ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})669 ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
1049 ac.overlays.pickerGeneration++670 ac.overlays.pickerGeneration++
1050 ac.overlays.pickerMu.Unlock()671 ac.overlays.pickerMu.Unlock()
1051- token := sess.attachmentToken(ac, ac.transport())
1052- effect, admitted := ac.beginAttachmentEffect(token)
1053- require.True(t, admitted)
1054- token.effect = effect
1055- pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
1056- require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
1057- effect.End()
1058 672
1059- require.Eventually(t, func() bool {673+ gone := make(chan struct{})
1060- ac.overlays.pickerMu.Lock()674+ d.afterClientGoneDetach = func() { close(gone) }
1061- defer ac.overlays.pickerMu.Unlock()675+ token := sess.attachmentToken(ac, tr)
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())
1103 effect, admitted := ac.beginAttachmentEffect(token)676 effect, admitted := ac.beginAttachmentEffect(token)
1104 require.True(t, admitted)677 require.True(t, admitted)
1105 token.effect = effect678 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}679+ defer effect.End()
1107- require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))680+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1108- effect.End()681+ target := picker.Target{Session: key.ID(), RemoteKey: &key}
1109- receiveRemotePickerClose(t, dialStarted, "remote picker dial")682+ err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")
1110-683+ require.EqualError(t, err, sendErr)
1111- d.handlePickerInput(ac, []byte{keys.ESC})684+ require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")
1112- escTimer := receiveRemotePicker(t, clock.timers, "picker escape timer")685+ select {
1113- escTimer.ch <- time.Now()686+ case <-gone:
1114- require.Eventually(t, func() bool {687+ t.Fatal("failed control send reached clientGoneForAttachment")
1115- ac.overlays.pickerMu.Lock()688+ default:
1116- defer ac.overlays.pickerMu.Unlock()689+ }
1117- return ac.overlays.pickerRemoteSelection == nil
1118- }, time.Second, 5*time.Millisecond)
1119- require.True(t, ac.overlays.pickerActive(), "Escape cancels the pending remote activation but keeps the picker open")
1120 require.Same(t, sess, ac.currentAttachmentSession())690 require.Same(t, sess, ac.currentAttachmentSession())
1121- require.Never(t, func() bool {
1122- select {
1123- case frame := <-sends:
1124- return frame.Type == ports.MsgAttachTarget
1125- default:
1126- return false
1127- }
1128- }, 100*time.Millisecond, 5*time.Millisecond)
1129- d.mu.Lock()
1130- require.Empty(t, d.remoteViews)
1131- d.mu.Unlock()
1132- d.shutdownAll(ports.ReasonServerShutdown)
1133 }691 }
1134 692
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+
1135 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {701 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
1136 t.Helper()702 t.Helper()
1137 tr, sends := newCapturingTransport(t)703 tr, sends := newCapturingTransport(t)
@@ -1479,31 +1045,26 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
1479 ac.overlays.pickerMu.Unlock()1045 ac.overlays.pickerMu.Unlock()
1480 }1046 }
1481 1047
1482-func TestRemoteRowsRequireFreshRichIdentityForPickerActivation(t *testing.T) {1048+func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) {
1483 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}1049 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1484- session := ports.RemoteCatalogSession{1050+ for _, tt := range []struct {
1485- LifecycleID: remoteLifecycleForTest(), Name: key.Name, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Name: "main"}},
1486- }
1487- for _, test := range []struct {
1488 status remoteHostStatus1051 status remoteHostStatus
1489- want bool1052+ ready bool
1490 }{1053 }{
1491- {status: remoteHostCached},1054+ {status: remoteHostCached, ready: false},
1492- {status: remoteHostRefreshing},1055+ {status: remoteHostRefreshing, ready: false},
1493- {status: remoteHostFresh, want: true},1056+ {status: remoteHostStale, ready: false},
1494- {status: remoteHostStale},1057+ {status: remoteHostMalformed, ready: false},
1495- {status: remoteHostUnreachable},1058+ {status: remoteHostFresh, ready: true},
1496- {status: remoteHostMalformed},1059+ {status: remoteHostUnreachable, ready: false},
1497- {status: remoteHostVersionMismatch},
1498 } {1060 } {
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)
1500 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()1063 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1501- require.Equal(t, test.want, selectable, "status %d", test.status)1064+ require.Equal(t, tt.ready, selectable, "status %d selection", tt.status)
1502- require.Equal(t, test.want, view.RemoteAttachReady, "status %d", test.status)
1503 }1065 }
1504-1066+ mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostVersionMismatch, time.Time{})
1505- legacy := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, remoteHostFresh, time.Unix(1, 0))1067+ require.False(t, mismatch.RemoteAttachReady)
1506- require.False(t, legacy.RemoteAttachReady, "count-only cached rows cannot initiate proxied picker activation")1068+ _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1507- _, selectable := picker.New([]picker.SessionView{legacy}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1508 require.False(t, selectable)1069 require.False(t, selectable)
1509 }1070 }
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) {
26 d.clientGone(sess, ac, tr, false)26 d.clientGone(sess, ac, tr, false)
27 }27 }
28 28
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+
29 func TestRouteRemoteTargetRejectsSameNameReplacement(t *testing.T) {51 func TestRouteRemoteTargetRejectsSameNameReplacement(t *testing.T) {
30 d := newTestDaemon(t, nil, stubClock{})52 d := newTestDaemon(t, nil, stubClock{})
31 sess := addControlSession(d, "work", "tab-new", "pane-new")53 sess := addControlSession(d, "work", "tab-new", "pane-new")
M internal/usecase/daemon/remote_stopped_route_test.go +37 -0
@@ -10,6 +10,43 @@ import (
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11 )11 )
12 12
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+
13 func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) {50 func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) {
14 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})51 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
15 lifecycle := remoteLifecycleForTest()52 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 (
299 )299 )
300 300
301 func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {301 func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
302- if ac != nil && ac.renderMode == ports.RenderModeProxiedContent {
303- return d.paintProxiedContent(entry, ac, reset, lease)
304- }
305 // Session-owned PTY preparation remains local; attachment rendering is302 // Session-owned PTY preparation remains local; attachment rendering is
306 // captured through session.captureRenderState.303 // captured through session.captureRenderState.
307 sess := entry304 sess := entry
@@ -427,7 +424,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
427 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,424 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
428 }425 }
429 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)426 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
430- captureRequest := renderCaptureRequest{427+ state, ok := entry.captureRenderState(ac, renderCaptureRequest{
431 bars: bars,428 bars: bars,
432 overlays: capturedOverlays,429 overlays: capturedOverlays,
433 preview: preview,430 preview: preview,
@@ -436,8 +433,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
436 styleGeneration: applied.Generation,433 styleGeneration: applied.Generation,
437 reset: reset,434 reset: reset,
438 lease: lease,435 lease: lease,
439- }436+ })
440- state, ok := entry.captureRenderState(ac, captureRequest)
441 endCapture(0, ok)437 endCapture(0, ok)
442 if !ok {438 if !ok {
443 ac.sendMu.Unlock()439 ac.sendMu.Unlock()
M internal/usecase/daemon/render_pipeline.go +10 -34
@@ -426,22 +426,9 @@ func commitDamageReceipts(receipts []damageReceipt) {
426 }426 }
427 }427 }
428 428
429-// emitFrame is the local-session compatibility entry point for the sole429+// emitFrame is the sole side-effecting half of the pipeline. The caller holds
430-// side-effecting half of the pipeline. The caller holds sendMu for the430+// sendMu for the complete capture/compose/emit transaction.
431-// complete capture/compose/emit transaction.
432 func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {431 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)
445 var ownedMarks runtimeMarkBatch432 var ownedMarks runtimeMarkBatch
446 var marks *runtimeMarkBatch433 var marks *runtimeMarkBatch
447 if len(batches) != 0 {434 if len(batches) != 0 {
@@ -453,19 +440,22 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
453 // follows emitFrame's release of attachment ownership.440 // follows emitFrame's release of attachment ownership.
454 defer marks.flush()441 defer marks.flush()
455 }442 }
456- if owner == nil || ac == nil || state == nil {443+ if entry == nil || entry.core() == nil || ac == nil || state == nil {
457 if ac != nil {444 if ac != nil {
458 ac.sendMu.Unlock()445 ac.sendMu.Unlock()
459 }446 }
460 return false447 return false
461 }448 }
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 {
463 ac.sendMu.Unlock()453 ac.sendMu.Unlock()
464 return false454 return false
465 }455 }
466 if state.lease != nil {456 if state.lease != nil {
467 rc := attachmentRenderCoordinator(entry)457 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) {
469 ac.sendMu.Unlock()459 ac.sendMu.Unlock()
470 return false460 return false
471 }461 }
@@ -486,12 +476,7 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
486 endDiff(0, err == nil)476 endDiff(0, err == nil)
487 if err != nil {477 if err != nil {
488 ac.sendMu.Unlock()478 ac.sendMu.Unlock()
489- d.log.Error("render draw failed", "err", err, "session", attachmentOwnerName(owner))479+ d.log.Error("render draw failed", "err", err, "session", entry.core().name)
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- }
495 // Without a coordinator reportError repaints synchronously. Suppress only480 // Without a coordinator reportError repaints synchronously. Suppress only
496 // that nested notice repaint; leave the guard before returning so a later,481 // that nested notice repaint; leave the guard before returning so a later,
497 // independent failed transaction can still notify the user.482 // independent failed transaction can still notify the user.
@@ -566,11 +551,6 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
566 // represents the captured frame. Lock panes only under sendMu and with no551 // represents the captured frame. Lock panes only under sendMu and with no
567 // session guard held.552 // session guard held.
568 commitDamageReceipts(state.receipts)553 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- }
574 if ac.renderStages.emit != nil {554 if ac.renderStages.emit != nil {
575 ac.renderStages.emit()555 ac.renderStages.emit()
576 }556 }
@@ -581,11 +561,7 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
581 // admission first. Detachment freezes the gate and therefore cannot mutate561 // admission first. Detachment freezes the gate and therefore cannot mutate
582 // ownership until any enclosing admitted operation has also ended.562 // ownership until any enclosing admitted operation has also ended.
583 if marks.attachmentEffect == nil {563 if marks.attachmentEffect == nil {
584- if entry != nil {564+ d.detachOnSendError(entry, ac, sendTr)
585- d.detachOnSendError(entry, ac, sendTr)
586- } else if remote {
587- d.clientGoneRemote(view, attachmentOwnerToken(view, ac, sendTr), false)
588- }
589 } else {565 } else {
590 // Capture the exact admitted capability, including its coordinator566 // Capture the exact admitted capability, including its coordinator
591 // lease, before End permits a new attachment publication. Reserve567 // lease, before End permits a new attachment publication. Reserve
M internal/usecase/daemon/resize_publication.go +2 -2
@@ -28,11 +28,11 @@ func (p resizeCommitPublication) current() bool {
28 if p.connection.effect == nil {28 if p.connection.effect == nil {
29 return false29 return false
30 }30 }
31- if p.connection.effect.ended.Load() || !sameAttachmentOwner(p.connection.owner, p.session) ||31+ if p.connection.effect.ended.Load() || p.connection.sess != p.session ||
32 p.connection.ac != p.attachment || p.connection.lease != p.lease ||32 p.connection.ac != p.attachment || p.connection.lease != p.lease ||
33 p.connection.generation != p.attachment.connectionGeneration.Load() ||33 p.connection.generation != p.attachment.connectionGeneration.Load() ||
34 !p.attachment.transportSnapshotCurrent(p.connection.transport) ||34 !p.attachment.transportSnapshotCurrent(p.connection.transport) ||
35- !sameAttachmentOwner(p.attachment.currentAttachmentOwner(), p.session) || p.coordinator == nil {35+ p.attachment.currentSession() != p.session || p.coordinator == nil {
36 return false36 return false
37 }37 }
38 registered := attachmentRegistered(p.session, p.attachment)38 registered := attachmentRegistered(p.session, p.attachment)
M internal/usecase/daemon/resume.go +67 -85
@@ -11,6 +11,17 @@ import (
11 11
12 var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race")12 var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race")
13 13
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+
14 func newResumeToken() uint64 {25 func newResumeToken() uint64 {
15 var b [8]byte26 var b [8]byte
16 if _, err := rand.Read(b[:]); err != nil {27 if _, err := rand.Read(b[:]); err != nil {
@@ -41,48 +52,47 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
41 // it returns 0 and never creates a marker after terminal52 // it returns 0 and never creates a marker after terminal
42 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.53 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.
43 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {54 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
44- return d.markParkingInFlightOwner(sess, ac)55+ if sess == nil || ac == nil || !ac.resumeCapable {
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 {
52 return 056 return 0
53 }57 }
54 d.mu.Lock()58 d.mu.Lock()
55 defer d.mu.Unlock()59 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 {
57 return 067 return 0
58 }68 }
59 if ac.resumeToken == 0 {69 if ac.resumeToken == 0 {
60 ac.resumeToken = d.nextResumeTokenLocked()70 ac.resumeToken = d.nextResumeTokenLocked()
61 }71 }
62- d.ensureParkingInFlightLocked(owner, ac)72+ d.ensureParkingInFlightLocked(sess, ac)
63- d.log.Info("parking in flight", "session", attachmentOwnerName(owner))73+ d.log.Info("parking in flight", "session", sess.name)
64 return ac.resumeToken74 return ac.resumeToken
65 }75 }
66 76
67 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.77 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
68 // Caller holds d.mu and has verified the daemon is not closing.78 // 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) {
70 token := ac.resumeToken80 token := ac.resumeToken
71 if token == 0 {81 if token == 0 {
72 return82 return
73 }83 }
74 if existing := d.parking[token]; existing != nil {84 if existing := d.parking[token]; existing != nil {
75 if existing.ac == ac {85 if existing.ac == ac {
76- existing.owner = owner86+ existing.sess = sess
77 return87 return
78 }88 }
79 delete(d.parking, token)89 delete(d.parking, token)
80 existing.closeDone()90 existing.closeDone()
81 }91 }
82 d.parking[token] = &parkingAttachment{92 d.parking[token] = &parkingAttachment{
83- owner: owner,93+ sess: sess,
84- ac: ac,94+ ac: ac,
85- done: make(chan struct{}),95+ done: make(chan struct{}),
86 }96 }
87 }97 }
88 98
@@ -100,16 +110,21 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
100 // If another path already detached/unrouted, that path110 // If another path already detached/unrouted, that path
101 // owns park publication.111 // owns park publication.
102 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {112 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) {
107 if token == 0 || ac == nil {113 if token == 0 || ac == nil {
108 return114 return
109 }115 }
110 d.mu.Lock()116 d.mu.Lock()
111 defer d.mu.Unlock()117 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 {
113 return128 return
114 }129 }
115 d.clearParkingInFlightLocked(token, ac)130 d.clearParkingInFlightLocked(token, ac)
@@ -139,27 +154,13 @@ func (d *Daemon) purgeAllParkingLocked() {
139 // the session is removed before park publication. Caller holds d.mu.154 // the session is removed before park publication. Caller holds d.mu.
140 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {155 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
141 for token, pending := range d.parking {156 for token, pending := range d.parking {
142- if localSession(pending.owner) == sess {157+ if pending.sess == sess {
143 delete(d.parking, token)158 delete(d.parking, token)
144 pending.closeDone()159 pending.closeDone()
145 }160 }
146 }161 }
147 }162 }
148 163
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-
163 // waitParkingInFlight waits for an identified same-client parking publication164 // waitParkingInFlight waits for an identified same-client parking publication
164 // to finish. Returns true when a matching in-flight entry was observed.165 // to finish. Returns true when a matching in-flight entry was observed.
165 // Unknown and mismatched credentials return false without waiting.166 // Unknown and mismatched credentials return false without waiting.
@@ -177,7 +178,7 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
177 d.mu.Unlock()178 d.mu.Unlock()
178 return false179 return false
179 }180 }
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 {
181 d.mu.Unlock()182 d.mu.Unlock()
182 return false183 return false
183 }184 }
@@ -192,34 +193,27 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
192 }193 }
193 194
194 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {195 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
195- return d.prepareParkAttachmentOwner(sess, ac)196+ if !ac.resumeCapable {
196-}
197-
198-func (d *Daemon) prepareParkAttachmentOwner(owner attachmentOwner, ac *attachedClient) bool {
199- if normalizeAttachmentOwner(owner) == nil || ac == nil || !ac.resumeCapable {
200 return false197 return false
201 }198 }
202 d.mu.Lock()199 d.mu.Lock()
203 defer d.mu.Unlock()200 defer d.mu.Unlock()
204- if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {201+ if d.closing {
205 return false202 return false
206 }203 }
207 if ac.resumeToken == 0 {204 if ac.resumeToken == 0 {
208 ac.resumeToken = d.nextResumeTokenLocked()205 ac.resumeToken = d.nextResumeTokenLocked()
209 }206 }
210 // Do not recreate parking markers here: post-detach publication races207 // Do not recreate parking markers here: post-detach publication races
211- // terminal owner removal and can strand IntentResume waiters. Callers that208+ // terminal session removal and can strand IntentResume waiters. Callers
212- // need the detach→park gap advertised must publish it while still exact.209+ // that need the detach→park gap advertised must markParkingInFlight while
213- d.log.Info("parking client prepared", "session", attachmentOwnerName(owner))210+ // still the exact live owner. Direct parkAttachment still parks below.
211+ d.log.Info("parking client prepared", "session", sess.name)
214 return true212 return true
215 }213 }
216 214
217 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {215 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
218- return d.parkAttachmentOwner(sess, ac)216+ if !d.prepareParkAttachment(sess, ac) {
219-}
220-
221-func (d *Daemon) parkAttachmentOwner(owner attachmentOwner, ac *attachedClient) bool {
222- if !d.prepareParkAttachmentOwner(owner, ac) {
223 return false217 return false
224 }218 }
225 // A parked attachment has no session owner. Release pane snapshots before219 // A parked attachment has no session owner. Release pane snapshots before
@@ -234,7 +228,7 @@ func (d *Daemon) parkAttachmentOwner(owner attachmentOwner, ac *attachedClient)
234 ac.overlays.pickerMu.Unlock()228 ac.overlays.pickerMu.Unlock()
235 }229 }
236 d.mu.Lock()230 d.mu.Lock()
237- if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {231+ if d.closing || d.sessions[sess.id] != sess {
238 d.clearParkingInFlightLocked(ac.resumeToken, ac)232 d.clearParkingInFlightLocked(ac.resumeToken, ac)
239 d.mu.Unlock()233 d.mu.Unlock()
240 return false234 return false
@@ -254,7 +248,7 @@ func (d *Daemon) parkAttachmentOwner(owner attachmentOwner, ac *attachedClient)
254 }248 }
255 grace := d.resumeParkGrace249 grace := d.resumeParkGrace
256 timer := d.clock.NewTimer(grace)250 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{})}
258 ac.parked = true252 ac.parked = true
259 d.parked[token] = parked253 d.parked[token] = parked
260 d.clearParkingInFlightLocked(token, ac)254 d.clearParkingInFlightLocked(token, ac)
@@ -268,7 +262,7 @@ func (d *Daemon) parkAttachmentOwner(owner attachmentOwner, ac *attachedClient)
268 }262 }
269 oldSame.closeDone()263 oldSame.closeDone()
270 }264 }
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)
272 d.watchParkedTimer(token, parked)266 d.watchParkedTimer(token, parked)
273 return true267 return true
274 }268 }
@@ -297,7 +291,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
297 d.removeParkedLocked(token, parked)291 d.removeParkedLocked(token, parked)
298 d.mu.Unlock()292 d.mu.Unlock()
299 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)293 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)
301 return295 return
302 }296 }
303 d.mu.Unlock()297 d.mu.Unlock()
@@ -366,16 +360,11 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
366 ac.resumeToken = token360 ac.resumeToken = token
367 ac.parked = true361 ac.parked = true
368 ac.connectionGeneration.Add(1)362 ac.connectionGeneration.Add(1)
369- sess := localSession(parked.owner)363+ sess := parked.sess
370- view, remote := normalizeAttachmentOwner(parked.owner).(*remoteView)
371 if sess != nil {364 if sess != nil {
372 sess.mu.Lock()365 sess.mu.Lock()
373 sess.unregisterAttachmentLocked(ac)366 sess.unregisterAttachmentLocked(ac)
374 sess.mu.Unlock()367 sess.mu.Unlock()
375- } else if remote {
376- view.mu.Lock()
377- view.unregisterAttachmentLocked(ac)
378- view.mu.Unlock()
379 }368 }
380 // Recreate the parked entry and its watcher instead of restoring a timer369 // Recreate the parked entry and its watcher instead of restoring a timer
381 // that may already have fired while the claim held the old entry. This also370 // 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 {
385 }374 }
386 parked.closeDone()375 parked.closeDone()
387 rearmed := &parkedAttachment{376 rearmed := &parkedAttachment{
388- owner: parked.owner,377+ sess: parked.sess,
389 ac: ac,378 ac: ac,
390 pickerGeneration: parked.pickerGeneration,379 pickerGeneration: parked.pickerGeneration,
391 timer: d.clock.NewTimer(d.resumeParkGrace),380 timer: d.clock.NewTimer(d.resumeParkGrace),
@@ -398,9 +387,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
398 if sess != nil {387 if sess != nil {
399 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")388 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
400 }389 }
401- if remote {
402- d.parkRemoteViewWarm(view)
403- }
404 d.watchParkedTimer(token, rearmed)390 d.watchParkedTimer(token, rearmed)
405 if captured != nil {391 if captured != nil {
406 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))392 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
@@ -434,20 +420,7 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
434 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {420 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
435 var retirements []parkedAttachmentRetirement421 var retirements []parkedAttachmentRetirement
436 for token, parked := range d.parked {422 for token, parked := range d.parked {
437- if localSession(parked.owner) == sess {423+ if parked.sess == 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 {
451 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))424 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
452 }425 }
453 }426 }
@@ -520,6 +493,10 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
520 d.mu.Unlock()493 d.mu.Unlock()
521 return d.resumeParked(h, tr, sz)494 return d.resumeParked(h, tr, sz)
522 }495 }
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+ }
523 d.mu.Unlock()500 d.mu.Unlock()
524 501
525 if clientMismatch {502 if clientMismatch {
@@ -559,7 +536,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
559 if !d.parkAttachment(sess, ac) {536 if !d.parkAttachment(sess, ac) {
560 // Detach already published; retire the captured link exactly once so537 // Detach already published; retire the captured link exactly once so
561 // shutdown cannot leave an orphaned open transport with no owner.538 // shutdown cannot leave an orphaned open transport with no owner.
562- d.clearParkingInFlight(ac.resumeToken, ac)539+ d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
563 d.resetScreenDefaultColors(sess)540 d.resetScreenDefaultColors(sess)
564 ac.clearPreviousSession()541 ac.clearPreviousSession()
565 _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))542 _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))
@@ -600,7 +577,7 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
600 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}577 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
601 }578 }
602 if d.parked[h.ResumeToken] != parked || parked.claimed {579 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 {
604 d.mu.Unlock()581 d.mu.Unlock()
605 ac.sendMu.Unlock()582 ac.sendMu.Unlock()
606 return nil, nil, false, errResumeTokenLifecycleRace583 return nil, nil, false, errResumeTokenLifecycleRace
@@ -625,11 +602,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
625 if parked == nil || h.ResumeToken == 0 {602 if parked == nil || h.ResumeToken == 0 {
626 return nil, nil, false, nil603 return nil, nil, false, nil
627 }604 }
628- sess := localSession(parked.owner)605+ sess := parked.sess
629- registered := sess != nil && d.sessions[sess.id] == sess606+ registered := d.sessions[sess.id] == sess
630 if !registered {607 if !registered {
631 d.removeParkedLocked(h.ResumeToken, parked)608 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)
633 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}610 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
634 }611 }
635 ac := parked.ac612 ac := parked.ac
@@ -641,6 +618,9 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
641 d.log.Warn("resume rejected", "session", sess.name, "err", reason)618 d.log.Warn("resume rejected", "session", sess.name, "err", reason)
642 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}619 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
643 }620 }
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+ }
644 // Claim the attachment while retaining the old credential until Welcome is624 // Claim the attachment while retaining the old credential until Welcome is
645 // accepted. A failed handshake can therefore restore this exact parked625 // accepted. A failed handshake can therefore restore this exact parked
646 // entry; another resume sees claimed and loses atomically.626 // entry; another resume sees claimed and loses atomically.
@@ -659,7 +639,9 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
659 // The resumed session's snapshot is the sole source for future PTY children.639 // The resumed session's snapshot is the sole source for future PTY children.
660 // Existing PTYs retain the environment they were started with.640 // Existing PTYs retain the environment they were started with.
661 sess.mu.Lock()641 sess.mu.Lock()
662- sess.env = copyEnvironment(h.Env)642+ if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
643+ sess.env = copyEnvironment(h.Env)
644+ }
663 sess.terminal = terminalEnv{TrueColor: h.TrueColor}645 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
664 sess.mu.Unlock()646 sess.mu.Unlock()
665 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with647 // 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 {
97 }97 }
98 }98 }
99 99
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+
100 func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {167 func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
101 clock := &signalClock{timers: make(chan *signalTimer, 8)}168 clock := &signalClock{timers: make(chan *signalTimer, 8)}
102 pty, release := newBlockingPTY(t)169 pty, release := newBlockingPTY(t)
@@ -533,7 +600,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
533 d.mu.Unlock()600 d.mu.Unlock()
534 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")601 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
535 require.Same(t, ac, parkingInGap.ac)602 require.Same(t, ac, parkingInGap.ac)
536- require.Same(t, sess, localSession(parkingInGap.owner))603+ require.Same(t, sess, parkingInGap.sess)
537 require.False(t, parkedInGap, "fixture: park must not have published yet")604 require.False(t, parkedInGap, "fixture: park must not have published yet")
538 605
539 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})606 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
@@ -1268,7 +1335,7 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1268 require.NoError(t, err)1335 require.NoError(t, err)
1269 1336
1270 previous := &session{sessionCore: sessionCore{id: "previous"}}1337 previous := &session{sessionCore: sessionCore{id: "previous"}}
1271- ac.previousOwner.Set(previous)1338+ ac.previousSession.Set(previous)
1272 d.clientGone(sess, ac, ac.transport(), false)1339 d.clientGone(sess, ac, ac.transport(), false)
1273 1340
1274 token := ac.resumeToken1341 token := ac.resumeToken
@@ -1276,14 +1343,14 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1276 parked := d.parked[token]1343 parked := d.parked[token]
1277 d.mu.Unlock()1344 d.mu.Unlock()
1278 require.NotNil(t, parked)1345 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")
1280 1347
1281 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))1348 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
1282 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)1349 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
1283 require.NoError(t, err)1350 require.NoError(t, err)
1284 require.Same(t, sess, resumedSess)1351 require.Same(t, sess, resumedSess)
1285 require.Same(t, ac, resumedAC)1352 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")
1287 }1354 }
1288 1355
1289 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {1356 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
@@ -1301,7 +1368,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1301 {1368 {
1302 name: "session purge",1369 name: "session purge",
1303 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {1370 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
1304- return d.purgeParkedForSessionLocked(localSession(parked.owner))1371+ return d.purgeParkedForSessionLocked(parked.sess)
1305 },1372 },
1306 },1373 },
1307 } {1374 } {
@@ -1313,7 +1380,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1313 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)1380 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1314 require.NoError(t, err)1381 require.NoError(t, err)
1315 1382
1316- ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "previous"}})1383+ ac.previousSession.Set(&session{sessionCore: sessionCore{id: "previous"}})
1317 d.clientGone(sess, ac, ac.transport(), false)1384 d.clientGone(sess, ac, ac.transport(), false)
1318 1385
1319 d.mu.Lock()1386 d.mu.Lock()
@@ -1323,7 +1390,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1323 d.mu.Unlock()1390 d.mu.Unlock()
1324 d.finishParkedAttachmentRetirements(retirements)1391 d.finishParkedAttachmentRetirements(retirements)
1325 1392
1326- require.Nil(t, ac.previousOwner.Get())1393+ require.Nil(t, ac.previousSession.Get())
1327 })1394 })
1328 }1395 }
1329 }1396 }
@@ -1548,6 +1615,34 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
1548 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])1615 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])
1549 }1616 }
1550 1617
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+
1551 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {1646 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {
1552 pty, release := newBlockingPTY(t)1647 pty, release := newBlockingPTY(t)
1553 defer release()1648 defer release()
M internal/usecase/daemon/session.go +22 -19
@@ -467,8 +467,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
467 if err := domain.ValidateSessionName(name); err != nil {467 if err := domain.ValidateSessionName(name); err != nil {
468 return err468 return err
469 }469 }
470- source := token.localSession()470+ if token.sess == nil || token.ac == nil || token.effect == nil {
471- if source == nil || token.ac == nil || token.effect == nil {
472 return errAttachmentTransition471 return errAttachmentTransition
473 }472 }
474 token.effect.bindActionEnd(d, "create-session")473 token.effect.bindActionEnd(d, "create-session")
@@ -476,11 +475,15 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
476 475
477 var created *session476 var created *session
478 transition, err := d.transitionAttachment(attachmentTransitionRequest{477 transition, err := d.transitionAttachment(attachmentTransitionRequest{
479- source: source, next: token.ac,478+ source: token.sess, next: token.ac,
480 479
481 expectedTransport: token.transport, sourceToken: &token, action: "create-session",480 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
482 copySourceEnvironment: true, ready: true,481 copySourceEnvironment: true, ready: true,
483 createTargetLocked: func() (*session, error) {482 createTargetLocked: func() (*session, error) {
483+ source := token.sess
484+ if source == nil {
485+ return nil, errAttachmentTransition
486+ }
484 if d.closing {487 if d.closing {
485 return nil, errors.New("daemon is shutting down")488 return nil, errors.New("daemon is shutting down")
486 }489 }
@@ -503,8 +506,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
503 }506 }
504 507
505 d.touchMRU(created)508 d.touchMRU(created)
506- if source != nil {509+ if token.sess != nil {
507- token.ac.recordPreviousSession(source)510+ token.ac.recordPreviousSession(token.sess)
508 }511 }
509 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)512 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
510 d.deferAttachmentTransitionCleanups(transition)513 d.deferAttachmentTransitionCleanups(transition)
@@ -770,15 +773,14 @@ func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool
770 }773 }
771 774
772 func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {775 func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
773- sess := token.localSession()776+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
774- if sess == nil || token.ac == nil || token.transport.transport == nil {
775 return false777 return false
776 }778 }
777 // Attachments without the session's optional primary render lease still779 // Attachments without the session's optional primary render lease still
778 // own an independent connection lifecycle and detach through the same exact780 // own an independent connection lifecycle and detach through the same exact
779 // transport/generation fence.781 // transport/generation fence.
780 if token.lease == nil {782 if token.lease == nil {
781- return d.detachIfCurrentTransport(sess, token.ac, token.transport)783+ return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
782 }784 }
783 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)785 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
784 defer frozen.unfreeze()786 defer frozen.unfreeze()
@@ -789,8 +791,8 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
789 d.afterDetachAttachmentEffectsFrozen()791 d.afterDetachAttachmentEffectsFrozen()
790 }792 }
791 d.mu.Lock()793 d.mu.Lock()
792- core := sess.core()794+ core := token.sess.core()
793- if core == nil || d.sessions[core.id] != sess {795+ if core == nil || d.sessions[core.id] != token.sess {
794 d.mu.Unlock()796 d.mu.Unlock()
795 return false797 return false
796 }798 }
@@ -801,11 +803,11 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
801 coordinator.mu.Lock()803 coordinator.mu.Lock()
802 }804 }
803 req := attachmentTransitionRequest{805 req := attachmentTransitionRequest{
804- source: sess, next: token.ac, expectedTransport: token.transport,806+ source: token.sess, next: token.ac, expectedTransport: token.transport,
805 }807 }
806- current := coordinator != nil && transitionSourceTokenCurrentLocked(token, sess, coordinator, req)808+ current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
807 if current {809 if current {
808- unregisterAttachmentSessionLocked(sess, token.ac)810+ unregisterAttachmentSessionLocked(token.sess, token.ac)
809 token.ac.connectionGeneration.Add(1)811 token.ac.connectionGeneration.Add(1)
810 token.ac.setSession(nil)812 token.ac.setSession(nil)
811 token.ac.invalidateFrozenAttachmentCapability()813 token.ac.invalidateFrozenAttachmentCapability()
@@ -1166,11 +1168,13 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1166 snapshot := sessionKillParticipants{target: target}1168 snapshot := sessionKillParticipants{target: target}
1167 if admission != nil {1169 if admission != nil {
1168 token := admission.token1170 token := admission.token
1169- source := token.localSession()1171+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1170- if source == nil || token.ac == nil || token.transport.transport == nil {1172+ return sessionKillParticipants{}, false
1173+ }
1174+ snapshot.source = token.sess
1175+ if snapshot.source == nil {
1171 return sessionKillParticipants{}, false1176 return sessionKillParticipants{}, false
1172 }1177 }
1173- snapshot.source = source
1174 snapshot.sourceToken = &admission.token1178 snapshot.sourceToken = &admission.token
1175 }1179 }
1176 1180
@@ -1267,11 +1271,10 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
1267 return true1271 return true
1268 }1272 }
1269 token := *snapshot.sourceToken1273 token := *snapshot.sourceToken
1270- source := token.localSession()
1271 req := attachmentTransitionRequest{1274 req := attachmentTransitionRequest{
1272- source: source, next: token.ac, expectedTransport: token.transport,1275+ source: token.sess, next: token.ac, expectedTransport: token.transport,
1273 }1276 }
1274- return transitionSourceTokenCurrentLocked(token, source, sourceCoordinator, req)1277+ return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
1275 }1278 }
1276 1279
1277 // stopInMemoryLifecycle cancels every session producer and closes every PTY1280 // stopInMemoryLifecycle cancels every session producer and closes every PTY
M internal/usecase/daemon/session_back.go +17 -78
@@ -5,64 +5,15 @@ import (
5 "github.com/bnema/vev/internal/usecase/picker"5 "github.com/bnema/vev/internal/usecase/picker"
6 )6 )
7 7
8-// backSession toggles this attachment between its current local session and8+// backSession toggles this attachment between its current session and the
9-// the immediately preceding successfully activated local session. A remote9+// immediately preceding successfully activated session.
10-// owner is retained in the owner history but is not interpreted as a local10+func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
11-// session; remote back navigation is published with remote transitions.11+ if d == nil || token.sess == nil || token.ac == nil {
12-func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken, afterRemoteTransition ...func()) error {
13- if d == nil || token.ac == nil {
14 return nil12 return nil
15 }13 }
16- if view, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {14+ target := token.ac.previousSession.Get()
17- previous := token.ac.previousOwner.Get()15+ if target == nil || target == token.sess || target.core() == nil || d.sessionByID(target.core().id) != target {
18- target := localSession(previous)16+ token.ac.clearPreviousSessionIf(target)
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)
66 return nil17 return nil
67 }18 }
68 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}19 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}
@@ -73,16 +24,9 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
73 if d == nil || current == nil || ac == nil {24 if d == nil || current == nil || ac == nil {
74 return25 return
75 }26 }
76- previous := ac.previousOwner.Get()27+ target := ac.previousSession.Get()
77- target := localSession(previous)28+ if target == nil || target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
78- if target == nil {29+ ac.clearPreviousSessionIf(target)
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)
86 d.invalidateRender(current, ac, true, "session_back.go")30 d.invalidateRender(current, ac, true, "session_back.go")
87 return31 return
88 }32 }
@@ -99,29 +43,24 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
99 }43 }
100 }44 }
101 45
102-// clearPreviousOwnerIf clears target only if it has not been replaced since46+// clearPreviousSessionIf clears target only if it has not been replaced since
103 // the caller observed it. This preserves a concurrent successful hand-off.47 // 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) {
105 if ac == nil {49 if ac == nil {
106 return50 return
107 }51 }
108- ac.previousOwner.With(func(previous *attachmentOwner) {52+ ac.previousSession.With(func(previous **session) {
109- if sameAttachmentOwner(*previous, target) {53+ if *previous == target {
110 *previous = nil54 *previous = nil
111 }55 }
112 })56 })
113 }57 }
114 58
115-// recordPreviousOwner is called only after a cross-owner hand-off has59+// recordPreviousSession is called only after a cross-session hand-off has
116 // committed. Keeping this after the commit makes failed/displaced switches60 // committed. Keeping this after the commit makes failed/displaced switches
117 // leave the toggle pair intact.61 // leave the toggle pair intact.
118-func (ac *attachedClient) recordPreviousOwner(origin attachmentOwner) {62+func (ac *attachedClient) recordPreviousSession(origin *session) {
119- origin = normalizeAttachmentOwner(origin)
120 if ac != nil && origin != nil {63 if ac != nil && origin != nil {
121- ac.previousOwner.Set(origin)64+ ac.previousSession.Set(origin)
122 }65 }
123 }66 }
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 @@
1 package daemon1 package daemon
2 2
3-import (3+import "github.com/bnema/vev/internal/domain"
4- "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/internal/ports"
6-)
7 4
8 // sessionGeometrySource identifies the attachment whose latest attach,5 // sessionGeometrySource identifies the attachment whose latest attach,
9 // resume, or resize claim controls shared PTY/layout geometry. claim is zero6 // resume, or resize claim controls shared PTY/layout geometry. claim is zero
@@ -65,11 +62,6 @@ func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource,
65 return domain.Size{}, sessionGeometrySource{}, false62 return domain.Size{}, sessionGeometrySource{}, false
66 }63 }
67 content := contentSize(source.size)64 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- }
73 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true65 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true
74 }66 }
75 67
M internal/usecase/daemon/status.go +2 -2
@@ -193,7 +193,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
193 name += "*"193 name += "*"
194 }194 }
195 if ac != nil && ac.remoteOrigin != "" {195 if ac != nil && ac.remoteOrigin != "" {
196- name += "@" + ac.remoteOrigin196+ name += " at " + ac.remoteOrigin
197 }197 }
198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
199 activeIndex := 0199 activeIndex := 0
@@ -276,7 +276,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
276 if ranked != nil {276 if ranked != nil {
277 state.rankedRecent = ranked277 state.rankedRecent = ranked
278 } else if d != nil {278 } else if d != nil {
279- state.mru = d.recentSessionsForAttachment(cur, ac)279+ state.mru = d.recentSessions(cur)
280 }280 }
281 return state281 return state
282 }282 }
M internal/usecase/daemon/status_test.go +1 -2
@@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
115 remoteOrigin string115 remoteOrigin string
116 wantAttachmentBar string116 wantAttachmentBar string
117 }{117 }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},118+ {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"},
119 {name: "local attachment", wantAttachmentBar: "vive"},119 {name: "local attachment", wantAttachmentBar: "vive"},
120 }120 }
121 121
@@ -1549,5 +1549,4 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1549 releasePTY2()1549 releasePTY2()
1550 hg.Wait()1550 hg.Wait()
1551 d.sessWg.Wait()1551 d.sessWg.Wait()
1552- d.waitNotifies()
1553 }1552 }
M internal/usecase/daemon/wire.go +8 -74
@@ -2,87 +2,21 @@
2 package daemon2 package daemon
3 3
4 import (4 import (
5- "strconv"
6-
7 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
9 )7 )
10 8
11-func frameWelcome(s *session, ac *attachedClient) ports.Frame {9+func frameWelcome(s *session, resumeToken uint64) 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.
60 s.mu.Lock()10 s.mu.Lock()
61- active := ac.viewSnapshot().tabID11+ w := ports.Welcome{
62- meta := ports.SessionMeta{12+ SessionID: string(s.id),
63- LifecycleID: s.incarnation,13+ SessionName: s.name,
64- Revision: revision,14+ Ephemeral: s.ephemeral,
65- SessionName: s.name,15+ Capabilities: ports.CapabilityResume,
66- Tabs: make([]ports.SessionTabMeta, 0, len(s.tabs)),
67- }
68- activePresent := false
69- for i, tab := range s.tabs {
70- id := domain.TabStableID(tab.stableID)
71- meta.Tabs = append(meta.Tabs, ports.SessionTabMeta{ID: id, Name: tabDisplayName(tab, i), Attention: tab.attention})
72- activePresent = activePresent || active == id
73 }16 }
74- if len(meta.Tabs) == 0 {
75- active = ""
76- } else if !activePresent {
77- active = meta.Tabs[0].ID
78- }
79- meta.ActiveTabID = active
80 s.mu.Unlock()17 s.mu.Unlock()
81- payload, err := ports.MarshalSessionMeta(meta)18+ w.ResumeToken = resumeToken
82- if err != nil {19+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
83- return ports.Frame{}, err
84- }
85- return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}, nil
86 }20 }
87 21
88 func frameError(code uint16, text string) ports.Frame {22 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) {
23 })23 })
24 24
25 for range 1000 {25 for range 1000 {
26- frame := frameWelcome(sess, ac)26+ frame := frameWelcome(sess, ac.resumeToken)
27 welcome, err := ports.UnmarshalWelcome(frame.Payload)27 welcome, err := ports.UnmarshalWelcome(frame.Payload)
28 if err != nil {28 if err != nil {
29 t.Fatalf("UnmarshalWelcome() error = %v", err)29 t.Fatalf("UnmarshalWelcome() error = %v", err)
M internal/usecase/palette/fuzzy_test.go +10 -10
@@ -99,9 +99,9 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
99 NewCommandResult(cmd("ZZZ", "", "work tools")),99 NewCommandResult(cmd("ZZZ", "", "work tools")),
100 },100 },
101 query: "work",101 query: "work",
102- wantText: []string{"WORK", "work", "work", "WQORRK", "ZZZ"},102+ wantText: []string{"WORK", "Resume session work", "WQORRK", "Switch to session work", "ZZZ"},
103- wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand},103+ wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindCommand, ResultKindActiveSession, ResultKindCommand},
104- wantPositions: [][]int{{0, 1, 2, 3}, {0, 1, 2, 3}, {0, 1, 2, 3}, {0, 2, 3, 5}, nil},104+ wantPositions: [][]int{{0, 1, 2, 3}, {15, 16, 17, 18}, {0, 2, 3, 5}, {1, 8, 20, 21}, nil},
105 },105 },
106 {106 {
107 name: "equivalent sessions sort by normalized text",107 name: "equivalent sessions sort by normalized text",
@@ -110,12 +110,12 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
110 NewStoppedSessionResult("aAlpha", time.Time{}),110 NewStoppedSessionResult("aAlpha", time.Time{}),
111 },111 },
112 query: "a",112 query: "a",
113- wantText: []string{"aAlpha", "aBravo"},113+ wantText: []string{"Resume session aAlpha", "Resume session aBravo"},
114 wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession},114 wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession},
115- wantPositions: [][]int{{0}, {0}},115+ wantPositions: [][]int{{15}, {15}},
116 },116 },
117 {117 {
118- name: "kind then normalized text then stable order",118+ name: "display prefix positions sort before later active names",
119 results: []Result{119 results: []Result{
120 NewStoppedSessionResult("aBravo", time.Time{}),120 NewStoppedSessionResult("aBravo", time.Time{}),
121 NewStoppedSessionResult("aAlpha", time.Time{}),121 NewStoppedSessionResult("aAlpha", time.Time{}),
@@ -125,10 +125,10 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
125 NewCommandResult(cmd("AX", "", "")),125 NewCommandResult(cmd("AX", "", "")),
126 },126 },
127 query: "a",127 query: "a",
128- wantText: []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"},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, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession},129+ wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession},
130- wantPositions: [][]int{{0}, {0}, {0}, {0}, {0}, {0}},130+ wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}},
131- wantActiveIDs: map[int]domain.SessionID{1: "e", 2: "e2", 3: "z"},131+ wantActiveIDs: map[int]domain.SessionID{3: "e", 4: "e2", 5: "z"},
132 },132 },
133 }133 }
134 134
M internal/usecase/palette/model.go +1 -2
@@ -332,10 +332,9 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
332 332
333 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {333 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
334 x, nextHighlight := 0, 0334 x, nextHighlight := 0, 0
335- prefixWidth := utf8.RuneCountInString(match.Result.sessionDisplayPrefix())
336 for _, r := range match.Result.DisplayText() {335 for _, r := range match.Result.DisplayText() {
337 cellStyle := style336 cellStyle := style
338- if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x {337+ if nextHighlight < len(match.Positions) && match.Positions[nextHighlight] == x {
339 cellStyle = selection338 cellStyle = selection
340 cellStyle.Bold = true339 cellStyle.Bold = true
341 nextHighlight++340 nextHighlight++
M internal/usecase/palette/model_test.go +3 -3
@@ -465,11 +465,11 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
465 m := New(results)465 m := New(results)
466 results[1] = NewActiveSessionResult("changed", created, "changed-id")466 results[1] = NewActiveSessionResult("changed", created, "changed-id")
467 467
468- for _, r := range "work" {468+ for _, r := range "switch" {
469 m.Insert(r)469 m.Insert(r)
470 }470 }
471 matches := m.Matches()471 matches := m.Matches()
472- require.Len(t, matches, 2)472+ require.Len(t, matches, 1)
473 require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())473 require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
474 matches[0].Result = NewCommandResult(cmd("BAD", "", ""))474 matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
475 require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())475 require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
@@ -481,7 +481,7 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
481 require.True(t, active)481 require.True(t, active)
482 require.Equal(t, domain.SessionID("work-id"), id)482 require.Equal(t, domain.SessionID("work-id"), id)
483 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")483 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())
485 _, argument := m.ArgumentCommand()485 _, argument := m.ArgumentCommand()
486 require.False(t, argument)486 require.False(t, argument)
487 487
M internal/usecase/palette/result.go +1 -4
@@ -68,10 +68,7 @@ func (r Result) sessionDisplayPrefix() string {
68 }68 }
69 69
70 func (r Result) SearchText() string {70 func (r Result) SearchText() string {
71- if r.kind == ResultKindCommand {71+ return r.DisplayText()
72- return r.command.Code
73- }
74- return r.session.name
75 }72 }
76 73
77 // Command returns the command payload only for command results.74 // 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) {
30 require.True(t, ok)30 require.True(t, ok)
31 require.Equal(t, created, createdAt)31 require.Equal(t, created, createdAt)
32 require.Equal(t, "Switch to session work", active.DisplayText())32 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())
34 id, ok := active.SessionID()34 id, ok := active.SessionID()
35 require.True(t, ok)35 require.True(t, ok)
36 require.Equal(t, domain.SessionID("session-work"), id)36 require.Equal(t, domain.SessionID("session-work"), id)
@@ -43,6 +43,31 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
43 require.True(t, ok)43 require.True(t, ok)
44 require.Equal(t, created, createdAt)44 require.Equal(t, created, createdAt)
45 require.Equal(t, "Resume session archive", stopped.DisplayText())45 require.Equal(t, "Resume session archive", stopped.DisplayText())
46+ require.Equal(t, "Resume session archive", stopped.SearchText())
46 _, ok = stopped.SessionID()47 _, ok = stopped.SessionID()
47 require.False(t, ok)48 require.False(t, ok)
48 }49 }
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