detached e9e3f59

Merge pull request #190 from bnema/feat/client-global-recent-navigation

feat(client): make recent session navigation global
commit
e9e3f592a6ea68112df3bebcb3893fcc0fac9a3a
author
bnema · · 2026-08-10 05:10 UTC
committer
GitHub · · 2026-08-10 05:10 UTC
merge of
e4fd2d1 + 5ee2a06 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
120 files · +6624 -10009
A .agent-reviews/redteam.md +94 -0
@@ -0,0 +1,94 @@
1+# Stack 2 route ledger/protocol red-team
2+
3+## Builder decision
4+
5+Add Stack 2 as daemon-neutral plumbing on top of the completed presentation branch:
6+
7+- model route origins, exact lifecycle/name targets, committed identity, bounded complete snapshots, opaque key/generation navigation actions, and closed failure codes in `internal/ports`;
8+- bump the protocol version and append an optional exact target section to `Hello` while preserving the version peeker's first field;
9+- wire explicit local, remote, and discovery origins at `internal/app`/client composition boundaries;
10+- keep the client ledger private, bounded, lock-protected, and separate from display values and transport capabilities;
11+- validate exact local targets in the daemon and preserve existing remote-target authority;
12+- leave JRS/BSK command authority and the atomic global cutover for Stack 3.
13+
14+## Critic pass
15+
16+The read-only reviewer worker did not return a retrievable result in this harness.
17+The builder completed a local high-impact critic pass below and recorded each
18+objection with its resolution and acceptance check.
19+
20+## Evidence so far
21+
22+- PR #188 dependency branch already has strict Hello peeking and remote lifecycle/tab target validation.
23+- `internal/ports/wire.go` requires `Hello.Version` first; the new exact-target section is appended after the existing remote/policy section.
24+- Existing daemon route authority remains in `routeWithContext`; exact local validation is an additional lifecycle check before ordinary attach routing.
25+- Focused ports, client, app, and daemon tests pass after the current incremental changes.
26+
27+## Accepted risks
28+
29+- Stack 2 introduces codec/model seams and private transition primitives but does not yet publish snapshots or replace command authority; those remain explicit Stack 3 work.
30+- The first reviewer-worker attempts did not produce a retrievable result in this harness, so the builder performed the required critic pass locally before final validation.
31+
32+## Builder critic pass and resolutions
33+
34+- **Welcome identity mismatch:** a malicious or buggy daemon could return a committed lifecycle/name different from the Welcome metadata. `UnmarshalWelcome` now rejects mismatched session name or ephemeral state.
35+- **Stale exact target side effects:** exact local validation waits for an existing restore barrier before checking lifecycle identity; it does not create or mutate a session and rejects lifecycle replacement before normal attach.
36+- **Snapshot leakage:** `RecentRouteEntry` contains only opaque key/generation and display fields. Dialers, requests, resume credentials, and exact targets remain in private `routeRecord` values and are copied out only through navigation lookup.
37+- **Transactional navigation:** connector failure, stale key/generation, or committed-target mismatch returns before ledger commit; origin is restored from the selected private record rather than connector input.
38+- **Bounds/strictness:** snapshot entry count, labels, refs, closed enums, bool encodings, truncation, trailing bytes, and protocol-version peeking are covered by tests; focused race tests pass.
39+
40+## Follow-up fixes
41+
42+The implementation now commits a successful Welcome into the private ledger before
43+raw-mode entry, publishes a complete snapshot frame, and accepts/validates that
44+frame on the daemon attachment. The active route is metadata-only, the private
45+history cap is 20, origin keys distinguish daemon endpoints, route labels reject
46+invalid UTF-8/control text, navigation actions carry the snapshot generation,
47+active selection is a no-op, and failed transitions invoke the prior-route
48+restore hook before returning failure.
49+
50+# Client-owned route-position red-team
51+
52+## Builder decision
53+
54+Keep live tab/view authority on each daemon attachment, but remember the last
55+stable tab ID per exact client route in the process-local route ledger. Add an
56+optional preferred stable tab to Hello and a daemon-to-client route-position
57+publication bound to an exact lifecycle/name target. A missing preferred tab is
58+a hint miss and falls back to the daemon's normal attachment tab; it never
59+changes session identity or shared session tab state.
60+
61+## Critic pass
62+
63+- **Multiple clients:** daemon-global or session-global memory would couple
64+ clients. The remembered cursor stays in each Runner's private route ledger;
65+ the daemon applies it only to the requesting attachment.
66+- **Stale/out-of-order publications:** a tab ID without session identity could
67+ corrupt the route selected by a concurrent transition. RoutePosition carries
68+ the exact lifecycle/name target, and the ledger applies it only to that route.
69+- **Handshake ordering:** publishing another server frame before Welcome would
70+ recreate the resume-token failure found during manual testing. Initial route
71+ position is emitted only by post-Welcome rendering; Welcome stays first.
72+- **Deleted tabs:** remembered positions are hints, not exact picker authority.
73+ The daemon validates stable IDs under target-session ownership and falls back
74+ without rejecting the attachment when the tab no longer exists.
75+- **Discovery metadata:** RemoteTarget includes a point-in-time live tab and must
76+ not become route memory. Committed route records drop that catalog selector;
77+ PreferredTabID is the independent mutable cursor.
78+- **Resume and identity authority:** a preferred tab cannot select a session. It
79+ is accepted only for attach/resume and only after resume-token or exact-target
80+ session resolution succeeds.
81+- **Untrusted IDs:** both Hello and RoutePosition reject empty/oversized/control
82+ stable IDs and trailing or truncated payloads.
83+- **Publication completeness:** tab selection and repair always invalidate a
84+ render. RoutePosition is serialized with the attachment's post-commit output
85+ transaction and cached per transport incarnation; output rebase forces an
86+ initial publication after attach/resume.
87+
88+## Accepted risks
89+
90+- The state is intentionally process-memory only. Restarting the client loses
91+ remembered tabs; restarting a daemon does not make the daemon the memory
92+ owner, and missing/replaced stable IDs safely fall back.
93+- RoutePosition is an additional small control frame only when the exact target
94+ or active stable tab changes, not on every output diff.
M AGENTS.md +1 -1
@@ -64,7 +64,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
65 65
66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types occupy `1–13` (`MsgOutputResetRequest` is `13`) and `15` (`MsgRemotePreviewRequest`); server types occupy `16–23` (`MsgSessionMeta` is `23`) and `25–26` (`MsgAttachTarget` and `MsgRemotePreviewResponse`). Types `14` and `24` remain reserved.67+- Client message types occupy `1–13` (`MsgOutputResetRequest` is `13`) and `15` (`MsgRemotePreviewRequest`); server types occupy `16–23` (`MsgNavigationAction` is `23`) and `25–30` (`MsgAttachTarget`, `MsgRemotePreviewResponse`, `MsgCommittedRouteIdentity`, `MsgRecentRouteSnapshot`, `MsgNavigateRecentRoute`, and `MsgRouteNavigationFailure`). Types `14` and `24` remain reserved.
68 - Version negotiation requires strict equality.68 - Version negotiation requires strict equality.
69 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.69 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
70 - Bump `ProtocolVersion` for any message layout change.70 - Bump `ProtocolVersion` for any message layout change.
M docs/ansi-content-replay-phase0.md +2 -2
@@ -1,6 +1,6 @@
1 # Phase 0: ANSI content replay gate1 # Phase 0: ANSI content replay gate
2 2
3-The test-only prototype in `internal/usecase/daemon/ansi_content_replay_phase0_test.go` records the current feasibility boundary for remote content:3+The former test-only prototype in `internal/usecase/daemon/ansi_content_replay_phase0_test.go` recorded the feasibility boundary for remote content. That prototype was removed when direct remote ownership became the production boundary; the constraints it established remain:
4 4
5 - ANSI produced by `pkg/renderer` is replayed into a private `pkg/vt.Screen`.5 - ANSI produced by `pkg/renderer` is replayed into a private `pkg/vt.Screen`.
6 - The screen has nil response, bell, notification, progress, and clipboard callbacks, so replay cannot emit remote host-side effects.6 - The screen has nil response, bell, notification, progress, and clipboard callbacks, so replay cannot emit remote host-side effects.
@@ -8,4 +8,4 @@ The test-only prototype in `internal/usecase/daemon/ansi_content_replay_phase0_t
8 - The captured grid can be handed to a local renderer for composition; the remote ANSI stream is not passed to that renderer or a transport.8 - The captured grid can be handed to a local renderer for composition; the remote ANSI stream is not passed to that renderer or a transport.
9 - A test-only ordered-frame model requires metadata before the first full content frame, contiguous state-bearing deltas, and base-zero full resets. Gaps and malformed resets are rejected.9 - A test-only ordered-frame model requires metadata before the first full content frame, contiguous state-bearing deltas, and base-zero full resets. Gaps and malformed resets are rejected.
10 10
11-This is feasibility evidence only. It does not change the production renderer, protocol, layout, or attach flow. The remaining gate is to specify and integrate a production remote metadata/content ownership contract around this boundary; no compatibility path is implied by Phase 0.11+This remains feasibility evidence only. Direct remote attachments keep the remote daemon as the owner of the renderer, protocol, layout, and attach flow; no ANSI replay or local shadow-session compatibility path is implied.
M docs/remote-resilience.md +16 -0
@@ -25,6 +25,22 @@ 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 single client process keeps a
31+bounded, in-memory route history across local, direct remote, and picker-
32+discovered attachments. The daemon renders the latest attachment snapshot;
33+active routes are metadata-only, while `JRS` and `BSK` send typed key/generation
34+actions back to the client. History is process-local and is not persisted or
35+shared between clients.
36+
37+A picker-selected remote target is a direct handoff with one input pump. Exact
38+lifecycle/name identities are carried separately from display labels, and a
39+successful daemon-local switch publishes a new committed identity before the
40+client republishes its snapshot. These navigation actions and the strict
41+handshake layout use protocol v27. Attach handshakes may carry an exact target,
42+and successful Welcomes can return the daemon's committed identity.
43+
28 ## Durable record compatibility44 ## Durable record compatibility
29 45
30 The catalogue record format is currently version 4 because records now retain46 The catalogue record format is currently version 4 because records now retain
M internal/app/cmd.go +15 -2
@@ -128,11 +128,20 @@ type cmdDeps struct {
128 stdout io.Writer128 stdout io.Writer
129 getenv func(string) string129 getenv func(string) string
130 dial func(context.Context, string) (ports.Transport, error)130 dial func(context.Context, string) (ports.Transport, error)
131+ ensure func(context.Context, string) (ports.Transport, error)
131 clock ports.Clock132 clock ports.Clock
132 }133 }
133 134
134 func runCmd(ctx context.Context, invocation cmdInvocation) error {135 func runCmd(ctx context.Context, invocation cmdInvocation) error {
135- return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial, clock: clock.New()})136+ return runCmdWithDeps(ctx, invocation, cmdDeps{
137+ stdout: os.Stdout,
138+ getenv: os.Getenv,
139+ dial: realDial,
140+ ensure: func(ctx context.Context, dir string) (ports.Transport, error) {
141+ return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
142+ },
143+ clock: clock.New(),
144+ })
136 }145 }
137 146
138 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {147 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
@@ -166,7 +175,11 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
166 return fmt.Errorf("encoding command: %w", err)175 return fmt.Errorf("encoding command: %w", err)
167 }176 }
168 177
169- transport, err := deps.dial(ctx, ipc.SocketDir())178+ dial := deps.dial
179+ if invocation.slug == "remote-catalog" && deps.ensure != nil {
180+ dial = deps.ensure
181+ }
182+ transport, err := dial(ctx, ipc.SocketDir())
170 if err != nil {183 if err != nil {
171 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}184 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
172 }185 }
M internal/app/cmd_test.go +19 -0
@@ -156,6 +156,25 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
156 }156 }
157 }157 }
158 158
159+func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {
160+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
161+ ensureCalls := 0
162+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
163+ stdout: io.Discard,
164+ getenv: func(string) string { return "" },
165+ dial: func(context.Context, string) (ports.Transport, error) {
166+ t.Fatal("remote catalog used non-starting daemon dial")
167+ return nil, errors.New("unexpected dial")
168+ },
169+ ensure: func(context.Context, string) (ports.Transport, error) {
170+ ensureCalls++
171+ return transport, nil
172+ },
173+ })
174+ require.NoError(t, err)
175+ require.Equal(t, 1, ensureCalls)
176+}
177+
159 func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {178 func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
160 for _, invocation := range []cmdInvocation{179 for _, invocation := range []cmdInvocation{
161 {slug: "move-pane"},180 {slug: "move-pane"},
M internal/app/run.go +47 -44
@@ -835,16 +835,7 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
835 }, nil835 }, nil
836 }836 }
837 837
838-type attachHandoffKey struct {838+const maxAttachTargetHandoffs = 32
839- endpoint string
840- session string
841- intent uint8
842- lifecycle domain.SessionLifecycleID
843- liveTab domain.TabStableID
844- stopped domain.TabSelector
845-}
846-
847-const maxAttachHandoffHops = 32
848 839
849 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {840 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
850 if activeSession != "" {841 if activeSession != "" {
@@ -866,30 +857,27 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
866 factory := deps.remoteDialerFactory857 factory := deps.remoteDialerFactory
867 var remoteSelection *domain.RemoteSessionTarget858 var remoteSelection *domain.RemoteSessionTarget
868 var remoteEnvironmentPolicy ports.EnvironmentPolicy859 var remoteEnvironmentPolicy ports.EnvironmentPolicy
869- seenHandoffs := make(map[attachHandoffKey]struct{})860+ remoteDisplayOrigin := remoteTarget
870- handoffHops := 0861+ routeOrigin := ports.RouteOriginLocal
871- admitHandoff := func(next attachHandoffKey) error {862+ routeOriginKey := "local"
872- if _, seen := seenHandoffs[next]; seen {
873- return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session)
874- }
875- if handoffHops >= maxAttachHandoffHops {
876- return fmt.Errorf("vev: attach handoff exceeded maximum of %d hops", maxAttachHandoffHops)
877- }
878- handoffHops++
879- seenHandoffs[next] = struct{}{}
880- return nil
881- }
882 if remoteTarget != "" {863 if remoteTarget != "" {
864+ routeOrigin = ports.RouteOriginRemote
865+ routeOriginKey = remoteTarget
883 if modeErr != nil {866 if modeErr != nil {
884 return modeErr867 return modeErr
885 }868 }
886 if factory == nil {869 if factory == nil {
887 factory = defaultRemoteDialerFactory()870 factory = defaultRemoteDialerFactory()
888 }871 }
889- seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
890 }872 }
891- var handoff client.AttachHandoffFunc873+ pickerHandoff := remoteTarget == ""
892- handoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {874+ pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy ports.EnvironmentPolicy) ports.EnvironmentPolicy {
875+ if pickerHandoff && target == nil {
876+ return ports.EnvironmentPolicyDaemonOwned
877+ }
878+ return policy
879+ }
880+ handoff := func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
893 if err := validateRemoteAttachHandoff(target); err != nil {881 if err := validateRemoteAttachHandoff(target); err != nil {
894 return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)882 return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)
895 }883 }
@@ -899,25 +887,33 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
899 if factory == nil {887 if factory == nil {
900 factory = defaultRemoteDialerFactory()888 factory = defaultRemoteDialerFactory()
901 }889 }
902- next := attachHandoffKey{endpoint: target.Endpoint, session: target.Session, intent: target.Intent}
903 var selection *domain.RemoteSessionTarget890 var selection *domain.RemoteSessionTarget
904 if target.RemoteTarget != nil {891 if target.RemoteTarget != nil {
905 copyTarget := *target.RemoteTarget892 copyTarget := *target.RemoteTarget
906 selection = &copyTarget893 selection = &copyTarget
907- next.lifecycle = copyTarget.LifecycleID
908- next.liveTab = copyTarget.LiveTabID
909- next.stopped = copyTarget.StoppedTab
910- }
911- if err := admitHandoff(next); err != nil {
912- return nil, client.AttachRequest{}, err
913 }894 }
914 dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log)895 dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log)
915 if err != nil {896 if err != nil {
916 return nil, client.AttachRequest{}, err897 return nil, client.AttachRequest{}, err
917 }898 }
918- return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: target.EnvironmentPolicy}, nil899+ policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)
900+ displayOrigin := target.Endpoint
901+ if selection != nil {
902+ displayOrigin = selection.DisplayOrigin
903+ }
904+ return dialer, client.AttachRequest{
905+ Intent: target.Intent,
906+ SessionName: target.Session,
907+ Remote: true,
908+ Origin: ports.RouteOriginDiscovery,
909+ OriginKey: target.Endpoint,
910+ RemoteTarget: selection,
911+ RemoteOrigin: displayOrigin,
912+ EnvironmentPolicy: policy,
913+ }, nil
919 }914 }
920 915
916+ handoffAttempts := 0
921 for {917 for {
922 var err error918 var err error
923 if remoteTarget != "" {919 if remoteTarget != "" {
@@ -938,10 +934,16 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
938 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),934 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
939 AttachHandoff: handoff,935 AttachHandoff: handoff,
940 Remote: true,936 Remote: true,
937+ Origin: ports.RouteOriginRemote,
938+ OriginKey: remoteTarget,
941 }, client.AttachRequest{939 }, client.AttachRequest{
942 Intent: intent,940 Intent: intent,
943 SessionName: name,941 SessionName: name,
942+ Remote: true,
943+ Origin: routeOrigin,
944+ OriginKey: routeOriginKey,
944 RemoteTarget: remoteSelection,945 RemoteTarget: remoteSelection,
946+ RemoteOrigin: remoteDisplayOrigin,
945 EnvironmentPolicy: remoteEnvironmentPolicy,947 EnvironmentPolicy: remoteEnvironmentPolicy,
946 })948 })
947 } else {949 } else {
@@ -956,7 +958,9 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
956 RuntimeObserver: deps.runtimeObserver,958 RuntimeObserver: deps.runtimeObserver,
957 AttachHandoff: handoff,959 AttachHandoff: handoff,
958 Remote: false,960 Remote: false,
959- }, client.AttachRequest{Intent: intent, SessionName: name})961+ Origin: ports.RouteOriginLocal,
962+ OriginKey: "local",
963+ }, client.AttachRequest{Intent: intent, SessionName: name, Origin: ports.RouteOriginLocal, OriginKey: "local"})
960 }964 }
961 965
962 var handoffErr *client.AttachTargetError966 var handoffErr *client.AttachTargetError
@@ -969,24 +973,23 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
969 if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil {973 if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil {
970 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)974 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
971 }975 }
972- next := attachHandoffKey{endpoint: handoffErr.Target.Endpoint, session: handoffErr.Target.Session, intent: handoffErr.Target.Intent}976+ if handoffAttempts >= maxAttachTargetHandoffs {
973- if handoffErr.Target.RemoteTarget != nil {977+ return fmt.Errorf("vev: attach handoff exceeded maximum of %d attempts", maxAttachTargetHandoffs)
974- next.lifecycle = handoffErr.Target.RemoteTarget.LifecycleID
975- next.liveTab = handoffErr.Target.RemoteTarget.LiveTabID
976- next.stopped = handoffErr.Target.RemoteTarget.StoppedTab
977- }
978- if err := admitHandoff(next); err != nil {
979- return err
980 }978 }
979+ handoffAttempts++
981 remoteTarget = handoffErr.Target.Endpoint980 remoteTarget = handoffErr.Target.Endpoint
981+ remoteDisplayOrigin = remoteTarget
982+ routeOrigin = ports.RouteOriginDiscovery
983+ routeOriginKey = remoteTarget
982 name = handoffErr.Target.Session984 name = handoffErr.Target.Session
983 intent = handoffErr.Target.Intent985 intent = handoffErr.Target.Intent
984 remoteSelection = nil986 remoteSelection = nil
985- remoteEnvironmentPolicy = handoffErr.Target.EnvironmentPolicy
986 if handoffErr.Target.RemoteTarget != nil {987 if handoffErr.Target.RemoteTarget != nil {
987 copyTarget := *handoffErr.Target.RemoteTarget988 copyTarget := *handoffErr.Target.RemoteTarget
988 remoteSelection = &copyTarget989 remoteSelection = &copyTarget
990+ remoteDisplayOrigin = copyTarget.DisplayOrigin
989 }991 }
992+ remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.EnvironmentPolicy)
990 if modeErr != nil {993 if modeErr != nil {
991 return modeErr994 return modeErr
992 }995 }
M internal/app/run_test.go +59 -19
@@ -6,7 +6,6 @@ import (
6 "context"6 "context"
7 "encoding/json"7 "encoding/json"
8 "errors"8 "errors"
9- "fmt"
10 "io"9 "io"
11 "log/slog"10 "log/slog"
12 "net"11 "net"
@@ -575,7 +574,7 @@ func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
575 t.Run(tt.name, func(t *testing.T) {574 t.Run(tt.name, func(t *testing.T) {
576 cmd, err := parseArgs([]string{"attach", tt.arg})575 cmd, err := parseArgs([]string{"attach", tt.arg})
577 require.NoError(t, err)576 require.NoError(t, err)
578- connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.AttachRequest{577+ connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{
579 Intent: cmd.intent,578 Intent: cmd.intent,
580 SessionName: cmd.name,579 SessionName: cmd.name,
581 })580 })
@@ -845,8 +844,8 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
845 }844 }
846 gotDialer = nd.name845 gotDialer = nd.name
847 gotRemote = deps.Remote846 gotRemote = deps.Remote
848- if request.Remote {847+ if !request.Remote || request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned {
849- t.Fatal("remote carriage metadata must not be in the attach request")848+ t.Fatal("direct CLI remote attach must preserve client-owned environment policy")
850 }849 }
851 gotClipboard = deps.Clipboard850 gotClipboard = deps.Clipboard
852 if request.Intent != ports.IntentAttach || request.SessionName != "work" {851 if request.Intent != ports.IntentAttach || request.SessionName != "work" {
@@ -871,6 +870,29 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
871 }870 }
872 }871 }
873 872
873+func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testing.T) {
874+ factory := portsmocks.NewMockRemoteDialerFactory(t)
875+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
876+ factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
877+
878+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
879+ remoteDialerFactory: factory,
880+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
881+ require.True(t, request.Remote)
882+ require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
883+ nextDialer, nextRequest, err := deps.AttachHandoff(ports.AttachTarget{
884+ Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach,
885+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
886+ })
887+ require.NoError(t, err)
888+ require.NotNil(t, nextDialer)
889+ require.Equal(t, ports.EnvironmentPolicyClientOwned, nextRequest.EnvironmentPolicy)
890+ return nil
891+ },
892+ })
893+ require.NoError(t, err)
894+}
895+
874 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {896 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
875 factory := portsmocks.NewMockRemoteDialerFactory(t)897 factory := portsmocks.NewMockRemoteDialerFactory(t)
876 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()898 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
@@ -878,9 +900,11 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
878 var calls int900 var calls int
879 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{901 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
880 remoteDialerFactory: factory,902 remoteDialerFactory: factory,
881- runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {903+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
882 calls++904 calls++
883 require.NotNil(t, deps.Dialer)905 require.NotNil(t, deps.Dialer)
906+ require.True(t, request.Remote)
907+ require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy, "direct CLI remote ownership must survive a handoff")
884 if calls == 1 {908 if calls == 1 {
885 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}909 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
886 }910 }
@@ -891,36 +915,40 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
891 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")915 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
892 }916 }
893 917
894-func TestRunAttachWithDepsRejectsHandoffLoop(t *testing.T) {918+func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
895 factory := portsmocks.NewMockRemoteDialerFactory(t)919 factory := portsmocks.NewMockRemoteDialerFactory(t)
896 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()920 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
921+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once()
897 922
898- calls := 0
899 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{923 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
900 remoteDialerFactory: factory,924 remoteDialerFactory: factory,
901- runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {925+ runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
902- calls++926+ dialer, request, err := deps.AttachHandoff(ports.AttachTarget{
903- return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}927+ Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach,
928+ })
929+ require.NoError(t, err)
930+ require.NotNil(t, dialer)
931+ require.Equal(t, "work", request.SessionName)
932+ return nil
904 },933 },
905 })934 })
906- require.ErrorContains(t, err, "attach handoff loop")935+ require.NoError(t, err)
907- require.Equal(t, 1, calls)
908 }936 }
909 937
910-func TestRunAttachWithDepsCapsUniqueHandoffHops(t *testing.T) {938+func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
911 factory := portsmocks.NewMockRemoteDialerFactory(t)939 factory := portsmocks.NewMockRemoteDialerFactory(t)
912- factory.EXPECT().DialerForRemote(mock.Anything, mock.Anything, ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachHandoffHops + 1)940+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1)
913 941
914 calls := 0942 calls := 0
915 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{943 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
916 remoteDialerFactory: factory,944 remoteDialerFactory: factory,
917 runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {945 runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
918 calls++946 calls++
919- return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: fmt.Sprintf("remote-%d.example", calls), Session: "work", Intent: ports.IntentAttach}}947+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
920 },948 },
921 })949 })
922- require.ErrorContains(t, err, "exceeded maximum")950+ require.ErrorContains(t, err, "attach handoff exceeded maximum")
923- require.Equal(t, maxAttachHandoffHops+1, calls)951+ require.Equal(t, maxAttachTargetHandoffs+1, calls)
924 }952 }
925 953
926 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {954 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
@@ -934,11 +962,11 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
934 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {962 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
935 if deps.Remote {963 if deps.Remote {
936 remoteCalls++964 remoteCalls++
937- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked"}, request)965+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", RemoteOrigin: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
938 return nil966 return nil
939 }967 }
940 localCalls++968 localCalls++
941- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, request)969+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal, OriginKey: "local"}, request)
942 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}970 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
943 },971 },
944 })972 })
@@ -954,6 +982,18 @@ func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
954 }{982 }{
955 {name: "invalid host", target: ports.AttachTarget{Endpoint: "remote host", Session: "work", Intent: ports.IntentAttach}},983 {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}},984 {name: "invalid session", target: ports.AttachTarget{Endpoint: "remote.example", Session: "bad name", Intent: ports.IntentAttach}},
985+ {name: "tokenless resume", target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentResume}},
986+ {
987+ name: "tokenless resume with remote target",
988+ target: ports.AttachTarget{
989+ Endpoint: "remote.example", Session: "work", Intent: ports.IntentResume,
990+ RemoteTarget: &domain.RemoteSessionTarget{
991+ Endpoint: "remote.example", DisplayOrigin: "remote.example",
992+ LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work", LiveTabID: "tab-1",
993+ },
994+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
995+ },
996+ },
957 }997 }
958 for _, tt := range tests {998 for _, tt := range tests {
959 t.Run(tt.name, func(t *testing.T) {999 t.Run(tt.name, func(t *testing.T) {
M internal/ports/frame.go +37 -4
@@ -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 = 29
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,8 +22,9 @@ 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–23 and 25–31. Values 14 and 24 remain
26-// reserved for future extensions.26+// reserved for future extensions. Route metadata/navigation messages use the
27+// post-26 range.
27 const (28 const (
28 MsgHello MsgType = 129 MsgHello MsgType = 1
29 MsgInput MsgType = 230 MsgInput MsgType = 2
@@ -47,9 +48,16 @@ const (
47 MsgPong MsgType = 2048 MsgPong MsgType = 20
48 MsgSessions MsgType = 2149 MsgSessions MsgType = 21
49 MsgCommandResult MsgType = 2250 MsgCommandResult MsgType = 22
50- MsgSessionMeta MsgType = 2351+ MsgNavigationAction MsgType = 23
51 MsgAttachTarget MsgType = 2552 MsgAttachTarget MsgType = 25
52 MsgRemotePreviewResponse MsgType = 2653 MsgRemotePreviewResponse MsgType = 26
54+ // Route navigation metadata/control frames occupy the post-26 server range;
55+ // 14 and 24 remain reserved for compatibility with older peers.
56+ MsgCommittedRouteIdentity MsgType = 27
57+ MsgRecentRouteSnapshot MsgType = 28
58+ MsgNavigateRecentRoute MsgType = 29
59+ MsgRouteNavigationFailure MsgType = 30
60+ MsgRoutePosition MsgType = 31
53 )61 )
54 62
55 // Frame is the unit of exchange over a Transport: a typed, length-delimited63 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -61,6 +69,31 @@ type Frame struct {
61 Payload []byte69 Payload []byte
62 }70 }
63 71
72+// NavigationCapabilities advertises the bounded client routes available to a
73+// directly attached daemon.
74+type NavigationCapabilities uint8
75+
76+const (
77+ NavigationCapabilityHomePicker NavigationCapabilities = 1 << iota
78+ NavigationCapabilityBack
79+)
80+
81+// StartupOverlay selects the one startup overlay opened by a navigation route.
82+type StartupOverlay uint8
83+
84+const (
85+ StartupOverlayNone StartupOverlay = iota
86+ StartupOverlaySessionPicker
87+)
88+
89+// NavigationAction is a server request for one bounded client route transition.
90+type NavigationAction uint8
91+
92+const (
93+ NavigationOpenHomePicker NavigationAction = 1
94+ NavigationBack NavigationAction = 2
95+)
96+
64 // RemotePreviewSchemaVersion is independent from the attachment IPC version.97 // RemotePreviewSchemaVersion is independent from the attachment IPC version.
65 const RemotePreviewSchemaVersion uint16 = 198 const RemotePreviewSchemaVersion uint16 = 1
66 99
M internal/ports/frame_test.go +10 -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 != 29 {
7- t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -18,12 +18,12 @@ 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) {
24+ if MsgNavigationAction != 23 {
25+ t.Fatalf("MsgNavigationAction = %d, want 23", MsgNavigationAction)
26+ }
27 tests := []struct {27 tests := []struct {
28 name string28 name string
29 typ MsgType29 typ MsgType
@@ -35,8 +35,11 @@ func TestMsgTypeUnique(t *testing.T) {
35 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},35 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
36 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},36 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
37 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},37 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
38- {"MsgCommandResult", MsgCommandResult}, {"MsgSessionMeta", MsgSessionMeta},38+ {"MsgCommandResult", MsgCommandResult},
39- {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget},39+ {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
40+ {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
41+ {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
42+ {"MsgRoutePosition", MsgRoutePosition},
40 }43 }
41 44
42 seen := make(map[MsgType]string, len(tests))45 seen := make(map[MsgType]string, len(tests))
A internal/ports/navigation_test.go +96 -0
@@ -0,0 +1,96 @@
1+package ports
2+
3+import (
4+ "encoding/hex"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestNavigationActionWireTable(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ action NavigationAction
15+ payload []byte
16+ valid bool
17+ }{
18+ {name: "open home", action: NavigationOpenHomePicker, payload: []byte{1}, valid: true},
19+ {name: "back", action: NavigationBack, payload: []byte{2}, valid: true},
20+ {name: "zero", action: 0, payload: []byte{0}, valid: false},
21+ {name: "unknown", action: 3, payload: []byte{3}, valid: false},
22+ }
23+
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ got := MarshalNavigationAction(tt.action)
27+ if !tt.valid {
28+ require.Nil(t, got)
29+ _, err := UnmarshalNavigationAction(tt.payload)
30+ require.ErrorIs(t, err, ErrInvalidNavigation)
31+ return
32+ }
33+ require.Equal(t, tt.payload, got)
34+ decoded, err := UnmarshalNavigationAction(got)
35+ require.NoError(t, err)
36+ require.Equal(t, tt.action, decoded)
37+ assertAllPrefixesFail(t, got, UnmarshalNavigationAction)
38+ _, err = UnmarshalNavigationAction(append(append([]byte(nil), got...), 0))
39+ require.ErrorIs(t, err, ErrInvalidNavigation)
40+ })
41+ }
42+}
43+
44+func TestHelloNavigationValidationTable(t *testing.T) {
45+ base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
46+ remoteTarget := &domain.RemoteSessionTarget{
47+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: domain.SessionLifecycleID{1},
48+ SessionName: "work", LiveTabID: "tab-1",
49+ }
50+ tests := []struct {
51+ name string
52+ hello Hello
53+ valid bool
54+ capability NavigationCapabilities
55+ overlay StartupOverlay
56+ wantPayload string
57+ }{
58+ {name: "ordinary", hello: base, valid: true},
59+ {name: "resume route", hello: Hello{Version: ProtocolVersion, Intent: IntentResume, Size: domain.Size{Cols: 80, Rows: 24}}, valid: true},
60+ {name: "daemon-owned home picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: true, capability: NavigationCapabilityHomePicker},
61+ {name: "remote-target home picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityHomePicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: true, capability: NavigationCapabilityHomePicker},
62+ {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
63+ {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
64+ {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001d02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},
66+ {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
67+ {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
68+ {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
69+ {name: "picker without back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ err := ValidateHello(tt.hello)
75+ if tt.valid {
76+ require.NoError(t, err)
77+ payload := MarshalHello(tt.hello)
78+ require.NotNil(t, payload)
79+ if tt.wantPayload != "" {
80+ expected, decodeErr := hex.DecodeString(tt.wantPayload)
81+ require.NoError(t, decodeErr)
82+ require.Equal(t, expected, payload)
83+ assertAllPrefixesFail(t, expected, UnmarshalHello)
84+ assertTrailingGarbageFails(t, expected, UnmarshalHello)
85+ }
86+ decoded, decodeErr := UnmarshalHello(payload)
87+ require.NoError(t, decodeErr)
88+ require.Equal(t, tt.capability, decoded.NavigationCapabilities)
89+ require.Equal(t, tt.overlay, decoded.StartupOverlay)
90+ return
91+ }
92+ require.Error(t, err)
93+ require.Nil(t, MarshalHello(tt.hello))
94+ })
95+ }
96+}
M internal/ports/remote_target_wire_test.go +41 -54
@@ -2,7 +2,7 @@ package ports
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5- "reflect"5+ "errors"
6 "testing"6 "testing"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -80,54 +80,16 @@ func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) {
80 }80 }
81 }81 }
82 82
83-func TestProxiedHelloGoldenAndStrict(t *testing.T) {
84- target := testRemoteTarget(false)
85- msg := Hello{
86- Version: ProtocolVersion, Intent: IntentAttach, RenderMode: RenderModeProxiedContent,
87- ClientID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, ResumeToken: 0x0102030405060708,
88- Name: target.SessionName, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 2,
89- RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
90- }
91- want := []byte{
92- 0, 25, IntentAttach, byte(RenderModeProxiedContent),
93- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
94- 1, 2, 3, 4, 5, 6, 7, 8,
95- 0, 4, 'w', 'o', 'r', 'k', 0, 80, 0, 24, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0,
96- 1,
97- 0, 15, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
98- 0, 9, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
99- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
100- 0, 4, 'w', 'o', 'r', 'k', 0, 0, 5, 't', 'a', 'b', '-', '3', 0, byte(EnvironmentPolicyDaemonOwned),
101- }
102- payload := MarshalHello(msg)
103- if !bytes.Equal(payload, want) {
104- t.Fatalf("proxied Hello bytes = %x, want %x", payload, want)
105- }
106- decoded, err := UnmarshalHello(payload)
107- if err != nil || !reflect.DeepEqual(decoded, msg) || decoded.RemoteTarget == msg.RemoteTarget {
108- t.Fatalf("proxied Hello = %#v, error %v", decoded, err)
109- }
110- assertAllPrefixesFail(t, payload, UnmarshalHello)
111- assertTrailingGarbageFails(t, payload, UnmarshalHello)
112-
113- if MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, RenderMode: RenderModeProxiedContent, Size: domain.Size{Cols: 80, Rows: 24}}) != nil {
114- t.Fatal("proxied Hello without exact target marshaled")
115- }
116- malformed := append([]byte(nil), payload...)
117- malformed[3] = 2
118- if _, err := UnmarshalHello(malformed); err == nil {
119- t.Fatal("unknown render mode decoded")
120- }
121-}
122-
123 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {83 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
124 target := testRemoteTarget(false)84 target := testRemoteTarget(false)
125 tests := []struct {85 tests := []struct {
126- name string86+ name string
127- validate func() error87+ validate func() error
128- marshalBad func() []byte88+ marshalBad func() []byte
129- marshalGood func() []byte89+ marshalGood func() []byte
130- unmarshal func([]byte) error90+ unmarshal func([]byte) error
91+ policyOffset int
92+ wantErr error
131 }{93 }{
132 {94 {
133 name: "hello",95 name: "hello",
@@ -152,7 +114,9 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
152 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,114 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
153 })115 })
154 },116 },
155- unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },117+ unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
118+ policyOffset: 8,
119+ wantErr: ErrInvalidHello,
156 },120 },
157 {121 {
158 name: "attach target",122 name: "attach target",
@@ -174,14 +138,16 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
174 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,138 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
175 })139 })
176 },140 },
177- unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },141+ unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
142+ policyOffset: 1,
143+ wantErr: ErrInvalidAttachTarget,
178 },144 },
179 }145 }
180 146
181 for _, tt := range tests {147 for _, tt := range tests {
182 t.Run(tt.name, func(t *testing.T) {148 t.Run(tt.name, func(t *testing.T) {
183- if err := tt.validate(); err == nil {149+ if err := tt.validate(); !errors.Is(err, tt.wantErr) {
184- t.Fatal("direct validation accepted client-owned policy for remote target")150+ t.Fatalf("direct validation error = %v, want %v", err, tt.wantErr)
185 }151 }
186 if payload := tt.marshalBad(); payload != nil {152 if payload := tt.marshalBad(); payload != nil {
187 t.Fatalf("marshal accepted client-owned policy: %x", payload)153 t.Fatalf("marshal accepted client-owned policy: %x", payload)
@@ -199,15 +165,36 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
199 if err := tt.unmarshal(trailing); err == nil {165 if err := tt.unmarshal(trailing); err == nil {
200 t.Fatal("trailing garbage unexpectedly decoded")166 t.Fatal("trailing garbage unexpectedly decoded")
201 }167 }
202- payload[len(payload)-1] = byte(EnvironmentPolicyClientOwned)168+ payload[len(payload)-tt.policyOffset] = byte(EnvironmentPolicyClientOwned)
203- if err := tt.unmarshal(payload); err == nil {169+ if err := tt.unmarshal(payload); !errors.Is(err, tt.wantErr) {
204- t.Fatal("unmarshal accepted client-owned policy for remote target")170+ t.Fatalf("mutated policy error = %v, want %v", err, tt.wantErr)
205 }171 }
206 })172 })
207 }173 }
208 }174 }
209 175
210-func TestTargetWireIncludesRequiredV25Section(t *testing.T) {176+func TestAttachTargetRejectsResumeIntent(t *testing.T) {
177+ tests := []AttachTarget{
178+ {Endpoint: "host", Session: "work", Intent: IntentResume},
179+ {
180+ Endpoint: "build@mule:2222",
181+ Session: "work",
182+ Intent: IntentResume,
183+ RemoteTarget: testRemoteTarget(false),
184+ EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
185+ },
186+ }
187+ for _, target := range tests {
188+ if err := ValidateAttachTarget(target); !errors.Is(err, ErrInvalidAttachTarget) {
189+ t.Fatalf("ValidateAttachTarget(%#v) error = %v, want %v", target, err, ErrInvalidAttachTarget)
190+ }
191+ if got := MarshalAttachTarget(target); got != nil {
192+ t.Fatalf("MarshalAttachTarget(%#v) = %x, want nil", target, got)
193+ }
194+ }
195+}
196+
197+func TestTargetWireIncludesRequiredV27Section(t *testing.T) {
211 want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}198 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})199 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
213 if !bytes.Equal(got, want) {200 if !bytes.Equal(got, want) {
A internal/ports/routes.go +247 -0
@@ -0,0 +1,247 @@
1+package ports
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "unicode"
7+ "unicode/utf8"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+// RouteOrigin identifies how a client reached a daemon. The origin is client
13+// composition metadata, not an authority granted to the daemon.
14+type RouteOrigin uint8
15+
16+const (
17+ RouteOriginLocal RouteOrigin = iota + 1
18+ RouteOriginRemote
19+ RouteOriginDiscovery
20+)
21+
22+func (o RouteOrigin) valid() bool {
23+ switch o {
24+ case RouteOriginLocal, RouteOriginRemote, RouteOriginDiscovery:
25+ return true
26+ default:
27+ return false
28+ }
29+}
30+
31+func (o RouteOrigin) Validate() error {
32+ if !o.valid() {
33+ return errors.New("invalid route origin")
34+ }
35+ return nil
36+}
37+
38+// RouteKind identifies the kind of target represented by one display entry.
39+type RouteKind uint8
40+
41+const (
42+ RouteKindLocal RouteKind = iota + 1
43+ RouteKindRemote
44+)
45+
46+func (k RouteKind) valid() bool {
47+ switch k {
48+ case RouteKindLocal, RouteKindRemote:
49+ return true
50+ default:
51+ return false
52+ }
53+}
54+
55+func (k RouteKind) Validate() error {
56+ if !k.valid() {
57+ return errors.New("invalid route kind")
58+ }
59+ return nil
60+}
61+
62+// RouteReachability is a closed display hint. It is never used as proof that
63+// a route can be attached; the exact target and daemon still decide that.
64+type RouteReachability uint8
65+
66+const (
67+ RouteReachabilityUnknown RouteReachability = iota
68+ RouteReachabilityReachable
69+ RouteReachabilityUnavailable
70+)
71+
72+func (r RouteReachability) valid() bool {
73+ switch r {
74+ case RouteReachabilityUnknown, RouteReachabilityReachable, RouteReachabilityUnavailable:
75+ return true
76+ default:
77+ return false
78+ }
79+}
80+
81+func (r RouteReachability) Validate() error {
82+ if !r.valid() {
83+ return errors.New("invalid route reachability")
84+ }
85+ return nil
86+}
87+
88+// ValidateRouteLabel rejects malformed or terminal-control text before it can
89+// enter a daemon-rendered route snapshot. Empty labels are allowed only when
90+// the caller explicitly marks them optional.
91+func ValidateRouteLabel(value string, allowEmpty bool) error {
92+ if !allowEmpty && value == "" {
93+ return errors.New("route label is empty")
94+ }
95+ if len(value) > RouteLabelMaxBytes {
96+ return errors.New("route label is too long")
97+ }
98+ if !utf8.ValidString(value) {
99+ return errors.New("route label is not valid UTF-8")
100+ }
101+ for _, r := range value {
102+ if unicode.IsControl(r) {
103+ return errors.New("route label contains a control character")
104+ }
105+ if unicode.Is(unicode.Bidi_Control, r) || r == '\u2028' || r == '\u2029' {
106+ return errors.New("route label contains a bidirectional or line-separator control character")
107+ }
108+ }
109+ return nil
110+}
111+
112+// ExactSessionTarget is the daemon-neutral identity required to attach to a
113+// specific session lifecycle. The transport endpoint is deliberately absent:
114+// the selected route's dialer owns that boundary.
115+type ExactSessionTarget struct {
116+ LifecycleID domain.SessionLifecycleID
117+ SessionName string
118+}
119+
120+func (t ExactSessionTarget) Validate() error {
121+ if t.LifecycleID == (domain.SessionLifecycleID{}) {
122+ return errors.New("missing session lifecycle")
123+ }
124+ if err := domain.ValidateSessionName(t.SessionName); err != nil {
125+ return fmt.Errorf("invalid session name: %w", err)
126+ }
127+ return nil
128+}
129+
130+// CommittedRouteIdentity is the identity a daemon commits after accepting an
131+// attach. It is separate from display data so a stale presentation cannot
132+// become an attach authority.
133+type CommittedRouteIdentity struct {
134+ Target ExactSessionTarget
135+ Ephemeral bool
136+}
137+
138+func (i CommittedRouteIdentity) Validate() error {
139+ if err := i.Target.Validate(); err != nil {
140+ return err
141+ }
142+ return nil
143+}
144+
145+// RoutePosition is mutable per-client route state published by the daemon.
146+// Target binds the tab cursor to one exact session lifecycle so delayed frames
147+// cannot update another route.
148+type RoutePosition struct {
149+ Target ExactSessionTarget
150+ ActiveTabID domain.TabStableID
151+}
152+
153+func (p RoutePosition) Validate() error {
154+ if err := p.Target.Validate(); err != nil {
155+ return err
156+ }
157+ if err := domain.ValidateTabStableID(p.ActiveTabID); err != nil {
158+ return fmt.Errorf("invalid active tab ID: %w", err)
159+ }
160+ return nil
161+}
162+
163+// RouteRef is an opaque client-ledger reference. It is intentionally not a
164+// lifecycle identifier and is meaningful only with the matching generation.
165+type RouteRef struct {
166+ Key uint64
167+ Generation uint64
168+}
169+
170+func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 }
171+
172+func (r RouteRef) Validate() error {
173+ if r.empty() {
174+ return nil
175+ }
176+ if r.Key == 0 || r.Generation == 0 {
177+ return errors.New("route reference must contain key and generation")
178+ }
179+ return nil
180+}
181+
182+// RecentRouteEntry is the complete daemon-neutral display view of one route.
183+// It contains no dialer, credential, endpoint, live session pointer, or attach
184+// target. The client ledger keeps those private beside this value.
185+type RecentRouteEntry struct {
186+ Key uint64
187+ Generation uint64
188+ Name string
189+ HostLabel string
190+ Kind RouteKind
191+ Ephemeral bool
192+ Attention bool
193+ Reachability RouteReachability
194+}
195+
196+// RecentRouteSnapshot is an immutable, bounded publication of the client
197+// route ledger. Entries are ordered from most recent to least recent.
198+type RecentRouteSnapshot struct {
199+ Generation uint64
200+ Active RouteRef
201+ Previous RouteRef
202+ Home RouteRef
203+ Entries []RecentRouteEntry
204+}
205+
206+// RouteNavigationAction asks the client to resolve one entry from a specific
207+// complete snapshot. It carries no session name, endpoint, or credential.
208+type RouteNavigationAction struct {
209+ SnapshotGeneration uint64
210+ Key uint64
211+ Generation uint64
212+}
213+
214+// RouteNavigationFailure is a bounded taxonomy for a rejected or stale route
215+// action. User-facing text remains local to the receiving client.
216+type RouteNavigationFailure struct {
217+ Key uint64
218+ Generation uint64
219+ Code RouteFailureCode
220+}
221+
222+type RouteFailureCode uint8
223+
224+const (
225+ RouteFailureStaleSelection RouteFailureCode = iota + 1
226+ RouteFailureNoSuchRoute
227+ RouteFailureUnavailable
228+ RouteFailureTargetChanged
229+ RouteFailureOriginUnavailable
230+)
231+
232+func (c RouteFailureCode) valid() bool {
233+ switch c {
234+ case RouteFailureStaleSelection, RouteFailureNoSuchRoute, RouteFailureUnavailable, RouteFailureTargetChanged, RouteFailureOriginUnavailable:
235+ return true
236+ default:
237+ return false
238+ }
239+}
240+
241+const (
242+ // RouteSnapshotMaxEntries bounds one immutable publication and the amount
243+ // of work a receiver performs before returning to its transport loop. The
244+ // private client history may use a smaller product cap.
245+ RouteSnapshotMaxEntries = 32
246+ RouteLabelMaxBytes = 128
247+)
A internal/ports/routes_test.go +221 -0
@@ -0,0 +1,221 @@
1+package ports
2+
3+import (
4+ "encoding/hex"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func testExactTarget() ExactSessionTarget {
12+ return ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1, 2, 3}, SessionName: "work"}
13+}
14+
15+func TestExactSessionTargetCodecIsStrict(t *testing.T) {
16+ want := testExactTarget()
17+ encoded, err := MarshalExactSessionTarget(want)
18+ require.NoError(t, err)
19+ require.Equal(t, "010203000000000000000000000000000004776f726b", hex.EncodeToString(encoded))
20+ assertAllPrefixesFail(t, encoded, UnmarshalExactSessionTarget)
21+ _, err = UnmarshalExactSessionTarget(append(append([]byte(nil), encoded...), 0))
22+ require.Error(t, err)
23+
24+ got, err := UnmarshalExactSessionTarget(encoded)
25+ require.NoError(t, err)
26+ require.Equal(t, want, got)
27+
28+ _, err = MarshalExactSessionTarget(ExactSessionTarget{SessionName: "work"})
29+ require.ErrorIs(t, err, ErrInvalidRouteWire)
30+}
31+
32+func TestHelloExactTargetRoundTrip(t *testing.T) {
33+ target := testExactTarget()
34+ msg := Hello{
35+ Version: ProtocolVersion,
36+ Intent: IntentAttach,
37+ Name: target.SessionName,
38+ Size: domain.Size{Cols: 80, Rows: 24},
39+ ExactTarget: &target,
40+ PreferredTabID: "tab-2",
41+ }
42+
43+ encoded := MarshalHello(msg)
44+ require.NotEmpty(t, encoded)
45+ got, err := UnmarshalHello(encoded)
46+ require.NoError(t, err)
47+ require.Equal(t, msg, got)
48+
49+ invalid := msg
50+ invalid.Intent = IntentNew
51+ require.Error(t, ValidateHello(invalid))
52+
53+ invalid = msg
54+ invalid.PreferredTabID = "bad tab"
55+ require.Error(t, ValidateHello(invalid))
56+}
57+
58+func TestRoutePositionCodecIsStrict(t *testing.T) {
59+ want := RoutePosition{Target: testExactTarget(), ActiveTabID: "tab-2"}
60+ encoded, err := MarshalRoutePosition(want)
61+ require.NoError(t, err)
62+ require.Equal(t, "010203000000000000000000000000000004776f726b00057461622d32", hex.EncodeToString(encoded))
63+ assertAllPrefixesFail(t, encoded, UnmarshalRoutePosition)
64+ _, err = UnmarshalRoutePosition(append(append([]byte(nil), encoded...), 0))
65+ require.Error(t, err)
66+
67+ got, err := UnmarshalRoutePosition(encoded)
68+ require.NoError(t, err)
69+ require.Equal(t, want, got)
70+
71+ _, err = MarshalRoutePosition(RoutePosition{Target: testExactTarget()})
72+ require.ErrorIs(t, err, ErrInvalidRouteWire)
73+ _, err = MarshalRoutePosition(RoutePosition{Target: testExactTarget(), ActiveTabID: "bad tab"})
74+ require.ErrorIs(t, err, ErrInvalidRouteWire)
75+}
76+
77+func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) {
78+ identity := &CommittedRouteIdentity{Target: testExactTarget(), Ephemeral: true}
79+ want := Welcome{SessionID: "daemon-session", SessionName: "work", Ephemeral: true, CommittedIdentity: identity}
80+
81+ got, err := UnmarshalWelcome(MarshalWelcome(want))
82+ require.NoError(t, err)
83+ require.Equal(t, want, got)
84+}
85+
86+func TestWelcomeRejectsMismatchedCommittedIdentity(t *testing.T) {
87+ identity := &CommittedRouteIdentity{Target: testExactTarget()}
88+ payload := MarshalWelcome(Welcome{SessionID: "daemon-session", SessionName: "other", CommittedIdentity: identity})
89+
90+ require.Nil(t, payload)
91+}
92+
93+func TestCommittedRouteIdentityCodec(t *testing.T) {
94+ want := CommittedRouteIdentity{Target: testExactTarget(), Ephemeral: true}
95+ encoded, err := MarshalCommittedRouteIdentity(want)
96+ require.NoError(t, err)
97+
98+ require.Equal(t, "010203000000000000000000000000000004776f726b01", hex.EncodeToString(encoded))
99+ assertAllPrefixesFail(t, encoded, UnmarshalCommittedRouteIdentity)
100+ _, err = UnmarshalCommittedRouteIdentity(append(append([]byte(nil), encoded...), 0))
101+ require.Error(t, err)
102+
103+ got, err := UnmarshalCommittedRouteIdentity(encoded)
104+ require.NoError(t, err)
105+ require.Equal(t, want, got)
106+
107+ mutated := append([]byte(nil), encoded...)
108+ mutated[len(mutated)-1] = 2
109+ _, err = UnmarshalCommittedRouteIdentity(mutated)
110+ require.Error(t, err)
111+}
112+
113+func testRouteSnapshot() RecentRouteSnapshot {
114+ return RecentRouteSnapshot{
115+ Generation: 9,
116+ Active: RouteRef{Key: 11, Generation: 3},
117+ Previous: RouteRef{Key: 12, Generation: 4},
118+ Home: RouteRef{Key: 11, Generation: 3},
119+ Entries: []RecentRouteEntry{
120+ {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},
121+ },
122+ }
123+}
124+
125+func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
126+ want := testRouteSnapshot()
127+ encoded, err := MarshalRecentRouteSnapshot(want)
128+ require.NoError(t, err)
129+
130+ got, err := UnmarshalRecentRouteSnapshot(encoded)
131+ require.NoError(t, err)
132+ require.Equal(t, want, got)
133+
134+ require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c000000000000000400046c6f677300046564676502000100", hex.EncodeToString(encoded))
135+ assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot)
136+ _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0))
137+ require.Error(t, err)
138+
139+ invalidCases := []struct {
140+ name string
141+ mutate func(*RecentRouteSnapshot)
142+ }{
143+ {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }},
144+ {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) {
145+ snapshot.Entries = append(snapshot.Entries, RecentRouteEntry{Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal})
146+ }},
147+ {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }},
148+ {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) {
149+ snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1)
150+ for i := range snapshot.Entries {
151+ snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal}
152+ }
153+ }},
154+ }
155+ for _, tc := range invalidCases {
156+ t.Run(tc.name, func(t *testing.T) {
157+ invalid := want
158+ tc.mutate(&invalid)
159+ _, err := MarshalRecentRouteSnapshot(invalid)
160+ require.ErrorIs(t, err, ErrInvalidRouteWire)
161+ })
162+ }
163+}
164+
165+func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) {
166+ _, err := UnmarshalRecentRouteSnapshot(make([]byte, MaxFrameLen))
167+ require.ErrorIs(t, err, ErrInvalidRouteWire)
168+}
169+
170+func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {
171+ values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})}
172+ for _, value := range values {
173+ t.Run("name/"+value, func(t *testing.T) {
174+ _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{
175+ Generation: 1,
176+ Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: value, Kind: RouteKindLocal}},
177+ })
178+ require.Error(t, err)
179+ })
180+ t.Run("host/"+value, func(t *testing.T) {
181+ _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{
182+ Generation: 1,
183+ Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: "work", HostLabel: value, Kind: RouteKindRemote}},
184+ })
185+ require.Error(t, err)
186+ })
187+ }
188+}
189+
190+func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) {
191+ action := RouteNavigationAction{SnapshotGeneration: 9, Key: 7, Generation: 8}
192+ encoded, err := MarshalRouteNavigationAction(action)
193+ require.NoError(t, err)
194+ require.Equal(t, "000000000000000900000000000000070000000000000008", hex.EncodeToString(encoded))
195+ assertAllPrefixesFail(t, encoded, UnmarshalRouteNavigationAction)
196+ _, err = UnmarshalRouteNavigationAction(append(append([]byte(nil), encoded...), 0))
197+ require.Error(t, err)
198+ got, err := UnmarshalRouteNavigationAction(encoded)
199+ require.NoError(t, err)
200+ require.Equal(t, action, got)
201+
202+ failure := RouteNavigationFailure{Key: 7, Generation: 8, Code: RouteFailureStaleSelection}
203+ encoded, err = MarshalRouteNavigationFailure(failure)
204+ require.NoError(t, err)
205+ require.Equal(t, "0000000000000007000000000000000801", hex.EncodeToString(encoded))
206+ assertAllPrefixesFail(t, encoded, UnmarshalRouteNavigationFailure)
207+ _, err = UnmarshalRouteNavigationFailure(append(append([]byte(nil), encoded...), 0))
208+ require.Error(t, err)
209+ gotFailure, err := UnmarshalRouteNavigationFailure(encoded)
210+ require.NoError(t, err)
211+ require.Equal(t, failure, gotFailure)
212+
213+ for _, invalid := range []RouteNavigationAction{{}, {SnapshotGeneration: 1, Key: 1}, {SnapshotGeneration: 1, Generation: 1}, {Key: 1, Generation: 1}} {
214+ _, err := MarshalRouteNavigationAction(invalid)
215+ require.Error(t, err)
216+ }
217+ for _, invalid := range []RouteNavigationFailure{{}, {Key: 1, Generation: 1}, {Key: 1, Generation: 1, Code: 255}} {
218+ _, err := MarshalRouteNavigationFailure(invalid)
219+ require.Error(t, err)
220+ }
221+}
A internal/ports/routes_wire.go +498 -0
@@ -0,0 +1,498 @@
1+package ports
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+var ErrInvalidRouteWire = errors.New("invalid route wire message")
11+
12+func validateRouteString(value, field string, allowEmpty bool) error {
13+ if err := ValidateRouteLabel(value, allowEmpty); err != nil {
14+ return fmt.Errorf("%s: %w", field, err)
15+ }
16+ return nil
17+}
18+
19+func marshalExactSessionTarget(w *payloadWriter, target ExactSessionTarget) {
20+ w.putBytes(target.LifecycleID[:])
21+ w.putString(target.SessionName)
22+}
23+
24+func marshalExactTargetSection(w *payloadWriter, target *ExactSessionTarget) {
25+ w.putBool(target != nil)
26+ if target != nil {
27+ marshalExactSessionTarget(w, *target)
28+ }
29+}
30+
31+func unmarshalExactTargetSection(r *payloadReader) (*ExactSessionTarget, error) {
32+ present, err := r.getBool()
33+ if err != nil {
34+ return nil, err
35+ }
36+ if !present {
37+ return nil, nil
38+ }
39+ target, err := unmarshalExactSessionTarget(r)
40+ if err != nil {
41+ return nil, err
42+ }
43+ return &target, nil
44+}
45+
46+func skipExactTargetSection(r *payloadReader) error {
47+ present, err := r.getBool()
48+ if err != nil {
49+ return err
50+ }
51+ if !present {
52+ return nil
53+ }
54+ if _, err := unmarshalExactSessionTarget(r); err != nil {
55+ return err
56+ }
57+ return nil
58+}
59+
60+func unmarshalExactSessionTarget(r *payloadReader) (ExactSessionTarget, error) {
61+ var target ExactSessionTarget
62+ lifecycle, err := r.getBytes(len(target.LifecycleID))
63+ if err != nil {
64+ return ExactSessionTarget{}, err
65+ }
66+ copy(target.LifecycleID[:], lifecycle)
67+ if target.SessionName, err = r.getString(); err != nil {
68+ return ExactSessionTarget{}, err
69+ }
70+ if err := target.Validate(); err != nil {
71+ return ExactSessionTarget{}, fmt.Errorf("%w: exact target: %v", ErrInvalidRouteWire, err)
72+ }
73+ return target, nil
74+}
75+
76+// MarshalExactSessionTarget encodes the exact lifecycle/name pair used by
77+// Hello and route-transition tests.
78+func MarshalExactSessionTarget(target ExactSessionTarget) ([]byte, error) {
79+ if err := target.Validate(); err != nil {
80+ return nil, fmt.Errorf("%w: exact target: %v", ErrInvalidRouteWire, err)
81+ }
82+ w := payloadWriter{}
83+ marshalExactSessionTarget(&w, target)
84+ return w.b, nil
85+}
86+
87+// UnmarshalExactSessionTarget decodes one exact lifecycle/name pair and rejects
88+// truncation, invalid identity, and trailing bytes.
89+func UnmarshalExactSessionTarget(b []byte) (ExactSessionTarget, error) {
90+ r := payloadReader{b: b}
91+ target, err := unmarshalExactSessionTarget(&r)
92+ if err != nil {
93+ return ExactSessionTarget{}, err
94+ }
95+ if err := r.done(); err != nil {
96+ return ExactSessionTarget{}, err
97+ }
98+ return target, nil
99+}
100+
101+func validateCommittedRouteIdentity(identity CommittedRouteIdentity) error {
102+ if err := identity.Validate(); err != nil {
103+ return fmt.Errorf("%w: committed identity: %v", ErrInvalidRouteWire, err)
104+ }
105+ return nil
106+}
107+
108+// MarshalCommittedRouteIdentity encodes the daemon's committed exact session
109+// identity without exposing transport or client-ledger details.
110+func MarshalCommittedRouteIdentity(identity CommittedRouteIdentity) ([]byte, error) {
111+ if err := validateCommittedRouteIdentity(identity); err != nil {
112+ return nil, err
113+ }
114+ w := payloadWriter{}
115+ marshalCommittedRouteIdentityFields(&w, identity)
116+ return w.b, nil
117+}
118+
119+func marshalCommittedRouteIdentityFields(w *payloadWriter, identity CommittedRouteIdentity) {
120+ marshalExactSessionTarget(w, identity.Target)
121+ w.putBool(identity.Ephemeral)
122+}
123+
124+// marshalCommittedIdentitySection shares the standalone identity body and
125+// validates embedded identities before Welcome serialization.
126+func marshalCommittedIdentitySection(w *payloadWriter, identity *CommittedRouteIdentity) bool {
127+ w.putBool(identity != nil)
128+ if identity == nil {
129+ return true
130+ }
131+ if err := validateCommittedRouteIdentity(*identity); err != nil {
132+ return false
133+ }
134+ marshalCommittedRouteIdentityFields(w, *identity)
135+ return true
136+}
137+
138+func unmarshalCommittedIdentitySection(r *payloadReader) (*CommittedRouteIdentity, error) {
139+ present, err := r.getBool()
140+ if err != nil {
141+ return nil, err
142+ }
143+ if !present {
144+ return nil, nil
145+ }
146+ target, err := unmarshalExactSessionTarget(r)
147+ if err != nil {
148+ return nil, err
149+ }
150+ ephemeral, err := r.getBool()
151+ if err != nil {
152+ return nil, err
153+ }
154+ identity := &CommittedRouteIdentity{Target: target, Ephemeral: ephemeral}
155+ if err := validateCommittedRouteIdentity(*identity); err != nil {
156+ return nil, err
157+ }
158+ return identity, nil
159+}
160+
161+func UnmarshalCommittedRouteIdentity(b []byte) (CommittedRouteIdentity, error) {
162+ r := payloadReader{b: b}
163+ target, err := unmarshalExactSessionTarget(&r)
164+ if err != nil {
165+ return CommittedRouteIdentity{}, err
166+ }
167+ ephemeral, err := r.getBool()
168+ if err != nil {
169+ return CommittedRouteIdentity{}, err
170+ }
171+ if err := r.done(); err != nil {
172+ return CommittedRouteIdentity{}, err
173+ }
174+ identity := CommittedRouteIdentity{Target: target, Ephemeral: ephemeral}
175+ if err := validateCommittedRouteIdentity(identity); err != nil {
176+ return CommittedRouteIdentity{}, err
177+ }
178+ return identity, nil
179+}
180+
181+// MarshalRoutePosition encodes one exact session and its attachment-local tab
182+// cursor. Route positions are mutable client memory, not attach authority.
183+func MarshalRoutePosition(position RoutePosition) ([]byte, error) {
184+ if err := position.Validate(); err != nil {
185+ return nil, fmt.Errorf("%w: route position: %v", ErrInvalidRouteWire, err)
186+ }
187+ w := payloadWriter{}
188+ marshalExactSessionTarget(&w, position.Target)
189+ w.putString(string(position.ActiveTabID))
190+ return w.b, nil
191+}
192+
193+// UnmarshalRoutePosition decodes one strict route-position payload.
194+func UnmarshalRoutePosition(b []byte) (RoutePosition, error) {
195+ r := payloadReader{b: b}
196+ target, err := unmarshalExactSessionTarget(&r)
197+ if err != nil {
198+ return RoutePosition{}, err
199+ }
200+ activeTabID, err := r.getString()
201+ if err != nil {
202+ return RoutePosition{}, err
203+ }
204+ if err := r.done(); err != nil {
205+ return RoutePosition{}, err
206+ }
207+ position := RoutePosition{Target: target, ActiveTabID: domain.TabStableID(activeTabID)}
208+ if err := position.Validate(); err != nil {
209+ return RoutePosition{}, fmt.Errorf("%w: route position: %v", ErrInvalidRouteWire, err)
210+ }
211+ return position, nil
212+}
213+
214+func validateRouteRef(ref RouteRef) error {
215+ if err := ref.Validate(); err != nil {
216+ return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err)
217+ }
218+ return nil
219+}
220+
221+func marshalRouteRef(w *payloadWriter, ref RouteRef) {
222+ w.putUint64(ref.Key)
223+ w.putUint64(ref.Generation)
224+}
225+
226+func unmarshalRouteRef(r *payloadReader) (RouteRef, error) {
227+ key, err := r.getUint64()
228+ if err != nil {
229+ return RouteRef{}, err
230+ }
231+ generation, err := r.getUint64()
232+ if err != nil {
233+ return RouteRef{}, err
234+ }
235+ ref := RouteRef{Key: key, Generation: generation}
236+ if err := validateRouteRef(ref); err != nil {
237+ return RouteRef{}, err
238+ }
239+ return ref, nil
240+}
241+
242+func validateRecentRouteEntry(entry RecentRouteEntry) error {
243+ if entry.Key == 0 || entry.Generation == 0 {
244+ return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire)
245+ }
246+ if err := validateRouteString(entry.Name, "route name", false); err != nil {
247+ return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err)
248+ }
249+ if err := validateRouteString(entry.HostLabel, "route host label", true); err != nil {
250+ return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err)
251+ }
252+ if !entry.Kind.valid() {
253+ return fmt.Errorf("%w: invalid route kind", ErrInvalidRouteWire)
254+ }
255+ if !entry.Reachability.valid() {
256+ return fmt.Errorf("%w: invalid route reachability", ErrInvalidRouteWire)
257+ }
258+ return nil
259+}
260+
261+func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error {
262+ if len(snapshot.Entries) > RouteSnapshotMaxEntries {
263+ return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
264+ }
265+ if snapshot.Generation == 0 && len(snapshot.Entries) != 0 {
266+ return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire)
267+ }
268+ if err := validateRouteRef(snapshot.Active); err != nil {
269+ return err
270+ }
271+ if err := validateRouteRef(snapshot.Previous); err != nil {
272+ return err
273+ }
274+ if err := validateRouteRef(snapshot.Home); err != nil {
275+ return err
276+ }
277+ if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous {
278+ return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire)
279+ }
280+ refs := make(map[RouteRef]struct{}, len(snapshot.Entries))
281+ for _, entry := range snapshot.Entries {
282+ if snapshot.Active == (RouteRef{Key: entry.Key, Generation: entry.Generation}) {
283+ return fmt.Errorf("%w: active route must be metadata-only", ErrInvalidRouteWire)
284+ }
285+ if err := validateRecentRouteEntry(entry); err != nil {
286+ return err
287+ }
288+ ref := RouteRef{Key: entry.Key, Generation: entry.Generation}
289+ if _, exists := refs[ref]; exists {
290+ return fmt.Errorf("%w: duplicate route entry", ErrInvalidRouteWire)
291+ }
292+ refs[ref] = struct{}{}
293+ }
294+ for _, item := range []struct {
295+ name string
296+ ref RouteRef
297+ }{
298+ {name: "previous", ref: snapshot.Previous},
299+ {name: "home", ref: snapshot.Home},
300+ } {
301+ if item.ref.empty() || item.ref == snapshot.Active {
302+ // Active is metadata-only and intentionally excluded from the
303+ // recent display entries. Home may point at active as well.
304+ continue
305+ }
306+ if _, exists := refs[item.ref]; !exists {
307+ return fmt.Errorf("%w: %s route reference is absent from snapshot", ErrInvalidRouteWire, item.name)
308+ }
309+ }
310+ return nil
311+}
312+
313+// MarshalRecentRouteSnapshot encodes a complete bounded client route view.
314+func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) {
315+ if err := validateRecentRouteSnapshot(snapshot); err != nil {
316+ return nil, err
317+ }
318+ w := payloadWriter{}
319+ w.putUint64(snapshot.Generation)
320+ marshalRouteRef(&w, snapshot.Active)
321+ marshalRouteRef(&w, snapshot.Previous)
322+ marshalRouteRef(&w, snapshot.Home)
323+ w.putUint8(uint8(len(snapshot.Entries)))
324+ for _, entry := range snapshot.Entries {
325+ w.putUint64(entry.Key)
326+ w.putUint64(entry.Generation)
327+ w.putString(entry.Name)
328+ w.putString(entry.HostLabel)
329+ w.putUint8(uint8(entry.Kind))
330+ w.putBool(entry.Ephemeral)
331+ w.putBool(entry.Attention)
332+ w.putUint8(uint8(entry.Reachability))
333+ }
334+ // Keep the frame bound defensive even though current entry/label bounds
335+ // make this unreachable; it protects the wire contract if those bounds
336+ // change independently in a future protocol revision.
337+ if len(w.b) > MaxFrameLen-1 {
338+ return nil, fmt.Errorf("%w: route snapshot is too large", ErrInvalidRouteWire)
339+ }
340+ return w.b, nil
341+}
342+
343+// UnmarshalRecentRouteSnapshot decodes and strictly validates one complete
344+// immutable snapshot, including every referenced active/previous/home entry.
345+func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) {
346+ if len(b) > MaxFrameLen-1 {
347+ return RecentRouteSnapshot{}, ErrInvalidRouteWire
348+ }
349+ r := payloadReader{b: b}
350+ var snapshot RecentRouteSnapshot
351+ var err error
352+ if snapshot.Generation, err = r.getUint64(); err != nil {
353+ return RecentRouteSnapshot{}, err
354+ }
355+ if snapshot.Active, err = unmarshalRouteRef(&r); err != nil {
356+ return RecentRouteSnapshot{}, err
357+ }
358+ if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil {
359+ return RecentRouteSnapshot{}, err
360+ }
361+ if snapshot.Home, err = unmarshalRouteRef(&r); err != nil {
362+ return RecentRouteSnapshot{}, err
363+ }
364+ count, err := r.getUint8()
365+ if err != nil {
366+ return RecentRouteSnapshot{}, err
367+ }
368+ if int(count) > RouteSnapshotMaxEntries {
369+ return RecentRouteSnapshot{}, fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
370+ }
371+ if count != 0 {
372+ snapshot.Entries = make([]RecentRouteEntry, 0, int(count))
373+ }
374+ for range int(count) {
375+ var entry RecentRouteEntry
376+ if entry.Key, err = r.getUint64(); err != nil {
377+ return RecentRouteSnapshot{}, err
378+ }
379+ if entry.Generation, err = r.getUint64(); err != nil {
380+ return RecentRouteSnapshot{}, err
381+ }
382+ if entry.Name, err = r.getString(); err != nil {
383+ return RecentRouteSnapshot{}, err
384+ }
385+ if entry.HostLabel, err = r.getString(); err != nil {
386+ return RecentRouteSnapshot{}, err
387+ }
388+ kind, err := r.getUint8()
389+ if err != nil {
390+ return RecentRouteSnapshot{}, err
391+ }
392+ entry.Kind = RouteKind(kind)
393+ if entry.Ephemeral, err = r.getBool(); err != nil {
394+ return RecentRouteSnapshot{}, err
395+ }
396+ if entry.Attention, err = r.getBool(); err != nil {
397+ return RecentRouteSnapshot{}, err
398+ }
399+ reachability, err := r.getUint8()
400+ if err != nil {
401+ return RecentRouteSnapshot{}, err
402+ }
403+ entry.Reachability = RouteReachability(reachability)
404+ snapshot.Entries = append(snapshot.Entries, entry)
405+ }
406+ if err := r.done(); err != nil {
407+ return RecentRouteSnapshot{}, err
408+ }
409+ if err := validateRecentRouteSnapshot(snapshot); err != nil {
410+ return RecentRouteSnapshot{}, err
411+ }
412+ return snapshot, nil
413+}
414+
415+func validateRouteNavigationAction(action RouteNavigationAction) error {
416+ if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {
417+ return fmt.Errorf("%w: navigation action identity is zero", ErrInvalidRouteWire)
418+ }
419+ return nil
420+}
421+
422+func MarshalRouteNavigationAction(action RouteNavigationAction) ([]byte, error) {
423+ if err := validateRouteNavigationAction(action); err != nil {
424+ return nil, err
425+ }
426+ w := payloadWriter{}
427+ w.putUint64(action.SnapshotGeneration)
428+ w.putUint64(action.Key)
429+ w.putUint64(action.Generation)
430+ return w.b, nil
431+}
432+
433+func UnmarshalRouteNavigationAction(b []byte) (RouteNavigationAction, error) {
434+ r := payloadReader{b: b}
435+ var action RouteNavigationAction
436+ var err error
437+ if action.SnapshotGeneration, err = r.getUint64(); err != nil {
438+ return RouteNavigationAction{}, err
439+ }
440+ if action.Key, err = r.getUint64(); err != nil {
441+ return RouteNavigationAction{}, err
442+ }
443+ if action.Generation, err = r.getUint64(); err != nil {
444+ return RouteNavigationAction{}, err
445+ }
446+ if err := r.done(); err != nil {
447+ return RouteNavigationAction{}, err
448+ }
449+ if err := validateRouteNavigationAction(action); err != nil {
450+ return RouteNavigationAction{}, err
451+ }
452+ return action, nil
453+}
454+
455+func validateRouteNavigationFailure(failure RouteNavigationFailure) error {
456+ if failure.Key == 0 || failure.Generation == 0 {
457+ return fmt.Errorf("%w: failure identity is zero", ErrInvalidRouteWire)
458+ }
459+ if !failure.Code.valid() {
460+ return fmt.Errorf("%w: invalid failure code", ErrInvalidRouteWire)
461+ }
462+ return nil
463+}
464+
465+func MarshalRouteNavigationFailure(failure RouteNavigationFailure) ([]byte, error) {
466+ if err := validateRouteNavigationFailure(failure); err != nil {
467+ return nil, err
468+ }
469+ w := payloadWriter{}
470+ w.putUint64(failure.Key)
471+ w.putUint64(failure.Generation)
472+ w.putUint8(uint8(failure.Code))
473+ return w.b, nil
474+}
475+
476+func UnmarshalRouteNavigationFailure(b []byte) (RouteNavigationFailure, error) {
477+ r := payloadReader{b: b}
478+ var failure RouteNavigationFailure
479+ var err error
480+ if failure.Key, err = r.getUint64(); err != nil {
481+ return RouteNavigationFailure{}, err
482+ }
483+ if failure.Generation, err = r.getUint64(); err != nil {
484+ return RouteNavigationFailure{}, err
485+ }
486+ code, err := r.getUint8()
487+ if err != nil {
488+ return RouteNavigationFailure{}, err
489+ }
490+ failure.Code = RouteFailureCode(code)
491+ if err := r.done(); err != nil {
492+ return RouteNavigationFailure{}, err
493+ }
494+ if err := validateRouteNavigationFailure(failure); err != nil {
495+ return RouteNavigationFailure{}, err
496+ }
497+ return failure, nil
498+}
M internal/ports/wire.go +165 -178
@@ -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,40 @@ 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. homePickerRoute indicates that the route may open its
88+// home/session picker (for example, a remote target or daemon-owned route).
89+func ValidateNavigation(capabilities NavigationCapabilities, overlay StartupOverlay, homePickerRoute bool) error {
90+ if !validNavigationCapabilities(capabilities) || !validStartupOverlay(overlay) {
91+ return ErrInvalidNavigation
92+ }
93+ home := capabilities&NavigationCapabilityHomePicker != 0
94+ back := capabilities&NavigationCapabilityBack != 0
95+ if (home && !homePickerRoute) || back != (overlay == StartupOverlaySessionPicker) {
96+ return ErrInvalidNavigation
97+ }
98+ if homePickerRoute && back {
99+ return ErrInvalidNavigation
100+ }
101+ return nil
102+}
91 103
92-func validRenderMode(mode RenderMode) bool {104+func validateHelloNavigation(h Hello) error {
93- return mode == RenderModeFullTerminal || mode == RenderModeProxiedContent105+ if h.Intent != IntentAttach && h.Intent != IntentResume {
106+ if h.NavigationCapabilities != 0 || h.StartupOverlay != StartupOverlayNone {
107+ return ErrInvalidNavigation
108+ }
109+ return nil
110+ }
111+ return ValidateNavigation(h.NavigationCapabilities, h.StartupOverlay, h.RemoteTarget != nil || h.EnvironmentPolicy == EnvironmentPolicyDaemonOwned)
94 }112 }
95 113
96 // Capability bits advertised in Welcome.114 // Capability bits advertised in Welcome.
@@ -127,7 +145,6 @@ const (
127 type Hello struct {145 type Hello struct {
128 Version uint16146 Version uint16
129 Intent uint8147 Intent uint8
130- RenderMode RenderMode
131 ClientID [16]byte148 ClientID [16]byte
132 ResumeToken uint64149 ResumeToken uint64
133 Name string150 Name string
@@ -144,8 +161,18 @@ type Hello struct {
144 // RemoteTarget is present only for a picker-selected remote attach. It is161 // RemoteTarget is present only for a picker-selected remote attach. It is
145 // carried through reconnects and is validated by the owning daemon before162 // carried through reconnects and is validated by the owning daemon before
146 // it creates or publishes any session resources.163 // it creates or publishes any session resources.
147- RemoteTarget *domain.RemoteSessionTarget164+ RemoteTarget *domain.RemoteSessionTarget
148- EnvironmentPolicy EnvironmentPolicy165+ // ExactTarget is an optional lifecycle/name identity selected by the client
166+ // ledger. It is transport-neutral and never replaces the daemon's authority
167+ // to validate or commit the target.
168+ ExactTarget *ExactSessionTarget
169+ // PreferredTabID is a client-owned route cursor. The daemon treats it as an
170+ // attachment-local hint and falls back when the stable tab no longer exists.
171+ PreferredTabID domain.TabStableID
172+ RemoteOrigin string
173+ EnvironmentPolicy EnvironmentPolicy
174+ NavigationCapabilities NavigationCapabilities
175+ StartupOverlay StartupOverlay
149 }176 }
150 177
151 // Input carries raw bytes typed/pasted by the client, destined for the PTY.178 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -232,12 +259,12 @@ type Ack struct {
232 259
233 // Welcome is the daemon's reply to a successful Hello.260 // Welcome is the daemon's reply to a successful Hello.
234 type Welcome struct {261 type Welcome struct {
235- SessionID string262+ SessionID string
236- SessionName string263+ SessionName string
237- Ephemeral bool264+ Ephemeral bool
238- ResumeToken uint64265+ ResumeToken uint64
239- RenderMode RenderMode266+ Capabilities uint32
240- Capabilities uint32267+ CommittedIdentity *CommittedRouteIdentity
241 }268 }
242 269
243 // ErrorMsg reports a protocol- or session-level failure to the client.270 // ErrorMsg reports a protocol- or session-level failure to the client.
@@ -313,21 +340,6 @@ type OutputResetRequest struct{}
313 340
314 // SessionTabMeta describes one tab in a remote session's metadata snapshot.341 // 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.342 // 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.343 // payloadWriter builds a message payload by appending fields in wire order.
332 type payloadWriter struct {344 type payloadWriter struct {
333 b []byte345 b []byte
@@ -384,9 +396,10 @@ func (w *payloadWriter) putLongBytes(b []byte) {
384 w.b = append(w.b, b...)396 w.b = append(w.b, b...)
385 }397 }
386 398
387-// marshalRemoteTargetSection writes the required v25 target/policy section.399+// marshalRemoteTargetSection writes the required v27 target/policy section.
388 // A target may be absent for direct local attaches, but its presence marker and400 // 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.401+// environment policy are never optional: v27 has no extension or legacy tail
402+// inside this section; the exact-target section follows it.
390 func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {403 func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {
391 if target == nil {404 if target == nil {
392 w.putBool(false)405 w.putBool(false)
@@ -699,15 +712,15 @@ func ValidateHello(h Hello) error {
699 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {712 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
700 return ErrInvalidHello713 return ErrInvalidHello
701 }714 }
702- if !validRenderMode(h.RenderMode) {
703- return ErrInvalidHello
704- }
705 if err := ValidateSize(h.Size); err != nil {715 if err := ValidateSize(h.Size); err != nil {
706 return fmt.Errorf("%w: size", ErrInvalidHello)716 return fmt.Errorf("%w: size", ErrInvalidHello)
707 }717 }
708 if !validEnvironmentPolicy(h.EnvironmentPolicy) {718 if !validEnvironmentPolicy(h.EnvironmentPolicy) {
709 return ErrInvalidHello719 return ErrInvalidHello
710 }720 }
721+ if err := validateHelloNavigation(h); err != nil {
722+ return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
723+ }
711 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {724 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {
712 return ErrInvalidHello725 return ErrInvalidHello
713 }726 }
@@ -716,8 +729,35 @@ func ValidateHello(h Hello) error {
716 return ErrInvalidHello729 return ErrInvalidHello
717 }730 }
718 }731 }
732+ if h.RemoteOrigin != "" {
733+ if err := domain.ValidateRemoteDisplayOrigin(h.RemoteOrigin); err != nil {
734+ return fmt.Errorf("%w: remote origin: %v", ErrInvalidHello, err)
735+ }
736+ }
737+ if h.PreferredTabID != "" {
738+ if h.Intent != IntentAttach && h.Intent != IntentResume {
739+ return ErrInvalidHello
740+ }
741+ if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil {
742+ return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err)
743+ }
744+ }
745+ if h.ExactTarget != nil {
746+ if h.Intent != IntentAttach && h.Intent != IntentResume {
747+ return ErrInvalidHello
748+ }
749+ if err := h.ExactTarget.Validate(); err != nil {
750+ return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err)
751+ }
752+ if h.Name != h.ExactTarget.SessionName {
753+ return ErrInvalidHello
754+ }
755+ if h.RemoteTarget != nil && (h.RemoteTarget.LifecycleID != h.ExactTarget.LifecycleID || h.RemoteTarget.SessionName != h.ExactTarget.SessionName) {
756+ return ErrInvalidHello
757+ }
758+ }
719 if h.RemoteTarget == nil {759 if h.RemoteTarget == nil {
720- if h.EnvironmentPolicy != EnvironmentPolicyClientOwned || h.RenderMode == RenderModeProxiedContent {760+ if h.EnvironmentPolicy != EnvironmentPolicyClientOwned && h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
721 return ErrInvalidHello761 return ErrInvalidHello
722 }762 }
723 return nil763 return nil
@@ -775,19 +815,19 @@ func ValidateAttachTarget(m AttachTarget) error {
775 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {815 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
776 return ErrInvalidAttachTarget816 return ErrInvalidAttachTarget
777 }817 }
818+ if m.Intent == IntentResume {
819+ return ErrInvalidAttachTarget
820+ }
778 if !validEnvironmentPolicy(m.EnvironmentPolicy) {821 if !validEnvironmentPolicy(m.EnvironmentPolicy) {
779 return ErrInvalidAttachTarget822 return ErrInvalidAttachTarget
780 }823 }
781 if m.RemoteTarget == nil {824 if m.RemoteTarget == nil {
782- if m.EnvironmentPolicy != EnvironmentPolicyClientOwned {
783- return ErrInvalidAttachTarget
784- }
785 return nil825 return nil
786 }826 }
787 if m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {827 if m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
788 return ErrInvalidAttachTarget828 return ErrInvalidAttachTarget
789 }829 }
790- if m.Intent != IntentAttach && m.Intent != IntentResume {830+ if m.Intent != IntentAttach {
791 return ErrInvalidAttachTarget831 return ErrInvalidAttachTarget
792 }832 }
793 if err := validateWireRemoteTarget(*m.RemoteTarget); err != nil {833 if err := validateWireRemoteTarget(*m.RemoteTarget); err != nil {
@@ -814,7 +854,6 @@ func MarshalHello(h Hello) []byte {
814 w := payloadWriter{}854 w := payloadWriter{}
815 w.putUint16(h.Version)855 w.putUint16(h.Version)
816 w.putUint8(h.Intent)856 w.putUint8(h.Intent)
817- w.putUint8(uint8(h.RenderMode))
818 w.putBytes(h.ClientID[:])857 w.putBytes(h.ClientID[:])
819 w.putUint64(h.ResumeToken)858 w.putUint64(h.ResumeToken)
820 w.putString(h.Name)859 w.putString(h.Name)
@@ -829,6 +868,11 @@ func MarshalHello(h Hello) []byte {
829 w.putLongString(entry)868 w.putLongString(entry)
830 }869 }
831 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)870 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
871+ marshalExactTargetSection(&w, h.ExactTarget)
872+ w.putString(string(h.PreferredTabID))
873+ w.putString(h.RemoteOrigin)
874+ w.putUint8(uint8(h.NavigationCapabilities))
875+ w.putUint8(uint8(h.StartupOverlay))
832 return w.b876 return w.b
833 }877 }
834 878
@@ -844,13 +888,6 @@ func preflightHello(b []byte) error {
844 if _, err := r.getUint8(); err != nil {888 if _, err := r.getUint8(); err != nil {
845 return err889 return err
846 }890 }
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 {891 if len(r.b) < 16 {
855 return errShortPayload892 return errShortPayload
856 }893 }
@@ -894,6 +931,21 @@ func preflightHello(b []byte) error {
894 if err := skipRemoteTargetSection(&r); err != nil {931 if err := skipRemoteTargetSection(&r); err != nil {
895 return err932 return err
896 }933 }
934+ if err := skipExactTargetSection(&r); err != nil {
935+ return err
936+ }
937+ if err := r.skipString(); err != nil {
938+ return err
939+ }
940+ if err := r.skipString(); err != nil {
941+ return err
942+ }
943+ if _, err := r.getUint8(); err != nil {
944+ return err
945+ }
946+ if _, err := r.getUint8(); err != nil {
947+ return err
948+ }
897 return r.done()949 return r.done()
898 }950 }
899 951
@@ -915,11 +967,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
915 if h.Intent, err = r.getUint8(); err != nil {967 if h.Intent, err = r.getUint8(); err != nil {
916 return Hello{}, err968 return Hello{}, err
917 }969 }
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))970 clientID, err := r.getBytes(len(h.ClientID))
924 if err != nil {971 if err != nil {
925 return Hello{}, err972 return Hello{}, err
@@ -974,6 +1021,27 @@ func UnmarshalHello(b []byte) (Hello, error) {
974 if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {1021 if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
975 return Hello{}, err1022 return Hello{}, err
976 }1023 }
1024+ if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil {
1025+ return Hello{}, err
1026+ }
1027+ preferredTabID, err := r.getString()
1028+ if err != nil {
1029+ return Hello{}, err
1030+ }
1031+ h.PreferredTabID = domain.TabStableID(preferredTabID)
1032+ if h.RemoteOrigin, err = r.getString(); err != nil {
1033+ return Hello{}, err
1034+ }
1035+ capabilities, err := r.getUint8()
1036+ if err != nil {
1037+ return Hello{}, err
1038+ }
1039+ h.NavigationCapabilities = NavigationCapabilities(capabilities)
1040+ overlay, err := r.getUint8()
1041+ if err != nil {
1042+ return Hello{}, err
1043+ }
1044+ h.StartupOverlay = StartupOverlay(overlay)
977 if err := r.done(); err != nil {1045 if err := r.done(); err != nil {
978 return Hello{}, err1046 return Hello{}, err
979 }1047 }
@@ -1258,7 +1326,7 @@ func UnmarshalAck(b []byte) (Ack, error) {
1258 1326
1259 // MarshalWelcome encodes m into a Welcome message payload.1327 // MarshalWelcome encodes m into a Welcome message payload.
1260 func MarshalWelcome(m Welcome) []byte {1328 func MarshalWelcome(m Welcome) []byte {
1261- if !validRenderMode(m.RenderMode) {1329+ if m.CommittedIdentity != nil && (m.SessionName != m.CommittedIdentity.Target.SessionName || m.Ephemeral != m.CommittedIdentity.Ephemeral) {
1262 return nil1330 return nil
1263 }1331 }
1264 w := payloadWriter{}1332 w := payloadWriter{}
@@ -1270,8 +1338,10 @@ func MarshalWelcome(m Welcome) []byte {
1270 w.putUint8(0)1338 w.putUint8(0)
1271 }1339 }
1272 w.putUint64(m.ResumeToken)1340 w.putUint64(m.ResumeToken)
1273- w.putUint8(uint8(m.RenderMode))
1274 w.putUint32(m.Capabilities)1341 w.putUint32(m.Capabilities)
1342+ if !marshalCommittedIdentitySection(&w, m.CommittedIdentity) {
1343+ return nil
1344+ }
1275 return w.b1345 return w.b
1276 }1346 }
1277 1347
@@ -1295,17 +1365,15 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
1295 if m.ResumeToken, err = r.getUint64(); err != nil {1365 if m.ResumeToken, err = r.getUint64(); err != nil {
1296 return Welcome{}, err1366 return Welcome{}, err
1297 }1367 }
1298- mode, err := r.getUint8()1368+ if m.Capabilities, err = r.getUint32(); err != nil {
1299- if err != nil {
1300 return Welcome{}, err1369 return Welcome{}, err
1301 }1370 }
1302- m.RenderMode = RenderMode(mode)1371+ if m.CommittedIdentity, err = unmarshalCommittedIdentitySection(&r); err != nil {
1303- if !validRenderMode(m.RenderMode) {
1304- return Welcome{}, errInvalidEnum
1305- }
1306- if m.Capabilities, err = r.getUint32(); err != nil {
1307 return Welcome{}, err1372 return Welcome{}, err
1308 }1373 }
1374+ if m.CommittedIdentity != nil && (m.CommittedIdentity.Target.SessionName != m.SessionName || m.CommittedIdentity.Ephemeral != m.Ephemeral) {
1375+ return Welcome{}, fmt.Errorf("%w: committed identity does not match welcome", ErrInvalidRouteWire)
1376+ }
1309 if err := r.done(); err != nil {1377 if err := r.done(); err != nil {
1310 return Welcome{}, err1378 return Welcome{}, err
1311 }1379 }
@@ -1532,111 +1600,6 @@ func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) {
1532 return OutputResetRequest{}, nil1600 return OutputResetRequest{}, nil
1533 }1601 }
1534 1602
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.1603 // MarshalKill encodes m into a Kill message payload.
1641 func MarshalKill(m Kill) []byte {1604 func MarshalKill(m Kill) []byte {
1642 w := payloadWriter{}1605 w := payloadWriter{}
@@ -1735,7 +1698,6 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
1735 }1698 }
1736 return Sessions{Sessions: sessions}, nil1699 return Sessions{Sessions: sessions}, nil
1737 }1700 }
1738-
1739 func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) }1701 func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) }
1740 1702
1741 func getInt16(r *payloadReader) (int, error) {1703 func getInt16(r *payloadReader) (int, error) {
@@ -2003,3 +1965,28 @@ func UnmarshalRemotePreview(data []byte) (RemotePreview, error) {
2003 }1965 }
2004 return p, nil1966 return p, nil
2005 }1967 }
1968+
1969+// MarshalNavigationAction encodes one bounded navigation action.
1970+func MarshalNavigationAction(action NavigationAction) []byte {
1971+ if action != NavigationOpenHomePicker && action != NavigationBack {
1972+ return nil
1973+ }
1974+ return []byte{byte(action)}
1975+}
1976+
1977+// UnmarshalNavigationAction decodes one strict navigation action payload.
1978+func UnmarshalNavigationAction(b []byte) (NavigationAction, error) {
1979+ r := payloadReader{b: b}
1980+ value, err := r.getUint8()
1981+ if err != nil {
1982+ return 0, ErrInvalidNavigation
1983+ }
1984+ if err := r.done(); err != nil {
1985+ return 0, ErrInvalidNavigation
1986+ }
1987+ action := NavigationAction(value)
1988+ if action != NavigationOpenHomePicker && action != NavigationBack {
1989+ return 0, ErrInvalidNavigation
1990+ }
1991+ return action, nil
1992+}
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 != 29 {
15- t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 29", 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, 29, 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, 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 +69 -120
@@ -53,6 +53,12 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func
53 })53 })
54 }54 }
55 55
56+func appendNoNavigationTail(payload []byte) []byte {
57+ // exact-target absent, preferred-tab absent, remote-origin absent,
58+ // navigation capabilities none, startup overlay none.
59+ return append(payload, 0, 0, 0, 0, 0, 0, 0)
60+}
61+
56 func TestHelloGoldenAndRoundTrip(t *testing.T) {62 func TestHelloGoldenAndRoundTrip(t *testing.T) {
57 tests := []struct {63 tests := []struct {
58 name string64 name string
@@ -73,7 +79,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
73 TrueColor: true,79 TrueColor: true,
74 MaxOutputInFlight: 8,80 MaxOutputInFlight: 8,
75 },81 },
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},82+ 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 },83 },
78 {84 {
79 name: "empty strings",85 name: "empty strings",
@@ -86,15 +92,16 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
86 Cwd: "",92 Cwd: "",
87 TrueColor: false,93 TrueColor: false,
88 },94 },
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},95+ 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 },96 },
91 }97 }
92 98
93 for _, tt := range tests {99 for _, tt := range tests {
94 t.Run(tt.name, func(t *testing.T) {100 t.Run(tt.name, func(t *testing.T) {
101+ want := appendNoNavigationTail(append([]byte(nil), tt.want...))
95 got := MarshalHello(tt.msg)102 got := MarshalHello(tt.msg)
96- if !bytes.Equal(got, tt.want) {103+ if !bytes.Equal(got, want) {
97- t.Fatalf("MarshalHello() = %#v, want %#v", got, tt.want)104+ t.Fatalf("MarshalHello() = %#v, want %#v", got, want)
98 }105 }
99 back, err := UnmarshalHello(got)106 back, err := UnmarshalHello(got)
100 if err != nil {107 if err != nil {
@@ -124,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
124 }131 }
125 got := MarshalHello(msg)132 got := MarshalHello(msg)
126 want := []byte{133 want := []byte{
127- 0x00, 0x19, 0x00, 0x00, // version, intent, render mode134+ 0x00, 0x1d, 0x00, // version, intent, first client ID byte
128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -138,6 +145,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',145 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
139 0x00, 0x00, // no remote target, client-owned environment146 0x00, 0x00, // no remote target, client-owned environment
140 }147 }
148+ want = appendNoNavigationTail(want)
141 require.Equal(t, want, got)149 require.Equal(t, want, got)
142 decoded, err := UnmarshalHello(want)150 decoded, err := UnmarshalHello(want)
143 require.NoError(t, err)151 require.NoError(t, err)
@@ -165,16 +173,24 @@ func TestHelloEnvironmentCodec(t *testing.T) {
165 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})173 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
166 withCount := func(count byte) []byte {174 withCount := func(count byte) []byte {
167 payload := append([]byte(nil), base...)175 payload := append([]byte(nil), base...)
168- payload[len(payload)-3] = count176+ // The uint32 environment count occupies bytes len-13 through len-10.
177+ payload[len(payload)-10] = count
169 return payload178 return payload
170 }179 }
180+ withEntries := func(count byte, entries ...byte) []byte {
181+ payload := withCount(count)
182+ // The nine-byte tail follows the environment-count field.
183+ tail := append([]byte(nil), payload[len(payload)-9:]...)
184+ payload = append(payload[:len(payload)-9], entries...)
185+ return append(payload, tail...)
186+ }
171 tests := []struct {187 tests := []struct {
172 name string188 name string
173 payload []byte189 payload []byte
174 }{190 }{
175 {name: "truncated count", payload: base[:len(base)-1]},191 {name: "truncated count", payload: base[:len(base)-1]},
176- {name: "truncated entry length", payload: append(withCount(1), 0, 0)},192+ {name: "truncated entry length", payload: withEntries(1, 0, 0)},
177- {name: "truncated entry body", payload: append(withCount(1), 0, 0, 0, 3, 'x')},193+ {name: "truncated entry body", payload: withEntries(1, 0, 0, 0, 3, 'x')},
178 {name: "impossible count", payload: withCount(2)},194 {name: "impossible count", payload: withCount(2)},
179 {name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},195 {name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},
180 }196 }
@@ -182,6 +198,9 @@ func TestHelloEnvironmentCodec(t *testing.T) {
182 t.Run(tt.name, func(t *testing.T) {198 t.Run(tt.name, func(t *testing.T) {
183 _, err := UnmarshalHello(tt.payload)199 _, err := UnmarshalHello(tt.payload)
184 require.Error(t, err)200 require.Error(t, err)
201+ if tt.name != "trailing garbage" {
202+ require.ErrorIs(t, err, errShortPayload)
203+ }
185 })204 })
186 }205 }
187 }206 }
@@ -342,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
342 }361 }
343 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
344 require.Equal(t, want, MarshalTheme(cleared))363 require.Equal(t, want, MarshalTheme(cleared))
345- require.Equal(t, uint16(25), ProtocolVersion)364+ require.Equal(t, uint16(29), ProtocolVersion)
346 }365 }
347 366
348 func TestResizeGoldenAndRoundTrip(t *testing.T) {367 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -413,6 +432,39 @@ func TestPongGoldenAndRoundTrip(t *testing.T) {
413 assertTrailingGarbageFails(t, got, UnmarshalPong)432 assertTrailingGarbageFails(t, got, UnmarshalPong)
414 }433 }
415 434
435+func TestMarshalWelcomeRejectsInvalidCommittedIdentity(t *testing.T) {
436+ got := MarshalWelcome(Welcome{
437+ SessionName: "work",
438+ CommittedIdentity: &CommittedRouteIdentity{
439+ Target: ExactSessionTarget{SessionName: "different"},
440+ },
441+ })
442+ require.Nil(t, got)
443+}
444+
445+func TestMarshalWelcomeRejectsCommittedIdentityMetadataMismatch(t *testing.T) {
446+ base := Welcome{
447+ SessionID: "session",
448+ SessionName: "work",
449+ Ephemeral: true,
450+ CommittedIdentity: &CommittedRouteIdentity{
451+ Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"},
452+ Ephemeral: true,
453+ },
454+ }
455+ for name, mutate := range map[string]func(*Welcome){
456+ "session name": func(w *Welcome) { w.SessionName = "other" },
457+ "ephemeral": func(w *Welcome) { w.Ephemeral = false },
458+ } {
459+ t.Run(name, func(t *testing.T) {
460+ got := base
461+ mutate(&got)
462+ require.Nil(t, MarshalWelcome(got))
463+ })
464+ }
465+ require.NotNil(t, MarshalWelcome(base))
466+}
467+
416 func TestWelcomeGoldenAndRoundTrip(t *testing.T) {468 func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
417 tests := []struct {469 tests := []struct {
418 name string470 name string
@@ -422,7 +474,7 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
422 {474 {
423 name: "ephemeral",475 name: "ephemeral",
424 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},476 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},477+ 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, 0x00},
426 },478 },
427 {479 {
428 name: "non-ephemeral empty name",480 name: "non-ephemeral empty name",
@@ -452,13 +504,6 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
452 assertTrailingGarbageFails(t, full, UnmarshalWelcome)504 assertTrailingGarbageFails(t, full, UnmarshalWelcome)
453 }505 }
454 506
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) {507 func TestCommandErrorCodes(t *testing.T) {
463 require.Equal(t, uint16(6), ErrUnknownCommand)508 require.Equal(t, uint16(6), ErrUnknownCommand)
464 require.Equal(t, uint16(7), ErrNotScriptable)509 require.Equal(t, uint16(7), ErrNotScriptable)
@@ -477,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
477 name: "minimal",522 name: "minimal",
478 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
479 want: []byte{524 want: []byte{
480- 0x00, 0x19,525+ 0x00, 0x1d,
481 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
482 0x00, // attached527 0x00, // attached
483 0x00, // self528 0x00, // self
@@ -500,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
500 JSON: true,545 JSON: true,
501 },546 },
502 want: []byte{547 want: []byte{
503- 0x00, 0x19,548+ 0x00, 0x1d,
504 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
505 0x00, // attached550 0x00, // attached
506 0x01, // self551 0x01, // self
@@ -898,9 +943,10 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
898 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {943 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}944 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.945 // 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)946+ want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0)
947+ want = appendNoNavigationTail(want)
902 want[1] = 14948 want[1] = 14
903- want[31], want[33] = 1, 1949+ want[30], want[32] = 1, 1
904 got := MarshalHello(hello)950 got := MarshalHello(hello)
905 requireBytesEqual(t, want, got)951 requireBytesEqual(t, want, got)
906 back, err := UnmarshalHello(got)952 back, err := UnmarshalHello(got)
@@ -928,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
928 name: "attached",974 name: "attached",
929 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
930 want: []byte{976 want: []byte{
931- 0x00, 0x19, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,977+ 0x00, 0x1d, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
932 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',978 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',979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
934 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -938,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
938 name: "control",984 name: "control",
939 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
940 want: []byte{986 want: []byte{
941- 0x00, 0x19, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,987+ 0x00, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
942 0x00, 0x02, 'l', 's', 0x00, 0x00,988 0x00, 0x02, 'l', 's', 0x00, 0x00,
943 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
944 },990 },
@@ -995,100 +1041,3 @@ func TestOutputResetRequestStrict(t *testing.T) {
995 require.Equal(t, OutputResetRequest{}, back)1041 require.Equal(t, OutputResetRequest{}, back)
996 assertTrailingGarbageFails(t, got, UnmarshalOutputResetRequest)1042 assertTrailingGarbageFails(t, got, UnmarshalOutputResetRequest)
997 }1043 }
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 +512 -101
@@ -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 {
@@ -167,21 +163,38 @@ type Dependencies struct {
167 // Remote selects client-side carriage presentation only; it never enters163 // Remote selects client-side carriage presentation only; it never enters
168 // the daemon-facing session request.164 // the daemon-facing session request.
169 Remote bool165 Remote bool
166+ // Origin is explicit composition metadata for the initial route. A zero
167+ // value is normalized to local for direct package callers and old tests.
168+ Origin ports.RouteOrigin
169+ OriginKey string
170 }170 }
171 171
172 // AttachRequest identifies the session for one client run. Remote is172 // AttachRequest identifies the session for one client run. Remote is
173 // client-only presentation metadata. RemoteTarget is an optional exact picker173 // client-only presentation metadata. RemoteTarget is an optional exact picker
174 // handoff and is serialized into Hello only when present.174 // handoff and is serialized into Hello only when present.
175 type AttachRequest struct {175 type AttachRequest struct {
176- Intent uint8176+ Intent uint8
177- SessionName string177+ SessionName string
178- RenderMode ports.RenderMode178+ Remote bool
179- Remote bool179+ Origin ports.RouteOrigin
180- RemoteTarget *domain.RemoteSessionTarget180+ OriginKey string
181- EnvironmentPolicy ports.EnvironmentPolicy181+ RemoteTarget *domain.RemoteSessionTarget
182+ ExactTarget *ports.ExactSessionTarget
183+ PreferredTabID domain.TabStableID
184+ RemoteOrigin string
185+ EnvironmentPolicy ports.EnvironmentPolicy
186+ NavigationCapabilities ports.NavigationCapabilities
187+ StartupOverlay ports.StartupOverlay
188+}
189+
190+// attachRoute captures the dialer, request, and resume token needed to
191+// restore a previous attachment route after a navigation handoff.
192+type attachRoute struct {
193+ dialer ports.Dialer
194+ request AttachRequest
195+ resumeToken uint64
182 }196 }
183 197
184-// Runner owns the client lifecycle across one or more attachment attempts.
185 type Runner struct {198 type Runner struct {
186 dialer ports.Dialer199 dialer ports.Dialer
187 term ports.Terminal200 term ports.Terminal
@@ -192,6 +205,9 @@ type Runner struct {
192 remoteHostLearner ports.RemoteHostLearner205 remoteHostLearner ports.RemoteHostLearner
193 attachHandoff AttachHandoffFunc206 attachHandoff AttachHandoffFunc
194 remote bool207 remote bool
208+ origin ports.RouteOrigin
209+ ledger *routeLedger
210+ routeFailure *ports.RouteNavigationFailure
195 211
196 inputMu sync.Mutex212 inputMu sync.Mutex
197 input *terminalInputPump213 input *terminalInputPump
@@ -213,6 +229,8 @@ func NewRunner(deps Dependencies) *Runner {
213 remoteHostLearner: deps.RemoteHostLearner,229 remoteHostLearner: deps.RemoteHostLearner,
214 attachHandoff: deps.AttachHandoff,230 attachHandoff: deps.AttachHandoff,
215 remote: deps.Remote,231 remote: deps.Remote,
232+ origin: normalizeRouteOrigin(deps.Origin, deps.Remote),
233+ ledger: newRouteLedger(),
216 }234 }
217 }235 }
218 236
@@ -232,33 +250,41 @@ func (r *Runner) terminalInput() *terminalInputPump {
232 }250 }
233 251
234 func validateAttachRequest(request AttachRequest) error {252 func validateAttachRequest(request AttachRequest) error {
235- switch request.RenderMode {253+ if request.Origin != 0 {
236- case ports.RenderModeFullTerminal, ports.RenderModeProxiedContent:254+ if err := request.Origin.Validate(); err != nil {
237- default:255+ return fmt.Errorf("vev: invalid route origin: %w", err)
238- return fmt.Errorf("vev: invalid render mode %d", request.RenderMode)256+ }
239 }257 }
240- switch request.Intent {258+ if request.OriginKey != "" {
241- case ports.IntentEphemeral:259+ if err := ports.ValidateRouteLabel(request.OriginKey, false); err != nil {
242- case ports.IntentNew, ports.IntentAttach, ports.IntentResume:260+ return fmt.Errorf("vev: invalid route origin key: %w", err)
243- if request.SessionName == "" {
244- return domain.ErrInvalidSessionName
245 }261 }
246- default:
247- return fmt.Errorf("vev: invalid session intent %d", request.Intent)
248 }262 }
249- if request.SessionName != "" {263+ if request.ExactTarget != nil {
250- if err := domain.ValidateSessionName(request.SessionName); err != nil {264+ if err := request.ExactTarget.Validate(); err != nil {
251- return err265+ return fmt.Errorf("vev: invalid exact target: %w", err)
266+ }
267+ if request.SessionName != request.ExactTarget.SessionName {
268+ return errors.New("vev: exact target session mismatch")
269+ }
270+ if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != request.ExactTarget.LifecycleID || request.RemoteTarget.SessionName != request.ExactTarget.SessionName) {
271+ return errors.New("vev: exact target does not match remote target")
252 }272 }
253 }273 }
274+ if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {
275+ return fmt.Errorf("vev: invalid session target: %w", err)
276+ }
277+ if err := ports.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.RemoteTarget != nil || request.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned); err != nil {
278+ return fmt.Errorf("vev: invalid navigation route: %w", err)
279+ }
254 if request.RemoteTarget == nil {280 if request.RemoteTarget == nil {
255- if request.RenderMode == ports.RenderModeProxiedContent {281+ if request.EnvironmentPolicy == ports.EnvironmentPolicyClientOwned {
256- return errors.New("vev: proxied attach request requires remote target")282+ return nil
257 }283 }
258- if request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned {284+ if request.Remote && request.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned {
259- return errors.New("vev: attach request without remote target requires client-owned environment")285+ return nil
260 }286 }
261- return nil287+ return errors.New("vev: attach without remote target requires a matching environment policy")
262 }288 }
263 if err := request.RemoteTarget.Validate(); err != nil {289 if err := request.RemoteTarget.Validate(); err != nil {
264 return fmt.Errorf("vev: invalid remote attach target: %w", err)290 return fmt.Errorf("vev: invalid remote attach target: %w", err)
@@ -270,7 +296,7 @@ func validateAttachRequest(request AttachRequest) error {
270 return errors.New("vev: remote attach target requires daemon-owned environment")296 return errors.New("vev: remote attach target requires daemon-owned environment")
271 }297 }
272 if request.Intent != ports.IntentAttach && request.Intent != ports.IntentResume {298 if request.Intent != ports.IntentAttach && request.Intent != ports.IntentResume {
273- return errors.New("vev: remote attach target requires attach or resume intent")299+ return errors.New("vev: remote attach target requires attach or resume")
274 }300 }
275 return nil301 return nil
276 }302 }
@@ -280,6 +306,9 @@ func validateAttachRequest(request AttachRequest) error {
280 // redials a lost link.306 // redials a lost link.
281 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {307 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
282 request = cloneAttachRequest(request)308 request = cloneAttachRequest(request)
309+ if request.Origin == 0 {
310+ request.Origin = r.origin
311+ }
283 if err := validateAttachRequest(request); err != nil {312 if err := validateAttachRequest(request); err != nil {
284 return err313 return err
285 }314 }
@@ -310,6 +339,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
310 resumeToken := uint64(0)339 resumeToken := uint64(0)
311 attemptRequest := request340 attemptRequest := request
312 dialer := r.dialer341 dialer := r.dialer
342+ var homeRoute *attachRoute
343+ var returnRoute *attachRoute
344+ returnResumeFallback := false
345+ homeNavigationPending := false
346+ returnNavigationPending := false
347+ routeNavigationPending := false
348+ routeNavigationResumeFallback := false
349+ var routeNavigationSelection *routeNavigationSelection
350+ var routeNavigationFallback *attachRoute
351+ var routeNavigationAction *ports.RouteNavigationAction
313 backoff := defaultReconnectBackoff.initial352 backoff := defaultReconnectBackoff.initial
314 themeState := &terminalThemeState{}353 themeState := &terminalThemeState{}
315 var rememberOnce sync.Once354 var rememberOnce sync.Once
@@ -359,6 +398,35 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
359 rawEntered: &rawEntered,398 rawEntered: &rawEntered,
360 stage: reconnectStageOfflineRetrying,399 stage: reconnectStageOfflineRetrying,
361 }400 }
401+ restoreReturnRoute := func(route attachRoute) {
402+ returnNavigationPending = true
403+ dialer = route.dialer
404+ attemptRequest = route.request
405+ if route.resumeToken != 0 {
406+ attemptRequest.Intent = ports.IntentResume
407+ } else {
408+ attemptRequest.Intent = ports.IntentAttach
409+ }
410+ attemptRequest.StartupOverlay = ports.StartupOverlayNone
411+ attemptRequest.NavigationCapabilities &^= ports.NavigationCapabilityBack | ports.NavigationCapabilityHomePicker
412+ resumeToken = route.resumeToken
413+ returnResumeFallback = route.resumeToken != 0
414+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
415+ backoff = defaultReconnectBackoff.initial
416+ }
417+ enterHomePicker := func() {
418+ dialer = homeRoute.dialer
419+ attemptRequest = homeRoute.request
420+ attemptRequest.Intent = ports.IntentAttach
421+ attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
422+ attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
423+ attemptRequest.RemoteTarget = nil
424+ attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
425+ attemptRequest.Remote = homeRoute.request.Remote || r.remote
426+ resumeToken = 0
427+ remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
428+ backoff = defaultReconnectBackoff.initial
429+ }
362 var input *terminalInputPump430 var input *terminalInputPump
363 defer func() {431 defer func() {
364 if input != nil {432 if input != nil {
@@ -377,6 +445,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
377 }445 }
378 446
379 for {447 for {
448+ if err := validateAttachRequest(attemptRequest); err != nil {
449+ return err
450+ }
380 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)451 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
381 transport, err := boundedDial(handshakeCtx, dialer)452 transport, err := boundedDial(handshakeCtx, dialer)
382 if err != nil {453 if err != nil {
@@ -412,7 +483,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
412 return errors.New("vev: dialer returned nil transport")483 return errors.New("vev: dialer returned nil transport")
413 }484 }
414 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)485 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
415- connection, err := NewSessionConnection(transport, attemptRequest)486+ connection, err := NewSessionConnection(transport, SessionTarget{
487+ Intent: attemptRequest.Intent,
488+ SessionName: attemptRequest.SessionName,
489+ })
416 if err != nil {490 if err != nil {
417 stopHandshakeTransport()491 stopHandshakeTransport()
418 finishHandshake()492 finishHandshake()
@@ -425,22 +499,24 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
425 linkEvents = reporter.LinkEvents()499 linkEvents = reporter.LinkEvents()
426 }500 }
427 result := (&attachAttempt{501 result := (&attachAttempt{
428- runner: r,502+ runner: r,
429- connection: connection,503+ dialer: dialer,
430- remote: remote,504+ connection: connection,
431- handshakeCtx: handshakeCtx,505+ remote: remote,
432- handshakeTimedOut: timedOut,506+ handshakeCtx: handshakeCtx,
433- finishHandshake: finishHandshake,507+ handshakeTimedOut: timedOut,
434- stopHandshakeTransport: stopHandshakeTransport,508+ finishHandshake: finishHandshake,
435- request: attemptRequest,509+ stopHandshakeTransport: stopHandshakeTransport,
436- resumeToken: resumeToken,510+ request: attemptRequest,
437- clientID: processClientID,511+ resumeToken: resumeToken,
438- milestones: &ms,512+ routeNavigationSelection: routeNavigationSelection,
439- themeState: themeState,513+ clientID: processClientID,
440- enterRaw: enterRaw,514+ milestones: &ms,
441- reconnect: reconnect,515+ themeState: themeState,
442- linkEvents: linkEvents,516+ enterRaw: enterRaw,
443- rememberRemoteHost: rememberRemoteHost,517+ reconnect: reconnect,
518+ linkEvents: linkEvents,
519+ rememberRemoteHost: rememberRemoteHost,
444 terminalInput: func() *terminalInputPump {520 terminalInput: func() *terminalInputPump {
445 return input521 return input
446 },522 },
@@ -453,14 +529,137 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
453 if !result.transportClosed {529 if !result.transportClosed {
454 _ = connection.Close()530 _ = connection.Close()
455 }531 }
532+ // Apply metadata from every processed frame before dispatching its
533+ // navigation action. A committed identity can arrive immediately before
534+ // a home/back action and must become the route request's authority first.
535+ if result.resumeToken != 0 {
536+ resumeToken = result.resumeToken
537+ }
538+ if result.sessionName != "" {
539+ attemptRequest.SessionName = result.sessionName
540+ }
541+ if result.committedIdentity != nil {
542+ identity := *result.committedIdentity
543+ if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target {
544+ attemptRequest.PreferredTabID = ""
545+ }
546+ if remoteTarget := attemptRequest.RemoteTarget; remoteTarget != nil &&
547+ (remoteTarget.LifecycleID != identity.Target.LifecycleID || remoteTarget.SessionName != identity.Target.SessionName) {
548+ attemptRequest.RemoteTarget = nil
549+ }
550+ attemptRequest.ExactTarget = &identity.Target
551+ }
552+ if result.routePosition != nil && attemptRequest.ExactTarget != nil &&
553+ *attemptRequest.ExactTarget == result.routePosition.Target {
554+ attemptRequest.PreferredTabID = result.routePosition.ActiveTabID
555+ attemptRequest.RemoteTarget = nil
556+ }
557+ if result.err == nil && result.welcomed && routeNavigationPending {
558+ routeNavigationPending = false
559+ routeNavigationSelection = nil
560+ routeNavigationResumeFallback = false
561+ routeNavigationFallback = nil
562+ routeNavigationAction = nil
563+ }
564+ if result.routeAction != nil {
565+ if r.ledger == nil {
566+ return errors.New("vev: route ledger unavailable")
567+ }
568+ selection, valid := r.ledger.navigationSelection(*result.routeAction)
569+ if !valid {
570+ return errors.New("vev: stale route navigation action")
571+ }
572+ if selection.noOp {
573+ continue
574+ }
575+ action := *result.routeAction
576+ routeNavigationAction = &action
577+ routeNavigationFallback = &attachRoute{dialer: selection.prior.dialer, request: selection.prior.request, resumeToken: selection.prior.resumeToken}
578+ dialer = selection.selected.dialer
579+ attemptRequest = cloneAttachRequest(selection.selected.request)
580+ if selection.selected.resumeToken != 0 {
581+ attemptRequest.Intent = ports.IntentResume
582+ } else {
583+ attemptRequest.Intent = ports.IntentAttach
584+ }
585+ // Back is transient to the picker overlay. Re-derive the home-picker
586+ // capability from the selected route instead of trusting request
587+ // metadata, which may have been cleared after a daemon-side switch.
588+ attemptRequest.StartupOverlay = ports.StartupOverlayNone
589+ attemptRequest.NavigationCapabilities = 0
590+ if homeRoute != nil && selection.selected.presentation.kind == ports.RouteKindRemote {
591+ attemptRequest.NavigationCapabilities = ports.NavigationCapabilityHomePicker
592+ }
593+ resumeToken = selection.selected.resumeToken
594+ routeNavigationPending = true
595+ routeNavigationSelection = &selection
596+ routeNavigationResumeFallback = selection.selected.resumeToken != 0
597+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
598+ backoff = defaultReconnectBackoff.initial
599+ continue
600+ }
601+ if result.action != 0 {
602+ switch result.action {
603+ case ports.NavigationOpenHomePicker:
604+ // The daemon only sends this action after accepting a Hello that
605+ // advertised Home support. The durable client-side prerequisite is
606+ // the captured route itself; request metadata may have been rebased by
607+ // committed identity or cursor updates since that Hello.
608+ if homeRoute == nil {
609+ return errors.New("vev: stale home navigation action")
610+ }
611+ returnRequest := attemptRequest
612+ if result.sessionName != "" {
613+ returnRequest.SessionName = result.sessionName
614+ }
615+ returnRoute = &attachRoute{dialer: dialer, request: returnRequest, resumeToken: result.resumeToken}
616+ homeNavigationPending = true
617+ enterHomePicker()
618+ continue
619+ case ports.NavigationBack:
620+ // As with Home, the daemon accepted Back in Hello before sending
621+ // this action. The retained concrete route is the durable client
622+ // prerequisite; request metadata may have been rebased meanwhile.
623+ if returnRoute == nil {
624+ return errors.New("vev: stale return navigation action")
625+ }
626+ route := *returnRoute
627+ returnRoute = nil
628+ homeNavigationPending = false
629+ returnNavigationPending = true
630+ restoreReturnRoute(route)
631+ continue
632+ default:
633+ return errors.New("vev: unsupported navigation action")
634+ }
635+ }
456 if result.target != nil {636 if result.target != nil {
457 if r.attachHandoff == nil {637 if r.attachHandoff == nil {
458 return &AttachTargetError{Target: *result.target}638 return &AttachTargetError{Target: *result.target}
459 }639 }
640+ if result.target.RemoteTarget != nil || result.target.Endpoint != "" {
641+ if homeRoute == nil && attemptRequest.RemoteTarget == nil {
642+ routeRequest := attemptRequest
643+ if result.sessionName != "" {
644+ routeRequest.SessionName = result.sessionName
645+ }
646+ routeRequest.Intent = ports.IntentAttach
647+ routeRequest.NavigationCapabilities = 0
648+ routeRequest.StartupOverlay = ports.StartupOverlayNone
649+ homeRoute = &attachRoute{dialer: dialer, request: routeRequest}
650+ }
651+ }
460 nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target)652 nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target)
461 if handoffErr != nil {653 if handoffErr != nil {
462 return handoffErr654 return handoffErr
463 }655 }
656+ if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned) {
657+ nextRequest.NavigationCapabilities |= ports.NavigationCapabilityHomePicker
658+ }
659+ if attemptRequest.StartupOverlay == ports.StartupOverlaySessionPicker {
660+ returnRoute = nil
661+ returnResumeFallback = false
662+ }
464 nextRequest = cloneAttachRequest(nextRequest)663 nextRequest = cloneAttachRequest(nextRequest)
465 if err := validateAttachRequest(nextRequest); err != nil {664 if err := validateAttachRequest(nextRequest); err != nil {
466 return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)665 return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)
@@ -470,24 +669,65 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
470 }669 }
471 dialer = nextDialer670 dialer = nextDialer
472 attemptRequest = nextRequest671 attemptRequest = nextRequest
473- remote = attemptRequest.Remote || r.remote672+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
474- reconnect.remote = remote
475 resumeToken = 0673 resumeToken = 0
476 backoff = defaultReconnectBackoff.initial674 backoff = defaultReconnectBackoff.initial
477 continue675 continue
478 }676 }
479- if result.resumeToken != 0 {
480- resumeToken = result.resumeToken
481- }
482- if result.sessionName != "" {
483- attemptRequest.SessionName = result.sessionName
484- }
485 if result.err == nil {677 if result.err == nil {
678+ if result.welcomed && returnNavigationPending {
679+ returnNavigationPending = false
680+ returnResumeFallback = false
681+ }
486 if clearErr := reconnect.clear(); clearErr != nil {682 if clearErr := reconnect.clear(); clearErr != nil {
487 return clearErr683 return clearErr
488 }684 }
489 return nil685 return nil
490 }686 }
687+ if result.welcomed {
688+ homeNavigationPending = false
689+ }
690+ if homeNavigationPending && returnRoute != nil {
691+ route := *returnRoute
692+ returnRoute = nil
693+ homeNavigationPending = false
694+ returnNavigationPending = true
695+ restoreReturnRoute(route)
696+ continue
697+ }
698+ if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) {
699+ attemptRequest.Intent = ports.IntentAttach
700+ resumeToken = 0
701+ routeNavigationResumeFallback = false
702+ continue
703+ }
704+ if routeNavigationPending && routeNavigationFallback != nil {
705+ if routeNavigationAction != nil {
706+ action := *routeNavigationAction
707+ r.routeFailure = &ports.RouteNavigationFailure{Key: action.Key, Generation: action.Generation, Code: routeFailureCode(result.err)}
708+ routeNavigationAction = nil
709+ }
710+ route := *routeNavigationFallback
711+ routeNavigationFallback = nil
712+ routeNavigationPending = false
713+ routeNavigationSelection = nil
714+ routeNavigationResumeFallback = false
715+ returnNavigationPending = true
716+ restoreReturnRoute(route)
717+ continue
718+ }
719+ if returnResumeFallback && resumeNeedsExactAttach(result.err) {
720+ attemptRequest.Intent = ports.IntentAttach
721+ resumeToken = 0
722+ returnResumeFallback = false
723+ continue
724+ }
725+ if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
726+ returnRoute = nil
727+ returnNavigationPending = false
728+ enterHomePicker()
729+ continue
730+ }
491 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {731 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
492 if clearErr := reconnect.clear(); clearErr != nil {732 if clearErr := reconnect.clear(); clearErr != nil {
493 return errors.Join(result.err, clearErr)733 return errors.Join(result.err, clearErr)
@@ -524,6 +764,13 @@ type reconnectUI struct {
524 stage reconnectStage764 stage reconnectStage
525 }765 }
526 766
767+func syncReconnectRemote(reconnect *reconnectUI, remote bool) bool {
768+ if reconnect != nil {
769+ reconnect.remote = remote
770+ }
771+ return remote
772+}
773+
527 func (u *reconnectUI) redraw(size domain.Size) error {774 func (u *reconnectUI) redraw(size domain.Size) error {
528 if !*u.rawEntered || !u.remote {775 if !*u.rawEntered || !u.remote {
529 return nil776 return nil
@@ -630,33 +877,39 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
630 }877 }
631 878
632 type attachAttempt struct {879 type attachAttempt struct {
633- runner *Runner880+ runner *Runner
634- connection *SessionConnection881+ dialer ports.Dialer
635- remote bool882+ connection *SessionConnection
636- transport ports.Transport883+ remote bool
637- handshakeCtx context.Context884+ transport ports.Transport
638- handshakeTimedOut <-chan struct{}885+ handshakeCtx context.Context
639- finishHandshake func()886+ handshakeTimedOut <-chan struct{}
640- stopHandshakeTransport func()887+ finishHandshake func()
641- request AttachRequest888+ stopHandshakeTransport func()
642- resumeToken uint64889+ request AttachRequest
643- clientID [16]byte890+ resumeToken uint64
644- milestones *milestones891+ routeNavigationSelection *routeNavigationSelection
645- themeState *terminalThemeState892+ clientID [16]byte
646- enterRaw func() error893+ milestones *milestones
647- reconnect *reconnectUI894+ themeState *terminalThemeState
648- linkEvents <-chan ports.LinkEvent895+ enterRaw func() error
649- rememberRemoteHost func()896+ reconnect *reconnectUI
650- terminalInput func() *terminalInputPump897+ linkEvents <-chan ports.LinkEvent
898+ rememberRemoteHost func()
899+ terminalInput func() *terminalInputPump
651 }900 }
652 901
653 type attachResult struct {902 type attachResult struct {
654- resumeToken uint64903+ resumeToken uint64
655- sessionName string904+ sessionName string
656- welcomed bool905+ committedIdentity *ports.CommittedRouteIdentity
657- transportClosed bool906+ routePosition *ports.RoutePosition
658- target *ports.AttachTarget907+ welcomed bool
659- err error908+ transportClosed bool
909+ target *ports.AttachTarget
910+ action ports.NavigationAction
911+ routeAction *ports.RouteNavigationAction
912+ err error
660 }913 }
661 914
662 // AttachTargetError requests that the composition root replace the current915 // AttachTargetError requests that the composition root replace the current
@@ -696,11 +949,10 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
696 949
697 func (a *attachAttempt) run(ctx context.Context) attachResult {950 func (a *attachAttempt) run(ctx context.Context) attachResult {
698 transport := a.transport951 transport := a.transport
699- request := a.request
700 if a.connection != nil {952 if a.connection != nil {
701 transport = a.connection.Transport()953 transport = a.connection.Transport()
702- request = a.connection.AttachRequest()
703 }954 }
955+ request := a.request
704 term := a.runner.term956 term := a.runner.term
705 clk := a.runner.clock957 clk := a.runner.clock
706 intent := request.Intent958 intent := request.Intent
@@ -712,7 +964,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
712 enterRaw := a.enterRaw964 enterRaw := a.enterRaw
713 reconnect := a.reconnect965 reconnect := a.reconnect
714 linkEvents := a.linkEvents966 linkEvents := a.linkEvents
715- remote := a.remote || request.Remote967+ remote := a.remote || a.request.Remote
716 clipboard := a.runner.clipboard968 clipboard := a.runner.clipboard
717 log := a.runner.logger969 log := a.runner.logger
718 observer := a.runner.runtimeObserver970 observer := a.runner.runtimeObserver
@@ -764,21 +1016,32 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
764 colorTerm := os.Getenv("COLORTERM")1016 colorTerm := os.Getenv("COLORTERM")
765 trueColor := DetectTrueColor(termEnv, colorTerm)1017 trueColor := DetectTrueColor(termEnv, colorTerm)
766 themeState.setTrueColor(trueColor)1018 themeState.setTrueColor(trueColor)
1019+ exactTarget := request.ExactTarget
1020+ if exactTarget == nil && request.RemoteTarget != nil {
1021+ exactTarget = &ports.ExactSessionTarget{
1022+ LifecycleID: request.RemoteTarget.LifecycleID,
1023+ SessionName: request.RemoteTarget.SessionName,
1024+ }
1025+ }
767 hello := ports.Hello{1026 hello := ports.Hello{
768- Version: ports.ProtocolVersion,1027+ Version: ports.ProtocolVersion,
769- Intent: intent,1028+ Intent: intent,
770- ClientID: clientID,1029+ ClientID: clientID,
771- ResumeToken: resumeToken,1030+ ResumeToken: resumeToken,
772- Name: name,1031+ Name: name,
773- RenderMode: request.RenderMode,1032+ Size: size,
774- Size: size,1033+ TermEnv: termEnv,
775- TermEnv: termEnv,1034+ Cwd: cwd,
776- Cwd: cwd,1035+ TrueColor: trueColor,
777- TrueColor: trueColor,1036+ MaxOutputInFlight: requestedOutputWindow(transport),
778- MaxOutputInFlight: requestedOutputWindow(transport),1037+ Env: os.Environ(),
779- Env: os.Environ(),1038+ RemoteTarget: request.RemoteTarget,
780- RemoteTarget: request.RemoteTarget,1039+ ExactTarget: exactTarget,
781- EnvironmentPolicy: request.EnvironmentPolicy,1040+ PreferredTabID: request.PreferredTabID,
1041+ RemoteOrigin: request.RemoteOrigin,
1042+ EnvironmentPolicy: request.EnvironmentPolicy,
1043+ NavigationCapabilities: request.NavigationCapabilities,
1044+ StartupOverlay: request.StartupOverlay,
782 }1045 }
783 if err := sendHandshake(func() error {1046 if err := sendHandshake(func() error {
784 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})1047 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
@@ -796,8 +1059,17 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
796 }); err != nil {1059 }); err != nil {
797 return handshakeFailure("awaiting welcome", err)1060 return handshakeFailure("awaiting welcome", err)
798 }1061 }
1062+ var committedIdentity *ports.CommittedRouteIdentity
1063+ var routePosition *ports.RoutePosition
799 result := func(welcomed bool, err error) attachResult {1064 result := func(welcomed bool, err error) attachResult {
800- return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}1065+ return attachResult{
1066+ resumeToken: resumeToken,
1067+ sessionName: name,
1068+ committedIdentity: cloneCommittedIdentity(committedIdentity),
1069+ routePosition: cloneRoutePosition(routePosition),
1070+ welcomed: welcomed,
1071+ err: err,
1072+ }
801 }1073 }
802 switch reply.Type {1074 switch reply.Type {
803 case ports.MsgWelcome:1075 case ports.MsgWelcome:
@@ -805,11 +1077,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
805 if derr != nil {1077 if derr != nil {
806 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))1078 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
807 }1079 }
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.ResumeToken1080 resumeToken = welcome.ResumeToken
812 name = welcome.SessionName1081 name = welcome.SessionName
1082+ committedIdentity = cloneCommittedIdentity(welcome.CommittedIdentity)
813 ms.welcomed = true1083 ms.welcomed = true
814 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)1084 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
815 if remote {1085 if remote {
@@ -830,6 +1100,46 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
830 return handshakeFailure("validating welcome", err)1100 return handshakeFailure("validating welcome", err)
831 }1101 }
832 welcomedResult := func(err error) attachResult { return result(true, err) }1102 welcomedResult := func(err error) attachResult { return result(true, err) }
1103+ if committedIdentity != nil && a.runner.ledger != nil {
1104+ if request.ExactTarget != nil && *request.ExactTarget != committedIdentity.Target {
1105+ return welcomedResult(errRouteTargetChanged)
1106+ }
1107+ if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
1108+ return welcomedResult(errRouteTargetChanged)
1109+ }
1110+ candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1111+ var commitErr error
1112+ if a.routeNavigationSelection != nil {
1113+ selection := *a.routeNavigationSelection
1114+ commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)
1115+ } else {
1116+ _, commitErr = a.runner.ledger.commitAttach(candidate)
1117+ }
1118+ if commitErr != nil {
1119+ return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
1120+ }
1121+ payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1122+ if err != nil {
1123+ return welcomedResult(fmt.Errorf("vev: encoding route snapshot: %w", err))
1124+ }
1125+ if err := sendHandshake(func() error {
1126+ return transport.Send(ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload})
1127+ }); err != nil {
1128+ return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))
1129+ }
1130+ }
1131+ if a.runner.routeFailure != nil {
1132+ payload, err := ports.MarshalRouteNavigationFailure(*a.runner.routeFailure)
1133+ if err != nil {
1134+ return welcomedResult(fmt.Errorf("vev: encoding route navigation failure: %w", err))
1135+ }
1136+ if err := sendHandshake(func() error {
1137+ return transport.Send(ports.Frame{Type: ports.MsgRouteNavigationFailure, Payload: payload})
1138+ }); err != nil {
1139+ return welcomedResult(fmt.Errorf("vev: publishing route navigation failure: %w", err))
1140+ }
1141+ a.runner.routeFailure = nil
1142+ }
833 1143
834 // 3. Enter raw mode after Welcome; Run owns restoration.1144 // 3. Enter raw mode after Welcome; Run owns restoration.
835 if err := enterRaw(); err != nil {1145 if err := enterRaw(); err != nil {
@@ -998,6 +1308,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
998 return welcomedResult(err)1308 return welcomedResult(err)
999 }1309 }
1000 }1310 }
1311+ // Welcome and every synchronous initial publication have committed. The
1312+ // handshake deadline must not own the long-lived runtime transport: a quiet
1313+ // or unchanged screen may legitimately produce no initial Output frame.
1314+ endHandshake()
1001 senderStarted = true1315 senderStarted = true
1002 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)1316 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
1003 stdinDone := make(chan struct{})1317 stdinDone := make(chan struct{})
@@ -1041,6 +1355,37 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1041 return context.Canceled1355 return context.Canceled
1042 }1356 }
1043 }1357 }
1358+ publishRouteSnapshot := func() error {
1359+ if a.runner.ledger == nil {
1360+ return errors.New("vev: route ledger unavailable")
1361+ }
1362+ payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1363+ if err != nil {
1364+ return err
1365+ }
1366+ select {
1367+ case sendCh <- ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}:
1368+ return nil
1369+ case <-loopCtx.Done():
1370+ return context.Canceled
1371+ }
1372+ }
1373+ sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error {
1374+ payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{
1375+ Key: action.Key,
1376+ Generation: action.Generation,
1377+ Code: code,
1378+ })
1379+ if err != nil {
1380+ return err
1381+ }
1382+ select {
1383+ case sendCh <- ports.Frame{Type: ports.MsgRouteNavigationFailure, Payload: payload}:
1384+ return nil
1385+ case <-loopCtx.Done():
1386+ return context.Canceled
1387+ }
1388+ }
1044 dismissReconnect := func() error {1389 dismissReconnect := func() error {
1045 if !reconnect.showing {1390 if !reconnect.showing {
1046 return nil1391 return nil
@@ -1196,6 +1541,72 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1196 handoff := welcomedResult(nil)1541 handoff := welcomedResult(nil)
1197 handoff.target = &target1542 handoff.target = &target
1198 return handoff1543 return handoff
1544+ case ports.MsgNavigationAction:
1545+ action, derr := ports.UnmarshalNavigationAction(r.frame.Payload)
1546+ if derr != nil {
1547+ return welcomedResult(fmt.Errorf("vev: decoding navigation action: %w", derr))
1548+ }
1549+ navigation := welcomedResult(nil)
1550+ navigation.action = action
1551+ return navigation
1552+ case ports.MsgNavigateRecentRoute:
1553+ action, derr := ports.UnmarshalRouteNavigationAction(r.frame.Payload)
1554+ if derr != nil {
1555+ return welcomedResult(fmt.Errorf("vev: decoding route navigation action: %w", derr))
1556+ }
1557+ if a.runner.ledger == nil {
1558+ if ferr := sendRouteFailure(action, ports.RouteFailureUnavailable); ferr != nil {
1559+ return welcomedResult(ferr)
1560+ }
1561+ continue
1562+ }
1563+ _, valid, noOp := a.runner.ledger.navigationRecord(action)
1564+ if !valid {
1565+ if ferr := sendRouteFailure(action, ports.RouteFailureStaleSelection); ferr != nil {
1566+ return welcomedResult(ferr)
1567+ }
1568+ continue
1569+ }
1570+ if noOp {
1571+ continue
1572+ }
1573+ navigation := welcomedResult(nil)
1574+ navigation.routeAction = &action
1575+ return navigation
1576+ case ports.MsgCommittedRouteIdentity:
1577+ identity, derr := ports.UnmarshalCommittedRouteIdentity(r.frame.Payload)
1578+ if derr != nil {
1579+ return welcomedResult(fmt.Errorf("vev: decoding committed route identity: %w", derr))
1580+ }
1581+ name = identity.Target.SessionName
1582+ committedIdentity = cloneCommittedIdentity(&identity)
1583+ if a.runner.ledger == nil {
1584+ return welcomedResult(errors.New("vev: route ledger unavailable"))
1585+ }
1586+ if _, derr := a.runner.ledger.commitCommittedIdentity(identity); derr != nil {
1587+ return welcomedResult(fmt.Errorf("vev: committing daemon-local route identity: %w", derr))
1588+ }
1589+ if derr := publishRouteSnapshot(); derr != nil {
1590+ return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr))
1591+ }
1592+ case ports.MsgRoutePosition:
1593+ position, derr := ports.UnmarshalRoutePosition(r.frame.Payload)
1594+ if derr != nil {
1595+ return welcomedResult(fmt.Errorf("vev: decoding route position: %w", derr))
1596+ }
1597+ if a.runner.ledger == nil {
1598+ return welcomedResult(errors.New("vev: route ledger unavailable"))
1599+ }
1600+ if derr := a.runner.ledger.updateRoutePosition(position); derr != nil {
1601+ return welcomedResult(fmt.Errorf("vev: remembering route position: %w", derr))
1602+ }
1603+ routePosition = cloneRoutePosition(&position)
1604+ case ports.MsgRouteNavigationFailure:
1605+ failure, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload)
1606+ if derr != nil {
1607+ return welcomedResult(fmt.Errorf("vev: decoding route navigation failure: %w", derr))
1608+ }
1609+ log.Warn("route navigation rejected", "key", failure.Key, "generation", failure.Generation, "code", failure.Code)
1199 case ports.MsgDetached:1610 case ports.MsgDetached:
1200 d, derr := ports.UnmarshalDetached(r.frame.Payload)1611 d, derr := ports.UnmarshalDetached(r.frame.Payload)
1201 if derr != nil {1612 if derr != nil {
M internal/usecase/client/client_test.go +403 -217
@@ -41,43 +41,73 @@ 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.Millisecond44+type reconnectTestClock struct {
45+ mu sync.Mutex
46+ timers []*reconnectTestTimer
47+}
45 48
46-type runnerTestClock struct {49+func (c *reconnectTestClock) Now() time.Time { return time.Time{} }
47- handshake chan *runnerTestTimer50+func (c *reconnectTestClock) NewTimer(d time.Duration) ports.Timer {
48- palette chan *runnerTestTimer51+ t := &reconnectTestTimer{ch: make(chan time.Time, 1), duration: d}
49- reconnect chan *runnerTestTimer52+ c.mu.Lock()
53+ c.timers = append(c.timers, t)
54+ c.mu.Unlock()
55+ return t
50 }56 }
51 57
52-type runnerTestTimer struct {58+func (c *reconnectTestClock) fireReconnect(t *testing.T) {
59+ t.Helper()
60+ require.Eventually(t, func() bool {
61+ c.mu.Lock()
62+ defer c.mu.Unlock()
63+ for _, timer := range c.timers {
64+ timer.mu.Lock()
65+ if timer.duration != 100*time.Millisecond {
66+ timer.mu.Unlock()
67+ continue
68+ }
69+ if !timer.stopped && !timer.fired {
70+ timer.fired = true
71+ select {
72+ case timer.ch <- time.Time{}:
73+ default:
74+ timer.fired = false
75+ timer.mu.Unlock()
76+ continue
77+ }
78+ timer.mu.Unlock()
79+ return true
80+ }
81+ timer.mu.Unlock()
82+ }
83+ return false
84+ }, time.Second, time.Millisecond)
85+}
86+
87+type reconnectTestTimer struct {
88+ mu sync.Mutex
53 ch chan time.Time89 ch chan time.Time
54 duration time.Duration90 duration time.Duration
91+ stopped bool
92+ fired bool
55 }93 }
56 94
57-func newRunnerTestClock() *runnerTestClock {95+func (t *reconnectTestTimer) C() <-chan time.Time { return t.ch }
58- return &runnerTestClock{96+func (t *reconnectTestTimer) Reset(d time.Duration) bool {
59- handshake: make(chan *runnerTestTimer, 4),97+ t.mu.Lock()
60- palette: make(chan *runnerTestTimer, 8),98+ t.duration = d
61- reconnect: make(chan *runnerTestTimer, 16),99+ t.fired = false
62- }100+ t.stopped = false
101+ t.mu.Unlock()
102+ return true
63 }103 }
64-104+func (t *reconnectTestTimer) Stop() bool {
65-func (*runnerTestClock) Now() time.Time { return time.Time{} }105+ t.mu.Lock()
66-func (c *runnerTestClock) NewTimer(duration time.Duration) ports.Timer {106+ defer t.mu.Unlock()
67- timer := &runnerTestTimer{ch: make(chan time.Time, 1), duration: duration}107+ wasStopped := t.stopped
68- if duration == ports.HandshakeTimeout {108+ t.stopped = true
69- c.handshake <- timer109+ return !wasStopped
70- } else if duration == 200*time.Millisecond {
71- c.palette <- timer
72- } else {
73- c.reconnect <- timer
74- }
75- return timer
76 }110 }
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 111
82 // recvItem is one scripted Recv result.112 // recvItem is one scripted Recv result.
83 type recvItem struct {113 type recvItem struct {
@@ -384,6 +414,238 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
384 }414 }
385 }415 }
386 416
417+func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) {
418+ term := newRunTerminal()
419+ defer term.in.unblock()
420+ lifecycle := domain.SessionLifecycleID{9, 8, 7}
421+ tr := &recordingTransport{recvs: []recvItem{
422+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
423+ SessionID: "s1",
424+ SessionName: "main",
425+ CommittedIdentity: &ports.CommittedRouteIdentity{
426+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "main"},
427+ },
428+ }))},
429+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
430+ }}
431+
432+ err := runTestClient(context.Background(), attachTestDependencies(tr, term, realClock{}), client.AttachRequest{
433+ Intent: ports.IntentAttach,
434+ SessionName: "main",
435+ Origin: ports.RouteOriginLocal,
436+ OriginKey: "local",
437+ })
438+ require.NoError(t, err)
439+
440+ var snapshot ports.RecentRouteSnapshot
441+ found := false
442+ for _, frame := range tr.Sends() {
443+ if frame.Type != ports.MsgRecentRouteSnapshot {
444+ continue
445+ }
446+ var err error
447+ snapshot, err = ports.UnmarshalRecentRouteSnapshot(frame.Payload)
448+ require.NoError(t, err)
449+ found = true
450+ }
451+ require.True(t, found, "successful Welcome must publish a route snapshot")
452+ require.NotZero(t, snapshot.Generation)
453+ require.NotZero(t, snapshot.Active.Key)
454+ require.Empty(t, snapshot.Entries, "the active route is metadata-only")
455+}
456+
457+func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
458+ term := newRunTerminal()
459+ defer term.in.unblock()
460+ lifecycle := domain.SessionLifecycleID{1, 2, 3}
461+ target := &domain.RemoteSessionTarget{
462+ Endpoint: "remote.example", DisplayOrigin: "remote.example", LifecycleID: lifecycle,
463+ SessionName: "work", LiveTabID: "tab-1",
464+ }
465+ request := client.AttachRequest{
466+ Intent: ports.IntentAttach, SessionName: "work", Remote: true,
467+ RemoteTarget: target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
468+ NavigationCapabilities: ports.NavigationCapabilityHomePicker,
469+ StartupOverlay: ports.StartupOverlayNone,
470+ }
471+ tr := &recordingTransport{recvs: []recvItem{
472+ {f: welcomeFrame(0)},
473+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
474+ }}
475+
476+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, term, realClock{}), request))
477+ hello := helloFromSend(t, tr)
478+ require.Equal(t, request.Intent, hello.Intent)
479+ require.Equal(t, request.SessionName, hello.Name)
480+ require.Equal(t, target, hello.RemoteTarget)
481+ require.Equal(t, request.EnvironmentPolicy, hello.EnvironmentPolicy)
482+ require.Equal(t, request.NavigationCapabilities, hello.NavigationCapabilities)
483+ require.Equal(t, request.StartupOverlay, hello.StartupOverlay)
484+}
485+
486+func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) {
487+ term := newRunTerminal()
488+ defer term.in.unblock()
489+ localLifecycle := domain.SessionLifecycleID{1}
490+ remoteLifecycle := domain.SessionLifecycleID{2}
491+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
492+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
493+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
494+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
495+ }))
496+ }
497+ remoteTarget := domain.RemoteSessionTarget{
498+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
499+ SessionName: "work", LiveTabID: "tab-1",
500+ }
501+ local1 := &recordingTransport{recvs: []recvItem{
502+ {f: welcome("local", localLifecycle)},
503+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
504+ Endpoint: "remote", Session: "work", Intent: ports.IntentAttach,
505+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
506+ }))},
507+ }}
508+ remote := &recordingTransport{recvs: []recvItem{
509+ {f: welcome("work", remoteLifecycle)},
510+ {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
511+ }}
512+ local2 := &recordingTransport{recvs: []recvItem{
513+ {f: welcome("local", localLifecycle)},
514+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
515+ }}
516+ localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2}}
517+ remoteDialer := &sequenceDialer{trs: []ports.Transport{remote}}
518+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
519+ deps.AttachHandoff = func(ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
520+ target := ports.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "work"}
521+ return remoteDialer, client.AttachRequest{
522+ Intent: ports.IntentAttach, SessionName: "work", Remote: true,
523+ Origin: ports.RouteOriginRemote, OriginKey: "remote", ExactTarget: &target,
524+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
525+ // Deliberately omit the copied Home capability. The accepted server
526+ // action remains valid because Runner retained the concrete home route.
527+ }, nil
528+ }
529+
530+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
531+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
532+ }))
533+ require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay)
534+}
535+
536+func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
537+ term := newRunTerminal()
538+ defer term.in.unblock()
539+
540+ localLifecycle := domain.SessionLifecycleID{1, 1, 1}
541+ remoteLifecycle := domain.SessionLifecycleID{2, 2, 2}
542+ newLifecycle := domain.SessionLifecycleID{3, 3, 3}
543+ remoteNewLifecycle := domain.SessionLifecycleID{4, 4, 4}
544+ welcome := func(name string, lifecycle domain.SessionLifecycleID, token uint64) ports.Frame {
545+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
546+ SessionID: name + "-id",
547+ SessionName: name,
548+ ResumeToken: token,
549+ Capabilities: ports.CapabilityResume,
550+ CommittedIdentity: &ports.CommittedRouteIdentity{
551+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
552+ },
553+ }))
554+ }
555+ remoteTarget := domain.RemoteSessionTarget{
556+ Endpoint: "remote",
557+ DisplayOrigin: "remote",
558+ LifecycleID: remoteLifecycle,
559+ SessionName: "remote-manual",
560+ LiveTabID: "tab-1",
561+ }
562+ remoteHandoff := ports.AttachTarget{
563+ Endpoint: "remote", Session: "remote-manual", Intent: ports.IntentAttach,
564+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
565+ }
566+
567+ local1 := &recordingTransport{recvs: []recvItem{
568+ {f: welcome("test", localLifecycle, 11)},
569+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(remoteHandoff))},
570+ }}
571+ local2 := &recordingTransport{recvs: []recvItem{
572+ {f: welcome("test", localLifecycle, 11)},
573+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{
574+ Target: ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"},
575+ }))},
576+ {f: frameOf(ports.MsgNavigateRecentRoute, mustMarshalRouteAction(ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 2, Generation: 2}))},
577+ }}
578+ local3 := &recordingTransport{recvs: []recvItem{
579+ {f: welcome("test", localLifecycle, 11)},
580+ {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationBack))},
581+ }}
582+ remote1 := &recordingTransport{recvs: []recvItem{
583+ {f: welcome("remote-manual", remoteLifecycle, 22)},
584+ {f: frameOf(ports.MsgNavigateRecentRoute, mustMarshalRouteAction(ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}))},
585+ }}
586+ remote2 := &recordingTransport{recvs: []recvItem{
587+ {f: welcome("remote-manual", remoteLifecycle, 22)},
588+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{
589+ Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"},
590+ }))},
591+ {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{
592+ Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2",
593+ }))},
594+ {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
595+ }}
596+ remote3 := &recordingTransport{recvs: []recvItem{
597+ {f: welcome("remote-new", remoteNewLifecycle, 22)},
598+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
599+ }}
600+ localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2, local3}}
601+ remoteDialer := &sequenceDialer{trs: []ports.Transport{remote1, remote2, remote3}}
602+
603+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
604+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
605+ require.Equal(t, remoteHandoff, target)
606+ return remoteDialer, client.AttachRequest{
607+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
608+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
609+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
610+ }, nil
611+ }
612+
613+ err := runTestClient(context.Background(), deps, client.AttachRequest{
614+ Intent: ports.IntentAttach, SessionName: "test", Origin: ports.RouteOriginLocal, OriginKey: "local",
615+ })
616+ remoteHello := helloFromSend(t, remote2)
617+ require.NoError(t, err)
618+
619+ require.Equal(t, ports.NavigationCapabilityHomePicker, remoteHello.NavigationCapabilities)
620+ remoteNewHello := helloFromSend(t, remote3)
621+ require.Equal(t, "remote-new", remoteNewHello.Name)
622+ require.Equal(t, &ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, remoteNewHello.ExactTarget)
623+}
624+
625+func mustMarshalCommittedIdentity(identity ports.CommittedRouteIdentity) []byte {
626+ payload, err := ports.MarshalCommittedRouteIdentity(identity)
627+ if err != nil {
628+ panic(err)
629+ }
630+ return payload
631+}
632+
633+func mustMarshalRouteAction(action ports.RouteNavigationAction) []byte {
634+ payload, err := ports.MarshalRouteNavigationAction(action)
635+ if err != nil {
636+ panic(err)
637+ }
638+ return payload
639+}
640+
641+func mustMarshalRoutePosition(position ports.RoutePosition) []byte {
642+ payload, err := ports.MarshalRoutePosition(position)
643+ if err != nil {
644+ panic(err)
645+ }
646+ return payload
647+}
648+
387 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {649 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
388 var out bytes.Buffer650 var out bytes.Buffer
389 var restoreCount atomic.Int32651 var restoreCount atomic.Int32
@@ -825,20 +1087,25 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
825 }1087 }
826 1088
827 type recordingTransport struct {1089 type recordingTransport struct {
1090+ mu sync.Mutex
828 recvs []recvItem1091 recvs []recvItem
829 sends []ports.Frame1092 sends []ports.Frame
830- onSend func(ports.Frame)
831 closed atomic.Int321093 closed atomic.Int32
832 }1094 }
833 1095
834 func (t *recordingTransport) Send(f ports.Frame) error {1096 func (t *recordingTransport) Send(f ports.Frame) error {
1097+ t.mu.Lock()
835 t.sends = append(t.sends, f)1098 t.sends = append(t.sends, f)
836- if t.onSend != nil {1099+ t.mu.Unlock()
837- t.onSend(f)
838- }
839 return nil1100 return nil
840 }1101 }
841 1102
1103+func (t *recordingTransport) Sends() []ports.Frame {
1104+ t.mu.Lock()
1105+ defer t.mu.Unlock()
1106+ return append([]ports.Frame(nil), t.sends...)
1107+}
1108+
842 func (t *recordingTransport) Recv() (ports.Frame, error) {1109 func (t *recordingTransport) Recv() (ports.Frame, error) {
843 if len(t.recvs) == 0 {1110 if len(t.recvs) == 0 {
844 return ports.Frame{}, io.EOF1111 return ports.Frame{}, io.EOF
@@ -877,9 +1144,10 @@ func (t *runTerminal) Flush() error { return nil }
877 1144
878 func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {1145 func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {
879 t.Helper()1146 t.Helper()
880- require.NotEmpty(t, tr.sends)1147+ sends := tr.Sends()
881- require.Equal(t, ports.MsgHello, tr.sends[0].Type)1148+ require.NotEmpty(t, sends)
882- h, err := ports.UnmarshalHello(tr.sends[0].Payload)1149+ require.Equal(t, ports.MsgHello, sends[0].Type)
1150+ h, err := ports.UnmarshalHello(sends[0].Payload)
883 require.NoError(t, err)1151 require.NoError(t, err)
884 return h1152 return h
885 }1153 }
@@ -888,203 +1156,121 @@ func welcomeFrame(token uint64) ports.Frame {
888 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))1156 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
889 }1157 }
890 1158
891-func TestRunReconnectsPreservesRichRemoteTarget(t *testing.T) {1159+func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
892- lifecycle := domain.SessionLifecycleID{}1160+ term := newRunTerminal()
893- lifecycle[0] = 11161+ defer term.in.unblock()
894- otherLifecycle := domain.SessionLifecycleID{}1162+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
895- otherLifecycle[0] = 21163+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
896- tests := []struct {1164+ tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
897- name string1165+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
898- target domain.RemoteSessionTarget1166+ clock := &reconnectTestClock{}
899- }{1167+ result := make(chan error, 1)
900- {1168+ go func() {
901- name: "live tab",1169+ result <- runTestClient(context.Background(), testDependencies(d, term, clock, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
902- target: domain.RemoteSessionTarget{1170+ }()
903- Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,1171+ clock.fireReconnect(t)
904- SessionName: "main", LiveTabID: "tab-live",1172+ clock.fireReconnect(t)
905- },1173+ require.NoError(t, <-result)
906- },1174+ require.Equal(t, int32(3), d.calls.Load())
907- {1175+ require.Equal(t, int32(1), term.rawCount.Load())
908- name: "stopped tab",1176+ require.Equal(t, int32(1), term.restoreCount.Load())
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 1177
942- clock := newRunnerTestClock()1178+ h1 := helloFromSend(t, tr1)
943- done := make(chan error, 1)1179+ h2 := helloFromSend(t, tr2)
944- go func() {1180+ h3 := helloFromSend(t, tr3)
945- done <- runTestClient(context.Background(), testDependencies(d, term, clock, nil, nil), request)1181+ require.Equal(t, ports.IntentAttach, h1.Intent)
946- }()1182+ require.Zero(t, h1.ResumeToken)
947- handshakeTimer := <-clock.handshake1183+ require.Equal(t, ports.IntentResume, h2.Intent)
948- require.Equal(t, testPreWelcomeTimeout, handshakeTimer.duration)1184+ require.Equal(t, uint64(11), h2.ResumeToken)
949- firstReconnect := <-clock.reconnect1185+ require.Equal(t, ports.IntentResume, h3.Intent)
950- require.Equal(t, runnerInitialBackoff, firstReconnect.duration)1186+ require.Equal(t, uint64(22), h3.ResumeToken)
951- firstReconnect.fire()1187+ require.Equal(t, h1.ClientID, h2.ClientID)
952- handshakeTimer = <-clock.handshake1188+ require.Equal(t, h1.ClientID, h3.ClientID)
953- require.Equal(t, testPreWelcomeTimeout, handshakeTimer.duration)1189+ require.Contains(t, term.out.String(), "reconnecting…")
954- secondReconnect := <-clock.reconnect1190+ require.Contains(t, term.out.String(), "\x1b[2K")
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 }1191 }
983 1192
984-func TestRunSendsRequestedProxiedRenderMode(t *testing.T) {1193+func TestReconnectRestoresLastPublishedRouteTab(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()1194 term := newRunTerminal()
991 defer term.in.unblock()1195 defer term.in.unblock()
992- tr := &recordingTransport{recvs: []recvItem{1196+ lifecycle := domain.SessionLifecycleID{7, 8, 9}
993- {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{1197+ target := ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}
994- SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeProxiedContent,1198+ welcome := func(token uint64) ports.Frame {
995- }))},1199+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
1200+ SessionID: "work", SessionName: "work", ResumeToken: token, Capabilities: ports.CapabilityResume,
1201+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: target},
1202+ }))
1203+ }
1204+ tr1 := &recordingTransport{recvs: []recvItem{
1205+ {f: welcome(11)},
1206+ {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{Target: target, ActiveTabID: "tab-2"}))},
1207+ {err: io.EOF},
1208+ }}
1209+ tr2 := &recordingTransport{recvs: []recvItem{
1210+ {f: welcome(22)},
996 {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},1211 {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
997 }}1212 }}
998- deps := testDependencies(&sequenceDialer{trs: []ports.Transport{tr}}, term, realClock{}, nil, nil)1213+ dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
999- err := runTestClient(context.Background(), deps, client.AttachRequest{1214+ clock := &reconnectTestClock{}
1000- Intent: ports.IntentAttach, SessionName: "main", RenderMode: ports.RenderModeProxiedContent,1215+ result := make(chan error, 1)
1001- RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,1216+ go func() {
1002- })1217+ result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{
1003- require.NoError(t, err)1218+ Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal, OriginKey: "local",
1004- require.Equal(t, ports.RenderModeProxiedContent, helloFromSend(t, tr).RenderMode)1219+ })
1220+ }()
1221+ clock.fireReconnect(t)
1222+ require.NoError(t, <-result)
1223+
1224+ reconnectHello := helloFromSend(t, tr2)
1225+ require.Equal(t, domain.TabStableID("tab-2"), reconnectHello.PreferredTabID)
1005 }1226 }
1006 1227
1007-func TestRunRejectsMismatchedProxiedWelcomeBeforeEnteringRawMode(t *testing.T) {1228+func TestRemoteReconnectDropsStalePickerTargetAfterDaemonSessionSwitch(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()1229 term := newRunTerminal()
1014 defer term.in.unblock()1230 defer term.in.unblock()
1015- tr := &recordingTransport{recvs: []recvItem{{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{1231+ oldLifecycle := domain.SessionLifecycleID{1, 2, 3}
1016- SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeFullTerminal,1232+ newLifecycle := domain.SessionLifecycleID{4, 5, 6}
1017- }))}}}1233+ remoteTarget := &domain.RemoteSessionTarget{
1018-1234+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: oldLifecycle,
1019- err := runTestClient(context.Background(), testDependencies(&sequenceDialer{trs: []ports.Transport{tr}}, term, realClock{}, nil, nil), client.AttachRequest{1235+ SessionName: "old", LiveTabID: "tab-1",
1020- Intent: ports.IntentAttach, SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
1021- RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1022- })
1023- require.ErrorContains(t, err, "daemon accepted render mode")
1024- require.Zero(t, term.rawCount.Load())
1025-}
1026-
1027-func TestRunnerRejectsInvalidAttachRequestBeforeHello(t *testing.T) {
1028- lifecycle := domain.SessionLifecycleID{}
1029- lifecycle[0] = 1
1030- validTarget := domain.RemoteSessionTarget{
1031- Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
1032- SessionName: "main", LiveTabID: "tab-live",
1033 }1236 }
1034- tests := []struct {1237+ welcome := func(name string, lifecycle domain.SessionLifecycleID, token uint64) ports.Frame {
1035- name string1238+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
1036- request client.AttachRequest1239+ SessionID: name, SessionName: name, ResumeToken: token, Capabilities: ports.CapabilityResume,
1037- wantErr string1240+ CommittedIdentity: &ports.CommittedRouteIdentity{
1038- }{1241+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
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 },1242 },
1066- wantErr: "remote attach target session mismatch",1243+ }))
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 }1244 }
1079-1245+ tr1 := &recordingTransport{recvs: []recvItem{
1080- for _, tt := range tests {1246+ {f: welcome("old", oldLifecycle, 11)},
1081- t.Run(tt.name, func(t *testing.T) {1247+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{
1082- dialer := &sequenceDialer{}1248+ Target: ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"},
1083- err := client.NewRunner(client.Dependencies{Dialer: dialer}).Run(context.Background(), tt.request)1249+ }))},
1084- require.ErrorContains(t, err, tt.wantErr)1250+ {err: io.EOF},
1085- require.Zero(t, dialer.calls.Load(), "invalid request must be rejected before dialing and Hello")1251+ }}
1252+ tr2 := &recordingTransport{recvs: []recvItem{
1253+ {f: welcome("new", newLifecycle, 22)},
1254+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1255+ }}
1256+ dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
1257+ clock := &reconnectTestClock{}
1258+ result := make(chan error, 1)
1259+ go func() {
1260+ result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{
1261+ Intent: ports.IntentAttach, SessionName: "old", Remote: true,
1262+ Origin: ports.RouteOriginDiscovery, OriginKey: "remote",
1263+ RemoteTarget: remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1086 })1264 })
1087- }1265+ }()
1266+ clock.fireReconnect(t)
1267+ require.NoError(t, <-result)
1268+
1269+ reconnectHello := helloFromSend(t, tr2)
1270+ require.Equal(t, ports.IntentResume, reconnectHello.Intent)
1271+ require.Equal(t, "new", reconnectHello.Name)
1272+ require.Equal(t, &ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"}, reconnectHello.ExactTarget)
1273+ require.Nil(t, reconnectHello.RemoteTarget)
1088 }1274 }
1089 1275
1090 func TestAttachRememberRemoteHost(t *testing.T) {1276 func TestAttachRememberRemoteHost(t *testing.T) {
A internal/usecase/client/navigation_test.go +60 -0
@@ -0,0 +1,60 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestSyncReconnectRemoteKeepsRequestAndToastClassificationTogether(t *testing.T) {
12+ reconnect := &reconnectUI{}
13+ for _, tt := range []struct {
14+ name string
15+ remote bool
16+ }{
17+ {name: "local picker", remote: false},
18+ {name: "remote handoff", remote: true},
19+ } {
20+ t.Run(tt.name, func(t *testing.T) {
21+ require.Equal(t, tt.remote, syncReconnectRemote(reconnect, tt.remote))
22+ require.Equal(t, tt.remote, reconnect.remote)
23+ })
24+ }
25+}
26+
27+func TestValidateAttachRequestNavigationTable(t *testing.T) {
28+ var lifecycle domain.SessionLifecycleID
29+ lifecycle[0] = 1
30+ remoteRoute := &domain.RemoteSessionTarget{
31+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
32+ SessionName: "work", LiveTabID: "tab-1",
33+ }
34+ tests := []struct {
35+ name string
36+ request AttachRequest
37+ valid bool
38+ }{
39+ {name: "ordinary route", request: AttachRequest{}, valid: true},
40+ {name: "home picker without remote route", request: AttachRequest{Intent: ports.IntentAttach, SessionName: "work", NavigationCapabilities: ports.NavigationCapabilityHomePicker}, valid: false},
41+ {name: "home picker on daemon-owned remote route", request: AttachRequest{Intent: ports.IntentAttach, SessionName: "work", Remote: true, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, NavigationCapabilities: ports.NavigationCapabilityHomePicker}, valid: true},
42+ {name: "back on client-owned route", request: AttachRequest{StartupOverlay: ports.StartupOverlaySessionPicker, NavigationCapabilities: ports.NavigationCapabilityBack}, valid: true},
43+ {name: "back on remote-target route", request: AttachRequest{Intent: ports.IntentAttach, SessionName: "work", RemoteTarget: remoteRoute, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, StartupOverlay: ports.StartupOverlaySessionPicker, NavigationCapabilities: ports.NavigationCapabilityBack}, valid: false},
44+ {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 4}, valid: false},
45+ {name: "back without startup picker", request: AttachRequest{NavigationCapabilities: ports.NavigationCapabilityBack}, valid: false},
46+ {name: "startup picker without back", request: AttachRequest{StartupOverlay: ports.StartupOverlaySessionPicker}, valid: false},
47+ }
48+
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ err := validateAttachRequest(tt.request)
52+ if tt.valid {
53+ require.NoError(t, err)
54+ return
55+ }
56+ require.Error(t, err)
57+ require.ErrorContains(t, err, "navigation")
58+ })
59+ }
60+}
M internal/usecase/client/palette_attach_generation_test.go +18 -2
@@ -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
@@ -43,7 +43,11 @@ func (*attachPaletteClock) Now() time.Time { return time.Time{} }
43 func (c *attachPaletteClock) NewTimer(delay time.Duration) ports.Timer {43 func (c *attachPaletteClock) NewTimer(delay time.Duration) ports.Timer {
44 timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{}), duration: delay}44 timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{}), duration: delay}
45 if delay == ports.HandshakeTimeout {45 if delay == ports.HandshakeTimeout {
46- c.handshakeTimers <- timer46+ select {
47+ case c.handshakeTimers <- timer:
48+ default:
49+ panic("unexpected extra handshake timer")
50+ }
47 } else {51 } else {
48 c.timers <- timer52 c.timers <- timer
49 }53 }
@@ -248,6 +252,18 @@ func paletteReply(foreground, background string, slot uint8, color string) strin
248 return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"252 return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
249 }253 }
250 254
255+func TestAttachHandshakeDeadlineStopsBeforeRuntimeWithoutInitialOutput(t *testing.T) {
256+ h := startAttachPaletteHarness(&terminalThemeState{})
257+ select {
258+ case <-h.handshakeTimer.stopped:
259+ // Welcome and synchronous client publications commit the handshake. A
260+ // quiet runtime is allowed to produce no Output frame.
261+ case <-time.After(2 * time.Second):
262+ t.Fatal("handshake deadline still owned the long-lived runtime transport")
263+ }
264+ h.detach(t)
265+}
266+
251 func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {267 func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {
252 t.Setenv("COLORTERM", "truecolor")268 t.Setenv("COLORTERM", "truecolor")
253 state := &terminalThemeState{}269 state := &terminalThemeState{}
M internal/usecase/client/reconnect.go +8 -3
@@ -49,6 +49,14 @@ func nextReconnectBackoff(cur, limit time.Duration) time.Duration {
49 return cur49 return cur
50 }50 }
51 51
52+func resumeNeedsExactAttach(err error) bool {
53+ if errors.Is(err, errRouteTargetChanged) {
54+ return true
55+ }
56+ protocolErr, ok := errors.AsType[*ProtocolError](err)
57+ return ok && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget)
58+}
59+
52 func shouldReconnect(err error) bool {60 func shouldReconnect(err error) bool {
53 if err == nil {61 if err == nil {
54 return false62 return false
@@ -56,9 +64,6 @@ func shouldReconnect(err error) bool {
56 if _, ok := errors.AsType[*ProtocolError](err); ok {64 if _, ok := errors.AsType[*ProtocolError](err); ok {
57 return false65 return false
58 }66 }
59- if errors.Is(err, errRenderModeMismatch) {
60- return false
61- }
62 _, ok := errors.AsType[*DetachedError](err)67 _, ok := errors.AsType[*DetachedError](err)
63 return !ok68 return !ok
64 }69 }
M internal/usecase/client/reconnect_toast_test.go +18 -0
@@ -354,6 +354,24 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c
354 }354 }
355 }355 }
356 356
357+func TestResumeNeedsExactAttach(t *testing.T) {
358+ for _, test := range []struct {
359+ name string
360+ err error
361+ want bool
362+ }{
363+ {name: "moved resume token", err: errRouteTargetChanged, want: true},
364+ {name: "missing target", err: &ProtocolError{Code: ports.ErrNoSuchTarget}, want: true},
365+ {name: "missing session", err: &ProtocolError{Code: ports.ErrNoSuchSession}, want: true},
366+ {name: "internal", err: &ProtocolError{Code: ports.ErrInternal}},
367+ {name: "transport", err: io.EOF},
368+ } {
369+ t.Run(test.name, func(t *testing.T) {
370+ require.Equal(t, test.want, resumeNeedsExactAttach(test.err))
371+ })
372+ }
373+}
374+
357 func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {375 func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
358 out := newReconnectToastOutputRecorder()376 out := newReconnectToastOutputRecorder()
359 var flushes atomic.Int32377 var flushes atomic.Int32
M internal/usecase/client/remote_attach_validation_test.go +22 -0
@@ -1,14 +1,36 @@
1 package client1 package client
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10 )12 )
11 13
14+func TestMalformedAttachRequestsNeverDial(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ request AttachRequest
18+ }{
19+ {name: "missing session name", request: AttachRequest{Intent: ports.IntentAttach}},
20+ {name: "unsafe session name", request: AttachRequest{Intent: ports.IntentAttach, SessionName: "bad name"}},
21+ {name: "daemon-owned without remote", request: AttachRequest{Intent: ports.IntentAttach, SessionName: "main", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}},
22+ {name: "ephemeral with navigation capability", request: AttachRequest{Intent: ports.IntentEphemeral, NavigationCapabilities: ports.NavigationCapabilityHomePicker}},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ // No EXPECT: any Dial call fails the test.
27+ dialer := portsmocks.NewMockDialer(t)
28+ err := NewRunner(Dependencies{Dialer: dialer}).Run(context.Background(), tt.request)
29+ require.Error(t, err)
30+ })
31+ }
32+}
33+
12 func TestValidateAttachRequestRequiresDaemonOwnedEnvironmentForRemoteTarget(t *testing.T) {34 func TestValidateAttachRequestRequiresDaemonOwnedEnvironmentForRemoteTarget(t *testing.T) {
13 var lifecycle domain.SessionLifecycleID35 var lifecycle domain.SessionLifecycleID
14 lifecycle[0] = 136 lifecycle[0] = 1
A internal/usecase/client/routes.go +697 -0
@@ -0,0 +1,697 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// maxRouteLedgerEntries is the product history cap. It is deliberately
13+// smaller than the wire parser bound so an active metadata entry can be
14+// excluded without allowing an oversized client history.
15+const maxRouteLedgerEntries = 20
16+
17+var (
18+ errRouteResumeUnavailable = errors.New("route resume unavailable")
19+ errRouteStaleSelection = errors.New("route selection is stale")
20+ errRouteTargetChanged = errors.New("route target changed")
21+)
22+
23+// routeKey is process-local identity. It is never derived from a daemon
24+// lifecycle ID and is not exposed outside the client ledger. originKey keeps
25+// distinct daemon endpoints separate even when their lifecycle/name pairs
26+// happen to match; neither is serialized into the route snapshot.
27+type routeKey uint64
28+
29+type routeGeneration uint64
30+
31+type routeIdentity struct {
32+ key routeKey
33+ generation routeGeneration
34+}
35+
36+func (i routeIdentity) empty() bool { return i.key == 0 && i.generation == 0 }
37+
38+func (i routeIdentity) wire() ports.RouteRef {
39+ return ports.RouteRef{Key: uint64(i.key), Generation: uint64(i.generation)}
40+}
41+
42+func identityFromWire(ref ports.RouteRef) routeIdentity {
43+ return routeIdentity{key: routeKey(ref.Key), generation: routeGeneration(ref.Generation)}
44+}
45+
46+// routePresentation contains only immutable display data. Connection and
47+// attach capabilities live in routeRecord beside it and are never copied into
48+// a RecentRouteSnapshot.
49+type routePresentation struct {
50+ name string
51+ hostLabel string
52+ kind ports.RouteKind
53+ ephemeral bool
54+ attention bool
55+ reachability ports.RouteReachability
56+}
57+
58+type routeCandidate struct {
59+ origin ports.RouteOrigin
60+ originKey string
61+ target ports.ExactSessionTarget
62+ presentation routePresentation
63+ dialer ports.Dialer
64+ request AttachRequest
65+ resumeToken uint64
66+ home bool
67+}
68+
69+type routeRecord struct {
70+ identity routeIdentity
71+ origin ports.RouteOrigin
72+ originKey string
73+ target ports.ExactSessionTarget
74+ presentation routePresentation
75+ dialer ports.Dialer
76+ request AttachRequest
77+ resumeToken uint64
78+ home bool
79+}
80+
81+// routeLedger owns client-global route identity/history for one client
82+// process. Its lock protects both the bounded record set and active/previous
83+// references; snapshots are copied while holding the lock and are immutable
84+// after publication.
85+type routeLedger struct {
86+ mu sync.RWMutex
87+ nextKey routeKey
88+ generation routeGeneration
89+ entries []routeRecord
90+ active routeIdentity
91+ previous routeIdentity
92+ home routeIdentity
93+
94+ discoveryMu sync.Mutex
95+ discoveryCycle uint64
96+}
97+
98+func newRouteLedger() *routeLedger { return &routeLedger{} }
99+
100+func cloneCommittedIdentity(identity *ports.CommittedRouteIdentity) *ports.CommittedRouteIdentity {
101+ if identity == nil {
102+ return nil
103+ }
104+ clone := *identity
105+ return &clone
106+}
107+
108+func cloneRoutePosition(position *ports.RoutePosition) *ports.RoutePosition {
109+ if position == nil {
110+ return nil
111+ }
112+ clone := *position
113+ return &clone
114+}
115+
116+func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64) routeCandidate {
117+ origin := normalizeRouteOrigin(request.Origin, request.Remote)
118+ originKey := normalizeRouteOriginKey(request.OriginKey, origin)
119+ kind := ports.RouteKindLocal
120+ hostLabel := ""
121+ if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal {
122+ kind = ports.RouteKindRemote
123+ if request.RemoteTarget != nil {
124+ hostLabel = request.RemoteTarget.DisplayOrigin
125+ }
126+ if hostLabel == "" {
127+ hostLabel = originKey
128+ }
129+ }
130+ request = cloneAttachRequest(request)
131+ request.Origin = origin
132+ request.OriginKey = originKey
133+ request.SessionName = identity.Target.SessionName
134+ request.ExactTarget = &identity.Target
135+ request.RemoteOrigin = hostLabel
136+ // Discovery targets are point-in-time attach authority, including a live
137+ // tab selector. Once Welcome commits an exact route, mutable tab memory is
138+ // carried independently by PreferredTabID.
139+ request.RemoteTarget = nil
140+ return routeCandidate{
141+ origin: origin,
142+ originKey: originKey,
143+ target: identity.Target,
144+ presentation: routePresentation{
145+ name: identity.Target.SessionName,
146+ hostLabel: hostLabel,
147+ kind: kind,
148+ ephemeral: identity.Ephemeral,
149+ reachability: ports.RouteReachabilityReachable,
150+ },
151+ dialer: dialer,
152+ request: request,
153+ resumeToken: resumeToken,
154+ }
155+}
156+
157+func routeCandidateForCommittedIdentity(active routeRecord, identity ports.CommittedRouteIdentity) routeCandidate {
158+ request := cloneAttachRequest(active.request)
159+ request.SessionName = identity.Target.SessionName
160+ request.ExactTarget = &identity.Target
161+ request.RemoteTarget = nil
162+ if active.target != identity.Target {
163+ request.PreferredTabID = ""
164+ }
165+ return routeCandidate{
166+ origin: active.origin,
167+ originKey: active.originKey,
168+ target: identity.Target,
169+ presentation: routePresentation{
170+ name: identity.Target.SessionName,
171+ hostLabel: active.presentation.hostLabel,
172+ kind: active.presentation.kind,
173+ ephemeral: identity.Ephemeral,
174+ reachability: ports.RouteReachabilityReachable,
175+ },
176+ dialer: active.dialer,
177+ request: request,
178+ resumeToken: active.resumeToken,
179+ home: false,
180+ }
181+}
182+
183+func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin {
184+ if origin != 0 {
185+ return origin
186+ }
187+ if remote {
188+ return ports.RouteOriginRemote
189+ }
190+ return ports.RouteOriginLocal
191+}
192+
193+func routeFailureCode(err error) ports.RouteFailureCode {
194+ var protocolErr *ProtocolError
195+ if errors.As(err, &protocolErr) {
196+ switch protocolErr.Code {
197+ case ports.ErrNoSuchSession, ports.ErrNoSuchTarget:
198+ return ports.RouteFailureNoSuchRoute
199+ }
200+ }
201+ if errors.Is(err, errRouteTargetChanged) {
202+ return ports.RouteFailureTargetChanged
203+ }
204+ return ports.RouteFailureUnavailable
205+}
206+
207+func normalizeRouteOriginKey(key string, origin ports.RouteOrigin) string {
208+ if key != "" {
209+ return key
210+ }
211+ switch origin {
212+ case ports.RouteOriginLocal:
213+ return "local"
214+ case ports.RouteOriginDiscovery:
215+ return "discovery"
216+ default:
217+ return "remote"
218+ }
219+}
220+
221+func validateRoutePresentation(p routePresentation) error {
222+ if err := ports.ValidateRouteLabel(p.name, false); err != nil {
223+ return err
224+ }
225+ if err := ports.ValidateRouteLabel(p.hostLabel, true); err != nil {
226+ return err
227+ }
228+ if err := p.kind.Validate(); err != nil {
229+ return err
230+ }
231+ if err := p.reachability.Validate(); err != nil {
232+ return err
233+ }
234+ return nil
235+}
236+
237+func (l *routeLedger) nextIdentityLocked() (routeIdentity, error) {
238+ if l.nextKey == ^routeKey(0) || l.generation == ^routeGeneration(0) {
239+ return routeIdentity{}, errors.New("route ledger identity space exhausted")
240+ }
241+ l.nextKey++
242+ l.generation++
243+ return routeIdentity{key: l.nextKey, generation: l.generation}, nil
244+}
245+
246+func (l *routeLedger) findByTargetLocked(origin ports.RouteOrigin, originKey string, target ports.ExactSessionTarget) int {
247+ for i := range l.entries {
248+ entry := l.entries[i]
249+ if entry.origin == origin && entry.originKey == originKey && entry.target == target {
250+ return i
251+ }
252+ }
253+ return -1
254+}
255+
256+func (l *routeLedger) moveToFrontLocked(index int, entry routeRecord) {
257+ if index < 0 {
258+ l.entries = append([]routeRecord{entry}, l.entries...)
259+ return
260+ }
261+ l.entries = append(l.entries[:index], l.entries[index+1:]...)
262+ l.entries = append([]routeRecord{entry}, l.entries...)
263+}
264+
265+func (l *routeLedger) removeAtLocked(index int) {
266+ if index < 0 || index >= len(l.entries) {
267+ return
268+ }
269+ removed := l.entries[index].identity
270+ l.entries = append(l.entries[:index], l.entries[index+1:]...)
271+ for _, ref := range []*routeIdentity{&l.active, &l.previous, &l.home} {
272+ if *ref == removed {
273+ *ref = routeIdentity{}
274+ }
275+ }
276+}
277+
278+func (l *routeLedger) evictLocked() {
279+ for len(l.entries) > maxRouteLedgerEntries {
280+ index := len(l.entries) - 1
281+ for i := len(l.entries) - 1; i >= 0; i-- {
282+ identity := l.entries[i].identity
283+ if identity != l.active && identity != l.previous && identity != l.home {
284+ index = i
285+ break
286+ }
287+ }
288+ l.removeAtLocked(index)
289+ }
290+}
291+
292+func prepareRouteCandidate(candidate routeCandidate) (routeCandidate, error) {
293+ if err := candidate.origin.Validate(); err != nil {
294+ return routeCandidate{}, err
295+ }
296+ if err := candidate.target.Validate(); err != nil {
297+ return routeCandidate{}, err
298+ }
299+ if err := validateRoutePresentation(candidate.presentation); err != nil {
300+ return routeCandidate{}, err
301+ }
302+ candidate.originKey = normalizeRouteOriginKey(candidate.originKey, candidate.origin)
303+ candidate.request = cloneAttachRequest(candidate.request)
304+ candidate.request.Origin = candidate.origin
305+ candidate.request.OriginKey = candidate.originKey
306+ candidate.request.ExactTarget = &candidate.target
307+ return candidate, nil
308+}
309+
310+func (l *routeLedger) commit(candidate routeCandidate) (routeIdentity, error) {
311+ candidate, err := prepareRouteCandidate(candidate)
312+ if err != nil {
313+ return routeIdentity{}, err
314+ }
315+
316+ l.mu.Lock()
317+ defer l.mu.Unlock()
318+ return l.commitLocked(candidate)
319+}
320+
321+// commitAttach assigns the process home route while holding the same lock as
322+// the commit. Two concurrent initial attachments therefore cannot both claim
323+// an empty home reference.
324+func (l *routeLedger) commitAttach(candidate routeCandidate) (routeIdentity, error) {
325+ candidate, err := prepareRouteCandidate(candidate)
326+ if err != nil {
327+ return routeIdentity{}, err
328+ }
329+
330+ l.mu.Lock()
331+ defer l.mu.Unlock()
332+ candidate.home = l.home.empty()
333+ return l.commitLocked(candidate)
334+}
335+
336+// commitCommittedIdentity snapshots the current active record and commits the
337+// daemon's local transition under one lock. The active route, origin, dialer,
338+// and attach request cannot be read from different ledger generations.
339+func (l *routeLedger) commitCommittedIdentity(identity ports.CommittedRouteIdentity) (routeIdentity, error) {
340+ if err := identity.Validate(); err != nil {
341+ return routeIdentity{}, err
342+ }
343+
344+ l.mu.Lock()
345+ defer l.mu.Unlock()
346+ activeIndex := l.indexByIdentityLocked(l.active)
347+ if activeIndex < 0 {
348+ return routeIdentity{}, errors.New("active route unavailable")
349+ }
350+ candidate, err := prepareRouteCandidate(routeCandidateForCommittedIdentity(l.entries[activeIndex], identity))
351+ if err != nil {
352+ return routeIdentity{}, err
353+ }
354+ return l.commitLocked(candidate)
355+}
356+
357+// updateRoutePosition remembers a cursor only for the currently active exact
358+// route. Delayed frames from a prior transition are valid wire messages but do
359+// not mutate another route.
360+func (l *routeLedger) updateRoutePosition(position ports.RoutePosition) error {
361+ if err := position.Validate(); err != nil {
362+ return err
363+ }
364+ l.mu.Lock()
365+ defer l.mu.Unlock()
366+ activeIndex := l.indexByIdentityLocked(l.active)
367+ if activeIndex < 0 || l.entries[activeIndex].target != position.Target {
368+ return nil
369+ }
370+ entry := &l.entries[activeIndex]
371+ if entry.request.PreferredTabID == position.ActiveTabID {
372+ return nil
373+ }
374+ // The tab cursor is mutable payload on an otherwise unchanged route.
375+ // Rotating the route generation here would invalidate navigation actions
376+ // captured immediately before a paint published the new cursor.
377+ entry.request.PreferredTabID = position.ActiveTabID
378+ entry.request.RemoteTarget = nil
379+ return nil
380+}
381+
382+func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) {
383+ identity, err := l.nextIdentityLocked()
384+ if err != nil {
385+ return routeIdentity{}, err
386+ }
387+ index := l.findByTargetLocked(candidate.origin, candidate.originKey, candidate.target)
388+ if index >= 0 {
389+ identity.key = l.entries[index].identity.key
390+ }
391+ isHome := candidate.home || (l.home.key != 0 && l.home.key == identity.key)
392+ entry := routeRecord{
393+ identity: identity,
394+ origin: candidate.origin,
395+ originKey: candidate.originKey,
396+ target: candidate.target,
397+ presentation: candidate.presentation,
398+ dialer: candidate.dialer,
399+ request: candidate.request,
400+ resumeToken: candidate.resumeToken,
401+ home: isHome,
402+ }
403+ oldActive := l.active
404+ l.moveToFrontLocked(index, entry)
405+ if !oldActive.empty() && oldActive.key != identity.key {
406+ l.previous = oldActive
407+ }
408+ if l.previous.key == identity.key {
409+ l.previous = routeIdentity{}
410+ }
411+ l.active = identity
412+ if isHome {
413+ l.home = identity
414+ }
415+ if l.home.key == identity.key {
416+ l.home.generation = identity.generation
417+ }
418+ if l.previous.key != 0 {
419+ if previousIndex := l.indexByIdentityLocked(l.previous); previousIndex < 0 {
420+ l.previous = routeIdentity{}
421+ }
422+ }
423+ l.evictLocked()
424+ return identity, nil
425+}
426+
427+func (l *routeLedger) indexByIdentityLocked(identity routeIdentity) int {
428+ for i := range l.entries {
429+ if l.entries[i].identity == identity {
430+ return i
431+ }
432+ }
433+ return -1
434+}
435+
436+func (l *routeLedger) lookup(ref ports.RouteRef) (routeRecord, bool) {
437+ identity := identityFromWire(ref)
438+ if identity.empty() {
439+ return routeRecord{}, false
440+ }
441+ l.mu.RLock()
442+ defer l.mu.RUnlock()
443+ for _, entry := range l.entries {
444+ if entry.identity == identity {
445+ return cloneRouteRecord(entry), true
446+ }
447+ }
448+ return routeRecord{}, false
449+}
450+
451+func cloneRouteRecord(entry routeRecord) routeRecord {
452+ entry.request = cloneAttachRequest(entry.request)
453+ return entry
454+}
455+
456+func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
457+ l.mu.RLock()
458+ defer l.mu.RUnlock()
459+
460+ snapshot := ports.RecentRouteSnapshot{
461+ Generation: uint64(l.generation),
462+ Active: l.active.wire(),
463+ Previous: l.previous.wire(),
464+ Home: l.home.wire(),
465+ }
466+ if len(l.entries) == 0 {
467+ return snapshot
468+ }
469+ snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries))
470+ for _, entry := range l.entries {
471+ if entry.identity == l.active {
472+ continue
473+ }
474+ snapshot.Entries = append(snapshot.Entries, ports.RecentRouteEntry{
475+ Key: uint64(entry.identity.key),
476+ Generation: uint64(entry.identity.generation),
477+ Name: entry.presentation.name,
478+ HostLabel: entry.presentation.hostLabel,
479+ Kind: entry.presentation.kind,
480+ Ephemeral: entry.presentation.ephemeral,
481+ Attention: entry.presentation.attention,
482+ Reachability: entry.presentation.reachability,
483+ })
484+ }
485+ return snapshot
486+}
487+
488+func (l *routeLedger) activeRef() ports.RouteRef {
489+ l.mu.RLock()
490+ defer l.mu.RUnlock()
491+ return l.active.wire()
492+}
493+
494+func (l *routeLedger) previousRef() ports.RouteRef {
495+ l.mu.RLock()
496+ defer l.mu.RUnlock()
497+ return l.previous.wire()
498+}
499+
500+func (l *routeLedger) homeRef() ports.RouteRef {
501+ l.mu.RLock()
502+ defer l.mu.RUnlock()
503+ return l.home.wire()
504+}
505+
506+// routeTransitionPlan is the private lookup result used by navigation. It
507+// preserves the original origin and exact target while leaving display labels
508+// and transport capabilities separate.
509+type routeTransitionPlan struct {
510+ identity routeIdentity
511+ record routeRecord
512+ snapshotGeneration routeGeneration
513+ active routeIdentity
514+ origin ports.RouteOrigin
515+ originKey string
516+ target ports.ExactSessionTarget
517+}
518+
519+type routeTransitionCommit struct {
520+ identity ports.CommittedRouteIdentity
521+ presentation routePresentation
522+ dialer ports.Dialer
523+ resumeToken uint64
524+}
525+
526+type routeTransitionConnector interface {
527+ Resume(context.Context, routeRecord) (routeTransitionCommit, error)
528+ Attach(context.Context, routeRecord) (routeTransitionCommit, error)
529+ Restore(context.Context, routeRecord) error
530+}
531+
532+type routeNavigationSelection struct {
533+ selected routeRecord
534+ prior routeRecord
535+ snapshotGeneration routeGeneration
536+ active routeIdentity
537+ noOp bool
538+}
539+
540+// navigationSelection validates an action against the latest complete
541+// snapshot and captures the selected route, live restoration route, snapshot
542+// generation, and active identity under one read lock. The commit phase uses
543+// the captured generation and active identity to reject interleavings that
544+// changed the ledger while dialing.
545+func (l *routeLedger) navigationSelection(action ports.RouteNavigationAction) (routeNavigationSelection, bool) {
546+ if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {
547+ return routeNavigationSelection{}, false
548+ }
549+ identity := identityFromWire(ports.RouteRef{Key: action.Key, Generation: action.Generation})
550+ l.mu.RLock()
551+ defer l.mu.RUnlock()
552+ if uint64(l.generation) != action.SnapshotGeneration {
553+ return routeNavigationSelection{}, false
554+ }
555+ selection := routeNavigationSelection{
556+ snapshotGeneration: l.generation,
557+ active: l.active,
558+ }
559+ if identity == l.active {
560+ selection.noOp = true
561+ return selection, true
562+ }
563+ selectedIndex := l.indexByIdentityLocked(identity)
564+ activeIndex := l.indexByIdentityLocked(l.active)
565+ if selectedIndex < 0 || activeIndex < 0 {
566+ return routeNavigationSelection{}, false
567+ }
568+ selection.selected = cloneRouteRecord(l.entries[selectedIndex])
569+ selection.prior = cloneRouteRecord(l.entries[activeIndex])
570+ return selection, true
571+}
572+
573+// navigationRecord validates that an action belongs to the latest complete
574+// snapshot. The active route is metadata-only and selecting it is a no-op.
575+func (l *routeLedger) navigationRecord(action ports.RouteNavigationAction) (routeRecord, bool, bool) {
576+ selection, ok := l.navigationSelection(action)
577+ return selection.selected, ok, selection.noOp
578+}
579+
580+// navigate resolves a captured key/generation, attempts resume before exact
581+// attach, and commits only after the connector returns a matching identity.
582+// A failed transition restores the prior live route and leaves history alone.
583+func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error {
584+ selection, ok := l.navigationSelection(action)
585+ if !ok {
586+ return errRouteStaleSelection
587+ }
588+ if selection.noOp {
589+ return nil
590+ }
591+ record := selection.selected
592+ prior := selection.prior
593+ if connector == nil {
594+ return errors.New("route transition connector is nil")
595+ }
596+ if ctx == nil {
597+ return errors.New("route transition context is nil")
598+ }
599+ selected := ports.RouteRef{Key: action.Key, Generation: action.Generation}
600+ plan := routeTransitionPlan{
601+ identity: identityFromWire(selected),
602+ record: record,
603+ snapshotGeneration: selection.snapshotGeneration,
604+ active: selection.active,
605+ origin: record.origin,
606+ originKey: record.originKey,
607+ target: record.target,
608+ }
609+ restoreOnFailure := func(err error) error {
610+ if restoreErr := connector.Restore(ctx, prior); restoreErr != nil {
611+ return errors.Join(err, fmt.Errorf("restoring prior route: %w", restoreErr))
612+ }
613+ return err
614+ }
615+
616+ var commit routeTransitionCommit
617+ var err error
618+ if record.resumeToken != 0 {
619+ commit, err = connector.Resume(ctx, record)
620+ if errors.Is(err, errRouteResumeUnavailable) {
621+ commit, err = connector.Attach(ctx, record)
622+ }
623+ } else {
624+ commit, err = connector.Attach(ctx, record)
625+ }
626+ if err != nil {
627+ return restoreOnFailure(err)
628+ }
629+ if err := commit.identity.Validate(); err != nil {
630+ return restoreOnFailure(fmt.Errorf("invalid committed route identity: %w", err))
631+ }
632+ if commit.identity.Target != plan.target {
633+ return restoreOnFailure(errRouteTargetChanged)
634+ }
635+ if err := validateRoutePresentation(commit.presentation); err != nil {
636+ return restoreOnFailure(err)
637+ }
638+
639+ candidate := routeCandidate{
640+ origin: plan.origin,
641+ originKey: plan.originKey,
642+ target: plan.target,
643+ presentation: commit.presentation,
644+ dialer: commit.dialer,
645+ request: cloneAttachRequest(plan.record.request),
646+ resumeToken: commit.resumeToken,
647+ home: plan.record.home,
648+ }
649+ candidate.request.Origin = plan.origin
650+ candidate.request.OriginKey = plan.originKey
651+ candidate.request.ExactTarget = &candidate.target
652+ if err := l.commitTransition(plan.identity, plan.snapshotGeneration, plan.active, candidate); err != nil {
653+ return restoreOnFailure(err)
654+ }
655+ return nil
656+}
657+
658+func (l *routeLedger) commitTransition(selected routeIdentity, snapshotGeneration routeGeneration, active routeIdentity, candidate routeCandidate) error {
659+ candidate, err := prepareRouteCandidate(candidate)
660+ if err != nil {
661+ return err
662+ }
663+ l.mu.Lock()
664+ defer l.mu.Unlock()
665+ if l.generation != snapshotGeneration || l.active != active {
666+ return errRouteStaleSelection
667+ }
668+ if l.indexByIdentityLocked(selected) < 0 {
669+ return errRouteStaleSelection
670+ }
671+ if _, err := l.commitLocked(candidate); err != nil {
672+ return err
673+ }
674+ return nil
675+}
676+
677+// beginDiscoveryCycle and discoveryCycleCurrent deliberately use a lock
678+// separate from route history. A stale discovery result cannot clear or commit
679+// unrelated navigation history.
680+func (l *routeLedger) beginDiscoveryCycle() uint64 {
681+ l.discoveryMu.Lock()
682+ defer l.discoveryMu.Unlock()
683+ l.discoveryCycle++
684+ if l.discoveryCycle == 0 {
685+ l.discoveryCycle++
686+ }
687+ return l.discoveryCycle
688+}
689+
690+func (l *routeLedger) discoveryCycleCurrent(cycle uint64) bool {
691+ if cycle == 0 {
692+ return false
693+ }
694+ l.discoveryMu.Lock()
695+ defer l.discoveryMu.Unlock()
696+ return l.discoveryCycle == cycle
697+}
A internal/usecase/client/routes_test.go +370 -0
@@ -0,0 +1,370 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func routeTestTarget(index byte) ports.ExactSessionTarget {
15+ return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{index + 1}, SessionName: "session-" + string(rune('a'+index%26)) + string(rune('0'+index/26))}
16+}
17+
18+func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate {
19+ target := routeTestTarget(index)
20+ return routeCandidate{
21+ origin: origin,
22+ target: target,
23+ presentation: routePresentation{
24+ name: target.SessionName,
25+ kind: ports.RouteKindLocal,
26+ reachability: ports.RouteReachabilityReachable,
27+ },
28+ request: AttachRequest{
29+ Intent: ports.IntentAttach,
30+ SessionName: target.SessionName,
31+ Origin: origin,
32+ },
33+ }
34+}
35+
36+func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
37+ target := routeTestTarget(1)
38+ candidate := routeCandidateForAttach(AttachRequest{
39+ Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true,
40+ Origin: ports.RouteOriginRemote, OriginKey: "remote",
41+ }, ports.CommittedRouteIdentity{Target: target}, nil, 0)
42+
43+ require.Nil(t, candidate.request.RemoteTarget)
44+ require.Equal(t, "remote", candidate.request.RemoteOrigin)
45+ require.Equal(t, "remote", candidate.presentation.hostLabel)
46+}
47+
48+func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
49+ ledger := newRouteLedger()
50+ var keys []routeKey
51+ for i := byte(0); i < maxRouteLedgerEntries+8; i++ {
52+ candidate := routeTestCandidate(i, ports.RouteOriginLocal)
53+ candidate.home = i == 0
54+ identity, err := ledger.commit(candidate)
55+ require.NoError(t, err)
56+ keys = append(keys, identity.key)
57+ }
58+
59+ snapshot := ledger.snapshot()
60+ require.Len(t, ledger.entries, maxRouteLedgerEntries)
61+ require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1)
62+ require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key)
63+ for _, entry := range snapshot.Entries {
64+ require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation})
65+ }
66+ require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key)
67+ require.Equal(t, uint64(keys[0]), snapshot.Home.Key, "home is retained when older entries are evicted")
68+
69+ for i := 1; i < len(snapshot.Entries); i++ {
70+ require.Greater(t, snapshot.Entries[i-1].Generation, snapshot.Entries[i].Generation)
71+ }
72+ originalName := snapshot.Entries[0].Name
73+ snapshot.Entries[0].Name = "mutated"
74+ fresh := ledger.snapshot()
75+ require.Equal(t, originalName, fresh.Entries[0].Name)
76+
77+ old := ledger.activeRef()
78+ _, err := ledger.commit(routeTestCandidate(maxRouteLedgerEntries+9, ports.RouteOriginLocal))
79+ require.NoError(t, err)
80+ require.NotEqual(t, old, ledger.activeRef())
81+ require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")
82+}
83+
84+func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) {
85+ ledger := newRouteLedger()
86+ first := routeTestCandidate(1, ports.RouteOriginLocal)
87+ firstIdentity, err := ledger.commit(first)
88+ require.NoError(t, err)
89+ firstRef := ledger.activeRef()
90+ firstSnapshotGeneration := ledger.snapshot().Generation
91+ require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"}))
92+ require.Equal(t, firstRef, ledger.activeRef(), "tab memory must not rotate route identity")
93+ require.Equal(t, firstSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots")
94+
95+ second := routeTestCandidate(2, ports.RouteOriginRemote)
96+ secondIdentity, err := ledger.commit(second)
97+ require.NoError(t, err)
98+ secondRef := ledger.activeRef()
99+ secondSnapshotGeneration := ledger.snapshot().Generation
100+ require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: second.target, ActiveTabID: "tab-second"}))
101+ require.Equal(t, secondRef, ledger.activeRef(), "tab memory must not rotate route identity")
102+ require.Equal(t, secondSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots")
103+
104+ firstRecord, ok := ledger.lookup(firstIdentity.wire())
105+ require.True(t, ok)
106+ require.Equal(t, domain.TabStableID("tab-first"), firstRecord.request.PreferredTabID)
107+ secondRecord, ok := ledger.lookup(secondIdentity.wire())
108+ require.True(t, ok)
109+ require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)
110+}
111+
112+func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {
113+ ledger := newRouteLedger()
114+ home := routeTestCandidate(1, ports.RouteOriginLocal)
115+ home.home = true
116+ homeIdentity, err := ledger.commit(home)
117+ require.NoError(t, err)
118+ committed, err := ledger.commitCommittedIdentity(ports.CommittedRouteIdentity{
119+ Target: routeTestTarget(2),
120+ Ephemeral: true,
121+ })
122+ require.NoError(t, err)
123+ require.Equal(t, uint64(homeIdentity.key), ledger.homeRef().Key)
124+ require.NotEqual(t, ledger.homeRef(), committed.wire())
125+ active, ok := ledger.lookup(ledger.activeRef())
126+ require.True(t, ok)
127+ require.Equal(t, committed, active.identity)
128+
129+ _, err = ledger.commit(routeTestCandidate(1, ports.RouteOriginLocal))
130+ require.NoError(t, err)
131+ require.True(t, mustRouteRecord(t, ledger, ledger.activeRef()).home, "upserting the home route must retain its marker")
132+}
133+
134+func TestRouteLedgerConcurrentInitialAttachmentsKeepOneHome(t *testing.T) {
135+ ledger := newRouteLedger()
136+ const attachments = 16
137+ errs := make(chan error, attachments)
138+ var wg sync.WaitGroup
139+ for i := byte(0); i < attachments; i++ {
140+ wg.Add(1)
141+ go func() {
142+ defer wg.Done()
143+ _, err := ledger.commitAttach(routeTestCandidate(i, ports.RouteOriginLocal))
144+ errs <- err
145+ }()
146+ }
147+ wg.Wait()
148+ close(errs)
149+ for err := range errs {
150+ require.NoError(t, err)
151+ }
152+
153+ ledger.mu.RLock()
154+ defer ledger.mu.RUnlock()
155+ homeCount := 0
156+ for _, entry := range ledger.entries {
157+ if entry.home {
158+ homeCount++
159+ require.Equal(t, entry.identity, ledger.home)
160+ }
161+ }
162+ require.Equal(t, 1, homeCount)
163+}
164+
165+func TestRouteLedgerTransitionRejectsConcurrentCommit(t *testing.T) {
166+ ledger := newRouteLedger()
167+ selected, err := ledger.commit(routeTestCandidate(1, ports.RouteOriginLocal))
168+ require.NoError(t, err)
169+ _, err = ledger.commit(routeTestCandidate(2, ports.RouteOriginLocal))
170+ require.NoError(t, err)
171+ selection, ok := ledger.navigationSelection(selected.wireNavigationAction(routeGeneration(ledger.snapshot().Generation)))
172+ require.True(t, ok)
173+
174+ _, err = ledger.commit(routeTestCandidate(3, ports.RouteOriginLocal))
175+ require.NoError(t, err)
176+ candidate := routeTestCandidate(4, ports.RouteOriginLocal)
177+ err = ledger.commitTransition(selected, selection.snapshotGeneration, selection.active, candidate)
178+ require.ErrorIs(t, err, errRouteStaleSelection)
179+ require.Equal(t, routeTestTarget(3), mustRouteRecord(t, ledger, ledger.activeRef()).target)
180+}
181+
182+func mustRouteRecord(t *testing.T, ledger *routeLedger, ref ports.RouteRef) routeRecord {
183+ t.Helper()
184+ record, ok := ledger.lookup(ref)
185+ require.True(t, ok)
186+ return record
187+}
188+
189+func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) {
190+ ledger := newRouteLedger()
191+ candidate := routeTestCandidate(1, ports.RouteOriginRemote)
192+ first, err := ledger.commit(candidate)
193+ require.NoError(t, err)
194+
195+ candidate.presentation.attention = true
196+ second, err := ledger.commit(candidate)
197+ require.NoError(t, err)
198+ require.Equal(t, first.key, second.key)
199+ require.Greater(t, second.generation, first.generation)
200+ _, ok := ledger.lookup(first.wire())
201+ require.False(t, ok, "old generation must not resolve")
202+}
203+
204+func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) {
205+ ledger := newRouteLedger()
206+ first := ledger.beginDiscoveryCycle()
207+ second := ledger.beginDiscoveryCycle()
208+
209+ require.NotEqual(t, first, second)
210+ require.False(t, ledger.discoveryCycleCurrent(first))
211+ require.True(t, ledger.discoveryCycleCurrent(second))
212+}
213+
214+type routeTestConnector struct {
215+ resumeErr error
216+ attachErr error
217+ resumeCalls int
218+ attachCalls int
219+ restoreCalls int
220+ restoreErr error
221+ restoreTarget ports.ExactSessionTarget
222+}
223+
224+func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) {
225+ c.resumeCalls++
226+ if c.resumeErr != nil {
227+ return routeTransitionCommit{}, c.resumeErr
228+ }
229+ return routeTransitionCommit{}, errors.New("unexpected resume success")
230+}
231+
232+func (c *routeTestConnector) Restore(_ context.Context, record routeRecord) error {
233+ c.restoreCalls++
234+ c.restoreTarget = record.target
235+ return c.restoreErr
236+}
237+
238+func (c *routeTestConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) {
239+ c.attachCalls++
240+ if c.attachErr != nil {
241+ return routeTransitionCommit{}, c.attachErr
242+ }
243+ return routeTransitionCommit{
244+ identity: ports.CommittedRouteIdentity{Target: record.target},
245+ presentation: routePresentation{
246+ name: record.presentation.name,
247+ kind: record.presentation.kind,
248+ reachability: ports.RouteReachabilityReachable,
249+ },
250+ resumeToken: 123,
251+ }, nil
252+}
253+
254+func TestRouteLedgerNavigateResumesThenFallsBackAndRestoresOrigin(t *testing.T) {
255+ ledger := newRouteLedger()
256+ candidate := routeTestCandidate(2, ports.RouteOriginDiscovery)
257+ candidate.originKey = "daemon-discovered"
258+ candidate.resumeToken = 77
259+ identity, err := ledger.commit(candidate)
260+ require.NoError(t, err)
261+ _, err = ledger.commit(routeTestCandidate(4, ports.RouteOriginLocal))
262+ require.NoError(t, err)
263+
264+ connector := &routeTestConnector{resumeErr: errRouteResumeUnavailable}
265+ action := identity.wireNavigationAction(routeGeneration(ledger.snapshot().Generation))
266+ err = ledger.navigate(context.Background(), action, connector)
267+ require.NoError(t, err)
268+ require.Equal(t, 1, connector.resumeCalls)
269+ require.Equal(t, 1, connector.attachCalls)
270+ require.Zero(t, connector.restoreCalls)
271+
272+ active, ok := ledger.lookup(ledger.activeRef())
273+ require.True(t, ok)
274+ require.Equal(t, ports.RouteOriginDiscovery, active.origin)
275+ require.Equal(t, "daemon-discovered", active.originKey)
276+ require.Equal(t, ports.RouteOriginDiscovery, active.request.Origin)
277+ require.Equal(t, candidate.target, active.target)
278+ require.Greater(t, active.identity.generation, identity.generation)
279+}
280+
281+func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) {
282+ ledger := newRouteLedger()
283+ identity, err := ledger.commit(routeTestCandidate(3, ports.RouteOriginLocal))
284+ require.NoError(t, err)
285+ _, err = ledger.commit(routeTestCandidate(5, ports.RouteOriginLocal))
286+ require.NoError(t, err)
287+ before := ledger.snapshot()
288+ action := identity.wireNavigationAction(routeGeneration(before.Generation))
289+
290+ connector := &routeTestConnector{attachErr: errors.New("offline")}
291+ err = ledger.navigate(context.Background(), action, connector)
292+ require.Error(t, err)
293+ require.Equal(t, before, ledger.snapshot())
294+ require.Equal(t, routeTestTarget(5), connector.restoreTarget)
295+ require.Equal(t, 1, connector.restoreCalls)
296+
297+ connector = &routeTestConnector{}
298+ // Force a mismatched committed target through a small wrapper.
299+ mismatch := mismatchingRouteConnector{routeTestConnector: connector}
300+ err = ledger.navigate(context.Background(), action, mismatch)
301+ require.ErrorIs(t, err, errRouteTargetChanged)
302+ require.Equal(t, before, ledger.snapshot())
303+ require.Equal(t, routeTestTarget(5), connector.restoreTarget)
304+ require.Equal(t, 1, connector.restoreCalls)
305+}
306+
307+func TestRouteLedgerNavigationRejectsStaleActionsAndActiveNoOp(t *testing.T) {
308+ ledger := newRouteLedger()
309+ active, err := ledger.commit(routeTestCandidate(6, ports.RouteOriginLocal))
310+ require.NoError(t, err)
311+ firstSnapshot := ledger.snapshot()
312+ before := ledger.snapshot()
313+
314+ require.NoError(t, ledger.navigate(context.Background(), active.wireNavigationAction(routeGeneration(firstSnapshot.Generation)), nil))
315+ require.Equal(t, before, ledger.snapshot())
316+
317+ _, err = ledger.commit(routeTestCandidate(7, ports.RouteOriginLocal))
318+ require.NoError(t, err)
319+ stale := active.wireNavigationAction(routeGeneration(firstSnapshot.Generation))
320+ connector := &routeTestConnector{}
321+ require.ErrorIs(t, ledger.navigate(context.Background(), stale, connector), errRouteStaleSelection)
322+ require.Zero(t, connector.resumeCalls)
323+ require.Zero(t, connector.attachCalls)
324+}
325+
326+type mismatchingRouteConnector struct{ *routeTestConnector }
327+
328+func (c mismatchingRouteConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) {
329+ return routeTransitionCommit{
330+ identity: ports.CommittedRouteIdentity{Target: routeTestTarget(99)},
331+ presentation: routePresentation{name: record.presentation.name, kind: record.presentation.kind},
332+ }, nil
333+}
334+
335+func (i routeIdentity) wireNavigationAction(snapshotGeneration routeGeneration) ports.RouteNavigationAction {
336+ return ports.RouteNavigationAction{SnapshotGeneration: uint64(snapshotGeneration), Key: uint64(i.key), Generation: uint64(i.generation)}
337+}
338+
339+func TestRouteLedgerConcurrentSnapshotsAndCommits(t *testing.T) {
340+ ledger := newRouteLedger()
341+ var wg sync.WaitGroup
342+ for worker := byte(0); worker < 4; worker++ {
343+ wg.Add(1)
344+ go func() {
345+ defer wg.Done()
346+ for i := byte(0); i < 20; i++ {
347+ _, _ = ledger.commit(routeTestCandidate(worker*20+i, ports.RouteOriginLocal))
348+ _ = ledger.snapshot()
349+ }
350+ }()
351+ }
352+ wg.Wait()
353+ require.LessOrEqual(t, len(ledger.snapshot().Entries), maxRouteLedgerEntries-1)
354+}
355+
356+func TestRouteLedgerSeparatesRemoteDaemonOrigins(t *testing.T) {
357+ ledger := newRouteLedger()
358+ firstCandidate := routeTestCandidate(40, ports.RouteOriginRemote)
359+ firstCandidate.originKey = "daemon-a"
360+ secondCandidate := firstCandidate
361+ secondCandidate.originKey = "daemon-b"
362+
363+ first, err := ledger.commit(firstCandidate)
364+ require.NoError(t, err)
365+ second, err := ledger.commit(secondCandidate)
366+ require.NoError(t, err)
367+
368+ require.NotEqual(t, first.key, second.key)
369+ require.Len(t, ledger.snapshot().Entries, 1)
370+}
M internal/usecase/client/session_connection.go +51 -18
@@ -4,38 +4,56 @@ 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 (
12+ errNilSessionTransport = errors.New("client: nil session transport")
13+ ErrEphemeralSessionName = errors.New("client: ephemeral sessions cannot have a name")
14+)
11 15
12-func cloneAttachRequest(request AttachRequest) AttachRequest {16+// SessionTarget is the validated daemon-facing session selection. Endpoint
13- if request.RemoteTarget != nil {17+// details are resolved before a transport is opened and never enter this type.
14- target := *request.RemoteTarget18+type SessionTarget struct {
15- request.RemoteTarget = &target19+ Intent uint8
20+ SessionName string
21+}
22+
23+func (t SessionTarget) validate() error {
24+ switch t.Intent {
25+ case ports.IntentEphemeral:
26+ if t.SessionName != "" {
27+ return ErrEphemeralSessionName
28+ }
29+ return nil
30+ case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
31+ if t.SessionName == "" {
32+ return domain.ErrInvalidSessionName
33+ }
34+ default:
35+ return fmt.Errorf("invalid session intent %d", t.Intent)
16 }36 }
17- return request37+ return domain.ValidateSessionName(t.SessionName)
18 }38 }
19 39
20-// SessionConnection owns one already-open transport and the complete40+// SessionConnection owns one already-open transport and its daemon-facing
21-// validated daemon-facing attach request. Local sockets and remote carriages41+// session target. Local sockets and remote carriages use this same owner.
22-// use this same owner.
23 type SessionConnection struct {42 type SessionConnection struct {
24 transport ports.Transport43 transport ports.Transport
25- request AttachRequest44+ target SessionTarget
26 }45 }
27 46
28 // NewSessionConnection creates a connection for an already-selected transport.47 // NewSessionConnection creates a connection for an already-selected transport.
29-// Request validation happens before the connection is used for a Hello.48+// Target validation happens before the connection is used for a Hello.
30-func NewSessionConnection(transport ports.Transport, request AttachRequest) (*SessionConnection, error) {49+func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) {
31 if transport == nil {50 if transport == nil {
32 return nil, errNilSessionTransport51 return nil, errNilSessionTransport
33 }52 }
34- request = cloneAttachRequest(request)53+ if err := target.validate(); err != nil {
35- if err := validateAttachRequest(request); err != nil {54+ return nil, fmt.Errorf("invalid session target: %w", err)
36- return nil, fmt.Errorf("invalid attach request: %w", err)
37 }55 }
38- return &SessionConnection{transport: transport, request: request}, nil56+ return &SessionConnection{transport: transport, target: target}, nil
39 }57 }
40 58
41 // Transport returns the owned carriage for this connection.59 // Transport returns the owned carriage for this connection.
@@ -46,12 +64,27 @@ func (c *SessionConnection) Transport() ports.Transport {
46 return c.transport64 return c.transport
47 }65 }
48 66
49-// AttachRequest returns the complete request retained for this connection.67+// AttachRequest returns the common request shape used after transport
68+// selection. It contains no remote/proxy semantic.
50 func (c *SessionConnection) AttachRequest() AttachRequest {69 func (c *SessionConnection) AttachRequest() AttachRequest {
51 if c == nil {70 if c == nil {
52 return AttachRequest{}71 return AttachRequest{}
53 }72 }
54- return cloneAttachRequest(c.request)73+ return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName}
74+}
75+
76+// cloneAttachRequest keeps the immutable request independent from transport
77+// ownership while protecting the exact remote target from caller mutation.
78+func cloneAttachRequest(request AttachRequest) AttachRequest {
79+ if request.RemoteTarget != nil {
80+ target := *request.RemoteTarget
81+ request.RemoteTarget = &target
82+ }
83+ if request.ExactTarget != nil {
84+ target := *request.ExactTarget
85+ request.ExactTarget = &target
86+ }
87+ return request
55 }88 }
56 89
57 // Close releases the owned transport.90 // Close releases the owned transport.
M internal/usecase/client/session_connection_test.go +23 -49
@@ -3,79 +3,53 @@ 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
33+ wantErrIs error
60 }{34 }{
61- {name: "ephemeral", request: client.AttachRequest{Intent: ports.IntentEphemeral}},35+ {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}},
62- {name: "new", request: client.AttachRequest{Intent: ports.IntentNew, SessionName: "work"}},36+ {name: "named ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral, SessionName: "work"}, wantErr: true, wantErrIs: client.ErrEphemeralSessionName},
63- {name: "attach", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},37+ {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}},
64- {name: "resume", request: client.AttachRequest{Intent: ports.IntentResume, SessionName: "work"}},38+ {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}},
65- {name: "missing named session", request: client.AttachRequest{Intent: ports.IntentAttach}, wantErr: true},39+ {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}},
66- {name: "unsafe session", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},40+ {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true},
67- {name: "unknown intent", request: client.AttachRequest{Intent: 99, SessionName: "work"}, wantErr: true},41+ {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
68- {name: "invalid render mode", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", RenderMode: ports.RenderMode(99)}, wantErr: true},42+ {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "work"}, 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 }43 }
73 for _, tt := range tests {44 for _, tt := range tests {
74 t.Run(tt.name, func(t *testing.T) {45 t.Run(tt.name, func(t *testing.T) {
75 transport := portsmocks.NewMockTransport(t)46 transport := portsmocks.NewMockTransport(t)
76- _, err := client.NewSessionConnection(transport, tt.request)47+ _, err := client.NewSessionConnection(transport, tt.target)
77 if tt.wantErr {48 if tt.wantErr {
78 require.Error(t, err)49 require.Error(t, err)
50+ if tt.wantErrIs != nil {
51+ require.ErrorIs(t, err, tt.wantErrIs)
52+ }
79 return53 return
80 }54 }
81 require.NoError(t, err)55 require.NoError(t, err)
@@ -84,6 +58,6 @@ func TestSessionConnectionAttachRequestValidation(t *testing.T) {
84 }58 }
85 59
86 func TestSessionConnectionRequiresTransport(t *testing.T) {60 func TestSessionConnectionRequiresTransport(t *testing.T) {
87- _, err := client.NewSessionConnection(nil, client.AttachRequest{Intent: ports.IntentEphemeral})61+ _, err := client.NewSessionConnection(nil, client.SessionTarget{Intent: ports.IntentEphemeral})
88 require.Error(t, err)62 require.Error(t, err)
89 }63 }
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 +35 -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,26 @@ 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 validates authority 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. A live effect ticket
113+// preserves the exact capability it captured for the effect because attachment
114+// transitions drain the gate before changing identity. This differs from
115+// attachmentCurrent, which revalidates the attachment's currently published identity.
148 func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {116 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() {117 if t.effect != nil && !t.effect.ended.Load() {
154 return true118 return true
155 }119 }
156- if t.ac == nil ||120+ if t.sess == nil || t.ac == nil ||
157 t.ac.connectionGeneration.Load() != t.generation ||121 t.ac.connectionGeneration.Load() != t.generation ||
158 !t.ac.transportSnapshotCurrent(t.transport) ||122 !t.ac.transportSnapshotCurrent(t.transport) ||
159- !sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) ||123+ t.ac.currentAttachmentSession() != t.sess ||
160- !attachmentRegisteredLocked(entry, t.ac) {124+ !attachmentRegisteredLocked(t.sess, t.ac) {
161 return false125 return false
162 }126 }
163 if t.lease == nil {127 if t.lease == nil {
164 return true128 return true
165 }129 }
166- rc := entry.core().coordinator.Load()130+ rc := t.sess.core().coordinator.Load()
167 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)131 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
168 }132 }
169 133
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 +20 -15
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
4 "sync"5 "sync"
5 "sync/atomic"6 "sync/atomic"
6 7
@@ -18,7 +19,7 @@ const (
18 // effect admission. It deliberately contains no independently mutable state:19 // effect admission. It deliberately contains no independently mutable state:
19 // a ticket is admitted only when every field matches the frame's token.20 // a ticket is admitted only when every field matches the frame's token.
20 type attachmentCapability struct {21 type attachmentCapability struct {
21- owner attachmentOwner22+ sess *session
22 generation uint6423 generation uint64
23 transport transportSnapshot24 transport transportSnapshot
24 lease *attachmentLease25 lease *attachmentLease
@@ -26,7 +27,7 @@ type attachmentCapability struct {
26 27
27 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {28 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
28 return attachmentCapability{29 return attachmentCapability{
29- owner: token.owner,30+ sess: token.sess,
30 generation: token.generation,31 generation: token.generation,
31 transport: token.transport,32 transport: token.transport,
32 lease: token.lease,33 lease: token.lease,
@@ -34,7 +35,7 @@ func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
34 }35 }
35 36
36 func (c attachmentCapability) matches(token attachmentConnectionToken) bool {37 func (c attachmentCapability) matches(token attachmentConnectionToken) bool {
37- return token.ac != nil && sameAttachmentOwner(c.owner, token.owner) &&38+ return token.ac != nil && c.sess != nil && token.sess != nil && c.sess == token.sess &&
38 c.generation == token.generation && c.transport.transport == token.transport.transport &&39 c.generation == token.generation && c.transport.transport == token.transport.transport &&
39 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease40 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
40 }41 }
@@ -219,19 +220,19 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken)
219 // after Welcome has completed so a replacement blocked behind that send can220 // after Welcome has completed so a replacement blocked behind that send can
220 // publish before readiness or first paint.221 // publish before readiness or first paint.
221 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {222 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
222- return ac.beginCurrentAttachmentOwnerEffect(sess, tr)223+ //nolint:contextcheck // This compatibility wrapper deliberately delegates with a fresh context; cancellable callers use the context variant below.
224+ return ac.beginCurrentAttachmentEffectContext(context.Background(), sess, tr)
223 }225 }
224 226
225-// beginCurrentAttachmentOwnerEffect waits out a transition that already froze227+func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Context, sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
226-// ac, then admits the capability derived from one exact attachment owner.228+ if ctx == nil {
227-// Handshakes use it after Welcome so a resumed remote view follows the same229+ ctx = context.Background()
228-// generation-fenced client lifecycle as a local session.230+ }
229-func (ac *attachedClient) beginCurrentAttachmentOwnerEffect(owner attachmentOwner, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {231+ if ac == nil || sess == nil || tr == nil {
230- if ac == nil || normalizeAttachmentOwner(owner) == nil || tr == nil {
231 return attachmentConnectionToken{}, nil, false232 return attachmentConnectionToken{}, nil, false
232 }233 }
233 for {234 for {
234- token := attachmentOwnerToken(owner, ac, tr)235+ token := sess.attachmentToken(ac, tr)
235 if token.ac == nil {236 if token.ac == nil {
236 return token, nil, false237 return token, nil, false
237 }238 }
@@ -245,8 +246,12 @@ func (ac *attachedClient) beginCurrentAttachmentOwnerEffect(owner attachmentOwne
245 if g.phase == attachmentEffectsFrozen {246 if g.phase == attachmentEffectsFrozen {
246 changed := g.changed247 changed := g.changed
247 g.mu.Unlock()248 g.mu.Unlock()
248- <-changed249+ select {
249- continue250+ case <-changed:
251+ continue
252+ case <-ctx.Done():
253+ return token, nil, false
254+ }
250 }255 }
251 g.mu.Unlock()256 g.mu.Unlock()
252 // A stable mismatch is only recoverable when a publication raced token257 // A stable mismatch is only recoverable when a publication raced token
@@ -279,13 +284,13 @@ func (ac *attachedClient) publishAttachmentCapability(token attachmentConnection
279 // bootstrapAttachmentCapability supports direct/headless session construction. It284 // bootstrapAttachmentCapability supports direct/headless session construction. It
280 // never changes an existing or frozen production publication.285 // never changes an existing or frozen production publication.
281 func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {286 func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {
282- if ac == nil || token.owner == nil || token.ac != ac {287+ if ac == nil || token.sess == nil || token.ac != ac {
283 return288 return
284 }289 }
285 g := &ac.attachmentEffects290 g := &ac.attachmentEffects
286 g.mu.Lock()291 g.mu.Lock()
287 g.initLocked()292 g.initLocked()
288- if g.phase == attachmentEffectsStable && g.capability.owner == nil {293+ if g.phase == attachmentEffectsStable && g.capability.sess == nil {
289 g.capability = capabilityFromToken(token)294 g.capability = capabilityFromToken(token)
290 g.failedTransport = transportSnapshot{}295 g.failedTransport = transportSnapshot{}
291 }296 }
M internal/usecase/daemon/attachment_effect_gate_test.go +76 -0
@@ -667,6 +667,82 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
667 }667 }
668 }668 }
669 669
670+func TestAttachedRouteSnapshotIsAcceptedAsAnAttachmentValue(t *testing.T) {
671+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
672+ transport := &closeTrackingTransport{}
673+ ac.replaceTransport(transport)
674+ rc := d.attachCoordinator(source, nil, ac, true)
675+ token := source.attachmentToken(ac, transport)
676+ token.lease = rc.attachmentLease(ac)
677+ ac.publishAttachmentCapability(token)
678+
679+ snapshot := ports.RecentRouteSnapshot{
680+ Generation: 4,
681+ Active: ports.RouteRef{Key: 8, Generation: 4},
682+ Home: ports.RouteRef{Key: 8, Generation: 4},
683+ Entries: []ports.RecentRouteEntry{{
684+ Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal,
685+ }},
686+ }
687+ payload, err := ports.MarshalRecentRouteSnapshot(snapshot)
688+ require.NoError(t, err)
689+
690+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}))
691+ require.Equal(t, snapshot, ac.routeSnapshotCopy())
692+}
693+
694+func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) {
695+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
696+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
697+ newTab(nil, domain.Size{Cols: 80, Rows: 23}),
698+ }}
699+ d.mu.Lock()
700+ d.sessions[target.id] = target
701+ d.mu.Unlock()
702+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
703+ Generation: 2,
704+ Active: ports.RouteRef{Key: 2, Generation: 2},
705+ Previous: ports.RouteRef{Key: 1, Generation: 1},
706+ Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}},
707+ })
708+
709+ transport := &closeTrackingTransport{}
710+ ac.replaceTransport(transport)
711+ rc := d.attachCoordinator(source, nil, ac, true)
712+ token := source.attachmentToken(ac, transport)
713+ token.lease = rc.attachmentLease(ac)
714+ ac.publishAttachmentCapability(token)
715+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
716+ Version: ports.ProtocolVersion, RequestID: 1, Slug: "back-session", Attached: true,
717+ })
718+ require.NoError(t, err)
719+
720+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
721+ require.Same(t, source, ac.currentAttachmentSession())
722+ frames := transport.Sends()
723+ require.NotEmpty(t, frames)
724+ var result ports.CommandResult
725+ for _, frame := range frames {
726+ if frame.Type != ports.MsgCommandResult {
727+ continue
728+ }
729+ result, err = ports.UnmarshalCommandResult(frame.Payload)
730+ require.NoError(t, err)
731+ break
732+ }
733+ require.True(t, result.OK, result.Text)
734+ var action ports.RouteNavigationAction
735+ for _, frame := range frames {
736+ if frame.Type != ports.MsgNavigateRecentRoute {
737+ continue
738+ }
739+ action, err = ports.UnmarshalRouteNavigationAction(frame.Payload)
740+ require.NoError(t, err)
741+ }
742+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}, action)
743+ require.False(t, transport.Closed(), "client navigation must not tear down the attachment")
744+}
745+
670 func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {746 func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
671 p1, release1 := newBlockingPTY(t)747 p1, release1 := newBlockingPTY(t)
672 p2, release2 := newBlockingPTY(t)748 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_parking_test.go +4 -1
@@ -137,6 +137,7 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) {
137 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)137 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
138 require.NoError(t, err)138 require.NoError(t, err)
139 oldToken := ac.resumeToken139 oldToken := ac.resumeToken
140+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
140 d.clientGone(sess, ac, oldTransport, false)141 d.clientGone(sess, ac, oldTransport, false)
141 142
142 resumedTransport := &closeTrackingTransport{}143 resumedTransport := &closeTrackingTransport{}
@@ -150,6 +151,7 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) {
150 require.False(t, oldRetained)151 require.False(t, oldRetained)
151 require.NotNil(t, newParked)152 require.NotNil(t, newParked)
152 require.NotEmpty(t, resumedTransport.Sends())153 require.NotEmpty(t, resumedTransport.Sends())
154+ require.Equal(t, ports.MsgWelcome, resumedTransport.Sends()[0].Type, "Welcome must remain the first server frame after Hello")
153 }155 }
154 156
155 func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) {157 func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) {
@@ -167,9 +169,10 @@ func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) {
167 require.Nil(t, ac.transport())169 require.Nil(t, ac.transport())
168 d.mu.Lock()170 d.mu.Lock()
169 parked := d.parked[token]171 parked := d.parked[token]
172+ claimed := parked != nil && parked.claimed
170 d.mu.Unlock()173 d.mu.Unlock()
171 require.NotNil(t, parked)174 require.NotNil(t, parked)
172- require.False(t, parked.claimed)175+ require.False(t, claimed)
173 require.Equal(t, token, ac.resumeToken)176 require.Equal(t, token, ac.resumeToken)
174 177
175 _, resumed, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize)178 _, resumed, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize)
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 {
A internal/usecase/daemon/attachment_route_identity_test.go +108 -0
@@ -0,0 +1,108 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type committedIdentityErrorTransport struct {
13+ closeTrackingTransport
14+}
15+
16+func (t *committedIdentityErrorTransport) Send(frame ports.Frame) error {
17+ if frame.Type == ports.MsgCommittedRouteIdentity {
18+ return errors.New("committed identity send failed")
19+ }
20+ return t.closeTrackingTransport.Send(frame)
21+}
22+
23+func TestAttachmentTransitionPublishesCommittedRouteIdentity(t *testing.T) {
24+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
25+ transport := &closeTrackingTransport{}
26+ ac.replaceTransport(transport)
27+ rc := d.attachCoordinator(source, nil, ac, true)
28+ token := source.attachmentToken(ac, transport)
29+ token.lease = rc.attachmentLease(ac)
30+ ac.publishAttachmentCapability(token)
31+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
32+
33+ target := &session{
34+ sessionCore: sessionCore{
35+ id: "target",
36+ name: "target",
37+ incarnation: domain.IncarnationID{2},
38+ },
39+ ctx: source.ctx,
40+ cancel: func() {},
41+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
42+ }
43+ d.mu.Lock()
44+ d.sessions[target.id] = target
45+ d.mu.Unlock()
46+
47+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
48+ source: source,
49+ target: target,
50+ next: ac,
51+ expectedTransport: transportSnapshot{transport: transport, incarnation: ac.transportSnapshot().incarnation},
52+ ready: true,
53+ })
54+ require.NoError(t, err)
55+ d.deferAttachmentTransitionCleanups(transition)
56+
57+ var identity ports.CommittedRouteIdentity
58+ found := false
59+ for _, frame := range transport.Sends() {
60+ if frame.Type != ports.MsgCommittedRouteIdentity {
61+ continue
62+ }
63+ var decodeErr error
64+ identity, decodeErr = ports.UnmarshalCommittedRouteIdentity(frame.Payload)
65+ require.NoError(t, decodeErr)
66+ found = true
67+ }
68+ require.True(t, found, "attachment transitions must publish their committed route identity")
69+ require.Equal(t, "target", identity.Target.SessionName)
70+ require.Equal(t, domain.IncarnationID{2}, identity.Target.LifecycleID)
71+}
72+
73+func TestAttachmentTransitionTearsDownAfterCommittedIdentitySendFailure(t *testing.T) {
74+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
75+ transport := &committedIdentityErrorTransport{}
76+ ac.replaceTransport(transport)
77+ rc := d.attachCoordinator(source, nil, ac, true)
78+ token := source.attachmentToken(ac, transport)
79+ token.lease = rc.attachmentLease(ac)
80+ ac.publishAttachmentCapability(token)
81+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
82+
83+ target := &session{
84+ sessionCore: sessionCore{
85+ id: "target",
86+ name: "target",
87+ incarnation: domain.IncarnationID{2},
88+ },
89+ ctx: source.ctx,
90+ cancel: func() {},
91+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
92+ }
93+ d.mu.Lock()
94+ d.sessions[target.id] = target
95+ d.mu.Unlock()
96+
97+ _, err := d.transitionAttachment(attachmentTransitionRequest{
98+ source: source,
99+ target: target,
100+ next: ac,
101+ expectedTransport: transportSnapshot{transport: transport, incarnation: ac.transportSnapshot().incarnation},
102+ ready: true,
103+ })
104+ require.ErrorContains(t, err, "committed identity send failed")
105+ require.Empty(t, target.snapshotAttachments())
106+ require.Empty(t, source.snapshotAttachments())
107+ require.True(t, transport.Closed())
108+}
M internal/usecase/daemon/attachment_transition.go +29 -4
@@ -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 source != nil && token.sess != nil && 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
@@ -167,21 +167,46 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
167 }167 }
168 168
169 frozen, err := d.freezeAttachmentTransition(req, participants)169 frozen, err := d.freezeAttachmentTransition(req, participants)
170- defer frozen.unfreeze()
171 if err != nil {170 if err != nil {
171+ frozen.unfreeze()
172 return attachmentTransitionResult{}, err172 return attachmentTransitionResult{}, err
173 }173 }
174 174
175 result, err := d.publishAttachmentTransition(req)175 result, err := d.publishAttachmentTransition(req)
176+ frozen.unfreeze()
176 if err != nil {177 if err != nil {
177 return result, err178 return result, err
178 }179 }
180+ // A not-ready publication is still inside the Hello handshake. Welcome
181+ // carries the committed identity for those attachments and must remain the
182+ // first server frame. Ready transitions notify an already-running client.
183+ if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
184+ identityErr := errAttachmentTransition
185+ if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {
186+ identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken())
187+ effect.End()
188+ }
189+ if identityErr != nil {
190+ d.abortPublishedAttachmentTransition(result)
191+ return attachmentTransitionResult{}, identityErr
192+ }
193+ }
179 if result.sourceGeometrySession != nil {194 if result.sourceGeometrySession != nil {
180 d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go")195 d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go")
181 }196 }
182 return result, nil197 return result, nil
183 }198 }
184 199
200+// abortPublishedAttachmentTransition tears down the newly published link before
201+// a post-publication control failure escapes to callers. The transition result is
202+// deliberately not handed back because its target membership is no longer valid.
203+func (d *Daemon) abortPublishedAttachmentTransition(result attachmentTransitionResult) {
204+ if result.published.ac != nil && result.published.transport.transport != nil {
205+ d.clientGoneWithoutNotice(result.published.sess, result.published.ac, result.published.transport.transport, true)
206+ }
207+ d.deferAttachmentTransitionCleanups(result)
208+}
209+
185 func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {210 func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
186 for _, cleanup := range result.cleanups {211 for _, cleanup := range result.cleanups {
187 d.attachmentCleanupWg.Go(cleanup.finish)212 d.attachmentCleanupWg.Go(cleanup.finish)
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/attention_test.go +0 -43
@@ -419,49 +419,6 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
419 }419 }
420 }420 }
421 421
422-// TestCloseRingingTabRefreshesOtherSessionBottomBar covers Fix 2c: closing a
423-// ringing tab in one session must refresh the bottom bar of a client attached
424-// to a DIFFERENT session, since that client's bar shows the ringing session's
425-// bell until it stops.
426-func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
427- pA1, releaseA1 := newBlockingPTY(t)
428- pA2, releaseA2 := newBlockingPTY(t)
429- pB, releaseB := newBlockingPTY(t)
430- defer releaseA1()
431- defer releaseA2()
432- defer releaseB()
433-
434- d, sessA, _, sendsA := newManualSessionWithPTYs(t, pA1, pA2)
435- sessA.name = "ringer"
436- ringing := sessA.tabs[1]
437- sessA.mu.Lock()
438- ringing.attention = true
439- ringing.attentionAt = time.Unix(1, 0)
440- sessA.mu.Unlock()
441- d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank
442-
443- trB, sendsB := newCapturingTransport(t)
444- acB := &attachedClient{tr: trB, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
445- acB.initOverlays()
446- sctxB, cancelB := context.WithCancel(d.serveCtx)
447- t.Cleanup(cancelB)
448- tbB := newTestTabWithContext(pB, sctxB, cancelB)
449- sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", attachments: map[*attachedClient]struct{}{acB: {}}}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}}
450- acB.setSession(sessB)
451- acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
452- d.sessions[sessB.id] = sessB
453-
454- d.paint(sessB, acB, true, nil)
455- before := mustOutputData(t, sendsB)
456- require.Contains(t, string(before), string(ui.AttentionGlyph))
457-
458- require.NoError(t, d.closeTab(sessA, ringing, true))
459- _ = mustOutputData(t, sendsA)
460-
461- after := mustOutputData(t, sendsB)
462- require.NotContains(t, string(after), string(ui.AttentionGlyph))
463-}
464-
465 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {422 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
466 d, sess, ac, sends, releases := newManualTabSession(t, 3)423 d, sess, ac, sends, releases := newManualTabSession(t, 3)
467 sess.mu.Lock()424 sess.mu.Lock()
M internal/usecase/daemon/client.go +91 -185
@@ -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
@@ -91,31 +85,20 @@ type attachedClient struct {
91 // view is attachment-local navigation state. It is never inferred from a85 // view is attachment-local navigation state. It is never inferred from a
92 // session-wide active tab, so multiple attachments can observe different86 // session-wide active tab, so multiple attachments can observe different
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+ lastRoutePosition ports.RoutePosition
103- // previousOwner is guarded independently. It retains navigation history97+ renderStages renderStageHooks // optional render and handoff observability hooks
104- // through temporary owner hand-offs and is cleared only on terminal teardown.98+ linkMu sync.Mutex
105- previousOwner Guarded[attachmentOwner]99+ sendMu sync.Mutex
106- // remoteRecent is attachment-local, display-only MRU state. Remote views do100+ routeMu sync.RWMutex
107- // not enter the local session registry, so these entries must not be usable101+ routeSnapshot ports.RecentRouteSnapshot
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,52 +211,21 @@ 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+// currentSession returns the attachment's owning local session.
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-
241-func (ac *attachedClient) currentAttachmentSession() *session {
242- return localSession(ac.currentAttachmentOwner())
243-}
244-
245-// currentSession is retained for local-session call sites. It deliberately
246-// returns nil for a remote view so local PTY/persistence paths fail closed.
247 func (ac *attachedClient) currentSession() *session {215 func (ac *attachedClient) currentSession() *session {
248- return ac.currentAttachmentSession()
249-}
250-
251-// navigationSession returns the local session that owns local navigation UI.
252-// A remote view has no local tabs or PTYs, but the attachment retains its
253-// immediate local predecessor so picker and palette shell state stay local.
254-func (ac *attachedClient) navigationSession() *session {
255 if ac == nil {216 if ac == nil {
256 return nil217 return nil
257 }218 }
258- if current := ac.currentAttachmentSession(); current != nil {219+ return ac.sess.Get()
259- return current
260- }
261- return localSession(ac.previousOwner.Get())
262 }220 }
263 221
264-func (ac *attachedClient) setAttachmentOwner(owner attachmentOwner) {222+// currentAttachmentSession is the explicit ownership-boundary alias used by
265- if ac != nil {223+// transition and rendering paths.
266- ac.owner.Set(normalizeAttachmentOwner(owner))224+func (ac *attachedClient) currentAttachmentSession() *session {
267- }225+ return ac.currentSession()
268 }226 }
269 227
270-func (ac *attachedClient) setSession(sess *session) { ac.setAttachmentOwner(sess) }228+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
271-
272-func (ac *attachedClient) clearPreviousSession() {
273- if ac != nil {
274- ac.previousOwner.Set(nil)
275- }
276-}
277 229
278 // pruneCaptureFrames releases snapshots for panes that have left their owner.230 // pruneCaptureFrames releases snapshots for panes that have left their owner.
279 // Callers must not hold daemon, session, tab, or pane locks.231 // Callers must not hold daemon, session, tab, or pane locks.
@@ -399,6 +351,21 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
399 return tr != nil && ac.transportIs(tr)351 return tr != nil && ac.transportIs(tr)
400 }352 }
401 353
354+func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) {
355+ snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...)
356+ ac.routeMu.Lock()
357+ ac.routeSnapshot = snapshot
358+ ac.routeMu.Unlock()
359+}
360+
361+func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot {
362+ ac.routeMu.RLock()
363+ defer ac.routeMu.RUnlock()
364+ snapshot := ac.routeSnapshot
365+ snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...)
366+ return snapshot
367+}
368+
402 func (ac *attachedClient) ackOutputState(epoch, state uint64) {369 func (ac *attachedClient) ackOutputState(epoch, state uint64) {
403 ac.sendMu.Lock()370 ac.sendMu.Lock()
404 defer ac.sendMu.Unlock()371 defer ac.sendMu.Unlock()
@@ -416,6 +383,7 @@ func (ac *attachedClient) rebaseOutput() {
416 if ac.output != nil {383 if ac.output != nil {
417 ac.output.rebase()384 ac.output.rebase()
418 }385 }
386+ ac.lastRoutePosition = ports.RoutePosition{}
419 }387 }
420 388
421 var errTransportReplaced = errors.New("client transport was replaced")389 var errTransportReplaced = errors.New("client transport was replaced")
@@ -616,11 +584,12 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
616 }584 }
617 585
618 type attachClientOptions struct {586 type attachClientOptions struct {
619- clientID [16]byte587+ clientID [16]byte
620- resumeCapable bool588+ resumeCapable bool
621- maxOutputInFlight uint8589+ maxOutputInFlight uint8
622- remoteOrigin string590+ remoteOrigin string
623- renderMode ports.RenderMode591+ navigationCapabilities ports.NavigationCapabilities
592+ startupOverlay ports.StartupOverlay
624 }593 }
625 594
626 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {595 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -661,15 +630,16 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
661 }630 }
662 output := newOutputStateStream(opts.maxOutputInFlight)631 output := newOutputStateStream(opts.maxOutputInFlight)
663 ac := &attachedClient{632 ac := &attachedClient{
664- tr: tr,633+ tr: tr,
665- output: output,634+ output: output,
666- size: sz,635+ size: sz,
667- view: attachmentView{windowRows: sz.Rows, windowSet: true},636+ view: attachmentView{windowRows: sz.Rows, windowSet: true},
668- clientID: opts.clientID,637+ clientID: opts.clientID,
669- remoteOrigin: opts.remoteOrigin,638+ remoteOrigin: opts.remoteOrigin,
670- renderMode: opts.renderMode,639+ navigationCapabilities: opts.navigationCapabilities,
671- resumeCapable: opts.resumeCapable,640+ startupOverlay: opts.startupOverlay,
672- resumeToken: resumeToken,641+ resumeCapable: opts.resumeCapable,
642+ resumeToken: resumeToken,
673 }643 }
674 output.attachment = ac644 output.attachment = ac
675 ac.initOverlays()645 ac.initOverlays()
@@ -812,23 +782,10 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
812 token.ac.captureFrames = nil782 token.ac.captureFrames = nil
813 token.ac.sendMu.Unlock()783 token.ac.sendMu.Unlock()
814 }784 }
815- if view, remote := token.owner.(*remoteView); remote {785+ 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 false786 return false
821 }787 }
822- if token.ac.renderMode == ports.RenderModeProxiedContent {788+ 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 true789 return true
833 }790 }
834 791
@@ -894,31 +851,25 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
894 d.invalidateRender(sess, ac, true, "client.go")851 d.invalidateRender(sess, ac, true, "client.go")
895 }852 }
896 853
897-func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) bool {854+func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) {
898 if !token.attachmentEffectCurrent() {855 if !token.attachmentEffectCurrent() {
899- return false856+ return
900 }857 }
901 clientTheme := themeFromMessage(msg)858 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)859 token.ac.setClientTheme(clientTheme)
911- sess := token.localSession()860+ sess := token.sess
912 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {861 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
913- return false862+ return
914 }863 }
915 if !token.attachmentEffectCurrent() {864 if !token.attachmentEffectCurrent() {
916- return false865+ return
917 }866 }
918- if rc := sess.renderCoordinator(); rc != nil {867+ rc := sess.renderCoordinator()
919- return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})868+ if token.lease == nil || rc == nil {
869+ d.invalidateRender(sess, token.ac, true, "client.go")
870+ return
920 }871 }
921- return false872+ rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
922 }873 }
923 874
924 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {875 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
@@ -954,23 +905,6 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
954 }905 }
955 ac.sendMu.Unlock()906 ac.sendMu.Unlock()
956 907
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() {908 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {
975 // The picker preview geometry is attachment-local. A resize must909 // The picker preview geometry is attachment-local. A resize must
976 // invalidate any in-flight remote request and fetch the selected row910 // invalidate any in-flight remote request and fetch the selected row
@@ -978,7 +912,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
978 d.registerPreviewForSelection(ac)912 d.registerPreviewForSelection(ac)
979 }913 }
980 914
981- sess := token.localSession()915+ sess := token.sess
982 if sess == nil {916 if sess == nil {
983 return false917 return false
984 }918 }
@@ -1036,21 +970,11 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
1036 d.notices.routingMu.Unlock()970 d.notices.routingMu.Unlock()
1037 return971 return
1038 }972 }
1039- sess := token.localSession()973+ 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()974 d.notices.routingMu.Unlock()
1048- if repaint && token.attachmentEffectCurrent() {
1049- d.scheduleRemoteViewPaint(view, token.ac, false)
1050- }
1051 return975 return
1052 }976 }
1053- repaint := d.mutateClientNotice(sess, token.ac, notice)977+ repaint := d.mutateClientNotice(token.sess, token.ac, notice)
1054 d.notices.routingMu.Unlock()978 d.notices.routingMu.Unlock()
1055 if repaint && token.attachmentEffectCurrent() {979 if repaint && token.attachmentEffectCurrent() {
1056 d.repaintForNotice(token.ac)980 d.repaintForNotice(token.ac)
@@ -1077,52 +1001,34 @@ func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, not
1077 }1001 }
1078 1002
1079 func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {1003 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 {1004 switch notice.Action {
1115 case ports.ClientNoticeClipboardFallback:1005 case ports.ClientNoticeClipboardFallback:
1116- return domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", Time: d.clock.Now()}, true1006+ return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
1117 case ports.ClientNoticeClipboardTooLarge:1007 case ports.ClientNoticeClipboardTooLarge:
1118- return domain.Notification{Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", Time: d.clock.Now()}, true1008+ return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
1119 case ports.ClientNoticeLinkDegraded:1009 case ports.ClientNoticeLinkDegraded:
1120- return domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", Time: d.clock.Now()}, true1010+ return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
1011+ case ports.ClientNoticeLinkConnected:
1012+ return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
1121 default:1013 default:
1122- return domain.Notification{}, false1014+ return false
1123 }1015 }
1124 }1016 }
1125 1017
1018+// recordClientNotice mutates notice history and the selected attachment while
1019+// routingMu keeps another connection from changing that selection. Rendering is left
1020+// to the caller after it releases routingMu.
1021+func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
1022+ n := d.notices.record(domain.Notification{
1023+ Code: code,
1024+ Severity: sev,
1025+ Message: message,
1026+ Time: d.clock.Now(),
1027+ SessionID: sess.id,
1028+ })
1029+ return d.publishToast(ac, n)
1030+}
1031+
1126 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The1032 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
1127 // returned value reports whether the synchronous request was accepted.1033 // returned value reports whether the synchronous request was accepted.
1128 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {1034 func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
M internal/usecase/daemon/client_frame_routing.go +83 -43
@@ -4,6 +4,7 @@ import (
4 "errors"4 "errors"
5 "runtime"5 "runtime"
6 6
7+ "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/command"9 "github.com/bnema/vev/internal/usecase/command"
9 )10 )
@@ -13,24 +14,24 @@ const connectionSnapshotAttempts = 8
13 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a14 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
14 // handoff lands between those reads. The attachment gate still performs the final15 // handoff lands between those reads. The attachment gate still performs the final
15 // mutation admission after this function returns.16 // mutation admission after this function returns.
16-func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentOwner, attachmentConnectionToken, bool) {17+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentConnectionToken, bool) {
17 for range connectionSnapshotAttempts {18 for range connectionSnapshotAttempts {
18 if !ac.currentTransportIs(tr) {19 if !ac.currentTransportIs(tr) {
19 break20 break
20 }21 }
21- owner := ac.currentAttachmentOwner()22+ sess := ac.currentAttachmentSession()
22- if owner == nil {23+ if sess == nil {
23 return nil, attachmentConnectionToken{}, false24 return nil, attachmentConnectionToken{}, false
24 }25 }
25- if sess := localSession(owner); sess != nil && d.afterConnectionSessionSnapshot != nil {26+ if d.afterConnectionSessionSnapshot != nil {
26 d.afterConnectionSessionSnapshot(sess)27 d.afterConnectionSessionSnapshot(sess)
27 }28 }
28- token := attachmentOwnerToken(owner, ac, tr)29+ token := attachmentToken(sess, ac, tr)
29- if token.ac == nil || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) {30+ if token.ac == nil || ac.currentAttachmentSession() != sess {
30 runtime.Gosched()31 runtime.Gosched()
31 continue32 continue
32 }33 }
33- return owner, token, true34+ return sess, token, true
34 }35 }
35 return nil, attachmentConnectionToken{}, false36 return nil, attachmentConnectionToken{}, false
36 }37 }
@@ -44,23 +45,19 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
44 return45 return
45 }46 }
46 for {47 for {
47- if !ac.currentTransportIs(tr) || ac.currentAttachmentOwner() == nil {48+ if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil {
48 return49 return
49 }50 }
50 f, err := tr.Recv()51 f, err := tr.Recv()
51 if err != nil {52 if err != nil {
52 for range connectionSnapshotAttempts {53 for range connectionSnapshotAttempts {
53- owner, token, ok := d.currentAttachmentConnection(ac, tr)54+ sess, _, ok := d.currentAttachmentConnection(ac, tr)
54 if !ok {55 if !ok {
55 return56 return
56 }57 }
57- if sess := localSession(owner); sess != nil {58+ d.clientGone(sess, ac, tr, false)
58- d.clientGone(sess, ac, tr, false)59+ current := ac.currentAttachmentSession()
59- } else if view, remote := owner.(*remoteView); remote {60+ 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 return61 return
65 }62 }
66 }63 }
@@ -129,17 +126,51 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
129 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {126 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
130 d.resetOutput(token)127 d.resetOutput(token)
131 }128 }
129+ case ports.MsgRecentRouteSnapshot:
130+ snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload)
131+ if derr == nil {
132+ token.ac.setRouteSnapshot(snapshot)
133+ } else {
134+ d.log.Warn("malformed recent route snapshot", "err", derr)
135+ }
136+ case ports.MsgRouteNavigationFailure:
137+ failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload)
138+ if derr != nil {
139+ d.log.Warn("malformed route navigation failure", "err", derr)
140+ break
141+ }
142+ message := "route navigation failed"
143+ switch failure.Code {
144+ case ports.RouteFailureStaleSelection:
145+ message = "that recent route is no longer available"
146+ case ports.RouteFailureTargetChanged:
147+ message = "that recent route changed before attach"
148+ case ports.RouteFailureOriginUnavailable:
149+ message = "the original route is no longer available"
150+ case ports.RouteFailureUnavailable:
151+ message = "the selected route is unavailable"
152+ case ports.RouteFailureNoSuchRoute:
153+ message = "that recent route no longer exists"
154+ }
155+ d.notify(token.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
132 case ports.MsgCommand:156 case ports.MsgCommand:
133 request, derr := ports.UnmarshalCommandRequest(f.Payload)157 request, derr := ports.UnmarshalCommandRequest(f.Payload)
134 if derr != nil {158 if derr != nil {
135 return false159 return false
136 }160 }
137 result := d.executeAttachedCommand(token, request)161 result := d.executeAttachedCommand(token, request)
138- if err := token.sendControl(frameCommandResult(result)); err != nil {162+ resultToken, ok := d.commandResultToken(token)
139- token.endAttachmentEffect()163+ if !ok {
140- d.detachOnAttachmentSendError(token, token.transport.transport)164+ // A remote handoff or explicit detach deliberately retired the old
165+ // attachment; it must not be treated as a stale-result send error.
141 return true166 return true
142 }167 }
168+ if err := resultToken.sendControl(frameCommandResult(result)); err != nil {
169+ resultToken.endAttachmentEffect()
170+ d.detachOnAttachmentSendError(resultToken, resultToken.transport.transport)
171+ return true
172+ }
173+ resultToken.endAttachmentEffect()
143 case ports.MsgPing:174 case ports.MsgPing:
144 if err := token.sendControl(framePong()); err != nil {175 if err := token.sendControl(framePong()); err != nil {
145 token.endAttachmentEffect()176 token.endAttachmentEffect()
@@ -153,6 +184,33 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
153 return false184 return false
154 }185 }
155 186
187+// commandResultToken renews the attachment effect after a local navigation
188+// transition ends the command's initiating effect. A retired transport/session
189+// means the command deliberately handed off or detached and has no result to
190+// send on the old carriage.
191+func (d *Daemon) commandResultToken(token attachmentConnectionToken) (attachmentConnectionToken, bool) {
192+ if token.ac == nil || !token.ac.currentTransportIs(token.transport.transport) {
193+ return attachmentConnectionToken{}, false
194+ }
195+ sess := token.ac.currentAttachmentSession()
196+ if sess == nil {
197+ return attachmentConnectionToken{}, false
198+ }
199+ resultToken := attachmentToken(sess, token.ac, token.transport.transport)
200+ if resultToken.ac == nil {
201+ return attachmentConnectionToken{}, false
202+ }
203+ if token.effect != nil && !token.effect.ended.Load() && token.current() {
204+ return token, true
205+ }
206+ ticket, admitted := token.ac.beginAttachmentEffect(resultToken)
207+ if !admitted {
208+ return attachmentConnectionToken{}, false
209+ }
210+ resultToken.effect = ticket
211+ return resultToken, true
212+}
213+
156 func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {214 func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {
157 result := ports.CommandResult{RequestID: request.RequestID}215 result := ports.CommandResult{RequestID: request.RequestID}
158 if request.Version != ports.ProtocolVersion {216 if request.Version != ports.ProtocolVersion {
@@ -175,17 +233,12 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
175 result.Text = "attached commands cannot target themselves"233 result.Text = "attached commands cannot target themselves"
176 return result234 return result
177 }235 }
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 != "" {236 if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {
184 result.Code = ports.ErrInvalidCommandArgs237 result.Code = ports.ErrInvalidCommandArgs
185 result.Text = "attached commands cannot override their active session target"238 result.Text = "attached commands cannot override their active session target"
186 return result239 return result
187 }240 }
188- sess := token.localSession()241+ sess := token.sess
189 if sess == nil || !token.attachmentEffectCurrent() {242 if sess == nil || !token.attachmentEffectCurrent() {
190 result.Code = ports.ErrNoSuchTarget243 result.Code = ports.ErrNoSuchTarget
191 result.Text = "attached session is no longer active"244 result.Text = "attached session is no longer active"
@@ -243,11 +296,7 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
243 ac.pipelineCache = composeCacheInput{}296 ac.pipelineCache = composeCacheInput{}
244 ac.pipelineScratch = composeCacheInput{}297 ac.pipelineScratch = composeCacheInput{}
245 ac.sendMu.Unlock()298 ac.sendMu.Unlock()
246- if view, remote := token.owner.(*remoteView); remote {299+ 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 true300 return true
252 }301 }
253 302
@@ -261,21 +310,12 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
261 ac.sendMu.Unlock()310 ac.sendMu.Unlock()
262 return false311 return false
263 }312 }
264- advanced := ac.output.ack(epoch, state)313+ acknowledged := ac.output.ack(epoch, state)
265 ac.sendMu.Unlock()314 ac.sendMu.Unlock()
266- if !advanced {315+ if acknowledged {
267- return true316+ if rc := token.sess.renderCoordinator(); rc != nil {
268- }
269- if sess := token.localSession(); sess != nil {
270- if rc := sess.renderCoordinator(); rc != nil {
271 rc.notifyAckForLease(token.lease)317 rc.notifyAckForLease(token.lease)
272 }318 }
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 }319 }
280- return true320+ return acknowledged
281 }321 }
M internal/usecase/daemon/client_transport_errors.go +23 -115
@@ -5,6 +5,14 @@ import "github.com/bnema/vev/internal/ports"
5 // clientGone detaches ac if it is still the session's current client. The5 // clientGone detaches ac if it is still the session's current client. The
6 // session remains registered and headless after the client is gone.6 // session remains registered and headless after the client is gone.
7 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {7 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
8+ d.clientGoneWithNotice(sess, ac, failed, explicit, true)
9+}
10+
11+func (d *Daemon) clientGoneWithoutNotice(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
12+ d.clientGoneWithNotice(sess, ac, failed, explicit, false)
13+}
14+
15+func (d *Daemon) clientGoneWithNotice(sess *session, ac *attachedClient, failed ports.Transport, explicit, notice bool) {
8 if sess == nil || ac == nil {16 if sess == nil || ac == nil {
9 return17 return
10 }18 }
@@ -28,7 +36,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
28 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)36 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
29 return // displaced, or the link was rebound after the precheck37 return // displaced, or the link was rebound after the precheck
30 }38 }
31- d.finishClientGone(sess, ac, failed, explicit)39+ d.finishClientGone(sess, ac, failed, explicit, notice)
32 }40 }
33 41
34 func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {42 func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {
@@ -37,10 +45,7 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
37 }45 }
38 token.effect.bindActionEnd(d, "detach")46 token.effect.bindActionEnd(d, "detach")
39 token.effect.End()47 token.effect.End()
40- if view, remote := token.owner.(*remoteView); remote {48+ sess := token.sess
41- return d.clientGoneRemote(view, token, explicit)
42- }
43- sess := token.localSession()
44 if sess == nil {49 if sess == nil {
45 return false50 return false
46 }51 }
@@ -52,99 +57,11 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
52 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)57 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
53 return false58 return false
54 }59 }
55- d.finishClientGone(sess, token.ac, token.transport.transport, explicit)60+ d.finishClientGone(sess, token.ac, token.transport.transport, explicit, true)
56- return true
57-}
58-
59-// clientGoneRemote retires only the current local-client binding to a remote
60-// view. The remote link itself remains a Phase 4 lifecycle concern; a failed
61-// local transport parks the stable view owner without inventing a local PTY.
62-func (d *Daemon) clientGoneRemote(view *remoteView, token attachmentConnectionToken, explicit bool) bool {
63- if d == nil || view == nil || token.ac == nil || !sameAttachmentOwner(token.owner, view) {
64- return false
65- }
66- ac := token.ac
67- var parkingToken uint64
68- if !explicit {
69- parkingToken = d.markParkingInFlightOwner(view, ac)
70- }
71- if d.beforeClientGoneDetach != nil {
72- d.beforeClientGoneDetach()
73- }
74- if !d.detachRemoteAttachmentCurrent(view, token) {
75- d.clearParkingInFlightIfAbandonedOwner(view, ac, parkingToken)
76- return false
77- }
78- return d.finishRemoteClientGone(view, ac, token.transport.transport, explicit)
79-}
80-
81-// finishRemoteClientGone completes remote local-client teardown only after the
82-// exact owner binding has been unpublished. It intentionally leaves remote-link
83-// lifecycle ownership to Phase 4.
84-func (d *Daemon) finishRemoteClientGone(view *remoteView, ac *attachedClient, transport ports.Transport, explicit bool) bool {
85- if d == nil || view == nil || ac == nil {
86- return false
87- }
88- d.unregisterPreview(ac)
89- if !explicit && d.parkAttachmentOwner(view, ac) {
90- _ = ac.closeCapturedTransport(transport)
91- d.parkRemoteViewWarm(view)
92- return true
93- }
94- d.clearParkingInFlight(ac.resumeToken, ac)
95- d.closePicker(ac)
96- ac.clearPreviousSession()
97- if explicit {
98- d.boundedSend(ac, frameDetached(ports.ReasonDetach))
99- }
100- _ = ac.closeCapturedTransport(transport)
101- d.parkRemoteViewWarm(view)
102 return true61 return true
103 }62 }
104 63
105-// detachRemoteAttachmentCurrent invalidates exactly the frozen remote-view64+func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit, notice bool) {
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) {
148 if sess == nil || ac == nil {65 if sess == nil || ac == nil {
149 return66 return
150 }67 }
@@ -157,29 +74,29 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
157 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")74 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
158 sess.mu.Lock()75 sess.mu.Lock()
159 ephemeral := sess.ephemeral76 ephemeral := sess.ephemeral
77+ name := sess.name
160 sess.mu.Unlock()78 sess.mu.Unlock()
161 if !ephemeral {79 if !ephemeral {
162 d.refreshSessionCwd(sess)80 d.refreshSessionCwd(sess)
163 }81 }
164- d.log.Info("client detach begin", "session", sess.name, "explicit", explicit, "ephemeral", ephemeral)82+ d.log.Info("client detach begin", "session", name, "explicit", explicit, "ephemeral", ephemeral)
165 oldTr := failed83 oldTr := failed
166 if oldTr == nil {84 if oldTr == nil {
167 oldTr = ac.transport()85 oldTr = ac.transport()
168 }86 }
169 if !explicit && d.parkAttachment(sess, ac) {87 if !explicit && d.parkAttachment(sess, ac) {
170 _ = ac.closeCapturedTransport(oldTr)88 _ = ac.closeCapturedTransport(oldTr)
171- d.log.Info("client parked", "session", sess.name)89+ d.log.Info("client parked", "session", name)
172 return90 return
173 }91 }
174 // Explicit winners (and non-explicit park failures) must drop any same-92 // Explicit winners (and non-explicit park failures) must drop any same-
175 // attachment parking marker left by a raced non-explicit teardown so93 // attachment parking marker left by a raced non-explicit teardown so
176 // IntentResume waiters are not stranded on a never-published park.94 // IntentResume waiters are not stranded on a never-published park.
177- d.clearParkingInFlight(ac.resumeToken, ac)95+ d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
178 d.closePicker(ac)96 d.closePicker(ac)
179 97
180 d.resetScreenDefaultColors(sess)98 d.resetScreenDefaultColors(sess)
181- ac.clearPreviousSession()99+ if explicit && notice {
182- if explicit {
183 // Synchronous so the ack is delivered before the transport closes100 // Synchronous so the ack is delivered before the transport closes
184 // (the client is actively awaiting it), but deadline-bounded so a101 // (the client is actively awaiting it), but deadline-bounded so a
185 // wedged client cannot pin this conn handler and hang Serve's102 // wedged client cannot pin this conn handler and hang Serve's
@@ -187,7 +104,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
187 d.boundedSend(ac, frameDetached(ports.ReasonDetach))104 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
188 }105 }
189 _ = ac.closeCapturedTransport(oldTr)106 _ = ac.closeCapturedTransport(oldTr)
190- d.log.Info("client detached", "session", sess.name, "explicit", explicit)107+ d.log.Info("client detached", "session", name, "explicit", explicit)
191 }108 }
192 109
193 // detachOnSendError drops a client whose transport failed, leaving the session110 // detachOnSendError drops a client whose transport failed, leaving the session
@@ -218,16 +135,7 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
218 if failed == nil || failed != token.transport.transport {135 if failed == nil || failed != token.transport.transport {
219 return136 return
220 }137 }
221- if view, remote := token.owner.(*remoteView); remote {138+ 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 {139 if sess == nil {
232 return140 return
233 }141 }
@@ -261,19 +169,19 @@ func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentConnectionTok
261 }169 }
262 170
263 func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {171 func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.Transport) {
172+ name := sess.nameSnapshot()
264 if rc := sess.renderCoordinator(); rc != nil {173 if rc := sess.renderCoordinator(); rc != nil {
265 rc.noteDetach(ac)174 rc.noteDetach(ac)
266 }175 }
267 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")176 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
268 if d.parkAttachment(sess, ac) {177 if d.parkAttachment(sess, ac) {
269 _ = ac.closeCapturedTransport(failed)178 _ = ac.closeCapturedTransport(failed)
270- d.log.Warn("parked client after send error", "session", sess.name)179+ d.log.Warn("parked client after send error", "session", name)
271 return180 return
272 }181 }
273- d.clearParkingInFlight(ac.resumeToken, ac)182+ d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
274 d.closePicker(ac)183 d.closePicker(ac)
275 d.resetScreenDefaultColors(sess)184 d.resetScreenDefaultColors(sess)
276- ac.clearPreviousSession()
277 _ = ac.closeCapturedTransport(failed)185 _ = ac.closeCapturedTransport(failed)
278- d.log.Warn("detached client after send error", "session", sess.name)186+ d.log.Warn("detached client after send error", "session", name)
279 }187 }
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 +205 -193
@@ -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,98 @@ 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+ welcomeSent := false
1151+ //nolint:contextcheck // handshakeCtx is intentionally not propagated: cancellation teardown must complete its geometry and ownership cleanup.
1152+ failAttachment := func() {
1153+ d.failHandshakeAttachment(sess, ac, tr, welcomeSent)
1154+ }
1155+ if err := handshakeCtx.Err(); err != nil {
1156+ failAttachment()
1157+ return
1158+ }
1159+ expected := ac.transportSnapshot()
1160+ welcomeToken := sess.attachmentToken(ac, tr)
1161+ welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1162+ if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1163+ if admitted {
1164+ welcomeTicket.End()
1165+ }
1166+ failAttachment()
1167+ return
1168+ }
1169+ if err := handshakeCtx.Err(); err != nil {
1170+ welcomeTicket.End()
1171+ failAttachment()
1172+ return
1173+ }
1174+ welcomeDone, welcomeErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1175+ frame, err := frameWelcome(sess, d.resumeTokenSnapshot(ac))
1176+ if err != nil {
1177+ return err
1178+ }
1179+ return ac.sendExpectedTransportForAttachment(expected, frame, welcomeTicket)
1180+ })
1181+ if welcomeErr != nil {
1182+ <-welcomeDone
1183+ welcomeTicket.End()
1184+ failAttachment()
1185+ return
1186+ }
1187+ welcomeSent = true
1188+ // Release Welcome's effect before discovering post-handshake authority so a
1189+ // replacement blocked behind the send can publish its generation and lease.
1190+ welcomeTicket.End()
1191+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffectContext(handshakeCtx, sess, tr)
1192+ if !admitted {
1193+ failAttachment()
1194+ return
1195+ }
1196+ postWelcomeLease := postWelcomeToken.lease
1197+ if postWelcomeToken.ac == nil {
1198+ postWelcomeTicket.End()
1199+ failAttachment()
1200+ return
1201+ }
1202+ postWelcomeRC := sess.renderCoordinator()
1203+ if postWelcomeLease != nil && (postWelcomeRC == nil || !postWelcomeRC.markAttachmentReady(postWelcomeLease)) {
1204+ postWelcomeTicket.End()
1205+ // The attachment was detached while Welcome was in flight; never let
1206+ // this stale handshake emit an Output frame.
1207+ failAttachment()
1208+ return
1209+ }
1210+ paintToken := sess.attachmentToken(ac, tr)
1211+ painted := make(chan bool, 1)
1212+ paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1213+ //nolint:contextcheck // firstPaintForTransition is capability-bounded; the surrounding handshake operation owns cancellation.
1214+ painted <- d.firstPaintForTransition(paintToken)
1215+ return nil
1216+ })
1217+ if paintErr != nil {
1218+ <-paintDone
1219+ postWelcomeTicket.End()
1220+ failAttachment()
1221+ return
1222+ }
1223+ if !<-painted || handshakeCtx.Err() != nil {
1224+ postWelcomeTicket.End()
1225+ failAttachment()
1226+ return
1227+ }
1228+ if !d.commitResumeClaim(ac) {
1229+ postWelcomeTicket.End()
1230+ failAttachment()
1231+ return
1232+ }
1233+ if ac.startupOverlay == ports.StartupOverlaySessionPicker {
1234+ d.enterPicker(sess, ac)
1235+ }
1236+ postWelcomeTicket.End()
1237+ stopHandshakeTransport()
1238+ finishHandshake()
1239+ d.runConnLoop(ac)
1240+ _ = tr.Close()
1262 }1241 }
1263 1242
1264 // protoErr is a session-level rejection carrying a wire ErrorMsg code.1243 // protoErr is a session-level rejection carrying a wire ErrorMsg code.
@@ -1287,6 +1266,12 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1287 return nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}1266 return nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
1288 }1267 }
1289 }1268 }
1269+ if h.PreferredTabID != "" {
1270+ // Route memory is a best-effort attachment cursor, not attach authority.
1271+ // It overrides point-in-time picker tab metadata when still present and
1272+ // otherwise falls back to the session's normal first-tab repair.
1273+ initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
1274+ }
1290 // Session state is the sole source for future PTY children. Update it before1275 // Session state is the sole source for future PTY children. Update it before
1291 // publishing the attachment; existing PTYs keep their original environment.1276 // publishing the attachment; existing PTYs keep their original environment.
1292 // A picker handoff deliberately leaves the daemon-owned environment and CWD1277 // A picker handoff deliberately leaves the daemon-owned environment and CWD
@@ -1298,12 +1283,14 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1298 sess.terminal = term1283 sess.terminal = term
1299 sess.mu.Unlock()1284 sess.mu.Unlock()
1300 opts := attachClientOptions{1285 opts := attachClientOptions{
1301- clientID: h.ClientID,1286+ clientID: h.ClientID,
1302- resumeCapable: true,1287+ resumeCapable: true,
1303- maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1288+ maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1304- renderMode: h.RenderMode,1289+ navigationCapabilities: h.NavigationCapabilities,
1305- }1290+ startupOverlay: h.StartupOverlay,
1306- if h.RemoteTarget != nil {1291+ remoteOrigin: h.RemoteOrigin,
1292+ }
1293+ if opts.remoteOrigin == "" && h.RemoteTarget != nil {
1307 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin1294 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
1308 }1295 }
1309 ac := d.prepareAttachedClientLocked(tr, sz, opts)1296 ac := d.prepareAttachedClientLocked(tr, sz, opts)
@@ -1327,6 +1314,20 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1327 return ac, nil1314 return ac, nil
1328 }1315 }
1329 1316
1317+func preferredTabIndex(sess *session, preferred domain.TabStableID) int {
1318+ if sess == nil || preferred == "" {
1319+ return -1
1320+ }
1321+ sess.mu.Lock()
1322+ defer sess.mu.Unlock()
1323+ for i, candidate := range sess.tabs {
1324+ if candidate != nil && domain.TabStableID(candidate.stableID) == preferred {
1325+ return i
1326+ }
1327+ }
1328+ return -1
1329+}
1330+
1330 func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {1331 func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1331 d.mu.Lock()1332 d.mu.Lock()
1332 var (1333 var (
@@ -1377,6 +1378,33 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1377 // route resolves a Hello to a session and a freshly attached client, creating1378 // route resolves a Hello to a session and a freshly attached client, creating
1378 // the session for ephemeral/new intents. Direct package callers retain the1379 // the session for ephemeral/new intents. Direct package callers retain the
1379 // daemon lifetime context; inbound handshakes use routeWithContext.1380 // daemon lifetime context; inbound handshakes use routeWithContext.
1381+func (d *Daemon) validateExactSessionTargetLocked(target ports.ExactSessionTarget) error {
1382+ if sess := d.findByNameLocked(target.SessionName); sess != nil {
1383+ if sess.incarnation != target.LifecycleID {
1384+ return &protoErr{ports.ErrNoSuchSession, "session lifecycle has changed: " + target.SessionName}
1385+ }
1386+ return nil
1387+ }
1388+ stopped, ok := d.stopped[target.SessionName]
1389+ if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {
1390+ return &protoErr{ports.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName}
1391+ }
1392+ return nil
1393+}
1394+
1395+func (d *Daemon) validateExactSessionTarget(ctx context.Context, target ports.ExactSessionTarget) error {
1396+ if err := target.Validate(); err != nil {
1397+ return &protoErr{ports.ErrNoSuchSession, "invalid exact session target"}
1398+ }
1399+ if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
1400+ return err
1401+ }
1402+
1403+ d.mu.Lock()
1404+ defer d.mu.Unlock()
1405+ return d.validateExactSessionTargetLocked(target)
1406+}
1407+
1380 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {1408 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
1381 ctx := d.serveCtx1409 ctx := d.serveCtx
1382 if ctx == nil {1410 if ctx == nil {
@@ -1397,9 +1425,20 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1397 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}1425 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
1398 }1426 }
1399 term := terminalEnv{TrueColor: h.TrueColor}1427 term := terminalEnv{TrueColor: h.TrueColor}
1428+ if h.Intent == ports.IntentResume && h.ResumeToken == 0 {
1429+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is required"}
1430+ }
1431+ if h.ExactTarget != nil && h.ResumeToken == 0 && h.Name != h.ExactTarget.SessionName {
1432+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "exact session target name mismatch"}
1433+ }
1400 if h.RemoteTarget != nil && h.ResumeToken == 0 {1434 if h.RemoteTarget != nil && h.ResumeToken == 0 {
1401 return d.routeRemoteTargetWithContext(ctx, h, tr)1435 return d.routeRemoteTargetWithContext(ctx, h, tr)
1402 }1436 }
1437+ if h.ExactTarget != nil && h.ResumeToken == 0 {
1438+ if err := d.validateExactSessionTarget(ctx, *h.ExactTarget); err != nil {
1439+ return nil, nil, err
1440+ }
1441+ }
1403 1442
1404 // A non-zero token is an authoritative resume credential. If it is unknown,1443 // A non-zero token is an authoritative resume credential. If it is unknown,
1405 // expired, or raced with lifecycle teardown, fail closed instead of routing1444 // expired, or raced with lifecycle teardown, fail closed instead of routing
@@ -1420,20 +1459,12 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1420 }1459 }
1421 return nil, nil, err1460 return nil, nil, err
1422 } else if ok {1461 } 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, nil1462 return sess, ac, nil
1428 }1463 }
1429 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}1464 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1430 }1465 }
1431 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {1466 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
1432 if ok {1467 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, nil1468 return sess, ac, nil
1438 }1469 }
1439 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}1470 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
@@ -1451,6 +1482,10 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1451 return nil, nil, err1482 return nil, nil, err
1452 }1483 }
1453 }1484 }
1485+ if h.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned && h.RemoteTarget == nil &&
1486+ (h.Intent == ports.IntentNew || h.Intent == ports.IntentEphemeral) {
1487+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "daemon-owned environment requires an exact remote target"}
1488+ }
1454 d.mu.Lock()1489 d.mu.Lock()
1455 // Shutdown/create interlock: once shutdown has begun (last session removed,1490 // Shutdown/create interlock: once shutdown has begun (last session removed,
1456 // or shutdownAll started) no new session may be created and no attach may1491 // or shutdownAll started) no new session may be created and no attach may
@@ -1460,32 +1495,16 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1460 d.mu.Unlock()1495 d.mu.Unlock()
1461 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}1496 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
1462 }1497 }
1463- switch h.Intent {1498+ // The first exact-target check happens before restore I/O. Recheck while
1464- case ports.IntentResume:1499+ // holding d.mu so a same-name lifecycle replacement cannot slip between
1465- // Resume miss/expiry falls back to normal attach semantics.1500+ // validation and attachment creation.
1466- sess := d.findByNameLocked(h.Name)1501+ if h.ExactTarget != nil && h.ResumeToken == 0 {
1467- created := false1502+ if err := d.validateExactSessionTargetLocked(*h.ExactTarget); err != nil {
1468- if sess == nil {1503+ d.mu.Unlock()
1469- stopped, ok := d.stopped[h.Name]1504+ return nil, nil, err
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 }1505 }
1487- return sess, ac, err1506+ }
1488-1507+ switch h.Intent {
1489 case ports.IntentEphemeral:1508 case ports.IntentEphemeral:
1490 name := d.allocEphemeralNameLocked()1509 name := d.allocEphemeralNameLocked()
1491 sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env)1510 sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env)
@@ -1493,11 +1512,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1493 d.mu.Unlock()1512 d.mu.Unlock()
1494 return nil, nil, err1513 return nil, nil, err
1495 }1514 }
1496- ac, err := d.finishAttach(sess, tr, sz, term, h)1515+ 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, err1516 return sess, ac, err
1502 1517
1503 case ports.IntentNew:1518 case ports.IntentNew:
@@ -1518,11 +1533,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1518 d.mu.Unlock()1533 d.mu.Unlock()
1519 return nil, nil, err1534 return nil, nil, err
1520 }1535 }
1521- ac, err := d.finishAttach(sess, tr, sz, term, h)1536+ 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, err1537 return sess, ac, err
1527 1538
1528 case ports.IntentAttach:1539 case ports.IntentAttach:
@@ -1535,18 +1546,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}1546 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
1536 }1547 }
1537 cwd := d.dirOrHome(stopped.cwd)1548 cwd := d.dirOrHome(stopped.cwd)
1549+ env := h.Env
1550+ if h.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned {
1551+ env = copyEnvironment(d.baseEnv)
1552+ }
1538 var err error1553 var err error
1539- sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)1554+ sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, env, stopped.tabNames)
1540 if err != nil {1555 if err != nil {
1541 d.mu.Unlock()1556 d.mu.Unlock()
1542 return nil, nil, err1557 return nil, nil, err
1543 }1558 }
1544 created = true1559 created = true
1545 }1560 }
1546- ac, err := d.finishAttach(sess, tr, sz, term, h)1561+ 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, err1562 return sess, ac, err
1551 1563
1552 default:1564 default:
A internal/usecase/daemon/exact_route_test.go +93 -0
@@ -0,0 +1,93 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRouteExactSessionTargetSelectsLifecycle(t *testing.T) {
13+ d := newTestDaemon(t, nil, stubClock{})
14+ sess := addControlSession(d, "work", "tab-1", "pane-1")
15+ sess.ephemeral = false
16+ sess.incarnation = remoteLifecycleForTest()
17+ tr, _ := newCapturingTransport(t)
18+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
19+ hello := ports.Hello{
20+ Version: ports.ProtocolVersion,
21+ Intent: ports.IntentAttach,
22+ Name: target.SessionName,
23+ Size: domain.Size{Cols: 80, Rows: 24},
24+ ExactTarget: &target,
25+ }
26+
27+ routed, ac, err := d.routeWithContext(context.Background(), hello, tr)
28+ require.NoError(t, err)
29+ require.Same(t, sess, routed)
30+ require.Same(t, sess, ac.currentAttachmentSession())
31+ d.clientGone(sess, ac, tr, false)
32+}
33+
34+func TestRouteExactSessionTargetRejectsNameMismatch(t *testing.T) {
35+ d := newTestDaemon(t, nil, stubClock{})
36+ sess := addControlSession(d, "work", "tab-1", "pane-1")
37+ sess.ephemeral = false
38+ sess.incarnation = remoteLifecycleForTest()
39+ tr, _ := newCapturingTransport(t)
40+ hello := ports.Hello{
41+ Version: ports.ProtocolVersion,
42+ Intent: ports.IntentAttach,
43+ Name: "other",
44+ Size: domain.Size{Cols: 80, Rows: 24},
45+ ExactTarget: &ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name},
46+ }
47+
48+ _, _, err := d.routeWithContext(context.Background(), hello, tr)
49+ var protocol *protoErr
50+ require.ErrorAs(t, err, &protocol)
51+ require.Equal(t, ports.ErrNoSuchSession, protocol.code)
52+}
53+
54+func TestLockedExactSessionTargetRejectsReplacement(t *testing.T) {
55+ d := newTestDaemon(t, nil, stubClock{})
56+ sess := addControlSession(d, "work", "tab-1", "pane-1")
57+ sess.ephemeral = false
58+ sess.incarnation = remoteLifecycleForTest()
59+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
60+ replacement := sess.incarnation
61+ replacement[0]++
62+ sess.incarnation = replacement
63+
64+ d.mu.Lock()
65+ err := d.validateExactSessionTargetLocked(target)
66+ d.mu.Unlock()
67+
68+ var protocol *protoErr
69+ require.ErrorAs(t, err, &protocol)
70+ require.Equal(t, ports.ErrNoSuchSession, protocol.code)
71+}
72+
73+func TestRouteExactSessionTargetRejectsLifecycleReplacement(t *testing.T) {
74+ d := newTestDaemon(t, nil, stubClock{})
75+ sess := addControlSession(d, "work", "tab-1", "pane-1")
76+ sess.ephemeral = false
77+ sess.incarnation = remoteLifecycleForTest()
78+ tr, _ := newCapturingTransport(t)
79+ wrong := sess.incarnation
80+ wrong[0]++
81+ hello := ports.Hello{
82+ Version: ports.ProtocolVersion,
83+ Intent: ports.IntentAttach,
84+ Name: sess.name,
85+ Size: domain.Size{Cols: 80, Rows: 24},
86+ ExactTarget: &ports.ExactSessionTarget{LifecycleID: wrong, SessionName: sess.name},
87+ }
88+
89+ _, _, err := d.routeWithContext(context.Background(), hello, tr)
90+ var protocol *protoErr
91+ require.ErrorAs(t, err, &protocol)
92+ require.Equal(t, ports.ErrNoSuchSession, protocol.code)
93+}
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/floating_render_test.go +1 -1
@@ -749,7 +749,7 @@ func benchmarkCapturedRenderState() capturedRenderState {
749 status: statusSnapshot{session: "main", tabs: []statusTab{{name: "main", paneTitle: "shell", active: true}}},749 status: statusSnapshot{session: "main", tabs: []statusTab{{name: "main", paneTitle: "shell", active: true}}},
750 topRight: "vev",750 topRight: "vev",
751 bottomRight: "main",751 bottomRight: "main",
752- mru: []recentSession{{name: "work"}, {name: "logs"}},752+ mru: []recentRouteDisplay{{name: "work"}, {name: "logs"}},
753 theme: terminalTheme,753 theme: terminalTheme,
754 },754 },
755 theme: terminalTheme,755 theme: terminalTheme,
M internal/usecase/daemon/handshake.go +25 -17
@@ -75,24 +75,38 @@ func watchHandshakeTransport(ctx context.Context, transport ports.Transport) fun
75 // handshake context remains live. Closing the transport is the interruption75 // handshake context remains live. Closing the transport is the interruption
76 // mechanism because ports.Transport methods do not accept contexts.76 // mechanism because ports.Transport methods do not accept contexts.
77 func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error {77 func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error {
78+ _, err := boundedHandshakeOperationTracked(ctx, transport, operation)
79+ return err
80+}
81+
82+// boundedHandshakeOperationTracked also returns a completion signal for the
83+// operation worker. Callers that own an effect gate must wait for this signal
84+// before releasing the gate after cancellation; the transport operation itself
85+// may still be unwinding after Close interrupts it.
86+func boundedHandshakeOperationTracked(ctx context.Context, transport ports.Transport, operation func() error) (<-chan struct{}, error) {
87+ done := make(chan struct{})
78 if err := ctx.Err(); err != nil {88 if err := ctx.Err(); err != nil {
89+ close(done)
79 _ = transport.Close()90 _ = transport.Close()
80- return err91+ return done, err
81 }92 }
82 completed := make(chan error, 1)93 completed := make(chan error, 1)
83- go func() { completed <- operation() }()94+ go func() {
95+ defer close(done)
96+ completed <- operation()
97+ }()
84 select {98 select {
85 case err := <-completed:99 case err := <-completed:
86 if ctxErr := ctx.Err(); ctxErr != nil {100 if ctxErr := ctx.Err(); ctxErr != nil {
87 _ = transport.Close()101 _ = transport.Close()
88- return ctxErr102+ return done, ctxErr
89 }103 }
90- return err104+ return done, err
91 case <-ctx.Done():105 case <-ctx.Done():
92 _ = transport.Close()106 _ = transport.Close()
93 // The result channel is buffered, so the operation can publish its107 // The result channel is buffered, so the operation can publish its
94 // completion after Close without keeping this cancellation path stuck.108 // completion after Close without keeping this cancellation path stuck.
95- return ctx.Err()109+ return done, ctx.Err()
96 }110 }
97 }111 }
98 112
@@ -114,14 +128,8 @@ func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fal
114 // failHandshakeAttachment synchronously retires the exact connection admitted128 // failHandshakeAttachment synchronously retires the exact connection admitted
115 // by route. A fresh route-owned session is purged only after its attachment is129 // by route. A fresh route-owned session is purged only after its attachment is
116 // removed; existing sessions and their unrelated attachments are preserved.130 // removed; existing sessions and their unrelated attachments are preserved.
117-func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {131+func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport, welcomed bool) {
118- d.failHandshakeAttachmentOwner(sess, ac, tr)132+ 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 {133 if tr != nil {
126 _ = tr.Close()134 _ = tr.Close()
127 }135 }
@@ -136,15 +144,15 @@ func (d *Daemon) failHandshakeAttachmentOwner(owner attachmentOwner, ac *attache
136 }144 }
137 return145 return
138 }146 }
139- if view, remote := owner.(*remoteView); remote {147+ if welcomed {
140- d.clientGoneRemote(view, attachmentOwnerToken(view, ac, tr), true)
141- } else if sess := localSession(owner); sess != nil {
142 d.clientGone(sess, ac, tr, true)148 d.clientGone(sess, ac, tr, true)
149+ } else {
150+ d.clientGoneWithoutNotice(sess, ac, tr, true)
143 }151 }
144 if tr != nil {152 if tr != nil {
145 _ = tr.Close()153 _ = tr.Close()
146 }154 }
147- if sess := localSession(owner); sess != nil && ac.routeCreatedSession {155+ if ac.routeCreatedSession {
148 sess.mu.Lock()156 sess.mu.Lock()
149 empty := len(sess.attachments) == 0157 empty := len(sess.attachments) == 0
150 sess.mu.Unlock()158 sess.mu.Unlock()
M internal/usecase/daemon/input.go +5 -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,12 @@ 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+ // Keep this defensive check even though token admission normally requires a session.
479- if sess == nil {401+ if h.connectionToken.sess == nil {
480 effect.End()402 effect.End()
481 return nil, nil, false403 return nil, nil, false
482 }404 }
483- return sess, effect, true405+ return h.connectionToken.sess, effect, true
484 }406 }
485 sess := h.ac.currentSession()407 sess := h.ac.currentSession()
486 if sess == nil {408 if sess == nil {
M internal/usecase/daemon/input_test.go +35 -187
@@ -778,197 +778,51 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
778 }778 }
779 }779 }
780 780
781-func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {781+func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
782- d, current, ac, _, releases := newRecentNavigationTestSessions(t)782+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
783- defer releaseAll(releases)
784- recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
785-
786- // Picker-style successful transition records its origin.
787- require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
788- for _, sess := range []*session{current, recent, current} {
789- runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
790- require.Same(t, sess, ac.currentSession())
791- }
792-}
793-
794-func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
795- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
796 defer releaseAll(releases)783 defer releaseAll(releases)
797- for _, tc := range []struct {784+ transport := ac.transport()
798- name string785+ rc := d.attachCoordinator(current, nil, ac, true)
799- prepare func()786+ token := current.attachmentToken(ac, transport)
800- }{787+ token.lease = rc.attachmentLease(ac)
801- {name: "no target", prepare: func() {}},788+ ac.publishAttachmentCapability(token)
802- {name: "stale target", prepare: func() { ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},789+ effect, admitted := ac.beginAttachmentEffect(token)
803- {name: "same session", prepare: func() { ac.previousOwner.Set(current) }},790+ require.True(t, admitted)
804- } {791+ defer effect.End()
805- t.Run(tc.name, func(t *testing.T) {792+ token.effect = effect
806- ac.clearPreviousSession()793+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
807- tc.prepare()794+ Generation: 3,
808- runPaletteCommand(t, d, current, ac, "BSK")795+ Active: ports.RouteRef{Key: 3, Generation: 3},
809- require.Same(t, current, ac.currentSession())796+ Previous: ports.RouteRef{Key: 2, Generation: 2},
810- if tc.name != "no target" {797+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}},
811- require.Nil(t, ac.previousOwner.Get(), "invalid target must not remain toggleable")798+ })
812- }799+
813- })800+ require.NoError(t, d.backSessionForAttachment(token))
814- }801+ frame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
802+ action, err := ports.UnmarshalRouteNavigationAction(frame.Payload)
803+ require.NoError(t, err)
804+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 3, Key: 2, Generation: 2}, action)
815 }805 }
816 806
817-func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {807+func TestBackSessionWithoutClientHistoryIsNoop(t *testing.T) {
818- d, current, ac, _, releases := newRecentNavigationTestSessions(t)808+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
819 defer releaseAll(releases)809 defer releaseAll(releases)
820- target := d.sessions[domain.SessionID("recent")]
821- ac.previousOwner.Set(target)
822-
823- // A displaced attachment makes the previously valid handoff stale by the
824- // time switchToTarget commits it.
825- current.mu.Lock()
826- clearAttachmentsForTestLocked(current)
827- current.mu.Unlock()
828 810
829 d.backSession(current, ac)811 d.backSession(current, ac)
830 812
831- require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")813+ require.Same(t, current, ac.currentSession())
832- require.Same(t, target, ac.previousOwner.Get(), "a failed handoff remains retryable")814+ require.Empty(t, d.notices.history())
833- history := d.notices.history()815+ requireNoOutputFrame(t, sends)
834- require.Len(t, history, 1, "the switch failure must be reported exactly once")
835- require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
836-}
837-
838-func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
839- ac := &attachedClient{}
840- stale := &session{sessionCore: sessionCore{id: "stale"}}
841- updated := &session{sessionCore: sessionCore{id: "updated"}}
842- ac.previousOwner.Set(stale)
843-
844- // Model a completed hand-off between observing a stale target and clearing
845- // it. The conditional clear must not erase the newer toggle destination.
846- ac.previousOwner.Set(updated)
847- ac.clearPreviousOwnerIf(stale)
848-
849- require.Same(t, updated, ac.previousOwner.Get())
850-}
851-
852-// backSession's invalid-target fallback is a render producer: it must publish
853-// exactly one coordinator invalidation instead of composing directly, while
854-// the valid-target hand-off (TestPaletteBackSessionTogglesPreviousSession) and
855-// the guarded stale clear (TestStaleBackSessionClearPreservesConcurrentTarget)
856-// keep their PR #73 behavior unchanged.
857-func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
858- for _, tc := range []struct {
859- name string
860- prepare func(current *session, ac *attachedClient)
861- }{
862- {name: "no previous target", prepare: func(*session, *attachedClient) {}},
863- {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
864- ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}})
865- }},
866- {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
867- ac.previousOwner.Set(current)
868- }},
869- } {
870- t.Run(tc.name, func(t *testing.T) {
871- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
872- defer releaseAll(releases)
873- invs := make(chan renderInvalidation, 4)
874- current.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
875- clock: d.clock,
876- wake: func(renderWake) {},
877- onInvalidate: func(inv renderInvalidation) { invs <- inv },
878- }))
879- tc.prepare(current, ac)
880-
881- d.backSession(current, ac)
882-
883- require.Same(t, current, ac.currentSession(), "an invalid target must not move the attachment")
884- awaitInvalidation(t, invs)
885- requireNoInvalidation(t, invs)
886- requireNoOutputFrame(t, sends)
887- })
888- }
889-}
890-
891-func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
892- for _, tc := range []struct {
893- name string
894- target func(*session, *session) picker.Target
895- wantCurrent string
896- wantPrev string
897- }{
898- {
899- name: "picker cross-session switch records origin",
900- target: func(_ *session, recent *session) picker.Target {
901- return picker.Target{Session: recent.id, TabIndex: -1}
902- },
903- wantCurrent: "recent",
904- wantPrev: "current",
905- },
906- {
907- name: "attention cross-session switch records origin",
908- target: func(_ *session, recent *session) picker.Target {
909- return picker.Target{Session: recent.id, TabIndex: 0}
910- },
911- wantCurrent: "recent",
912- wantPrev: "current",
913- },
914- {
915- name: "same-session tab switch does not record",
916- target: func(current *session, _ *session) picker.Target {
917- return picker.Target{Session: current.id, TabIndex: 0}
918- },
919- wantCurrent: "current",
920- wantPrev: "",
921- },
922- {
923- name: "missing target does not record",
924- target: func(_ *session, _ *session) picker.Target {
925- return picker.Target{Session: "missing", TabIndex: -1}
926- },
927- wantCurrent: "current",
928- wantPrev: "",
929- },
930- } {
931- t.Run(tc.name, func(t *testing.T) {
932- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
933- defer releaseAll(releases)
934- recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
935-
936- if tc.name == "missing target does not record" {
937- require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent)))
938- } else {
939- require.NoError(t, d.switchToTarget(current, ac, tc.target(current, recent)))
940- }
941- require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
942- if tc.wantPrev == "" {
943- require.Nil(t, ac.previousOwner.Get())
944- return
945- }
946- require.Equal(t, domain.SessionID(tc.wantPrev), localSession(ac.previousOwner.Get()).core().id)
947- })
948- }
949 }816 }
950 817
951-func TestTerminalTeardownClearsPreviousSession(t *testing.T) {818+func TestSwitchSourceDoesNotOwnPreviousRoute(t *testing.T) {
952- for _, tc := range []struct {819+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
953- name string820+ defer releaseAll(releases)
954- teardown func(*Daemon, *session, *attachedClient)821+ recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
955- }{
956- {name: "explicit detach", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), true) }},
957- {name: "non-park send error fallback", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
958- {name: "killed attached session", teardown: func(d *Daemon, sess *session, _ *attachedClient) {
959- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
960- }},
961- } {
962- t.Run(tc.name, func(t *testing.T) {
963- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
964- defer releaseAll(releases)
965-
966- ac.previousOwner.Set(d.sessions[domain.SessionID("recent")])
967- tc.teardown(d, current, ac)
968 822
969- require.Nil(t, ac.previousOwner.Get())823+ require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
970- })824+ require.Same(t, recent, ac.currentSession())
971- }825+ require.Equal(t, uint64(0), ac.routeSnapshotCopy().Generation, "route ownership starts in the client")
972 }826 }
973 827
974 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {828 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
@@ -990,12 +844,6 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached
990 return d, current, ac, sends, []func(){release1, release2, release3}844 return d, current, ac, sends, []func(){release1, release2, release3}
991 }845 }
992 846
993-func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) {
994- t.Helper()
995- d.handleInput(sess, ac, []byte("\x1b "))
996- d.handleInput(sess, ac, []byte(code+"\r"))
997-}
998-
999 func releaseAll(releases []func()) {847 func releaseAll(releases []func()) {
1000 for _, release := range releases {848 for _, release := range releases {
1001 release()849 release()
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_pane_postcommit.go +0 -1
@@ -116,7 +116,6 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
116 }116 }
117 for _, attachment := range p.retiredAttachments {117 for _, attachment := range p.retiredAttachments {
118 d.unregisterPreview(attachment.ac)118 d.unregisterPreview(attachment.ac)
119- attachment.ac.clearPreviousSession()
120 attachment.ac.clearCaptureFrames()119 attachment.ac.clearCaptureFrames()
121 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)120 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
122 }121 }
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/navigation_attachment_handoff_test.go +0 -6
@@ -32,12 +32,6 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
32 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")32 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
33 },33 },
34 },34 },
35- {
36- name: "palette recent session", action: "palette-recent-session",
37- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
38- return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)
39- },
40- },
41 {35 {
42 name: "session overflow", action: "overflow-session",36 name: "session overflow", action: "overflow-session",
43 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {37 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
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/overlay_runtime.go +16 -17
@@ -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 scopy "github.com/bnema/vev/internal/usecase/copy"10 scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/bnema/vev/internal/usecase/notices"11 "github.com/bnema/vev/internal/usecase/notices"
11 "github.com/bnema/vev/internal/usecase/palette"12 "github.com/bnema/vev/internal/usecase/palette"
@@ -31,11 +32,8 @@ type overlayRuntime struct {
31 pickerRemotePreview picker.Preview32 pickerRemotePreview picker.Preview
32 pickerRemotePreviewCancel context.CancelFunc33 pickerRemotePreviewCancel context.CancelFunc
33 pickerPreviewGeneration uint6434 pickerPreviewGeneration uint64
34- // pickerRemoteSelection fences one asynchronous remote picker activation.35+ pickerPending []byte
35- // It is cleared before the picker accepts another remote activation.36+ pickerESC pendingByteTimer
36- pickerRemoteSelection *remotePickerSelection
37- pickerPending []byte
38- pickerESC pendingByteTimer
39 37
40 // Test-only, unsynchronized lifecycle seams. Assign them before picker38 // Test-only, unsynchronized lifecycle seams. Assign them before picker
41 // publication or goroutine startup. Hooks run without pickerMu or39 // publication or goroutine startup. Hooks run without pickerMu or
@@ -43,13 +41,13 @@ type overlayRuntime struct {
43 beforeRemotePickerRegistration func()41 beforeRemotePickerRegistration func()
44 afterPickerRefreshBuild func(*picker.Model)42 afterPickerRefreshBuild func(*picker.Model)
45 43
46- paletteMu sync.Mutex44+ paletteMu sync.Mutex
47- palette *palette.Model45+ palette *palette.Model
48- paletteRecent []recentSession // immutable for this palette interaction46+ paletteRouteSnapshot ports.RecentRouteSnapshot
49- paletteGeneration uint6447+ paletteGeneration uint64
50- paletteHints palette.ContextualHints48+ paletteHints palette.ContextualHints
51- paletteFeedback string49+ paletteFeedback string
52- palettePending []byte50+ palettePending []byte
53 51
54 promptMu sync.Mutex52 promptMu sync.Mutex
55 prompt *promptui.Model53 prompt *promptui.Model
@@ -307,10 +305,10 @@ type overlayRenderSnapshot struct {
307 paletteModel *palette.Model305 paletteModel *palette.Model
308 // paletteHints is a copy captured under paletteMu. Rendering must use this306 // paletteHints is a copy captured under paletteMu. Rendering must use this
309 // immutable interaction snapshot rather than consult live session state.307 // immutable interaction snapshot rather than consult live session state.
310- paletteHints *palette.ContextualHints308+ paletteHints *palette.ContextualHints
311- paletteFeedback string309+ paletteFeedback string
312- paletteRecent []recentSession310+ paletteRouteSnapshot ports.RecentRouteSnapshot
313- paletteLocked bool311+ paletteLocked bool
314 312
315 promptActive bool313 promptActive bool
316 promptModel *promptui.Model314 promptModel *promptui.Model
@@ -381,7 +379,8 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
381 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)379 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
382 snap.paletteHints = &hints380 snap.paletteHints = &hints
383 snap.paletteFeedback = rt.paletteFeedback381 snap.paletteFeedback = rt.paletteFeedback
384- snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)382+ snap.paletteRouteSnapshot = rt.paletteRouteSnapshot
383+ snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...)
385 snap.paletteLocked = true384 snap.paletteLocked = true
386 } else {385 } else {
387 rt.paletteMu.Unlock()386 rt.paletteMu.Unlock()
M internal/usecase/daemon/palette.go +115 -55
@@ -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"
@@ -36,31 +37,26 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
36 }37 }
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 the client-owned route snapshot before taking paletteMu. The
40- // holding an overlay lock while inspecting live sessions. A remote view is41+ // snapshot is immutable for this palette interaction and the daemon never
41- // not a local session, so its retained local source must remain offered as a42+ // consults its own session history for recent-route commands.
42- // palette result and JRS candidate.43+ routeSnapshot := ac.routeSnapshotCopy()
43- current := sess
44- if _, remote := normalizeAttachmentOwner(ac.currentAttachmentOwner()).(*remoteView); remote {
45- current = nil
46- }
47- recent := d.recentSessions(current)
48 commands := d.paletteCommands()44 commands := d.paletteCommands()
49- results := d.paletteResults(current, commands)45+ results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin)
50 ac.overlays.paletteMu.Lock()46 ac.overlays.paletteMu.Lock()
51 ac.overlays.paletteGeneration++47 ac.overlays.paletteGeneration++
52 ac.overlays.palette = palette.New(results)48 ac.overlays.palette = palette.New(results)
53- ac.overlays.paletteRecent = recent49+ ac.overlays.paletteRouteSnapshot = routeSnapshot
54 ac.overlays.paletteHints = palette.ContextualHints{}50 ac.overlays.paletteHints = palette.ContextualHints{}
55 ac.overlays.paletteFeedback = ""51 ac.overlays.paletteFeedback = ""
56 ac.overlays.palettePending = nil52 ac.overlays.palettePending = nil
57 ac.overlays.paletteMu.Unlock()53 ac.overlays.paletteMu.Unlock()
58- d.invalidateAttachedOwner(ac, true, "palette.go")54+ d.invalidateRender(sess, ac, true, "palette.go")
59 }55 }
60 56
61 // paletteResults captures eligible named sessions before paletteMu so the57 // paletteResults captures eligible named sessions before paletteMu so the
62 // palette has an immutable lifecycle target without violating lock ordering.58 // palette has an immutable lifecycle target without violating lock ordering.
63-func (d *Daemon) paletteResults(current *session, commands []command.Command) []palette.Result {59+func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result {
64 d.mu.Lock()60 d.mu.Lock()
65 sessions := d.sessionsSnapshotLocked()61 sessions := d.sessionsSnapshotLocked()
66 stopped := make([]stoppedSession, 0, len(d.stopped))62 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -71,7 +67,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) []
71 }67 }
72 d.mu.Unlock()68 d.mu.Unlock()
73 69
74- results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped))70+ results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+len(routeSnapshot.Entries))
75 for _, cmd := range commands {71 for _, cmd := range commands {
76 results = append(results, palette.NewCommandResult(cmd))72 results = append(results, palette.NewCommandResult(cmd))
77 }73 }
@@ -92,18 +88,56 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) []
92 })88 })
93 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })89 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
94 results = append(results, active...)90 results = append(results, active...)
91+ localNames := make(map[string]struct{}, len(active)+len(stopped))
92+ for _, candidate := range active {
93+ name, _ := candidate.SessionName()
94+ localNames[name] = struct{}{}
95+ }
95 for _, candidate := range stopped {96 for _, candidate := range stopped {
96 results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))97 results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
98+ localNames[candidate.name] = struct{}{}
99+ }
100+ formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
101+ for i, entry := range routeSnapshot.Entries {
102+ if i >= len(formattedRoutes) {
103+ break
104+ }
105+ label := formattedRoutes[i].name
106+ if label == "" {
107+ continue
108+ }
109+ if _, represented := localNames[entry.Name]; represented {
110+ localRoute := entry.Kind == ports.RouteKindLocal
111+ currentRemoteRoute := entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin
112+ if localRoute || currentRemoteRoute {
113+ continue
114+ }
115+ }
116+ results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
117+ SnapshotGeneration: routeSnapshot.Generation,
118+ Key: entry.Key,
119+ Generation: entry.Generation,
120+ }))
97 }121 }
98 return results122 return results
99 }123 }
100 124
101-func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {125+func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints {
102- names := make([]string, len(recent))126+ formatted := formatRecentRouteSnapshot(snapshot)
103- for i, entry := range recent {127+ names := make([]string, len(formatted))
128+ for i, entry := range formatted {
104 names[i] = entry.name129 names[i] = entry.name
105 }130 }
106- return palette.BuildRecentSessionHints(names, args)131+ hints := palette.BuildRecentSessionHints(names, args)
132+ for i, entry := range snapshot.Entries {
133+ if i >= len(hints.Recent) {
134+ break
135+ }
136+ hints.Recent[i].SnapshotGeneration = snapshot.Generation
137+ hints.Recent[i].Key = entry.Key
138+ hints.Recent[i].Generation = entry.Generation
139+ }
140+ return hints
107 }141 }
108 142
109 func (d *Daemon) paletteCommands() []command.Command {143 func (d *Daemon) paletteCommands() []command.Command {
@@ -152,7 +186,7 @@ func (d *Daemon) recordPaletteUse(code string) {
152 }186 }
153 187
154 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {188 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
155- entry := ac.navigationSession()189+ entry := ac.currentAttachmentSession()
156 if entry == nil {190 if entry == nil {
157 return191 return
158 }192 }
@@ -160,8 +194,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
160 var cmd command.Command194 var cmd command.Command
161 var sessionTarget palette.Result195 var sessionTarget palette.Result
162 var hasSessionTarget bool196 var hasSessionTarget bool
197+ var routeTarget ports.RouteNavigationAction
198+ var hasRouteTarget bool
163 var args []string199 var args []string
164- var recent []recentSession200+ var routeSnapshot ports.RecentRouteSnapshot
165 var generation uint64201 var generation uint64
166 var rawQuery string202 var rawQuery string
167 changed, cancel, execute := false, false, false203 changed, cancel, execute := false, false, false
@@ -232,8 +268,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
232 return268 return
233 }269 }
234 args = action.Args270 args = action.Args
235- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)271+ routeSnapshot = ac.overlays.paletteRouteSnapshot
272+ routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
236 }273 }
274+ } else if action, ok := selected.RouteNavigationAction(); ok {
275+ routeTarget = action
276+ hasRouteTarget = true
237 } else if _, ok := selected.SessionName(); ok {277 } else if _, ok := selected.SessionName(); ok {
238 sessionTarget = selected278 sessionTarget = selected
239 hasSessionTarget = true279 hasSessionTarget = true
@@ -249,7 +289,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
249 ac.overlays.paletteFeedback = ""289 ac.overlays.paletteFeedback = ""
250 active, ok := ac.overlays.palette.ArgumentCommand()290 active, ok := ac.overlays.palette.ArgumentCommand()
251 if ok && active.Slug == "jump-recent-session" {291 if ok && active.Slug == "jump-recent-session" {
252- ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))292+ args := paletteArgs(ac.overlays.palette.Query(), active)
293+ if ac.overlays.paletteRouteSnapshot.Generation != 0 {
294+ ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args)
295+ } else {
296+ ac.overlays.paletteHints = palette.ContextualHints{}
297+ }
253 } else {298 } else {
254 ac.overlays.paletteHints = palette.ContextualHints{}299 ac.overlays.paletteHints = palette.ContextualHints{}
255 }300 }
@@ -259,12 +304,28 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
259 }304 }
260 ac.overlays.paletteMu.Unlock()305 ac.overlays.paletteMu.Unlock()
261 if cancel {306 if cancel {
262- d.invalidateAttachedOwner(ac, true, "palette.go")307+ d.invalidateRender(entry, ac, true, "palette.go")
263 return308 return
264 }309 }
265 if !execute {310 if !execute {
266 if changed {311 if changed {
267- d.invalidateAttachedOwner(ac, true, "palette.go")312+ d.invalidateRender(entry, ac, true, "palette.go")
313+ }
314+ return
315+ }
316+
317+ if hasRouteTarget {
318+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
319+ if err := exec.NavigateRecentRoute(routeTarget); err != nil {
320+ if errors.Is(err, errAttachmentTransition) {
321+ return
322+ }
323+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
324+ d.invalidateRender(entry, ac, true, "palette.go")
325+ return
326+ }
327+ if ac.closeExecutedPalette(generation, rawQuery) {
328+ d.invalidateRender(entry, ac, true, "palette.go")
268 }329 }
269 return330 return
270 }331 }
@@ -290,11 +351,13 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
290 }351 }
291 if err != nil {352 if err != nil {
292 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")353 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
293- d.invalidateAttachedOwner(ac, true, "palette.go")354+ d.invalidateRender(entry, ac, true, "palette.go")
294 return355 return
295 }356 }
296 if ac.closeExecutedPalette(generation, rawQuery) {357 if ac.closeExecutedPalette(generation, rawQuery) {
297- d.invalidateAttachedOwner(ac, true, "palette.go")358+ if current := ac.currentAttachmentSession(); current != nil {
359+ d.invalidateRender(current, ac, true, "palette.go")
360+ }
298 }361 }
299 return362 return
300 }363 }
@@ -303,21 +366,23 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
303 rank, err := command.ParsePositiveDecimal(args)366 rank, err := command.ParsePositiveDecimal(args)
304 if err != nil {367 if err != nil {
305 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")368 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
306- d.invalidateAttachedOwner(ac, true, "palette.go")369+ d.invalidateRender(entry, ac, true, "palette.go")
307 return370 return
308 }371 }
309- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect}372+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
310 if err := exec.JumpRecentSession(rank); err != nil {373 if err := exec.JumpRecentSession(rank); err != nil {
311 if errors.Is(err, errAttachmentTransition) {374 if errors.Is(err, errAttachmentTransition) {
312 return375 return
313 }376 }
314 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")377 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
315- d.invalidateAttachedOwner(ac, true, "palette.go")378+ d.invalidateRender(entry, ac, true, "palette.go")
316 return379 return
317 }380 }
318 if ac.closeExecutedPalette(generation, rawQuery) {381 if ac.closeExecutedPalette(generation, rawQuery) {
319 d.recordPaletteUse(cmd.Code)382 d.recordPaletteUse(cmd.Code)
320- d.invalidateAttachedOwner(ac, true, "palette.go")383+ if current := ac.currentAttachmentSession(); current != nil {
384+ d.invalidateRender(current, ac, true, "palette.go")
385+ }
321 }386 }
322 return387 return
323 }388 }
@@ -333,7 +398,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
333 currentToken := current.attachmentToken(ac, ac.transport())398 currentToken := current.attachmentToken(ac, ac.transport())
334 fresh, admitted := ac.beginAttachmentEffect(currentToken)399 fresh, admitted := ac.beginAttachmentEffect(currentToken)
335 if ac.closeExecutedPalette(generation, rawQuery) {400 if ac.closeExecutedPalette(generation, rawQuery) {
336- d.invalidateAttachedOwner(ac, true, "palette.go")401+ d.invalidateRender(current, ac, true, "palette.go")
337 }402 }
338 if admitted {403 if admitted {
339 fresh.End()404 fresh.End()
@@ -371,7 +436,7 @@ func paletteArgs(query string, cmd command.Command) []string {
371 func (ac *attachedClient) clearPaletteLocked() {436 func (ac *attachedClient) clearPaletteLocked() {
372 ac.overlays.paletteGeneration++437 ac.overlays.paletteGeneration++
373 ac.overlays.palette = nil438 ac.overlays.palette = nil
374- ac.overlays.paletteRecent = nil439+ ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}
375 ac.overlays.paletteHints = palette.ContextualHints{}440 ac.overlays.paletteHints = palette.ContextualHints{}
376 ac.overlays.paletteFeedback = ""441 ac.overlays.paletteFeedback = ""
377 ac.overlays.palettePending = nil442 ac.overlays.palettePending = nil
@@ -401,7 +466,7 @@ type paletteExec struct {
401 sess *session466 sess *session
402 attachment *session467 attachment *session
403 ac *attachedClient468 ac *attachedClient
404- recent []recentSession469+ routeSnapshot ports.RecentRouteSnapshot
405 actions daemonActionRunner470 actions daemonActionRunner
406 effect *attachmentEffectTicket471 effect *attachmentEffectTicket
407 redrawClosedPalette bool472 redrawClosedPalette bool
@@ -604,6 +669,9 @@ func (e paletteExec) RenameTab() error {
604 }669 }
605 670
606 func (e paletteExec) OpenSessionPicker() error {671 func (e paletteExec) OpenSessionPicker() error {
672+ if e.ac != nil && e.ac.navigationCapabilities&ports.NavigationCapabilityHomePicker != 0 && e.effect != nil {
673+ return e.d.sendNavigationActionForAttachment(e.effect.connectionToken(), ports.NavigationOpenHomePicker)
674+ }
607 e.d.enterPicker(e.sess, e.ac)675 e.d.enterPicker(e.sess, e.ac)
608 return nil676 return nil
609 }677 }
@@ -631,36 +699,28 @@ func (e paletteExec) YankLastNotification() error {
631 }699 }
632 700
633 func (e paletteExec) JumpRecentSession(rank int) error {701 func (e paletteExec) JumpRecentSession(rank int) error {
634- if rank < 1 || rank > len(e.recent) {702+ if e.routeSnapshot.Generation == 0 || rank < 1 || rank > len(e.routeSnapshot.Entries) {
635- return command.ErrInvalidArguments
636- }
637- target := e.recent[rank-1]
638- if e.d.sessionByID(target.id) == nil {
639 return command.ErrInvalidArguments703 return command.ErrInvalidArguments
640 }704 }
705+ entry := e.routeSnapshot.Entries[rank-1]
706+ return e.NavigateRecentRoute(ports.RouteNavigationAction{
707+ SnapshotGeneration: e.routeSnapshot.Generation,
708+ Key: entry.Key,
709+ Generation: entry.Generation,
710+ })
711+}
712+
713+func (e paletteExec) NavigateRecentRoute(action ports.RouteNavigationAction) error {
641 if e.d.beforeRecentSessionHandoff != nil {714 if e.d.beforeRecentSessionHandoff != nil {
642 e.d.beforeRecentSessionHandoff()715 e.d.beforeRecentSessionHandoff()
643 }716 }
644- var err error
645 if e.effect != nil {717 if e.effect != nil {
646- err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")718+ return e.d.sendRecentRouteNavigationActionForAttachment(e.effect.connectionToken(), action)
647- } else {
648- sess := e.attachment
649- if sess == nil {
650- sess = e.sess
651- }
652- if sess == nil {
653- return errAttachmentTransition
654- }
655- err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
656 }719 }
657- if e.effect != nil && errors.Is(err, errAttachmentTransition) {720+ if e.ac == nil || e.attachment == nil {
658- return err721+ return errAttachmentTransition
659 }722 }
660- if err != nil {723+ return e.d.sendRecentRouteNavigationActionForAttachment(e.attachment.attachmentToken(e.ac, e.ac.transport()), action)
661- return command.ErrInvalidArguments
662- }
663- return nil
664 }724 }
665 725
666 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {726 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
M internal/usecase/daemon/palette_test.go +128 -101
@@ -18,6 +18,31 @@ import (
18 "github.com/bnema/vev/pkg/renderer"18 "github.com/bnema/vev/pkg/renderer"
19 )19 )
20 20
21+func testRecentRouteSnapshot() ports.RecentRouteSnapshot {
22+ return ports.RecentRouteSnapshot{
23+ Generation: 1,
24+ Active: ports.RouteRef{Key: 1, Generation: 1},
25+ Entries: []ports.RecentRouteEntry{
26+ {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal},
27+ {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal},
28+ },
29+ }
30+}
31+
32+func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *attachedClient) attachmentConnectionToken {
33+ t.Helper()
34+ transport := ac.transport()
35+ rc := d.attachCoordinator(sess, nil, ac, true)
36+ token := sess.attachmentToken(ac, transport)
37+ token.lease = rc.attachmentLease(ac)
38+ ac.publishAttachmentCapability(token)
39+ effect, admitted := ac.beginAttachmentEffect(token)
40+ require.True(t, admitted)
41+ t.Cleanup(effect.End)
42+ token.effect = effect
43+ return token
44+}
45+
21 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {46 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
22 paletteColors := [16]renderer.RGB{}47 paletteColors := [16]renderer.RGB{}
23 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}48 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
@@ -220,25 +245,6 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
220 }, got)245 }, got)
221 }246 }
222 247
223-func TestPaletteRecentSessionsExcludeEphemeralSessions(t *testing.T) {
224- p, release := newBlockingPTY(t)
225- defer release()
226- d, current, ac, _ := newManualSessionWithPTYs(t, p)
227- named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
228- named.mruAt.Store(1)
229- ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
230- ephemeral.mruAt.Store(2)
231- d.sessions[named.id] = named
232- d.sessions[ephemeral.id] = ephemeral
233-
234- d.enterPalette(current, ac)
235- ac.overlays.paletteMu.Lock()
236- defer ac.overlays.paletteMu.Unlock()
237-
238- require.Len(t, ac.overlays.paletteRecent, 1)
239- require.Equal(t, named.id, ac.overlays.paletteRecent[0].id)
240-}
241-
242 func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {248 func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
243 p, release := newBlockingPTY(t)249 p, release := newBlockingPTY(t)
244 defer release()250 defer release()
@@ -359,6 +365,7 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
359 p, release := newBlockingPTY(t)365 p, release := newBlockingPTY(t)
360 defer release()366 defer release()
361 d, sess, ac, sends := newManualSessionWithPTYs(t, p)367 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
368+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1, Active: ports.RouteRef{Key: 1, Generation: 1}})
362 369
363 d.handleInput(sess, ac, []byte("\x1b "))370 d.handleInput(sess, ac, []byte("\x1b "))
364 awaitFrame(t, sends, ports.MsgOutput)371 awaitFrame(t, sends, ports.MsgOutput)
@@ -380,34 +387,95 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
380 func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {387 func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
381 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)388 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
382 defer releaseAll(releases)389 defer releaseAll(releases)
390+ ac.setRouteSnapshot(testRecentRouteSnapshot())
391+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
383 d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}})392 d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}})
384 393
385- d.handleInput(current, ac, []byte("\x1b "))394+ d.handleInputForAttachment(token, []byte("\x1b "))
386 awaitFrame(t, sends, ports.MsgOutput)395 awaitFrame(t, sends, ports.MsgOutput)
387- d.handleInput(current, ac, []byte("RJS"))396+ d.handleInputForAttachment(token, []byte("RJS"))
388 awaitFrame(t, sends, ports.MsgOutput)397 awaitFrame(t, sends, ports.MsgOutput)
389 ac.overlays.paletteMu.Lock()398 ac.overlays.paletteMu.Lock()
390 require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)399 require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)
391 ac.overlays.paletteMu.Unlock()400 ac.overlays.paletteMu.Unlock()
392- d.handleInput(current, ac, []byte(" 1\r"))401+ d.handleInputForAttachment(token, []byte(" 1\r"))
393 402
394- require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())403+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
404+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
405+ require.NoError(t, err)
406+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action)
407+ require.Same(t, current, ac.currentSession())
395 require.False(t, ac.overlays.paletteActive())408 require.False(t, ac.overlays.paletteActive())
396- awaitFrame(t, sends, ports.MsgOutput)
397- awaitFrame(t, sends, ports.MsgOutput)
398 409
399- d.handleInput(ac.currentSession(), ac, []byte("\x1b "))410+ d.handleInputForAttachment(token, []byte("\x1b "))
400 awaitFrame(t, sends, ports.MsgOutput)411 awaitFrame(t, sends, ports.MsgOutput)
401- d.handleInput(ac.currentSession(), ac, []byte("JRS"))412+ d.handleInputForAttachment(token, []byte("JRS"))
402 awaitFrame(t, sends, ports.MsgOutput)413 awaitFrame(t, sends, ports.MsgOutput)
403 ac.overlays.paletteMu.Lock()414 ac.overlays.paletteMu.Lock()
404 require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)415 require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
405 ac.overlays.paletteMu.Unlock()416 ac.overlays.paletteMu.Unlock()
406- require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())417+ require.Same(t, current, ac.currentSession())
407- d.handleInput(ac.currentSession(), ac, []byte("\r"))418+ d.handleInputForAttachment(token, []byte("\r"))
408 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")419 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
409 }420 }
410 421
422+func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) {
423+ d := newTestDaemon(t, nil, stubClock{})
424+ current := addControlSession(d, "hi", "tab-1", "pane-1")
425+ represented := addControlSession(d, "remote-manual", "tab-2", "pane-2")
426+ represented.ephemeral = false
427+ snapshot := ports.RecentRouteSnapshot{
428+ Generation: 2,
429+ Active: ports.RouteRef{Key: 1, Generation: 1},
430+ Entries: []ports.RecentRouteEntry{{
431+ Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote,
432+ }},
433+ }
434+
435+ for _, test := range []struct {
436+ name string
437+ currentOrigin string
438+ want []string
439+ }{
440+ {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}},
441+ {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}},
442+ } {
443+ t.Run(test.name, func(t *testing.T) {
444+ results := d.paletteResults(current, nil, snapshot, test.currentOrigin)
445+ var matching []string
446+ for _, result := range results {
447+ if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@remote" {
448+ matching = append(matching, result.DisplayText())
449+ }
450+ }
451+ require.Equal(t, test.want, matching)
452+ })
453+ }
454+}
455+
456+func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
457+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
458+ defer releaseAll(releases)
459+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
460+ Generation: 9,
461+ Active: ports.RouteRef{Key: 1, Generation: 1},
462+ Entries: []ports.RecentRouteEntry{{
463+ Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
464+ }},
465+ })
466+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
467+
468+ d.handleInputForAttachment(token, []byte("\x1b "))
469+ awaitFrame(t, sends, ports.MsgOutput)
470+ d.handleInputForAttachment(token, []byte("logs@edge\r"))
471+
472+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
473+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
474+ require.NoError(t, err)
475+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 9, Key: 8, Generation: 4}, action)
476+ require.False(t, ac.overlays.paletteActive())
477+}
478+
411 func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {479 func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
412 d, sess, ac, sends, releases := newManualTabSession(t, 2)480 d, sess, ac, sends, releases := newManualTabSession(t, 2)
413 defer releases[0]()481 defer releases[0]()
@@ -425,62 +493,20 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
425 func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {493 func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {
426 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)494 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
427 defer releaseAll(releases)495 defer releaseAll(releases)
428- captured := mustLocalSession(t, d.sessions[domain.SessionID("recent")])496+ ac.setRouteSnapshot(testRecentRouteSnapshot())
429- d.handleInput(current, ac, []byte("\x1b "))497+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
498+ d.handleInputForAttachment(token, []byte("\x1b "))
430 awaitFrame(t, sends, ports.MsgOutput)499 awaitFrame(t, sends, ports.MsgOutput)
431 // Reordering live MRU after opening must not shift rank 1 from its capture.500 // Reordering live MRU after opening must not shift rank 1 from its capture.
432 d.sessions[domain.SessionID("older")].core().mruAt.Store(100)501 d.sessions[domain.SessionID("older")].core().mruAt.Store(100)
433- d.handleInput(current, ac, []byte("JRS 1\r"))502+ d.handleInputForAttachment(token, []byte("JRS 1\r"))
434-
435- require.Same(t, captured, ac.currentSession())
436- require.False(t, ac.overlays.paletteActive())
437- awaitFrame(t, sends, ports.MsgOutput)
438- awaitFrame(t, sends, ports.MsgOutput)
439-}
440-
441-func TestPaletteJRSThenBSKReversesJump(t *testing.T) {
442- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
443- defer releaseAll(releases)
444-
445- d.handleInput(current, ac, []byte("\x1b "))
446- awaitFrame(t, sends, ports.MsgOutput)
447- d.handleInput(current, ac, []byte("JRS 1\r"))
448- require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
449- awaitFrame(t, sends, ports.MsgOutput)
450- awaitFrame(t, sends, ports.MsgOutput)
451 503
452- runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")504+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
505+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
506+ require.NoError(t, err)
507+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action)
453 require.Same(t, current, ac.currentSession())508 require.Same(t, current, ac.currentSession())
454-}509+ require.False(t, ac.overlays.paletteActive())
455-
456-func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
457- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
458- defer releaseAll(releases)
459-
460- // Establish a valid back-session target, then remove that target immediately
461- // after the command releases its role admission.
462- target := d.sessions[domain.SessionID("recent")]
463- ac.previousOwner.Set(target)
464- invalidations := installPaletteInvalidationObserver(current)
465- d.enterPalette(current, ac)
466- d.handlePaletteInput(ac, []byte("BSK"))
467- d.afterActionAttachmentEffectEnded = func(action string) {
468- if action == "back-session" {
469- d.mu.Lock()
470- delete(d.sessions, target.core().id)
471- d.mu.Unlock()
472- }
473- }
474-
475- token := current.attachmentToken(ac, ac.transport())
476- effect, admitted := ac.beginAttachmentEffect(token)
477- require.True(t, admitted)
478- d.handlePaletteInput(ac, []byte("\r"), effect)
479-
480- require.False(t, ac.overlays.paletteActive(), "a failed executed handoff must not leave a stale palette")
481- require.Same(t, current, ac.currentSession(), "failed handoff must preserve the source attachment")
482- invalidation := awaitTestValue(t, invalidations, "failed palette handoff did not invalidate rendering")
483- require.True(t, invalidation.reset)
484 }510 }
485 511
486 func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {512 func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {
@@ -505,12 +531,15 @@ func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing
505 require.True(t, invalidation.reset)531 require.True(t, invalidation.reset)
506 }532 }
507 533
508-func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {534+func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) {
509 p, release := newBlockingPTY(t)535 p, release := newBlockingPTY(t)
510 defer release()536 defer release()
511 d, sess, ac, sends := newManualSessionWithPTYs(t, p)537 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
512- target := &session{sessionCore: sessionCore{id: "captured", name: "captured"}}538+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
513- d.sessions[target.id] = target539+ Generation: 7,
540+ Active: ports.RouteRef{Key: 1, Generation: 1},
541+ Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}},
542+ })
514 543
515 validated := make(chan struct{})544 validated := make(chan struct{})
516 releaseHandoff := make(chan struct{})545 releaseHandoff := make(chan struct{})
@@ -518,38 +547,36 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
518 close(validated)547 close(validated)
519 <-releaseHandoff548 <-releaseHandoff
520 }549 }
521- d.handleInput(sess, ac, []byte("\x1b "))550+ token := beginRecentRoutePaletteEffect(t, d, sess, ac)
551+ d.handleInputForAttachment(token, []byte("\x1b "))
522 awaitFrame(t, sends, ports.MsgOutput)552 awaitFrame(t, sends, ports.MsgOutput)
523 inputHandled := make(chan struct{})553 inputHandled := make(chan struct{})
524 go func() {554 go func() {
525- d.handleInput(sess, ac, []byte("JRS 1\r"))555+ d.handleInputForAttachment(token, []byte("JRS 1\r"))
526 close(inputHandled)556 close(inputHandled)
527 }()557 }()
528- awaitTestCompletion(t, validated, "JRS did not validate its captured target")558+ awaitTestCompletion(t, validated, "JRS did not publish the captured route action")
529- d.mu.Lock()559+ // The daemon must not consult or revalidate the target lifecycle. The
530- delete(d.sessions, target.core().id)560+ // client owns exact-target validation after it receives this action.
531- d.mu.Unlock()
532 close(releaseHandoff)561 close(releaseHandoff)
562+ awaitTestCompletion(t, inputHandled, "JRS route-action input did not complete")
533 563
534- // switchToTarget repaints its failed hand-off before handlePaletteInput564+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
535- // records the generation-safe feedback and schedules the feedback repaint.565+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
536- // Wait for the input transaction, rather than mistaking that intermediate566+ require.NoError(t, err)
537- // repaint for publication of the final palette state.567+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 7, Key: 9, Generation: 4}, action)
538- awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete")568+ require.False(t, ac.overlays.paletteActive())
539- require.True(t, ac.overlays.paletteActive())
540- ac.overlays.paletteMu.Lock()
541- require.Equal(t, "JRS 1", ac.overlays.palette.Query())
542- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
543- ac.overlays.paletteMu.Unlock()
544 }569 }
545 570
546 func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {571 func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
547 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)572 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
548 defer releaseAll(releases)573 defer releaseAll(releases)
574+ ac.setRouteSnapshot(testRecentRouteSnapshot())
575+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
549 576
550- d.handleInput(current, ac, []byte("\x1b "))577+ d.handleInputForAttachment(token, []byte("\x1b "))
551 awaitFrame(t, sends, ports.MsgOutput)578 awaitFrame(t, sends, ports.MsgOutput)
552- d.handleInput(current, ac, []byte("JRS 3\r"))579+ d.handleInputForAttachment(token, []byte("JRS 3\r"))
553 580
554 require.Same(t, current, ac.currentSession())581 require.Same(t, current, ac.currentSession())
555 require.True(t, ac.overlays.paletteActive())582 require.True(t, ac.overlays.paletteActive())
M internal/usecase/daemon/picker.go +160 -160
@@ -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 }
@@ -184,8 +179,15 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
184 for _, item := range live {179 for _, item := range live {
185 views = append(views, item.view.pickerView())180 views = append(views, item.view.pickerView())
186 }181 }
182+ seenRemoteLifecycles := make(map[domain.SessionLifecycleID]struct{}, catalogRows)
187 for _, host := range catalog {183 for _, host := range catalog {
188 for _, session := range host.entry.Sessions {184 for _, session := range host.entry.Sessions {
185+ if session.LifecycleID != (domain.SessionLifecycleID{}) {
186+ if _, duplicate := seenRemoteLifecycles[session.LifecycleID]; duplicate {
187+ continue
188+ }
189+ seenRemoteLifecycles[session.LifecycleID] = struct{}{}
190+ }
189 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}191 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
190 if key.Validate() != nil {192 if key.Validate() != nil {
191 continue193 continue
@@ -252,10 +254,12 @@ func remotePickerReasonText(reason string) string {
252 return "host unreachable"254 return "host unreachable"
253 case "version_mismatch":255 case "version_mismatch":
254 return "version mismatch"256 return "version mismatch"
255- case "session_down":257+ case "session_stopped":
256- return "session down"258+ return "session stopped"
257 case "session_broken":259 case "session_broken":
258 return "session broken"260 return "session broken"
261+ case "malformed":
262+ return "catalog malformed"
259 case "refreshing":263 case "refreshing":
260 return "refreshing"264 return "refreshing"
261 case "identity_changed":265 case "identity_changed":
@@ -269,8 +273,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
269 rt := ac.overlays273 rt := ac.overlays
270 var previewGeneration uint64274 var previewGeneration uint64
271 var instance remotePickerInstance275 var instance remotePickerInstance
272- var selection *remotePickerSelection
273- var closed bool
274 return listInputState{276 return listInputState{
275 pending: &rt.pickerPending,277 pending: &rt.pickerPending,
276 esc: &rt.pickerESC,278 esc: &rt.pickerESC,
@@ -280,12 +282,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
280 unlock: rt.pickerMu.Unlock,282 unlock: rt.pickerMu.Unlock,
281 active: func() bool { return rt.picker != nil },283 active: func() bool { return rt.picker != nil },
282 closeLocked: func() {284 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}285 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
290 rt.picker = nil286 rt.picker = nil
291 rt.pickerIntent = pickerNavigate287 rt.pickerIntent = pickerNavigate
@@ -293,20 +289,17 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
293 previewGeneration = rt.pickerPreviewGeneration289 previewGeneration = rt.pickerPreviewGeneration
294 },290 },
295 afterClose: func() {291 afterClose: func() {
296- if selection != nil {292+ d.clearPreviewGeneration(ac, previewGeneration)
297- selection.cancel()293+ d.remotePickerClosed(instance)
298- }294+ if sess := ac.currentAttachmentSession(); sess != nil {
299- if closed {295+ d.invalidateRender(sess, ac, true, "picker.go")
300- d.clearPreviewGeneration(ac, previewGeneration)
301- d.remotePickerClosed(instance)
302 }296 }
303- d.invalidateAttachedOwner(ac, true, "picker.go")
304 },297 },
305 }298 }
306 }299 }
307 300
308 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {301 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
309- sess := ac.navigationSession()302+ sess := ac.currentAttachmentSession()
310 if sess == nil {303 if sess == nil {
311 return304 return
312 }305 }
@@ -349,12 +342,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
349 if result.action == 's' {342 if result.action == 's' {
350 d.togglePickerSort()343 d.togglePickerSort()
351 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})344 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
352- d.invalidateAttachedOwner(ac, true, "picker.go")345+ d.invalidateRender(sess, ac, true, "picker.go")
353 return346 return
354 }347 }
355 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {348 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
356 d.notifyRemotePickerUnavailable(sess, cursor)349 d.notifyRemotePickerUnavailable(sess, cursor)
357- d.invalidateAttachedOwner(ac, true, "picker.go")350+ d.invalidateRender(sess, ac, true, "picker.go")
358 return351 return
359 }352 }
360 if result.action == 'x' {353 if result.action == 'x' {
@@ -382,21 +375,44 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
382 defer fresh.End()375 defer fresh.End()
383 }376 }
384 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})377 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
385- d.invalidateAttachedOwner(ac, true, "picker.go")378+ d.invalidateRender(sess, ac, true, "picker.go")
386 return379 return
387 }380 }
388 if result.changed {381 if result.changed {
389 d.registerPreviewForSelection(ac)382 d.registerPreviewForSelection(ac)
390 }383 }
391 committing := (result.action == '\r' || result.action == '\n') && ok384 committing := (result.action == '\r' || result.action == '\n') && ok
385+ backNavigationSent := false
392 if result.exit && !committing {386 if result.exit && !committing {
387+ back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0
388+ if back {
389+ var token attachmentConnectionToken
390+ if len(effects) != 0 && effects[0] != nil {
391+ token = effects[0].connectionToken()
392+ } else {
393+ var effect *attachmentEffectTicket
394+ var admitted bool
395+ token, effect, admitted = ac.beginCurrentAttachmentEffect(sess, ac.transport())
396+ if !admitted {
397+ d.invalidateRender(sess, ac, true, "picker.go")
398+ return
399+ }
400+ defer effect.End()
401+ }
402+ if err := d.sendNavigationActionForAttachment(token, ports.NavigationBack); err != nil {
403+ d.reportAttachmentError(sess, err)
404+ d.invalidateRender(sess, ac, true, "picker.go")
405+ return
406+ }
407+ backNavigationSent = true
408+ }
393 d.closePicker(ac)409 d.closePicker(ac)
394 }410 }
395 if committing {411 if committing {
396 if intent == pickerMovePane || intent == pickerMoveTab {412 if intent == pickerMovePane || intent == pickerMoveTab {
397 if err := d.movePickerSourceError(source); err != nil {413 if err := d.movePickerSourceError(source); err != nil {
398 d.reportAttachmentError(sess, err)414 d.reportAttachmentError(sess, err)
399- d.invalidateAttachedOwner(ac, true, "picker.go")415+ d.invalidateRender(sess, ac, true, "picker.go")
400 return416 return
401 }417 }
402 if len(effects) != 0 && effects[0] != nil {418 if len(effects) != 0 && effects[0] != nil {
@@ -406,36 +422,14 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
406 err := d.commitMovePickerSelection(intent, source, target)422 err := d.commitMovePickerSelection(intent, source, target)
407 if err != nil {423 if err != nil {
408 d.reportAttachmentError(sess, movePickerUserError(err))424 d.reportAttachmentError(sess, movePickerUserError(err))
409- d.invalidateAttachedOwner(ac, true, "picker.go")425+ d.invalidateRender(sess, ac, true, "picker.go")
410 return426 return
411 }427 }
412- d.invalidateAttachedOwner(ac, true, "picker.go")428+ d.invalidateRender(sess, ac, true, "picker.go")
413 return429 return
414 }430 }
415 var err error431 var err error
416- if target.RemoteTarget != nil {432+ 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")433 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
440 } else {434 } else {
441 d.closePicker(ac)435 d.closePicker(ac)
@@ -450,8 +444,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
450 }444 }
451 return445 return
452 }446 }
453- if result.exit || result.changed {447+ if (result.exit || result.changed) && !backNavigationSent {
454- d.invalidateAttachedOwner(ac, true, "picker.go")448+ d.invalidateRender(sess, ac, true, "picker.go")
455 }449 }
456 }450 }
457 451
@@ -543,7 +537,7 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ
543 remoteTarget := *target.RemoteTarget537 remoteTarget := *target.RemoteTarget
544 message := "loading remote preview…"538 message := "loading remote preview…"
545 if remoteTarget.Stopped {539 if remoteTarget.Stopped {
546- message = "down session — preview unavailable"540+ message = "stopped session — preview unavailable"
547 }541 }
548 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)542 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)
549 if remoteTarget.Stopped {543 if remoteTarget.Stopped {
@@ -745,8 +739,6 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
745 return false739 return false
746 }740 }
747 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}741 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 = nil742 ac.overlays.picker = nil
751 ac.overlays.pickerTitle = ""743 ac.overlays.pickerTitle = ""
752 ac.overlays.pickerIntent = pickerNavigate744 ac.overlays.pickerIntent = pickerNavigate
@@ -755,9 +747,6 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
755 ac.overlays.pickerESC.stop()747 ac.overlays.pickerESC.stop()
756 previewGeneration := ac.overlays.pickerPreviewGeneration748 previewGeneration := ac.overlays.pickerPreviewGeneration
757 ac.overlays.pickerMu.Unlock()749 ac.overlays.pickerMu.Unlock()
758- if selection != nil {
759- selection.cancel()
760- }
761 d.clearPreviewGeneration(ac, previewGeneration)750 d.clearPreviewGeneration(ac, previewGeneration)
762 if instance.model != nil {751 if instance.model != nil {
763 d.remotePickerClosed(instance)752 d.remotePickerClosed(instance)
@@ -837,8 +826,7 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
837 }826 }
838 827
839 func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {828 func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
840- source := token.localSession()829+ if token.sess == nil || token.ac == nil {
841- if source == nil || token.ac == nil {
842 return nil830 return nil
843 }831 }
844 d.mu.Lock()832 d.mu.Lock()
@@ -848,15 +836,15 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
848 if !token.attachmentCurrent() {836 if !token.attachmentCurrent() {
849 return nil837 return nil
850 }838 }
851- d.invalidateRender(source, token.ac, true, "picker.go")839+ d.invalidateRender(token.sess, token.ac, true, "picker.go")
852 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)840 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
853 }841 }
854- if targetSess == source {842+ if targetSess == token.sess {
855 return nil843 return nil
856 }844 }
857 845
858 transition, err := d.transitionAttachment(attachmentTransitionRequest{846 transition, err := d.transitionAttachment(attachmentTransitionRequest{
859- source: source, target: targetSess, next: token.ac,847+ source: token.sess, target: targetSess, next: token.ac,
860 848
861 expectedTransport: token.transport, sourceToken: &token, action: action,849 expectedTransport: token.transport, sourceToken: &token, action: action,
862 expectedTargetLifecycle: pickerTargetLifecycleFence(target),850 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
@@ -878,7 +866,6 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
878 }866 }
879 }867 }
880 d.touchMRU(targetSess)868 d.touchMRU(targetSess)
881- token.ac.recordPreviousSession(source)
882 d.deferAttachmentTransitionCleanups(transition)869 d.deferAttachmentTransitionCleanups(transition)
883 d.firstPaintForTransition(transition.published)870 d.firstPaintForTransition(transition.published)
884 return nil871 return nil
@@ -891,132 +878,148 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
891 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})878 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
892 }879 }
893 880
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. The881 // switchToTargetForAttachment is the sole client-originated navigation entry. The
917 // intent captures the exact initiating capability before admission is released;882 // intent captures the exact initiating capability before admission is released;
918 // every active, stopped, and same-session target then uses transitionAttachment883 // every active, stopped, and same-session target then uses transitionAttachment
919 // for frozen, atomic source-token preflight.884 // for frozen, atomic source-token preflight.
920 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {885 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
921- if token.ac == nil {886+ if token.sess == nil || token.ac == nil || token.effect == nil {
922 return nil887 return nil
923 }888 }
924- if _, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {889+ if target.RemoteTarget != nil {
925- if target.RemoteTarget != nil {890+ var key domain.RemoteSessionKey
926- return d.startRemotePickerSelection(token, target, guard, action)
927- }
928 if target.RemoteKey != nil {891 if target.RemoteKey != nil {
929- return errAttachmentTransition892+ key = *target.RemoteKey
930 }893 }
931- previous := token.ac.previousOwner.Get()894+ return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
932- if !retainedLocalTargetMatches(target, previous) {895+ }
933- return errAttachmentTransition896+ if target.RemoteKey != nil {
897+ return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
898+ }
899+ token.effect.bindActionEnd(d, action)
900+ token.effect.End()
901+ if !token.attachmentCurrent() {
902+ return errAttachmentTransition
903+ }
904+ return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
905+}
906+
907+// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
908+// endpoint to the thin client. The daemon owns no remote session shadow: after
909+// the target is sent, the local attachment is detached and the client opens a
910+// fresh connection to the owning daemon.
911+func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
912+ failUnavailable := func() error {
913+ if token.attachmentCurrent() {
914+ d.notifyRemotePickerUnavailable(token.sess, target)
934 }915 }
935- transitioned := false916+ return errAttachmentTransition
936- if err := d.backSessionForAttachment(token, func() {917+ }
937- transitioned = true918+ var handoff ports.AttachTarget
938- if guard.closePicker {919+ if target.RemoteTarget != nil {
939- d.closePicker(token.ac)920+ remoteTarget := *target.RemoteTarget
921+ if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
922+ return failUnavailable()
923+ }
924+ if target.RemoteKey != nil {
925+ if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
926+ return failUnavailable()
940 }927 }
941- }); err != nil {
942- return err
943 }928 }
944- if !transitioned {929+ handoff = ports.AttachTarget{
945- return errAttachmentTransition930+ Endpoint: remoteTarget.Endpoint,
931+ Session: remoteTarget.SessionName,
932+ Intent: ports.IntentAttach,
933+ RemoteTarget: &remoteTarget,
934+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
946 }935 }
947- return nil936+ } else {
937+ if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
938+ return failUnavailable()
939+ }
940+ handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
948 }941 }
949- if token.localSession() == nil {942+ payload := ports.MarshalAttachTarget(handoff)
950- return nil943+ if payload == nil {
944+ return failUnavailable()
951 }945 }
952- if token.effect == nil {946+ if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
953- return errAttachmentTransition947+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
954 }948 }
955- if target.RemoteTarget != nil {949+ if guard.closePicker {
956- return d.startRemotePickerSelection(token, target, guard, action)950+ d.closePicker(token.ac)
957 }951 }
958- if target.RemoteKey != nil {952+ d.clientGoneForAttachment(token, false)
959- // Picker-originated remote activation requires the complete validated953+ return nil
960- // lifecycle/tab target. Legacy display keys must never fall back to a954+}
961- // client-owned attach handoff.955+
962- return errAttachmentTransition956+func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCacheEntry, bool) {
957+ if d.remoteCatalog.status[host] != remoteHostFresh {
958+ return ports.RemoteCatalogCacheEntry{}, false
963 }959 }
964- token.effect.bindActionEnd(d, action)960+ entry, ok := d.remoteCatalog.cache[host]
965- token.effect.End()961+ if !ok || remoteCatalogExpired(entry.FetchedAt, d.clock.Now()) {
966- source := token.localSession()962+ d.remoteCatalog.status[host] = remoteHostStale
967- if source == nil {963+ return ports.RemoteCatalogCacheEntry{}, false
968- return errAttachmentTransition
969 }964 }
970- return d.switchToTargetGuardedForAttachment(source, token.ac, target, guard, &token, action)965+ return entry, true
971 }966 }
972 967
973-func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {968+func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
974- if d == nil || target.Validate() != nil {969+ if d == nil {
975 return false970 return false
976 }971 }
977 d.remoteCatalog.mu.Lock()972 d.remoteCatalog.mu.Lock()
978 defer d.remoteCatalog.mu.Unlock()973 defer d.remoteCatalog.mu.Unlock()
979- now := d.clock.Now()974+ entry, ok := d.remoteCatalogEntryLocked(key.Host)
980- if d.remoteCatalog.status[target.Endpoint] == remoteHostFresh {975+ if !ok {
981- if entry, ok := d.remoteCatalog.cache[target.Endpoint]; ok && remoteCatalogExpired(entry.FetchedAt, now) {976+ return false
982- d.remoteCatalog.status[target.Endpoint] = remoteHostStale977+ }
978+ for _, session := range entry.Sessions {
979+ if session.Name == key.Name {
980+ return session.State == "up"
983 }981 }
984 }982 }
985- if d.remoteCatalog.status[target.Endpoint] != remoteHostFresh {983+ return false
984+}
985+
986+func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
987+ if d == nil || target.Validate() != nil {
986 return false988 return false
987 }989 }
988- for _, entry := range d.remoteCatalog.cache {990+ d.remoteCatalog.mu.Lock()
989- if entry.Host != target.Endpoint {991+ defer d.remoteCatalog.mu.Unlock()
992+ entry, ok := d.remoteCatalogEntryLocked(target.Endpoint)
993+ if !ok {
994+ return false
995+ }
996+ for _, session := range entry.Sessions {
997+ if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
990 continue998 continue
991 }999 }
992- for _, session := range entry.Sessions {1000+ if target.Stopped != remoteSessionStateStopped(session.State) || session.State == "broken" {
993- if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {1001+ return false
1002+ }
1003+ tabs := ports.CatalogTabs(session)
1004+ metadata := make([]domain.TabSelectorTab, 0, len(tabs))
1005+ for _, tab := range tabs {
1006+ metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
1007+ }
1008+ if target.Stopped {
1009+ _, ok := target.StoppedTab.Resolve(metadata)
1010+ return ok
1011+ }
1012+ found := false
1013+ for _, tab := range tabs {
1014+ if tab.ID != string(target.LiveTabID) {
994 continue1015 continue
995 }1016 }
996- if target.Stopped != (session.State == "down") || session.State == "broken" {1017+ if found {
997 return false1018 return false
998 }1019 }
999- tabs := ports.CatalogTabs(session)1020+ found = true
1000- metadata := make([]domain.TabSelectorTab, 0, len(tabs))
1001- for _, tab := range tabs {
1002- metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
1003- }
1004- if target.Stopped {
1005- _, ok := target.StoppedTab.Resolve(metadata)
1006- return ok
1007- }
1008- found := false
1009- for _, tab := range tabs {
1010- if tab.ID != string(target.LiveTabID) {
1011- continue
1012- }
1013- if found {
1014- return false
1015- }
1016- found = true
1017- }
1018- return found
1019 }1021 }
1022+ return found
1020 }1023 }
1021 return false1024 return false
1022 }1025 }
@@ -1204,7 +1207,6 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1204 return nil, attachmentTransitionResult{}, false1207 return nil, attachmentTransitionResult{}, false
1205 }1208 }
1206 d.touchMRU(targetSess)1209 d.touchMRU(targetSess)
1207- ac.recordPreviousSession(from)
1208 return targetSess, transition, true1210 return targetSess, transition, true
1209 }1211 }
1210 1212
@@ -1246,7 +1248,6 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1246 return targetSess, attachmentTransitionResult{}, false, err1248 return targetSess, attachmentTransitionResult{}, false, err
1247 }1249 }
1248 d.touchMRU(targetSess)1250 d.touchMRU(targetSess)
1249- ac.recordPreviousSession(from)
1250 return targetSess, transition, true, nil1251 return targetSess, transition, true, nil
1251 }1252 }
1252 1253
@@ -1277,7 +1278,6 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1277 return targetSess, attachmentTransitionResult{}, false, err1278 return targetSess, attachmentTransitionResult{}, false, err
1278 }1279 }
1279 d.touchMRU(targetSess)1280 d.touchMRU(targetSess)
1280- ac.recordPreviousSession(from)
1281 return targetSess, transition, true, nil1281 return targetSess, transition, true, nil
1282 }1282 }
1283 1283
M internal/usecase/daemon/picker_test.go +68 -41
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "io"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -19,7 +20,6 @@ import (
19 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"20 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
20 themeui "github.com/bnema/vev/internal/usecase/theme"21 themeui "github.com/bnema/vev/internal/usecase/theme"
21 "github.com/bnema/vev/pkg/renderer"22 "github.com/bnema/vev/pkg/renderer"
22- "github.com/bnema/vev/pkg/vt"
23 )23 )
24 24
25 var (25 var (
@@ -29,6 +29,14 @@ var (
29 29
30 // --- test doubles -----------------------------------------------------------30 // --- test doubles -----------------------------------------------------------
31 31
32+type remotePickerSendErrorTransport struct {
33+ err error
34+}
35+
36+func (t *remotePickerSendErrorTransport) Send(ports.Frame) error { return t.err }
37+func (*remotePickerSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
38+func (*remotePickerSendErrorTransport) Close() error { return nil }
39+
32 type refusingSnapshotDeleteRepository struct {40 type refusingSnapshotDeleteRepository struct {
33 noOpSnapshotRepository41 noOpSnapshotRepository
34 err error42 err error
@@ -878,6 +886,65 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
878 }886 }
879 }887 }
880 888
889+func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) {
890+ d, sess, ac, _, releases := newManualTabSession(t, 1)
891+ defer releases[0]()
892+ const sendErr = "navigation back send failed"
893+ tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}
894+ ac.replaceTransport(tr)
895+ ac.startupOverlay = ports.StartupOverlaySessionPicker
896+ ac.navigationCapabilities = ports.NavigationCapabilityBack
897+ ac.overlays.pickerMu.Lock()
898+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
899+ ac.overlays.pickerGeneration++
900+ ac.overlays.pickerMu.Unlock()
901+
902+ rc := d.attachCoordinator(sess, nil, ac, true)
903+ token := sess.attachmentToken(ac, tr)
904+ token.lease = rc.attachmentLease(ac)
905+ ac.publishAttachmentCapability(token)
906+ effect, admitted := ac.beginAttachmentEffect(token)
907+ require.True(t, admitted)
908+ defer effect.End()
909+ token.effect = effect
910+
911+ d.handlePickerInput(ac, []byte("\r"), effect)
912+ require.True(t, ac.overlays.pickerActive(), "failed navigation send must leave the picker open")
913+ require.Same(t, sess, ac.currentAttachmentSession())
914+ history := d.notices.history()
915+ require.NotEmpty(t, history)
916+ require.Contains(t, history[len(history)-1].Details, sendErr)
917+}
918+
919+func TestPickerNavigationBackWithoutCallerTicketKeepsPickerOpen(t *testing.T) {
920+ for _, tc := range []struct {
921+ name string
922+ passNil bool
923+ }{
924+ {name: "empty effects"},
925+ {name: "nil first effect", passNil: true},
926+ } {
927+ t.Run(tc.name, func(t *testing.T) {
928+ d, sess, ac, _, releases := newManualTabSession(t, 1)
929+ defer releases[0]()
930+ ac.startupOverlay = ports.StartupOverlaySessionPicker
931+ ac.navigationCapabilities = ports.NavigationCapabilityBack
932+ ac.overlays.pickerMu.Lock()
933+ ac.overlays.picker = picker.New([]picker.SessionView{{ID: sess.id, Name: sess.name, Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{})
934+ ac.overlays.pickerGeneration++
935+ ac.overlays.pickerMu.Unlock()
936+
937+ if tc.passNil {
938+ d.handlePickerInput(ac, []byte{3}, nil)
939+ } else {
940+ d.handlePickerInput(ac, []byte{3})
941+ }
942+
943+ require.True(t, ac.overlays.pickerActive(), "back with missing effect must keep picker open when fresh admission is unavailable")
944+ })
945+ }
946+}
947+
881 func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {948 func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
882 d, sess, ac, sends, releases := newManualTabSession(t, 1)949 d, sess, ac, sends, releases := newManualTabSession(t, 1)
883 defer releases[0]()950 defer releases[0]()
@@ -893,46 +960,6 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
893 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)960 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
894 }961 }
895 962
896-func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
897- p1, releasePTY1 := newBlockingPTY(t)
898- p2, releasePTY2 := newBlockingPTY(t)
899- defer releasePTY1()
900- defer releasePTY2()
901- d, source, ac, sends := newManualSessionWithPTYs(t, p1)
902- source.id, source.name = "source", "source"
903- delete(d.sessions, domain.SessionID("manual"))
904- d.sessions[source.id] = source
905- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {},
906- tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},
907- }
908- d.sessions[target.id] = target
909-
910- sourcePane := source.tabs[0].focusedPane()
911- targetPane := target.tabs[0].focusedPane()
912- require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1")
913- require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")
914- sourcePane.screen.Write([]byte("SOURCE"))
915- client := vt.NewScreen(80, 25)
916- d.firstPaint(source, ac)
917- sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
918- ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New)
919-
920- targetPane.screen.Write([]byte("TARGET"))
921- targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
922- require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
923- ac.previousOwner.Set(target)
924-
925- // Exercise the user-facing previous-session route, which delegates to the
926- // real switchToTarget hand-off and immediately emits its required reset.
927- d.backSession(source, ac)
928- firstReset := awaitFrame(t, sends, ports.MsgOutput)
929- out := mustApplyOutput(t, client, firstReset)
930- require.Zero(t, out.Base, "the first target frame must be the reset, not an eventual repair")
931- frame := strings.Join(frameRows(client.Frame), "\n")
932- require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")
933- require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
934-}
935-
936 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {963 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
937 p1, releasePTY1 := newBlockingPTY(t)964 p1, releasePTY1 := newBlockingPTY(t)
938 p2, releasePTY2 := newBlockingPTY(t)965 p2, releasePTY2 := newBlockingPTY(t)
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-}
A internal/usecase/daemon/recent_route_presentation.go +76 -0
@@ -0,0 +1,76 @@
1+package daemon
2+
3+// recentRouteKind identifies the origin represented by a presentation value.
4+// It is intentionally daemon-local in this layer; ports route kinds are
5+// projected into this render-only value at the daemon boundary.
6+type recentRouteKind uint8
7+
8+const (
9+ recentRouteLocal recentRouteKind = iota
10+ recentRouteRemote
11+)
12+
13+// recentRoutePresentation is the compact immutable value shared by snapshot
14+// projection and formatting. It contains only display data; it never carries
15+// session ownership, selection identity, transport capabilities, credentials,
16+// or attach targets.
17+type recentRoutePresentation struct {
18+ name string
19+ hostLabel string
20+ kind recentRouteKind
21+ ephemeral bool
22+ attention bool
23+}
24+
25+// recentRouteDisplay is a formatted, render-ready copy of a presentation. The
26+// source value is never mutated while duplicate names are qualified.
27+type recentRouteDisplay struct {
28+ name string
29+ kind recentRouteKind
30+ ephemeral bool
31+ attention bool
32+}
33+
34+// formatRecentRoutePresentations is the single label formatter used by the
35+// normal MRU bar and ranked recent-session palette rendering. It returns an
36+// owned slice so callers can retain the result without consulting live state.
37+func formatRecentRoutePresentations(entries []recentRoutePresentation) []recentRouteDisplay {
38+ if entries == nil {
39+ return nil
40+ }
41+ nameCounts := make(map[string]int, len(entries))
42+ for _, entry := range entries {
43+ nameCounts[entry.name]++
44+ }
45+
46+ out := make([]recentRouteDisplay, len(entries))
47+ for i, entry := range entries {
48+ out[i] = recentRouteDisplay{
49+ name: formatRecentRouteName(entry, nameCounts[entry.name] > 1),
50+ kind: entry.kind,
51+ ephemeral: entry.ephemeral,
52+ attention: entry.attention,
53+ }
54+ }
55+ return out
56+}
57+
58+func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string {
59+ name := entry.name
60+ if entry.ephemeral {
61+ name += "*"
62+ }
63+ if entry.kind != recentRouteRemote && !ambiguous {
64+ return name
65+ }
66+
67+ host := entry.hostLabel
68+ if host == "" {
69+ if entry.kind == recentRouteRemote {
70+ host = "remote"
71+ } else {
72+ host = "local"
73+ }
74+ }
75+ return name + "@" + host
76+}
A internal/usecase/daemon/recent_route_presentation_test.go +157 -0
@@ -0,0 +1,157 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/usecase/command"
8+ "github.com/bnema/vev/internal/usecase/palette"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestFormatRecentRoutePresentations(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ input []recentRoutePresentation
16+ want []recentRouteDisplay
17+ }{
18+ {
19+ name: "unique local name stays compact",
20+ input: []recentRoutePresentation{{
21+ name: "editor", kind: recentRouteLocal,
22+ }},
23+ want: []recentRouteDisplay{{
24+ name: "editor", kind: recentRouteLocal,
25+ }},
26+ },
27+ {
28+ name: "remote name is host qualified",
29+ input: []recentRoutePresentation{{
30+ name: "logs", hostLabel: "edge", kind: recentRouteRemote,
31+ }},
32+ want: []recentRouteDisplay{{
33+ name: "logs@edge", kind: recentRouteRemote,
34+ }},
35+ },
36+ {
37+ name: "duplicate local and remote names qualify every entry",
38+ input: []recentRoutePresentation{
39+ {name: "logs", kind: recentRouteLocal},
40+ {name: "logs", hostLabel: "edge", kind: recentRouteRemote},
41+ },
42+ want: []recentRouteDisplay{
43+ {name: "logs@local", kind: recentRouteLocal},
44+ {name: "logs@edge", kind: recentRouteRemote},
45+ },
46+ },
47+ {
48+ name: "missing host uses kind fallback",
49+ input: []recentRoutePresentation{{
50+ name: "logs", kind: recentRouteRemote,
51+ }},
52+ want: []recentRouteDisplay{{
53+ name: "logs@remote", kind: recentRouteRemote,
54+ }},
55+ },
56+ {
57+ name: "authoritative markers survive formatting",
58+ input: []recentRoutePresentation{{
59+ name: "scratch", kind: recentRouteLocal, ephemeral: true, attention: true,
60+ }},
61+ want: []recentRouteDisplay{{
62+ name: "scratch*", kind: recentRouteLocal, ephemeral: true, attention: true,
63+ }},
64+ },
65+ }
66+
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ input := append([]recentRoutePresentation(nil), tt.input...)
70+ got := formatRecentRoutePresentations(input)
71+
72+ require.Equal(t, tt.want, got)
73+ require.Equal(t, input, tt.input, "formatting must not mutate the source snapshot")
74+ })
75+ }
76+}
77+
78+func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) {
79+ snapshot := ports.RecentRouteSnapshot{
80+ Generation: 1,
81+ Entries: []ports.RecentRouteEntry{
82+ {Key: 1, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal, Attention: true},
83+ {Key: 2, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal},
84+ },
85+ }
86+
87+ hints := recentRouteHints(snapshot, nil)
88+ require.Equal(t, []palette.RecentSessionHint{
89+ {Rank: 1, Name: "logs@local", SnapshotGeneration: 1, Key: 1, Generation: 1},
90+ {Rank: 2, Name: "logs@local", SnapshotGeneration: 1, Key: 2, Generation: 1},
91+ }, hints.Recent)
92+
93+ hints = palette.ContextualHints{
94+ Kind: command.ContextHintRecentSessions,
95+ SelectedRank: 9,
96+ Recent: []palette.RecentSessionHint{
97+ {Rank: 4, Name: "stale-label"},
98+ {Rank: 9, Name: "stale-label"},
99+ },
100+ }
101+ ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot)
102+
103+ require.Equal(t, []rankedRecent{
104+ {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true},
105+ {rank: 9, name: "logs@local", kind: recentRouteLocal, selected: true},
106+ }, ranked)
107+}
108+
109+func TestFitRecentRouteEntriesKeepsWholeWideAndAttentionEntries(t *testing.T) {
110+ tests := []struct {
111+ name string
112+ entries []recentRouteDisplay
113+ width int
114+ want []string
115+ }{
116+ {
117+ name: "wide rune consumes its full cell width",
118+ entries: []recentRouteDisplay{
119+ {name: "界"},
120+ {name: "b"},
121+ },
122+ width: 12,
123+ want: []string{"界"},
124+ },
125+ {
126+ name: "attention bell is part of the entry width",
127+ entries: []recentRouteDisplay{
128+ {name: "a", attention: true},
129+ {name: "b"},
130+ },
131+ width: 12,
132+ want: []string{"a"},
133+ },
134+ }
135+
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ got := fitMRU(tt.entries, tt.width, 0, "copy")
139+ names := make([]string, len(got))
140+ for i, entry := range got {
141+ names[i] = entry.name
142+ }
143+ require.Equal(t, tt.want, names)
144+ })
145+ }
146+}
147+
148+func TestRankedRecentKeepsCapturedRanksWhenFitting(t *testing.T) {
149+ entries := []rankedRecent{
150+ {rank: 3, name: "wide界"},
151+ {rank: 7, name: "next"},
152+ }
153+
154+ got := fitRankedRecent(entries, 17, 0, "")
155+
156+ require.Equal(t, []rankedRecent{{rank: 3, name: "wide界"}}, got)
157+}
A internal/usecase/daemon/recent_route_snapshot.go +28 -0
@@ -0,0 +1,28 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation {
6+ if snapshot.Entries == nil {
7+ return nil
8+ }
9+ out := make([]recentRoutePresentation, len(snapshot.Entries))
10+ for i, entry := range snapshot.Entries {
11+ kind := recentRouteLocal
12+ if entry.Kind == ports.RouteKindRemote {
13+ kind = recentRouteRemote
14+ }
15+ out[i] = recentRoutePresentation{
16+ name: entry.Name,
17+ hostLabel: entry.HostLabel,
18+ kind: kind,
19+ ephemeral: entry.Ephemeral,
20+ attention: entry.Attention,
21+ }
22+ }
23+ return out
24+}
25+
26+func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay {
27+ return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot))
28+}
A internal/usecase/daemon/recent_route_snapshot_test.go +94 -0
@@ -0,0 +1,94 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) {
11+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
12+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
13+ Generation: 2,
14+ Active: ports.RouteRef{Key: 3, Generation: 2},
15+ Entries: []ports.RecentRouteEntry{
16+ {Key: 2, Generation: 1, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote},
17+ {Key: 1, Generation: 1, Name: "work", Kind: ports.RouteKindLocal},
18+ },
19+ })
20+
21+ state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{})
22+
23+ require.Len(t, state.mru, 2)
24+ require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name})
25+}
26+
27+func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
28+ _, _, ac, _ := newManualSessionWithPTYs(t, nil)
29+ entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}
30+ snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries}
31+ ac.setRouteSnapshot(snapshot)
32+
33+ entries[0].Name = "after"
34+ got := ac.routeSnapshotCopy()
35+ require.Equal(t, "before", got.Entries[0].Name)
36+ got.Entries[0].Name = "mutated copy"
37+ require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name)
38+}
39+
40+func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) {
41+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
42+ transport := &closeTrackingTransport{}
43+ ac.replaceTransport(transport)
44+ rc := d.attachCoordinator(source, nil, ac, true)
45+ token := source.attachmentToken(ac, transport)
46+ token.lease = rc.attachmentLease(ac)
47+ ac.publishAttachmentCapability(token)
48+ effect, admitted := ac.beginAttachmentEffect(token)
49+ require.True(t, admitted)
50+ defer effect.End()
51+ token.effect = effect
52+
53+ exec := paletteExec{
54+ d: d, sess: source, attachment: source, ac: ac,
55+ routeSnapshot: ports.RecentRouteSnapshot{
56+ Generation: 4,
57+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 3, Name: "remote", Kind: ports.RouteKindRemote}},
58+ },
59+ effect: effect,
60+ }
61+ require.NoError(t, exec.JumpRecentSession(1))
62+
63+ frames := transport.Sends()
64+ var action ports.RouteNavigationAction
65+ found := false
66+ for _, frame := range frames {
67+ if frame.Type != ports.MsgNavigateRecentRoute {
68+ continue
69+ }
70+ var err error
71+ action, err = ports.UnmarshalRouteNavigationAction(frame.Payload)
72+ require.NoError(t, err)
73+ found = true
74+ }
75+ require.True(t, found)
76+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 2, Generation: 3}, action)
77+}
78+
79+func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) {
80+ snapshot := ports.RecentRouteSnapshot{
81+ Generation: 8,
82+ Entries: []ports.RecentRouteEntry{{
83+ Key: 11, Generation: 7, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
84+ }},
85+ }
86+
87+ hints := recentRouteHints(snapshot, nil)
88+
89+ require.Len(t, hints.Recent, 1)
90+ require.Equal(t, "logs@edge", hints.Recent[0].Name)
91+ require.Equal(t, uint64(8), hints.Recent[0].SnapshotGeneration)
92+ require.Equal(t, uint64(11), hints.Recent[0].Key)
93+ require.Equal(t, uint64(7), hints.Recent[0].Generation)
94+}
D internal/usecase/daemon/recent_sessions.go +0 -146
@@ -1,146 +0,0 @@
1-package daemon
2-
3-import (
4- "sort"
5-
6- "github.com/bnema/vev/internal/domain"
7-)
8-
9-// recentSession is an immutable, value-only description of a session for one
10-// interaction. It deliberately does not retain a live session pointer.
11-type recentSession struct {
12- id domain.SessionID
13- name string
14- attention bool
15- mruAt uint64
16-}
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.
39-// Callers may retain the returned values for the lifetime of an interaction.
40-func (d *Daemon) recentSessions(current *session) []recentSession {
41- if d == nil {
42- return nil
43- }
44- d.mu.Lock()
45- recent := make([]recentSession, 0, len(d.sessions))
46- for _, sess := range d.sessions {
47- if sess == nil || sess == current {
48- continue
49- }
50- snap := sess.snapshotView(viewOptions{})
51- if snap.ephemeral {
52- continue
53- }
54- entry := recentSession{id: snap.id, name: snap.name, mruAt: snap.mruAt, attention: snap.hasAttention}
55- recent = append(recent, entry)
56- }
57- d.mu.Unlock()
58- return trimRecentSessions(recent)
59-}
60-
61-// recentSessionsForAttachment merges the daemon-wide local MRU with the
62-// attachment's display-only remote entries. Only chrome composition calls this
63-// method; picker, palette, and command routing keep using recentSessions.
64-func (d *Daemon) recentSessionsForAttachment(current *session, ac *attachedClient) []recentSession {
65- recent := d.recentSessions(current)
66- if ac == nil {
67- return recent
68- }
69- return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(remoteViewKey{})...))
70-}
71-
72-// recentSessionsForRemoteAttachment merges daemon-local entries with this
73-// attachment's remote history while omitting the remote view currently rendered
74-// in the status bar.
75-func (d *Daemon) recentSessionsForRemoteAttachment(current *remoteView, ac *attachedClient) []recentSession {
76- recent := d.recentSessions(nil)
77- if ac == nil {
78- return recent
79- }
80- var excluded remoteViewKey
81- if current != nil {
82- excluded = current.key
83- }
84- return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(excluded)...))
85-}
86-
87-func trimRecentSessions(recent []recentSession) []recentSession {
88- sort.SliceStable(recent, func(i, j int) bool {
89- if recent[i].mruAt == recent[j].mruAt {
90- return recent[i].name < recent[j].name
91- }
92- return recent[i].mruAt > recent[j].mruAt
93- })
94- if len(recent) > maxMRUSessions {
95- recent = recent[:maxMRUSessions]
96- }
97- return recent
98-}
99-
100-// recordRemoteRecent records only a successfully published remote owner. The
101-// caller invokes it after the attachment hand-off, outside daemon and view
102-// locks, so failed selections cannot affect the local status-bar history.
103-func (d *Daemon) recordRemoteRecent(ac *attachedClient, view *remoteView) {
104- if d == nil || ac == nil || view == nil {
105- return
106- }
107- view.mu.Lock()
108- entry := remoteRecentSession{key: view.key, displayOrigin: view.displayOrigin}
109- view.mu.Unlock()
110- if entry.key.sessionName == "" {
111- return
112- }
113- entry.mruAt = d.mruSeq.Add(1)
114- ac.remoteRecent.With(func(entries *[]remoteRecentSession) {
115- for i := range *entries {
116- if (*entries)[i].key == entry.key {
117- (*entries)[i] = entry
118- return
119- }
120- }
121- *entries = append(*entries, entry)
122- if len(*entries) > maxMRUSessions {
123- sort.SliceStable(*entries, func(i, j int) bool {
124- return (*entries)[i].mruAt > (*entries)[j].mruAt
125- })
126- *entries = (*entries)[:maxMRUSessions]
127- }
128- })
129-}
130-
131-func (ac *attachedClient) remoteRecentSessionsExcept(excluded remoteViewKey) []recentSession {
132- if ac == nil {
133- return nil
134- }
135- var recent []recentSession
136- ac.remoteRecent.With(func(entries *[]remoteRecentSession) {
137- recent = make([]recentSession, 0, len(*entries))
138- for _, entry := range *entries {
139- if entry.key == excluded {
140- continue
141- }
142- recent = append(recent, recentSession{name: entry.name(), mruAt: entry.mruAt})
143- }
144- })
145- return recent
146-}
M internal/usecase/daemon/remote_attach.go +55 -13
@@ -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,67 @@ 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+ return sess, ac, err
81+}
82+
83+func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
84+ payload := ports.MarshalNavigationAction(action)
85+ if payload == nil {
86+ return errAttachmentTransition
87+ }
88+ return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload})
89+}
90+
91+func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error {
92+ payload, err := ports.MarshalRouteNavigationAction(action)
80 if err != nil {93 if err != nil {
81- // The route-created session has no published client if finishAttach94+ return errAttachmentTransition
82- // fails. Retire it immediately rather than leaving a shadow live row.
83- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
84- return nil, nil, err
85 }95 }
86- ac.routeCreatedSession = true96+ return token.sendControl(ports.Frame{Type: ports.MsgNavigateRecentRoute, Payload: payload})
87- return sess, ac, nil
88 }97 }
89 98
90-func (d *Daemon) remoteTargetMatchesSession(sess *session, target domain.RemoteSessionTarget) bool {99+func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnectionToken) error {
91- if sess == nil {100+ if token.sess == nil {
101+ return errAttachmentTransition
102+ }
103+ token.sess.mu.Lock()
104+ identity := ports.CommittedRouteIdentity{
105+ Target: ports.ExactSessionTarget{LifecycleID: token.sess.incarnation, SessionName: token.sess.name},
106+ Ephemeral: token.sess.ephemeral,
107+ }
108+ token.sess.mu.Unlock()
109+ payload, err := ports.MarshalCommittedRouteIdentity(identity)
110+ if err != nil {
111+ return errAttachmentTransition
112+ }
113+ return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})
114+}
115+
116+func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
117+ ac, err := d.finishAttach(sess, tr, sz, term, h)
118+ if err != nil && routeCreated {
119+ if cleanupErr := d.killSession(sess, ports.ReasonSessionKilled, purge); cleanupErr != nil {
120+ err = errors.Join(err, cleanupErr)
121+ }
122+ return nil, err
123+ }
124+ if err == nil && routeCreated && ac != nil {
125+ ac.routeCreatedSession = true
126+ ac.routeSessionPurge = purge
127+ }
128+ return ac, err
129+}
130+
131+// remoteTargetMatchesSessionLocked validates the exact lifecycle and tab
132+// selector while the daemon registry lock is held. Callers use this before
133+// changing resume ownership so a stale target cannot partially claim a session.
134+func (d *Daemon) remoteTargetMatchesSessionLocked(sess *session, target domain.RemoteSessionTarget) bool {
135+ if sess == nil || d.sessions[sess.id] != sess {
92 return false136 return false
93 }137 }
94- d.mu.Lock()
95- defer d.mu.Unlock()
96 sess.mu.Lock()138 sess.mu.Lock()
97- matches := d.sessions[sess.id] == sess && sess.incarnation == target.LifecycleID && sess.name == target.SessionName139+ matches := sess.incarnation == target.LifecycleID && sess.name == target.SessionName
98 sess.mu.Unlock()140 sess.mu.Unlock()
99 if !matches {141 if !matches {
100 return false142 return false
M internal/usecase/daemon/remote_attach_test.go +58 -78
@@ -17,84 +17,64 @@ 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()
60+ d.remoteCatalog.status["arch"] = remoteHostFresh
61+ d.remoteCatalog.mu.Unlock()
62+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
63+ token := sess.attachmentToken(ac, ac.transport())
64+ effect, admitted := ac.beginAttachmentEffect(token)
65+ require.True(t, admitted)
66+ token.effect = effect
67+ defer effect.End()
68+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
69+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
70+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
71+ require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
72+ for {
73+ select {
74+ case frame := <-sends:
75+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type, "rejected handoff must not send an attach target")
76+ default:
77+ return
78+ }
99 }79 }
100 }80 }
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 +20 -20
@@ -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,21 @@ 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 = "up"
206- if session.Attached {214+ case "down", "stopped":
207- detail += " · attached"215+ detail = "stopped"
208- }
209- case "down":
210- detail = "down"
211 case "broken":216 case "broken":
212 detail = "broken"217 detail = "broken"
213 }218 }
@@ -454,12 +459,7 @@ func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64
454 if cacheChanged {459 if cacheChanged {
455 d.persistRemoteCatalogCache()460 d.persistRemoteCatalogCache()
456 }461 }
457- // A picker model may have been built from a fresh cached entry just before462+ 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 463
464 for _, host := range hosts {464 for _, host := range hosts {
465 d.sessWg.Go(func() {465 d.sessWg.Go(func() {
@@ -570,8 +570,8 @@ func (d *Daemon) refreshRemoteOpenPickers() {
570 continue570 continue
571 }571 }
572 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})572 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
573- // The picker is attachment-local: catalog changes repaint the actual573+ if entry := ac.currentAttachmentSession(); entry != nil {
574- // owner, which can be either the retained local session or a remote view.574+ d.invalidateRender(entry, ac, true, "remote_picker.go")
575- d.invalidateAttachedOwner(ac, true, "remote_picker.go")575+ }
576 }576 }
577 }577 }
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 +180 -562
@@ -6,16 +6,16 @@ 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"
13 12
13+ "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18- "github.com/bnema/vev/internal/usecase/keys"18+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/usecase/picker"19 "github.com/bnema/vev/internal/usecase/picker"
20 )20 )
21 21
@@ -57,9 +57,72 @@ 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))57 return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
58 }58 }
59 59
60+func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) {
61+ for _, state := range []string{"down", "stopped"} {
62+ t.Run(state, func(t *testing.T) {
63+ lifecycle := remoteLifecycleForTest()
64+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle}
65+ session := ports.RemoteCatalogSession{
66+ LifecycleID: lifecycle, Name: "work", State: state,
67+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
68+ }
69+ view := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0))
70+ require.True(t, view.Stopped)
71+ require.NotNil(t, view.RemoteTarget)
72+ require.True(t, view.RemoteTarget.Stopped)
73+ require.True(t, view.RemoteAttachReady, "structured stopped rows have an explicit safe restore target")
74+
75+ legacy := session
76+ legacy.LifecycleID = domain.SessionLifecycleID{}
77+ legacyView := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, legacy, remoteHostFresh, time.Unix(100, 0))
78+ require.True(t, legacyView.Stopped)
79+ require.True(t, legacyView.ConnectOnly)
80+ require.False(t, legacyView.RemoteAttachReady, "legacy stopped rows cannot be selected without lifecycle identity")
81+ })
82+ }
83+}
84+
85+func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) {
86+ for _, attached := range []bool{false, true} {
87+ view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, ports.RemoteCatalogSession{
88+ LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up", Attached: attached,
89+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0}},
90+ }, remoteHostFresh, time.Now())
91+ require.Equal(t, "up", view.RemoteDetail)
92+ }
93+}
94+
95+func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testing.T) {
96+ store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}
97+ d := newRemotePickerDaemon(store)
98+ lifecycle := remoteLifecycleForTest()
99+ session := ports.RemoteCatalogSession{
100+ LifecycleID: lifecycle, Name: "work", State: "up",
101+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
102+ }
103+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
104+ {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},
105+ {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},
106+ })
107+ d.remoteCatalog.mu.Lock()
108+ d.remoteCatalog.status["remote"] = remoteHostFresh
109+ d.remoteCatalog.status["vev@remote"] = remoteHostFresh
110+ d.remoteCatalog.mu.Unlock()
111+
112+ views, _ := d.pickerViews(nil, nil)
113+ var targets []*domain.RemoteSessionTarget
114+ for _, view := range views {
115+ if view.RemoteTarget != nil {
116+ targets = append(targets, view.RemoteTarget)
117+ }
118+ }
119+ require.Len(t, targets, 1)
120+ require.Equal(t, "remote", targets[0].Endpoint, "first pinned alias must remain the attach endpoint")
121+}
122+
60 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {123 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
61 now := time.Unix(100, 0)124 now := time.Unix(100, 0)
62- clock := &remotePreviewTestClock{now: now}125+ clock := &remotePickerClock{now: now}
63 d := newTestDaemon(t, nil, clock)126 d := newTestDaemon(t, nil, clock)
64 lifecycle := remoteLifecycleForTest()127 lifecycle := remoteLifecycleForTest()
65 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{128 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
@@ -80,7 +143,7 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
80 143
81 func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) {144 func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) {
82 now := time.Unix(100, 0)145 now := time.Unix(100, 0)
83- clock := &remotePreviewTestClock{now: now}146+ clock := &remotePickerClock{now: now}
84 d := newTestDaemon(t, nil, clock)147 d := newTestDaemon(t, nil, clock)
85 lifecycle := remoteLifecycleForTest()148 lifecycle := remoteLifecycleForTest()
86 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{149 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
@@ -205,30 +268,6 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
205 }268 }
206 }269 }
207 270
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) {271 func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
233 d := newRemotePickerDaemon(nil)272 d := newRemotePickerDaemon(nil)
234 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)273 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
@@ -381,6 +420,23 @@ type fixedRemoteRefreshClock struct{ now time.Time }
381 func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }420 func (c fixedRemoteRefreshClock) Now() time.Time { return c.now }
382 func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }421 func (fixedRemoteRefreshClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
383 422
423+type remotePickerClock struct {
424+ mu sync.Mutex
425+ now time.Time
426+}
427+
428+func (c *remotePickerClock) Now() time.Time {
429+ c.mu.Lock()
430+ defer c.mu.Unlock()
431+ return c.now
432+}
433+func (*remotePickerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
434+func (c *remotePickerClock) Advance(d time.Duration) {
435+ c.mu.Lock()
436+ c.now = c.now.Add(d)
437+ c.mu.Unlock()
438+}
439+
384 func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {440 func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {
385 t.Helper()441 t.Helper()
386 if catalog != nil {442 if catalog != nil {
@@ -416,8 +472,12 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
416 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}472 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
417 ac.overlays.pickerMu.Unlock()473 ac.overlays.pickerMu.Unlock()
418 require.True(t, d.registerRemotePicker(instance))474 require.True(t, d.registerRemotePicker(instance))
475+ lifecycle := remoteLifecycleForTest()
419 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{476 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
420- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "cached", State: "up"}},477+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
478+ LifecycleID: lifecycle, Name: "cached", State: "up",
479+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
480+ }},
421 }})481 }})
422 482
423 firstGeneration := d.startRemotePickerRefresh(instance)483 firstGeneration := d.startRemotePickerRefresh(instance)
@@ -430,9 +490,17 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
430 require.Contains(t, first, "mule")490 require.Contains(t, first, "mule")
431 requireNoRemoteLockViolations(t, catalog, nil)491 requireNoRemoteLockViolations(t, catalog, nil)
432 d.remoteCatalog.mu.Lock()492 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")493+ require.Equal(t, []ports.RemoteCatalogSession{{
494+ LifecycleID: lifecycle, Name: "cached", State: "up",
495+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
496+ }}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
434 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])497 require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
435 d.remoteCatalog.mu.Unlock()498 d.remoteCatalog.mu.Unlock()
499+ ac.overlays.pickerMu.Lock()
500+ refreshedModel := ac.overlays.picker
501+ ac.overlays.pickerMu.Unlock()
502+ _, selectable := refreshedModel.Selected()
503+ require.False(t, selectable, "refreshing status must gate picker activation")
436 504
437 secondGeneration := d.startRemotePickerRefresh(instance)505 secondGeneration := d.startRemotePickerRefresh(instance)
438 require.Greater(t, secondGeneration, firstGeneration)506 require.Greater(t, secondGeneration, firstGeneration)
@@ -455,55 +523,6 @@ func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T)
455 d.remoteCatalog.mu.Unlock()523 d.remoteCatalog.mu.Unlock()
456 }524 }
457 525
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) {526 func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
508 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}527 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
509 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))528 d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
@@ -635,506 +654,110 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
635 d.remoteCatalog.mu.Unlock()654 d.remoteCatalog.mu.Unlock()
636 }655 }
637 656
638-func TestRemoteCatalogRefreshUpdatesRemoteOwnerPicker(t *testing.T) {657+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)658 d := newRemotePickerDaemon(nil)
688- target := remoteLinkTestTarget()
689- require.NoError(t, target.Validate())
690 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{659 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
691- Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{660+ 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 }})661 }})
695 d.remoteCatalog.mu.Lock()662 d.remoteCatalog.mu.Lock()
696- d.remoteCatalog.status[target.Endpoint] = remoteHostFresh663+ d.remoteCatalog.status["arch"] = remoteHostFresh
697 d.remoteCatalog.mu.Unlock()664 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")665 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())666 token := sess.attachmentToken(ac, ac.transport())
716 effect, admitted := ac.beginAttachmentEffect(token)667 effect, admitted := ac.beginAttachmentEffect(token)
717 require.True(t, admitted)668 require.True(t, admitted)
718 token.effect = effect669 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}670+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
720- require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))671+ target := picker.Target{Session: key.ID(), RemoteKey: &key}
721- effect.End()672+ 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 673
674+ frame := receiveRemotePicker(t, sends, "attach target")
675+ require.Equal(t, ports.MsgAttachTarget, frame.Type)
676+ got, err := ports.UnmarshalAttachTarget(frame.Payload)
677+ require.NoError(t, err)
678+ require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, got)
679+ require.Nil(t, ac.currentAttachmentSession())
762 d.mu.Lock()680 d.mu.Lock()
763- require.Len(t, d.remoteViews, 1)681+ require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow")
764 d.mu.Unlock()682 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 }683 }
869 684
870-func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) {685+func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
871- d := newRemotePickerDaemon(nil)686+ tests := []struct {
872- target := remoteLinkTestTarget()687+ name string
873- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{688+ action ports.NavigationAction
874- Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{689+ }{
875- LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up", Tabs: []ports.RemoteCatalogTab{{ID: string(target.LiveTabID), Index: 0, Name: "main"}},690+ {name: "home picker", action: ports.NavigationOpenHomePicker},
876- }},691+ {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 }692 }
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 693
946-func TestRemotePickerWarmReselectActivatesSelectedAlternateTabWithoutSecondDial(t *testing.T) {694+ for _, tt := range tests {
947- d := newRemotePickerDaemon(nil)695+ t.Run(tt.name, func(t *testing.T) {
948- target := remoteLinkTestTarget()696+ d := newRemotePickerDaemon(nil)
949- alternate := target697+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
950- alternate.LiveTabID = "tab-2"698+ token := sess.attachmentToken(ac, ac.transport())
951- const alternateName = "alternate"699+ effect, admitted := ac.beginAttachmentEffect(token)
952- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{700+ require.True(t, admitted)
953- Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{701+ token.effect = effect
954- LifecycleID: target.LifecycleID, Name: target.SessionName, State: "up",702+ 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}},703+ frame := receiveRemotePicker(t, sends, "navigation action")
956- }},704+ require.Equal(t, ports.MsgNavigationAction, frame.Type)
957- }})705+ got, err := ports.UnmarshalNavigationAction(frame.Payload)
958- d.remoteCatalog.mu.Lock()706+ require.NoError(t, err)
959- d.remoteCatalog.status[target.Endpoint] = remoteHostFresh707+ require.Equal(t, tt.action, got)
960- d.remoteCatalog.mu.Unlock()708+ })
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 }709 }
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 }710 }
1032 711
1033-func TestRemotePickerSelectionFailureKeepsSourceAndPickerOpen(t *testing.T) {712+func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
1034 d := newRemotePickerDaemon(nil)713 d := newRemotePickerDaemon(nil)
1035- target := remoteLinkTestTarget()
1036 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{714 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
1037- Host: target.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{715+ 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 }})716 }})
1041 d.remoteCatalog.mu.Lock()717 d.remoteCatalog.mu.Lock()
1042- d.remoteCatalog.status[target.Endpoint] = remoteHostFresh718+ d.remoteCatalog.status["arch"] = remoteHostFresh
1043 d.remoteCatalog.mu.Unlock()719 d.remoteCatalog.mu.Unlock()
1044- d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{}}720+ cause := errors.New("remote attach send failed")
1045-721+ tr := portsmocks.NewMockTransport(t)
1046- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")722+ tr.EXPECT().Send(mock.Anything).Return(cause)
723+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local", tr)
1047 ac.overlays.pickerMu.Lock()724 ac.overlays.pickerMu.Lock()
1048 ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})725 ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
1049 ac.overlays.pickerGeneration++726 ac.overlays.pickerGeneration++
1050 ac.overlays.pickerMu.Unlock()727 ac.overlays.pickerMu.Unlock()
1051- token := sess.attachmentToken(ac, ac.transport())
1052- effect, admitted := ac.beginAttachmentEffect(token)
1053- require.True(t, admitted)
1054- token.effect = effect
1055- pickerTarget := picker.Target{Session: domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}.ID(), RemoteTarget: &target}
1056- require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))
1057- effect.End()
1058-
1059- require.Eventually(t, func() bool {
1060- ac.overlays.pickerMu.Lock()
1061- defer ac.overlays.pickerMu.Unlock()
1062- return ac.overlays.pickerRemoteSelection == nil
1063- }, time.Second, 5*time.Millisecond)
1064- require.True(t, ac.overlays.pickerActive(), "a failed remote link leaves the local picker usable")
1065- require.Same(t, sess, ac.currentAttachmentSession(), "a failed remote link leaves the source owner untouched")
1066- require.Never(t, func() bool {
1067- select {
1068- case frame := <-sends:
1069- return frame.Type == ports.MsgAttachTarget
1070- default:
1071- return false
1072- }
1073- }, 100*time.Millisecond, 5*time.Millisecond)
1074- d.mu.Lock()
1075- require.Empty(t, d.remoteViews)
1076- d.mu.Unlock()
1077- d.shutdownAll(ports.ReasonServerShutdown)
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 728
1097- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")729+ gone := make(chan struct{})
1098- ac.overlays.pickerMu.Lock()730+ d.afterClientGoneDetach = func() { close(gone) }
1099- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})731+ token := sess.attachmentToken(ac, tr)
1100- ac.overlays.pickerGeneration++
1101- ac.overlays.pickerMu.Unlock()
1102- token := sess.attachmentToken(ac, ac.transport())
1103 effect, admitted := ac.beginAttachmentEffect(token)732 effect, admitted := ac.beginAttachmentEffect(token)
1104 require.True(t, admitted)733 require.True(t, admitted)
1105 token.effect = effect734 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}735+ defer effect.End()
1107- require.NoError(t, d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{closePicker: true}, "picker-select"))736+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1108- effect.End()737+ target := picker.Target{Session: key.ID(), RemoteKey: &key}
1109- receiveRemotePickerClose(t, dialStarted, "remote picker dial")738+ err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")
1110-739+ var userErr *domain.UserError
1111- d.handlePickerInput(ac, []byte{keys.ESC})740+ require.ErrorAs(t, err, &userErr)
1112- escTimer := receiveRemotePicker(t, clock.timers, "picker escape timer")741+ require.Equal(t, "couldn't attach to remote session", userErr.Msg)
1113- escTimer.ch <- time.Now()742+ require.ErrorIs(t, err, cause)
1114- require.Eventually(t, func() bool {743+ require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")
1115- ac.overlays.pickerMu.Lock()744+ select {
1116- defer ac.overlays.pickerMu.Unlock()745+ case <-gone:
1117- return ac.overlays.pickerRemoteSelection == nil746+ t.Fatal("failed control send reached clientGoneForAttachment")
1118- }, time.Second, 5*time.Millisecond)747+ default:
1119- require.True(t, ac.overlays.pickerActive(), "Escape cancels the pending remote activation but keeps the picker open")748+ }
1120 require.Same(t, sess, ac.currentAttachmentSession())749 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 }750 }
1134 751
1135-func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {752+func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, transports ...ports.Transport) (*session, *attachedClient, chan ports.Frame) {
1136 t.Helper()753 t.Helper()
1137- tr, sends := newCapturingTransport(t)754+ var tr ports.Transport
755+ var sends chan ports.Frame
756+ if len(transports) != 0 {
757+ tr = transports[0]
758+ } else {
759+ tr, sends = newCapturingTransport(t)
760+ }
1138 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}761 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
1139 ac.initOverlays()762 ac.initOverlays()
1140 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})763 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
@@ -1479,31 +1102,26 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
1479 ac.overlays.pickerMu.Unlock()1102 ac.overlays.pickerMu.Unlock()
1480 }1103 }
1481 1104
1482-func TestRemoteRowsRequireFreshRichIdentityForPickerActivation(t *testing.T) {1105+func TestLegacyRemoteRowsAreSelectableOnlyWhenFresh(t *testing.T) {
1483 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}1106 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1484- session := ports.RemoteCatalogSession{1107+ 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 remoteHostStatus1108 status remoteHostStatus
1489- want bool1109+ ready bool
1490 }{1110 }{
1491- {status: remoteHostCached},1111+ {status: remoteHostCached, ready: false},
1492- {status: remoteHostRefreshing},1112+ {status: remoteHostRefreshing, ready: false},
1493- {status: remoteHostFresh, want: true},1113+ {status: remoteHostStale, ready: false},
1494- {status: remoteHostStale},1114+ {status: remoteHostMalformed, ready: false},
1495- {status: remoteHostUnreachable},1115+ {status: remoteHostFresh, ready: true},
1496- {status: remoteHostMalformed},1116+ {status: remoteHostUnreachable, ready: false},
1497- {status: remoteHostVersionMismatch},
1498 } {1117 } {
1499- view := remotePickerView(key, session, test.status, time.Unix(1, 0))1118+ view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "up"}, tt.status, time.Unix(1, 0))
1119+ 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()1120 _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
1501- require.Equal(t, test.want, selectable, "status %d", test.status)1121+ require.Equal(t, tt.ready, selectable, "status %d selection", tt.status)
1502- require.Equal(t, test.want, view.RemoteAttachReady, "status %d", test.status)
1503 }1122 }
1504-1123+ 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))1124+ require.False(t, mismatch.RemoteAttachReady)
1506- require.False(t, legacy.RemoteAttachReady, "count-only cached rows cannot initiate proxied picker activation")1125+ _, 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)1126 require.False(t, selectable)
1509 }1127 }
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 +24 -0
@@ -26,6 +26,30 @@ 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+ // The caller must hold d.mu on entry; finishRouteAttach releases it on
41+ // both success and error paths before returning.
42+ d.mu.Lock()
43+ _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, terminalEnv{}, hello, true, true)
44+ var protocol *protoErr
45+ require.ErrorAs(t, err, &protocol)
46+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
47+ d.mu.Lock()
48+ _, retained := d.sessions[sess.id]
49+ d.mu.Unlock()
50+ require.False(t, retained, "failed route attach must remove its newly created session")
51+}
52+
29 func TestRouteRemoteTargetRejectsSameNameReplacement(t *testing.T) {53 func TestRouteRemoteTargetRejectsSameNameReplacement(t *testing.T) {
30 d := newTestDaemon(t, nil, stubClock{})54 d := newTestDaemon(t, nil, stubClock{})
31 sess := addControlSession(d, "work", "tab-new", "pane-new")55 sess := addControlSession(d, "work", "tab-new", "pane-new")
M internal/usecase/daemon/remote_stopped_route_test.go +48 -0
@@ -10,6 +10,54 @@ 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+ for _, tt := range []struct {
15+ name string
16+ intent uint8
17+ }{
18+ {name: "new", intent: ports.IntentNew},
19+ {name: "ephemeral", intent: ports.IntentEphemeral},
20+ } {
21+ t.Run(tt.name, func(t *testing.T) {
22+ d := newTestDaemon(t, nil, stubClock{})
23+ hello := ports.Hello{
24+ Version: ports.ProtocolVersion, Intent: tt.intent, Name: "work",
25+ Size: domain.Size{Cols: 80, Rows: 24}, Cwd: "/untrusted/cwd",
26+ Env: []string{"UNTRUSTED=client"}, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
27+ }
28+ _, _, err := d.route(hello, &closeTrackingTransport{})
29+ var protocol *protoErr
30+ require.ErrorAs(t, err, &protocol)
31+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
32+ d.mu.Lock()
33+ sessions := len(d.sessions)
34+ d.mu.Unlock()
35+ require.Zero(t, sessions)
36+ })
37+ }
38+}
39+
40+func TestLegacyDaemonOwnedStoppedAttachUsesDaemonEnvironment(t *testing.T) {
41+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
42+ d.baseEnv = []string{"DAEMON=owned"}
43+ d.mu.Lock()
44+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/remote/work", tabNames: []string{"main"}, state: ports.SessionDown}
45+ d.mu.Unlock()
46+
47+ hello := ports.Hello{
48+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
49+ Size: domain.Size{Cols: 80, Rows: 24}, Cwd: "/untrusted/cwd",
50+ Env: []string{"UNTRUSTED=client"}, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
51+ }
52+ sess, ac, err := d.routeWithContext(context.Background(), hello, &closeTrackingTransport{})
53+ require.NoError(t, err)
54+ require.NotNil(t, ac)
55+ sess.mu.Lock()
56+ require.Equal(t, []string{"DAEMON=owned"}, sess.env)
57+ sess.mu.Unlock()
58+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
59+}
60+
13 func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) {61 func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) {
14 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})62 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
15 lifecycle := remoteLifecycleForTest()63 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 +3 -7
@@ -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
@@ -391,7 +388,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
391 if statusFeedback == "" && overlays.resizeActive {388 if statusFeedback == "" && overlays.resizeActive {
392 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"389 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
393 }390 }
394- bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRecent)391+ bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot)
395 applied := ac.getAppliedTheme()392 applied := ac.getAppliedTheme()
396 bars.theme = applied.Raw393 bars.theme = applied.Raw
397 if local {394 if local {
@@ -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_capture.go +1 -1
@@ -28,7 +28,7 @@ type renderCaptureScratch struct {
28 layoutFingerprint string28 layoutFingerprint string
29 layoutValid bool29 layoutValid bool
30 statusTabs []statusTab30 statusTabs []statusTab
31- mru []recentSession31+ mru []recentRouteDisplay
32 ranked []rankedRecent32 ranked []rankedRecent
33 titleIDs []layout.PaneID33 titleIDs []layout.PaneID
34 receipts []damageReceipt34 receipts []damageReceipt
M internal/usecase/daemon/render_coordinator_test.go +11 -13
@@ -268,10 +268,16 @@ func awaitLatestCoordinatorTimer(t *testing.T, clk *coordinatorMockClock) *coord
268 268
269 func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) {269 func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) {
270 t.Helper()270 t.Helper()
271- select {271+ for {
272- case frame := <-sends:272+ select {
273- t.Fatalf("unexpected output frame: %+v", frame)273+ case frame := <-sends:
274- default:274+ if frame.Type == ports.MsgRoutePosition {
275+ continue
276+ }
277+ t.Fatalf("unexpected output frame: %+v", frame)
278+ default:
279+ return
280+ }
275 }281 }
276 }282 }
277 283
@@ -1136,7 +1142,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
1136 var producerFiles = []string{1142 var producerFiles = []string{
1137 "attention.go", "client.go", "copymode.go", "floating.go", "input.go",1143 "attention.go", "client.go", "copymode.go", "floating.go", "input.go",
1138 "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",1144 "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",
1139- "session.go", "session_back.go",1145+ "session.go",
1140 }1146 }
1141 1147
1142 func TestProducerInvalidations(t *testing.T) {1148 func TestProducerInvalidations(t *testing.T) {
@@ -1248,14 +1254,6 @@ func TestProducerInvalidations(t *testing.T) {
1248 require.NoError(t, d.closeTab(sess, tb, true))1254 require.NoError(t, d.closeTab(sess, tb, true))
1249 },1255 },
1250 },1256 },
1251- {
1252- file: "session_back.go",
1253- name: "back session fallback without a target",
1254- run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1255- d.backSession(sess, ac)
1256- require.Same(t, sess, ac.currentSession())
1257- },
1258- },
1259 }1257 }
1260 1258
1261 exercised := make([]string, 0, len(cases))1259 exercised := make([]string, 0, len(cases))
M internal/usecase/daemon/render_pipeline.go +58 -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,29 @@ 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+ position := ports.RoutePosition{
452+ Target: ports.ExactSessionTarget{
453+ LifecycleID: entry.core().incarnation,
454+ SessionName: entry.core().name,
455+ },
456+ ActiveTabID: state.view.tabID,
457+ }
458+ entry.core().mu.Unlock()
459+ if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
463 ac.sendMu.Unlock()460 ac.sendMu.Unlock()
464 return false461 return false
465 }462 }
466 if state.lease != nil {463 if state.lease != nil {
467 rc := attachmentRenderCoordinator(entry)464 rc := attachmentRenderCoordinator(entry)
468- if entry == nil || rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {465+ if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
469 ac.sendMu.Unlock()466 ac.sendMu.Unlock()
470 return false467 return false
471 }468 }
@@ -486,12 +483,11 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
486 endDiff(0, err == nil)483 endDiff(0, err == nil)
487 if err != nil {484 if err != nil {
488 ac.sendMu.Unlock()485 ac.sendMu.Unlock()
489- d.log.Error("render draw failed", "err", err, "session", attachmentOwnerName(owner))486+ core := entry.core()
490- if entry == nil {487+ core.mu.Lock()
491- // A remote view has no session-scoped notice history or coordinator.488+ sessionName := core.name
492- // Its next remote publication will retry from the retained private VT.489+ core.mu.Unlock()
493- return true490+ d.log.Error("render draw failed", "err", err, "session", sessionName)
494- }
495 // Without a coordinator reportError repaints synchronously. Suppress only491 // Without a coordinator reportError repaints synchronously. Suppress only
496 // that nested notice repaint; leave the guard before returning so a later,492 // that nested notice repaint; leave the guard before returning so a later,
497 // independent failed transaction can still notify the user.493 // independent failed transaction can still notify the user.
@@ -566,14 +562,46 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
566 // represents the captured frame. Lock panes only under sendMu and with no562 // represents the captured frame. Lock panes only under sendMu and with no
567 // session guard held.563 // session guard held.
568 commitDamageReceipts(state.receipts)564 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 {565 if ac.renderStages.emit != nil {
575 ac.renderStages.emit()566 ac.renderStages.emit()
576 }567 }
568+
569+ if position.ActiveTabID != "" && position != ac.lastRoutePosition {
570+ payload, positionMarshalErr := ports.MarshalRoutePosition(position)
571+ if positionMarshalErr != nil {
572+ d.log.Error("marshal route position", "err", positionMarshalErr)
573+ } else {
574+ sendTransport := ac.transportSnapshot()
575+ sendTr = sendTransport.transport
576+ interruptible := false
577+ if marks.attachmentEffect != nil {
578+ interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
579+ if !interruptible {
580+ sendErr = errAttachmentTransition
581+ }
582+ }
583+ if sendErr == nil {
584+ if sendTr == nil {
585+ sendErr = errors.New("client transport is nil")
586+ } else {
587+ send := sendTr.Send
588+ if async, ok := sendTr.(ports.AsyncTransport); ok {
589+ send = async.SendAsync
590+ }
591+ sendErr = send(ports.Frame{Type: ports.MsgRoutePosition, Payload: payload})
592+ }
593+ }
594+ if marks.attachmentEffect != nil && interruptible {
595+ if sendErr != nil {
596+ marks.attachmentEffect.reportTransportFailure(sendTransport)
597+ }
598+ marks.attachmentEffect.endTransportSend()
599+ }
600+ if sendErr == nil {
601+ ac.lastRoutePosition = position
602+ }
603+ }
604+ }
577 }605 }
578 ac.sendMu.Unlock()606 ac.sendMu.Unlock()
579 if sendErr != nil {607 if sendErr != nil {
@@ -581,11 +609,7 @@ func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient,
581 // admission first. Detachment freezes the gate and therefore cannot mutate609 // admission first. Detachment freezes the gate and therefore cannot mutate
582 // ownership until any enclosing admitted operation has also ended.610 // ownership until any enclosing admitted operation has also ended.
583 if marks.attachmentEffect == nil {611 if marks.attachmentEffect == nil {
584- if entry != nil {612+ 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 {613 } else {
590 // Capture the exact admitted capability, including its coordinator614 // Capture the exact admitted capability, including its coordinator
591 // lease, before End permits a new attachment publication. Reserve615 // 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 +76 -94
@@ -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.nameSnapshot())
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
@@ -97,19 +107,24 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
97 107
98 // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this108 // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this
99 // teardown lost the seat while still the live owner (stale transport fence).109 // teardown lost the seat while still the live owner (stale transport fence).
100-// If another path already detached/unrouted, that path110+// The live owner clears its own marker; if another path already detached or
101-// owns park publication.111+// unrouted, that owning teardown is responsible for park publication/cleanup.
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.nameSnapshot() != 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 sess == nil || ac == nil || !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.nameSnapshot())
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.nameSnapshot(), "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.nameSnapshot())
301 return295 return
302 }296 }
303 d.mu.Unlock()297 d.mu.Unlock()
@@ -307,7 +301,6 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
307 // has verified d.parked[token] still points at parked when that matters.301 // has verified d.parked[token] still points at parked when that matters.
308 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {302 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
309 delete(d.parked, token)303 delete(d.parked, token)
310- parked.ac.clearPreviousSession()
311 parked.ac.resumeToken = 0304 parked.ac.resumeToken = 0
312 parked.ac.parked = false305 parked.ac.parked = false
313 if parked.timer != nil {306 if parked.timer != nil {
@@ -366,16 +359,11 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
366 ac.resumeToken = token359 ac.resumeToken = token
367 ac.parked = true360 ac.parked = true
368 ac.connectionGeneration.Add(1)361 ac.connectionGeneration.Add(1)
369- sess := localSession(parked.owner)362+ sess := parked.sess
370- view, remote := normalizeAttachmentOwner(parked.owner).(*remoteView)
371 if sess != nil {363 if sess != nil {
372 sess.mu.Lock()364 sess.mu.Lock()
373 sess.unregisterAttachmentLocked(ac)365 sess.unregisterAttachmentLocked(ac)
374 sess.mu.Unlock()366 sess.mu.Unlock()
375- } else if remote {
376- view.mu.Lock()
377- view.unregisterAttachmentLocked(ac)
378- view.mu.Unlock()
379 }367 }
380 // Recreate the parked entry and its watcher instead of restoring a timer368 // 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 also369 // that may already have fired while the claim held the old entry. This also
@@ -385,7 +373,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
385 }373 }
386 parked.closeDone()374 parked.closeDone()
387 rearmed := &parkedAttachment{375 rearmed := &parkedAttachment{
388- owner: parked.owner,376+ sess: parked.sess,
389 ac: ac,377 ac: ac,
390 pickerGeneration: parked.pickerGeneration,378 pickerGeneration: parked.pickerGeneration,
391 timer: d.clock.NewTimer(d.resumeParkGrace),379 timer: d.clock.NewTimer(d.resumeParkGrace),
@@ -398,9 +386,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
398 if sess != nil {386 if sess != nil {
399 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")387 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
400 }388 }
401- if remote {
402- d.parkRemoteViewWarm(view)
403- }
404 d.watchParkedTimer(token, rearmed)389 d.watchParkedTimer(token, rearmed)
405 if captured != nil {390 if captured != nil {
406 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))391 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
@@ -416,7 +401,6 @@ type parkedAttachmentRetirement struct {
416 401
417 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {402 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
418 delete(d.parked, token)403 delete(d.parked, token)
419- parked.ac.clearPreviousSession()
420 parked.ac.resumeToken = 0404 parked.ac.resumeToken = 0
421 parked.ac.parked = false405 parked.ac.parked = false
422 parked.ac.connectionGeneration.Add(1)406 parked.ac.connectionGeneration.Add(1)
@@ -434,20 +418,7 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
434 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {418 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
435 var retirements []parkedAttachmentRetirement419 var retirements []parkedAttachmentRetirement
436 for token, parked := range d.parked {420 for token, parked := range d.parked {
437- if localSession(parked.owner) == sess {421+ 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))422 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
452 }423 }
453 }424 }
@@ -520,11 +491,15 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
520 d.mu.Unlock()491 d.mu.Unlock()
521 return d.resumeParked(h, tr, sz)492 return d.resumeParked(h, tr, sz)
522 }493 }
494+ if credentialMatch && h.RemoteTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.RemoteTarget) {
495+ d.mu.Unlock()
496+ return nil, nil, false, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
497+ }
523 d.mu.Unlock()498 d.mu.Unlock()
524 499
525 if clientMismatch {500 if clientMismatch {
526 reason := "client id mismatch"501 reason := "client id mismatch"
527- d.log.Warn("resume rejected", "session", sess.name, "err", reason)502+ d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason)
528 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}503 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
529 }504 }
530 if !credentialMatch {505 if !credentialMatch {
@@ -559,9 +534,8 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
559 if !d.parkAttachment(sess, ac) {534 if !d.parkAttachment(sess, ac) {
560 // Detach already published; retire the captured link exactly once so535 // Detach already published; retire the captured link exactly once so
561 // shutdown cannot leave an orphaned open transport with no owner.536 // shutdown cannot leave an orphaned open transport with no owner.
562- d.clearParkingInFlight(ac.resumeToken, ac)537+ d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
563 d.resetScreenDefaultColors(sess)538 d.resetScreenDefaultColors(sess)
564- ac.clearPreviousSession()
565 _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))539 _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))
566 d.mu.Lock()540 d.mu.Lock()
567 closing := d.closing541 closing := d.closing
@@ -572,7 +546,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
572 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}546 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
573 }547 }
574 _ = ac.closeCapturedTransport(oldSnap.transport)548 _ = ac.closeCapturedTransport(oldSnap.transport)
575- d.log.Info("live resume credential parked for reconnect", "session", sess.name)549+ d.log.Info("live resume credential parked for reconnect", "session", sess.nameSnapshot())
576 return d.resumeParked(h, tr, sz)550 return d.resumeParked(h, tr, sz)
577 }551 }
578 552
@@ -600,7 +574,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"}574 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
601 }575 }
602 if d.parked[h.ResumeToken] != parked || parked.claimed {576 if d.parked[h.ResumeToken] != parked || parked.claimed {
603- if sess := localSession(parked.owner); sess != nil && d.sessions[sess.id] == sess {577+ if d.sessions[parked.sess.id] == parked.sess {
604 d.mu.Unlock()578 d.mu.Unlock()
605 ac.sendMu.Unlock()579 ac.sendMu.Unlock()
606 return nil, nil, false, errResumeTokenLifecycleRace580 return nil, nil, false, errResumeTokenLifecycleRace
@@ -625,11 +599,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
625 if parked == nil || h.ResumeToken == 0 {599 if parked == nil || h.ResumeToken == 0 {
626 return nil, nil, false, nil600 return nil, nil, false, nil
627 }601 }
628- sess := localSession(parked.owner)602+ sess := parked.sess
629- registered := sess != nil && d.sessions[sess.id] == sess603+ registered := d.sessions[sess.id] == sess
630 if !registered {604 if !registered {
631 d.removeParkedLocked(h.ResumeToken, parked)605 d.removeParkedLocked(h.ResumeToken, parked)
632- d.log.Warn("resume rejected", "session", attachmentOwnerName(parked.owner), "registered", false)606+ d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "registered", false)
633 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}607 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
634 }608 }
635 ac := parked.ac609 ac := parked.ac
@@ -638,9 +612,12 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
638 }612 }
639 if ac.clientID != h.ClientID {613 if ac.clientID != h.ClientID {
640 reason := "client id mismatch"614 reason := "client id mismatch"
641- d.log.Warn("resume rejected", "session", sess.name, "err", reason)615+ d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason)
642 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}616 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
643 }617 }
618+ if h.RemoteTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.RemoteTarget) {
619+ return nil, nil, false, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
620+ }
644 // Claim the attachment while retaining the old credential until Welcome is621 // Claim the attachment while retaining the old credential until Welcome is
645 // accepted. A failed handshake can therefore restore this exact parked622 // accepted. A failed handshake can therefore restore this exact parked
646 // entry; another resume sees claimed and loses atomically.623 // entry; another resume sees claimed and loses atomically.
@@ -659,9 +636,12 @@ 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.636 // The resumed session's snapshot is the sole source for future PTY children.
660 // Existing PTYs retain the environment they were started with.637 // Existing PTYs retain the environment they were started with.
661 sess.mu.Lock()638 sess.mu.Lock()
662- sess.env = copyEnvironment(h.Env)639+ if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
640+ sess.env = copyEnvironment(h.Env)
641+ }
663 sess.terminal = terminalEnv{TrueColor: h.TrueColor}642 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
664 sess.mu.Unlock()643 sess.mu.Unlock()
644+ preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)
665 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with645 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
666 // neither held. Reacquire them before returning to preserve this helper's646 // neither held. Reacquire them before returning to preserve this helper's
667 // locked-caller contract.647 // locked-caller contract.
@@ -672,6 +652,8 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
672 next: ac,652 next: ac,
673 653
674 expectedTransport: ac.transportSnapshot(),654 expectedTransport: ac.transportSnapshot(),
655+ activateTargetTab: preferredTabIndex >= 0,
656+ targetTabIndex: preferredTabIndex,
675 ready: false,657 ready: false,
676 })658 })
677 ac.sendMu.Lock()659 ac.sendMu.Lock()
@@ -681,6 +663,6 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
681 }663 }
682 d.deferAttachmentTransitionCleanups(transition)664 d.deferAttachmentTransitionCleanups(transition)
683 d.touchMRU(sess)665 d.touchMRU(sess)
684- d.log.Info("client resumed", "session", sess.name)666+ d.log.Info("client resumed", "session", sess.nameSnapshot())
685 return sess, ac, true, nil667 return sess, ac, true, nil
686 }668 }
M internal/usecase/daemon/resume_test.go +108 -14
@@ -97,6 +97,77 @@ 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+ d.mu.Lock()
108+ sessionCount := len(d.sessions)
109+ d.mu.Unlock()
110+ require.Zero(t, sessionCount)
111+}
112+
113+func TestResumeRejectsMismatchedRemoteTargetBeforeOwnershipMutation(t *testing.T) {
114+ pty, release := newBlockingPTY(t)
115+ defer release()
116+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
117+ oldTransport := &closeTrackingTransport{}
118+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
119+ require.NoError(t, err)
120+ token := ac.resumeToken
121+
122+ target := domain.RemoteSessionTarget{
123+ LifecycleID: sess.incarnation, SessionName: sess.name, LiveTabID: "missing-tab",
124+ }
125+ resume := helloResumeCapable(ports.IntentResume, "work", token)
126+ resume.RemoteTarget = &target
127+ resume.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
128+ _, _, err = d.route(resume, &closeTrackingTransport{})
129+ var protocol *protoErr
130+ require.ErrorAs(t, err, &protocol)
131+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
132+ require.Same(t, oldTransport, ac.transport())
133+ d.mu.Lock()
134+ _, parked := d.parked[token]
135+ d.mu.Unlock()
136+ require.False(t, parked, "mismatched target must not park the live attachment")
137+
138+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
139+}
140+
141+func TestParkedResumeRejectsMismatchedRemoteTargetBeforeClaim(t *testing.T) {
142+ pty, release := newBlockingPTY(t)
143+ defer release()
144+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
145+ oldTransport := &closeTrackingTransport{}
146+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
147+ require.NoError(t, err)
148+ token := ac.resumeToken
149+ d.clientGone(sess, ac, oldTransport, false)
150+
151+ target := domain.RemoteSessionTarget{
152+ LifecycleID: sess.incarnation, SessionName: sess.name, LiveTabID: "missing-tab",
153+ }
154+ resume := helloResumeCapable(ports.IntentResume, "work", token)
155+ resume.RemoteTarget = &target
156+ resume.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
157+ _, _, err = d.route(resume, &closeTrackingTransport{})
158+ var protocol *protoErr
159+ require.ErrorAs(t, err, &protocol)
160+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
161+ d.mu.Lock()
162+ parked := d.parked[token]
163+ claimed := parked != nil && parked.claimed
164+ d.mu.Unlock()
165+ require.NotNil(t, parked)
166+ require.False(t, claimed, "mismatched target must not claim the parked attachment")
167+
168+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
169+}
170+
100 func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {171 func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
101 clock := &signalClock{timers: make(chan *signalTimer, 8)}172 clock := &signalClock{timers: make(chan *signalTimer, 8)}
102 pty, release := newBlockingPTY(t)173 pty, release := newBlockingPTY(t)
@@ -130,9 +201,10 @@ func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
130 require.NotSame(t, parkTimer, rearmedTimer)201 require.NotSame(t, parkTimer, rearmedTimer)
131 d.mu.Lock()202 d.mu.Lock()
132 rearmed := d.parked[token]203 rearmed := d.parked[token]
204+ claimed := rearmed != nil && rearmed.claimed
133 d.mu.Unlock()205 d.mu.Unlock()
134 require.NotNil(t, rearmed)206 require.NotNil(t, rearmed)
135- require.False(t, rearmed.claimed)207+ require.False(t, claimed)
136 rearmedTimer.ch <- time.Time{}208 rearmedTimer.ch <- time.Time{}
137 d.expireParked(token, rearmed)209 d.expireParked(token, rearmed)
138 d.mu.Lock()210 d.mu.Lock()
@@ -533,7 +605,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
533 d.mu.Unlock()605 d.mu.Unlock()
534 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")606 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
535 require.Same(t, ac, parkingInGap.ac)607 require.Same(t, ac, parkingInGap.ac)
536- require.Same(t, sess, localSession(parkingInGap.owner))608+ require.Same(t, sess, parkingInGap.sess)
537 require.False(t, parkedInGap, "fixture: park must not have published yet")609 require.False(t, parkedInGap, "fixture: park must not have published yet")
538 610
539 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})611 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
@@ -1112,7 +1184,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
1112 require.Same(t, ac, resumedAC)1184 require.Same(t, ac, resumedAC)
1113 d.paint(resumedSess, resumedAC, true, nil)1185 d.paint(resumedSess, resumedAC, true, nil)
1114 1186
1115- sends := newTr.Sends()1187+ sends := testFramesOfType(newTr.Sends(), ports.MsgOutput)
1116 require.Len(t, sends, 1)1188 require.Len(t, sends, 1)
1117 first, err := ports.UnmarshalOutput(sends[0].Payload)1189 first, err := ports.UnmarshalOutput(sends[0].Payload)
1118 require.NoError(t, err)1190 require.NoError(t, err)
@@ -1122,7 +1194,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
1122 1194
1123 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))1195 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
1124 d.paint(resumedSess, resumedAC, false, nil)1196 d.paint(resumedSess, resumedAC, false, nil)
1125- sends = newTr.Sends()1197+ sends = testFramesOfType(newTr.Sends(), ports.MsgOutput)
1126 require.Len(t, sends, 2)1198 require.Len(t, sends, 2)
1127 second, err := ports.UnmarshalOutput(sends[1].Payload)1199 second, err := ports.UnmarshalOutput(sends[1].Payload)
1128 require.NoError(t, err)1200 require.NoError(t, err)
@@ -1172,7 +1244,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1172 require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))1244 require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
1173 d.firstPaint(resumedSess, resumedAC)1245 d.firstPaint(resumedSess, resumedAC)
1174 1246
1175- sends := newTransport.Sends()1247+ sends := testFramesOfType(newTransport.Sends(), ports.MsgOutput)
1176 require.Len(t, sends, 1)1248 require.Len(t, sends, 1)
1177 output, err := ports.UnmarshalOutput(sends[0].Payload)1249 output, err := ports.UnmarshalOutput(sends[0].Payload)
1178 require.NoError(t, err)1250 require.NoError(t, err)
@@ -1259,7 +1331,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
1259 require.Zero(t, parked)1331 require.Zero(t, parked)
1260 }1332 }
1261 1333
1262-func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {1334+func TestLiveParkAndResumeRetainsAttachmentWithoutDaemonHistory(t *testing.T) {
1263 pty, release := newBlockingPTY(t)1335 pty, release := newBlockingPTY(t)
1264 defer release()1336 defer release()
1265 d := newTestDaemon(t, newFactory(t, pty), stubClock{})1337 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -1267,8 +1339,6 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1267 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)1339 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1268 require.NoError(t, err)1340 require.NoError(t, err)
1269 1341
1270- previous := &session{sessionCore: sessionCore{id: "previous"}}
1271- ac.previousOwner.Set(previous)
1272 d.clientGone(sess, ac, ac.transport(), false)1342 d.clientGone(sess, ac, ac.transport(), false)
1273 1343
1274 token := ac.resumeToken1344 token := ac.resumeToken
@@ -1276,17 +1346,15 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1276 parked := d.parked[token]1346 parked := d.parked[token]
1277 d.mu.Unlock()1347 d.mu.Unlock()
1278 require.NotNil(t, parked)1348 require.NotNil(t, parked)
1279- require.Same(t, previous, ac.previousOwner.Get(), "a live parked attachment keeps its toggle")
1280 1349
1281 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))1350 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
1282 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)1351 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
1283 require.NoError(t, err)1352 require.NoError(t, err)
1284 require.Same(t, sess, resumedSess)1353 require.Same(t, sess, resumedSess)
1285 require.Same(t, ac, resumedAC)1354 require.Same(t, ac, resumedAC)
1286- require.Same(t, previous, resumedAC.previousOwner.Get(), "resume keeps the live attachment toggle")
1287 }1355 }
1288 1356
1289-func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {1357+func TestDiscardingParkedAttachmentDoesNotOwnPreviousSession(t *testing.T) {
1290 for _, tc := range []struct {1358 for _, tc := range []struct {
1291 name string1359 name string
1292 discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement1360 discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement
@@ -1301,7 +1369,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1301 {1369 {
1302 name: "session purge",1370 name: "session purge",
1303 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {1371 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
1304- return d.purgeParkedForSessionLocked(localSession(parked.owner))1372+ return d.purgeParkedForSessionLocked(parked.sess)
1305 },1373 },
1306 },1374 },
1307 } {1375 } {
@@ -1313,7 +1381,6 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1313 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)1381 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1314 require.NoError(t, err)1382 require.NoError(t, err)
1315 1383
1316- ac.previousOwner.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,6 @@ 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())
1327 })1393 })
1328 }1394 }
1329 }1395 }
@@ -1548,6 +1614,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])1614 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 }1615 }
1550 1616
1617+func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
1618+ pty, release := newBlockingPTY(t)
1619+ defer release()
1620+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1621+ d.baseEnv = []string{"DAEMON=owned"}
1622+ oldTransport := &closeTrackingTransport{}
1623+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
1624+ require.NoError(t, err)
1625+ sess.mu.Lock()
1626+ sess.env = copyEnvironment(d.baseEnv)
1627+ sess.mu.Unlock()
1628+ token := ac.resumeToken
1629+ require.True(t, sess.detachIfCurrent(ac))
1630+ require.True(t, d.parkAttachment(sess, ac))
1631+
1632+ resume := helloResumeCapable(ports.IntentResume, "work", token)
1633+ resume.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
1634+ resume.Env = []string{"UNTRUSTED=client"}
1635+ _, _, ok, err := d.resumeParked(resume, &closeTrackingTransport{}, defaultSize)
1636+ require.NoError(t, err)
1637+ require.True(t, ok)
1638+ sess.mu.Lock()
1639+ require.Equal(t, []string{"DAEMON=owned"}, sess.env)
1640+ sess.mu.Unlock()
1641+
1642+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
1643+}
1644+
1551 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {1645 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {
1552 pty, release := newBlockingPTY(t)1646 pty, release := newBlockingPTY(t)
1553 defer release()1647 defer release()
A internal/usecase/daemon/route_position_test.go +128 -0
@@ -0,0 +1,128 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRouteRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
14+ d := newTestDaemon(t, nil, stubClock{})
15+ sess := addControlSession(d, "work", "tab-1", "pane-1")
16+ sess.incarnation = domain.SessionLifecycleID{1}
17+ tr := &closeTrackingTransport{}
18+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
19+ hello := ports.Hello{
20+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
21+ Size: defaultSize, ExactTarget: &target, RemoteOrigin: "remote",
22+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
23+ }
24+
25+ _, ac, err := d.routeWithContext(context.Background(), hello, tr)
26+ require.NoError(t, err)
27+ require.Equal(t, "remote", ac.remoteOrigin)
28+ d.clientGone(sess, ac, tr, false)
29+}
30+
31+func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) {
32+ for _, test := range []struct {
33+ name string
34+ preferred domain.TabStableID
35+ want domain.TabStableID
36+ }{
37+ {name: "existing preferred tab", preferred: "tab-2", want: "tab-2"},
38+ {name: "missing preferred tab falls back", preferred: "missing", want: "tab-1"},
39+ } {
40+ t.Run(test.name, func(t *testing.T) {
41+ d := newTestDaemon(t, nil, stubClock{})
42+ sess := addControlSession(d, "work", "tab-1", "pane-1")
43+ sess.incarnation = domain.SessionLifecycleID{1}
44+ second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
45+ sess.mu.Lock()
46+ sess.tabs = append(sess.tabs, second)
47+ sess.mu.Unlock()
48+ tr := &closeTrackingTransport{}
49+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
50+ hello := ports.Hello{
51+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
52+ Size: defaultSize, ExactTarget: &target, PreferredTabID: test.preferred,
53+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
54+ }
55+
56+ _, ac, err := d.routeWithContext(context.Background(), hello, tr)
57+ require.NoError(t, err)
58+ sess.repairAttachmentView(ac)
59+ require.Equal(t, test.want, ac.viewSnapshot().tabID)
60+ d.clientGone(sess, ac, tr, false)
61+ })
62+ }
63+}
64+
65+func TestResumeAppliesPreferredTabToParkedAttachment(t *testing.T) {
66+ pty, release := newBlockingPTY(t)
67+ defer release()
68+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
69+ oldTransport := &closeTrackingTransport{}
70+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
71+ require.NoError(t, err)
72+ second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
73+ sess.mu.Lock()
74+ sess.tabs = append(sess.tabs, second)
75+ sess.mu.Unlock()
76+ sess.repairAttachmentView(ac)
77+ token := ac.resumeToken
78+ d.clientGone(sess, ac, oldTransport, false)
79+
80+ hello := helloResumeCapable(ports.IntentResume, "work", token)
81+ hello.PreferredTabID = "tab-2"
82+ newTransport := &closeTrackingTransport{}
83+ resumedSession, resumed, err := d.route(hello, newTransport)
84+ require.NoError(t, err)
85+ require.Same(t, ac, resumed)
86+ require.Equal(t, domain.TabStableID("tab-2"), resumed.viewSnapshot().tabID)
87+ d.clientGone(resumedSession, resumed, newTransport, false)
88+}
89+
90+func TestPaintPublishesChangedAttachmentRoutePosition(t *testing.T) {
91+ d := newTestDaemon(t, nil, stubClock{})
92+ sess := addControlSession(d, "work", "tab-1", "pane-1")
93+ sess.incarnation = domain.SessionLifecycleID{1}
94+ second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
95+ sess.mu.Lock()
96+ sess.tabs = append(sess.tabs, second)
97+ sess.mu.Unlock()
98+ tr := &closeTrackingTransport{}
99+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
100+ hello := ports.Hello{
101+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
102+ Size: defaultSize, ExactTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
103+ }
104+ _, ac, err := d.routeWithContext(context.Background(), hello, tr)
105+ require.NoError(t, err)
106+ rc := sess.renderCoordinator()
107+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
108+ sess.repairAttachmentView(ac)
109+
110+ require.True(t, d.firstPaintForTransition(sess.attachmentToken(ac, tr)))
111+ sess.selectAttachmentTab(ac, "tab-2")
112+ d.paint(sess, ac, false, nil)
113+
114+ var positions []ports.RoutePosition
115+ for _, frame := range tr.Sends() {
116+ if frame.Type != ports.MsgRoutePosition {
117+ continue
118+ }
119+ position, err := ports.UnmarshalRoutePosition(frame.Payload)
120+ require.NoError(t, err)
121+ positions = append(positions, position)
122+ }
123+ require.Equal(t, []ports.RoutePosition{
124+ {Target: target, ActiveTabID: "tab-1"},
125+ {Target: target, ActiveTabID: "tab-2"},
126+ }, positions)
127+ d.clientGone(sess, ac, tr, false)
128+}
M internal/usecase/daemon/session.go +29 -22
@@ -118,6 +118,15 @@ type session struct {
118 floatingLaunches map[*floatingLaunch]struct{}118 floatingLaunches map[*floatingLaunch]struct{}
119 }119 }
120 120
121+func (s *session) nameSnapshot() string {
122+ if s == nil {
123+ return ""
124+ }
125+ s.mu.Lock()
126+ defer s.mu.Unlock()
127+ return s.name
128+}
129+
121 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.130 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
122 type tab struct {131 type tab struct {
123 mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership132 mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
@@ -451,7 +460,6 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
451 }460 }
452 461
453 d.touchMRU(newSess)462 d.touchMRU(newSess)
454- ac.recordPreviousSession(from)
455 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)463 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
456 d.deferAttachmentTransitionCleanups(transition)464 d.deferAttachmentTransitionCleanups(transition)
457 d.firstPaintForTransition(transition.published)465 d.firstPaintForTransition(transition.published)
@@ -467,8 +475,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
467 if err := domain.ValidateSessionName(name); err != nil {475 if err := domain.ValidateSessionName(name); err != nil {
468 return err476 return err
469 }477 }
470- source := token.localSession()478+ if token.sess == nil || token.ac == nil || token.effect == nil {
471- if source == nil || token.ac == nil || token.effect == nil {
472 return errAttachmentTransition479 return errAttachmentTransition
473 }480 }
474 token.effect.bindActionEnd(d, "create-session")481 token.effect.bindActionEnd(d, "create-session")
@@ -476,11 +483,15 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
476 483
477 var created *session484 var created *session
478 transition, err := d.transitionAttachment(attachmentTransitionRequest{485 transition, err := d.transitionAttachment(attachmentTransitionRequest{
479- source: source, next: token.ac,486+ source: token.sess, next: token.ac,
480 487
481 expectedTransport: token.transport, sourceToken: &token, action: "create-session",488 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
482 copySourceEnvironment: true, ready: true,489 copySourceEnvironment: true, ready: true,
483 createTargetLocked: func() (*session, error) {490 createTargetLocked: func() (*session, error) {
491+ source := token.sess
492+ if source == nil {
493+ return nil, errAttachmentTransition
494+ }
484 if d.closing {495 if d.closing {
485 return nil, errors.New("daemon is shutting down")496 return nil, errors.New("daemon is shutting down")
486 }497 }
@@ -503,9 +514,6 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
503 }514 }
504 515
505 d.touchMRU(created)516 d.touchMRU(created)
506- if source != nil {
507- token.ac.recordPreviousSession(source)
508- }
509 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)517 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
510 d.deferAttachmentTransitionCleanups(transition)518 d.deferAttachmentTransitionCleanups(transition)
511 d.firstPaintForTransition(transition.published)519 d.firstPaintForTransition(transition.published)
@@ -770,15 +778,14 @@ func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool
770 }778 }
771 779
772 func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {780 func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
773- sess := token.localSession()781+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
774- if sess == nil || token.ac == nil || token.transport.transport == nil {
775 return false782 return false
776 }783 }
777 // Attachments without the session's optional primary render lease still784 // Attachments without the session's optional primary render lease still
778 // own an independent connection lifecycle and detach through the same exact785 // own an independent connection lifecycle and detach through the same exact
779 // transport/generation fence.786 // transport/generation fence.
780 if token.lease == nil {787 if token.lease == nil {
781- return d.detachIfCurrentTransport(sess, token.ac, token.transport)788+ return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
782 }789 }
783 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)790 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
784 defer frozen.unfreeze()791 defer frozen.unfreeze()
@@ -789,8 +796,8 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
789 d.afterDetachAttachmentEffectsFrozen()796 d.afterDetachAttachmentEffectsFrozen()
790 }797 }
791 d.mu.Lock()798 d.mu.Lock()
792- core := sess.core()799+ core := token.sess.core()
793- if core == nil || d.sessions[core.id] != sess {800+ if core == nil || d.sessions[core.id] != token.sess {
794 d.mu.Unlock()801 d.mu.Unlock()
795 return false802 return false
796 }803 }
@@ -801,11 +808,11 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
801 coordinator.mu.Lock()808 coordinator.mu.Lock()
802 }809 }
803 req := attachmentTransitionRequest{810 req := attachmentTransitionRequest{
804- source: sess, next: token.ac, expectedTransport: token.transport,811+ source: token.sess, next: token.ac, expectedTransport: token.transport,
805 }812 }
806- current := coordinator != nil && transitionSourceTokenCurrentLocked(token, sess, coordinator, req)813+ current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
807 if current {814 if current {
808- unregisterAttachmentSessionLocked(sess, token.ac)815+ unregisterAttachmentSessionLocked(token.sess, token.ac)
809 token.ac.connectionGeneration.Add(1)816 token.ac.connectionGeneration.Add(1)
810 token.ac.setSession(nil)817 token.ac.setSession(nil)
811 token.ac.invalidateFrozenAttachmentCapability()818 token.ac.invalidateFrozenAttachmentCapability()
@@ -1166,11 +1173,13 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1166 snapshot := sessionKillParticipants{target: target}1173 snapshot := sessionKillParticipants{target: target}
1167 if admission != nil {1174 if admission != nil {
1168 token := admission.token1175 token := admission.token
1169- source := token.localSession()1176+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1170- if source == nil || token.ac == nil || token.transport.transport == nil {1177+ return sessionKillParticipants{}, false
1178+ }
1179+ snapshot.source = token.sess
1180+ if snapshot.source == nil {
1171 return sessionKillParticipants{}, false1181 return sessionKillParticipants{}, false
1172 }1182 }
1173- snapshot.source = source
1174 snapshot.sourceToken = &admission.token1183 snapshot.sourceToken = &admission.token
1175 }1184 }
1176 1185
@@ -1267,11 +1276,10 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
1267 return true1276 return true
1268 }1277 }
1269 token := *snapshot.sourceToken1278 token := *snapshot.sourceToken
1270- source := token.localSession()
1271 req := attachmentTransitionRequest{1279 req := attachmentTransitionRequest{
1272- source: source, next: token.ac, expectedTransport: token.transport,1280+ source: token.sess, next: token.ac, expectedTransport: token.transport,
1273 }1281 }
1274- return transitionSourceTokenCurrentLocked(token, source, sourceCoordinator, req)1282+ return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
1275 }1283 }
1276 1284
1277 // stopInMemoryLifecycle cancels every session producer and closes every PTY1285 // stopInMemoryLifecycle cancels every session producer and closes every PTY
@@ -1602,7 +1610,6 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1602 }1610 }
1603 for _, attachment := range attachments {1611 for _, attachment := range attachments {
1604 d.unregisterPreview(attachment.ac)1612 d.unregisterPreview(attachment.ac)
1605- attachment.ac.clearPreviousSession()
1606 attachment.ac.clearCaptureFrames()1613 attachment.ac.clearCaptureFrames()
1607 }1614 }
1608 1615
M internal/usecase/daemon/session_back.go +16 -96
@@ -2,90 +2,37 @@ package daemon
2 2
3 import (3 import (
4 "github.com/bnema/vev/internal/domain"4 "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/internal/usecase/picker"5+ "github.com/bnema/vev/internal/ports"
6 )6 )
7 7
8-// backSession toggles this attachment between its current local session and8+// backSessionForAttachment delegates previous-route ownership to the client
9-// the immediately preceding successfully activated local session. A remote9+// once it has published a complete route snapshot. There is no daemon-local
10-// owner is retained in the owner history but is not interpreted as a local10+// previous-session fallback after the global-history cutover.
11-// session; remote back navigation is published with remote transitions.11+func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
12-func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken, afterRemoteTransition ...func()) error {12+ if d == nil || token.sess == nil || token.ac == nil {
13- if d == nil || token.ac == nil {
14 return nil13 return nil
15 }14 }
16- if view, remote := normalizeAttachmentOwner(token.owner).(*remoteView); remote {15+ snapshot := token.ac.routeSnapshotCopy()
17- previous := token.ac.previousOwner.Get()16+ if snapshot.Generation == 0 {
18- target := localSession(previous)17+ if d.log != nil {
19- if target == nil {18+ d.log.Debug("back-session skipped: attachment has no published route snapshot")
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 }19 }
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 nil20 return nil
63 }21 }
64- if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {22+ if snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 {
65- token.ac.clearPreviousOwnerIf(previous)
66 return nil23 return nil
67 }24 }
68- pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}25+ return d.sendRecentRouteNavigationActionForAttachment(token, ports.RouteNavigationAction{
69- return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")26+ SnapshotGeneration: snapshot.Generation,
27+ Key: snapshot.Previous.Key,
28+ Generation: snapshot.Previous.Generation,
29+ })
70 }30 }
71 31
72 func (d *Daemon) backSession(current *session, ac *attachedClient) {32 func (d *Daemon) backSession(current *session, ac *attachedClient) {
73 if d == nil || current == nil || ac == nil {33 if d == nil || current == nil || ac == nil {
74 return34 return
75 }35 }
76- previous := ac.previousOwner.Get()
77- target := localSession(previous)
78- if target == nil {
79- if previous == nil {
80- d.invalidateRender(current, ac, true, "session_back.go")
81- }
82- return
83- }
84- if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
85- ac.clearPreviousOwnerIf(previous)
86- d.invalidateRender(current, ac, true, "session_back.go")
87- return
88- }
89 token := attachmentToken(current, ac, ac.transport())36 token := attachmentToken(current, ac, ac.transport())
90 effect, admitted := ac.beginAttachmentEffect(token)37 effect, admitted := ac.beginAttachmentEffect(token)
91 if !admitted {38 if !admitted {
@@ -98,30 +45,3 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
98 d.reportError(current, err)45 d.reportError(current, err)
99 }46 }
100 }47 }
101-
102-// clearPreviousOwnerIf clears target only if it has not been replaced since
103-// the caller observed it. This preserves a concurrent successful hand-off.
104-func (ac *attachedClient) clearPreviousOwnerIf(target attachmentOwner) {
105- if ac == nil {
106- return
107- }
108- ac.previousOwner.With(func(previous *attachmentOwner) {
109- if sameAttachmentOwner(*previous, target) {
110- *previous = nil
111- }
112- })
113-}
114-
115-// recordPreviousOwner is called only after a cross-owner hand-off has
116-// committed. Keeping this after the commit makes failed/displaced switches
117-// leave the toggle pair intact.
118-func (ac *attachedClient) recordPreviousOwner(origin attachmentOwner) {
119- origin = normalizeAttachmentOwner(origin)
120- if ac != nil && origin != nil {
121- ac.previousOwner.Set(origin)
122- }
123-}
124-
125-func (ac *attachedClient) recordPreviousSession(origin *session) {
126- ac.recordPreviousOwner(origin)
127-}
A internal/usecase/daemon/session_back_route_test.go +47 -0
@@ -0,0 +1,47 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T) {
11+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
12+ transport := &closeTrackingTransport{}
13+ ac.replaceTransport(transport)
14+ rc := d.attachCoordinator(source, nil, ac, true)
15+ token := source.attachmentToken(ac, transport)
16+ token.lease = rc.attachmentLease(ac)
17+ ac.publishAttachmentCapability(token)
18+ effect, admitted := ac.beginAttachmentEffect(token)
19+ require.True(t, admitted)
20+ defer effect.End()
21+ token.effect = effect
22+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
23+ Generation: 5,
24+ Active: ports.RouteRef{Key: 9, Generation: 5},
25+ Previous: ports.RouteRef{Key: 7, Generation: 3},
26+ Entries: []ports.RecentRouteEntry{{
27+ Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal,
28+ }},
29+ })
30+
31+ require.NoError(t, d.backSessionForAttachment(token))
32+ frames := transport.Sends()
33+ require.NotEmpty(t, frames)
34+ var action ports.RouteNavigationAction
35+ found := false
36+ for _, frame := range frames {
37+ if frame.Type != ports.MsgNavigateRecentRoute {
38+ continue
39+ }
40+ var err error
41+ action, err = ports.UnmarshalRouteNavigationAction(frame.Payload)
42+ require.NoError(t, err)
43+ found = true
44+ }
45+ require.True(t, found)
46+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 5, Key: 7, Generation: 3}, action)
47+}
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/session_view.go +3 -3
@@ -24,9 +24,9 @@ type tabView struct {
24 }24 }
25 25
26 // sessionView is an immutable, value-only description of a session. It26 // sessionView is an immutable, value-only description of a session. It
27-// deliberately retains no live pointers, mirroring recentSession27+// deliberately retains no live pointers, mirroring route snapshot entries.
28-// (recent_sessions.go). Listing paths (picker, MRU bar, palette, and28+// Listing paths (picker, palette, and list-sessions) read local session state
29-// list-sessions) read local session state through snapshotView.29+// through snapshotView.
30 type sessionView struct {30 type sessionView struct {
31 id domain.SessionID31 id domain.SessionID
32 incarnation domain.IncarnationID32 incarnation domain.IncarnationID
M internal/usecase/daemon/status.go +72 -65
@@ -7,6 +7,7 @@ import (
7 "time"7 "time"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/command"11 "github.com/bnema/vev/internal/usecase/command"
11 "github.com/bnema/vev/internal/usecase/palette"12 "github.com/bnema/vev/internal/usecase/palette"
12 themeui "github.com/bnema/vev/internal/usecase/theme"13 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -87,7 +88,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
87 fittedMRU := fitMRU(state.mru, len(row), x, rightText)88 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
88 for i, sess := range fittedMRU {89 for i, sess := range fittedMRU {
89 style := styles.MRUStyle(i, len(fittedMRU))90 style := styles.MRUStyle(i, len(fittedMRU))
90- drawStatusSessionEntry(row, &x, sess.name, sess.attention, style, state.attentionFrame)91+ drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
91 }92 }
92 }93 }
93 drawRightPlainText(row, rightText, x, styles.SurfaceBar)94 drawRightPlainText(row, rightText, x, styles.SurfaceBar)
@@ -96,6 +97,8 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
96 type rankedRecent struct {97 type rankedRecent struct {
97 rank int98 rank int
98 name string99 name string
100+ kind recentRouteKind
101+ ephemeral bool
99 attention bool102 attention bool
100 selected bool103 selected bool
101 }104 }
@@ -112,9 +115,9 @@ func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent
112 writeStatusText(row, x, " ", style)115 writeStatusText(row, x, " ", style)
113 }116 }
114 117
115-func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, attention bool, style renderer.Style, attentionFrame int) {118+func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int) {
116- writeStatusText(row, x, " "+name, style)119+ writeStatusText(row, x, " "+entry.name, style)
117- if attention {120+ if entry.attention {
118 writeStatusText(row, x, " ", style)121 writeStatusText(row, x, " ", style)
119 writeBell(row, x, attentionFrame, style)122 writeBell(row, x, attentionFrame, style)
120 }123 }
@@ -160,7 +163,7 @@ type barState struct {
160 topRight string163 topRight string
161 bottomRight string164 bottomRight string
162 statusFeedback string165 statusFeedback string
163- mru []recentSession166+ mru []recentRouteDisplay
164 rankedRecent []rankedRecent167 rankedRecent []rankedRecent
165 attentionFrame int168 attentionFrame int
166 // theme is the client's terminal theme, if reported. Its zero value169 // theme is the client's terminal theme, if reported. Its zero value
@@ -222,43 +225,54 @@ func tabDisplayName(tb *tab, index int) string {
222 return strconv.Itoa(index + 1)225 return strconv.Itoa(index + 1)
223 }226 }
224 227
225-func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession) []rankedRecent {228+func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot ports.RecentRouteSnapshot) []rankedRecent {
226 if hints == nil || hints.Kind != command.ContextHintRecentSessions {229 if hints == nil || hints.Kind != command.ContextHintRecentSessions {
227 return nil230 return nil
228 }231 }
232+ var formatted []recentRouteDisplay
233+ if snapshot.Generation != 0 {
234+ formatted = formatRecentRouteSnapshot(snapshot)
235+ }
229 entries := make([]rankedRecent, 0, len(hints.Recent))236 entries := make([]rankedRecent, 0, len(hints.Recent))
230 for i, hint := range hints.Recent {237 for i, hint := range hints.Recent {
231- entry := rankedRecent{rank: hint.Rank, name: hint.Name, selected: hint.Rank == hints.SelectedRank}238+ display := recentRouteDisplay{name: hint.Name}
232- // recent was copied with the hint under paletteMu, so this only enriches239+ // The hint and route snapshot are immutable interaction data. The
233- // the render snapshot and never performs a live domain lookup.240+ // formatted snapshot is authoritative when available, keeping ranked
234- if i < len(recent) {241+ // JRS labels identical to normal MRU labels without a live lookup.
235- entry.attention = recent[i].attention242+ if i < len(formatted) {
243+ display = formatted[i]
236 }244 }
237- entries = append(entries, entry)245+ entries = append(entries, rankedRecent{
246+ rank: hint.Rank,
247+ name: display.name,
248+ kind: display.kind,
249+ ephemeral: display.ephemeral,
250+ attention: display.attention,
251+ selected: hint.Rank == hints.SelectedRank,
252+ })
238 }253 }
239 return entries254 return entries
240 }255 }
241 256
242 // barStateForPaletteHints selects snapshot-only contextual composition when a257 // barStateForPaletteHints selects snapshot-only contextual composition when a
243-// palette interaction has captured recent-session hints. Normal palette state258+// palette interaction has captured recent-route hints.
244-// continues to use the canonical, live MRU list.259+func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
245-func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {260+ return d.barStateForAttachmentPaletteHints(cur, statusFeedback, hints)
246- var entry *session
247- if cur != nil {
248- entry = cur
249- }
250- return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)
251 }261 }
252 262
253 // barStateForAttachmentPaletteHints composes daemon-owned chrome for an263 // barStateForAttachmentPaletteHints composes daemon-owned chrome for an
254 // attachment. Bar scripts remain local-session-only because264 // attachment. Bar scripts remain local-session-only because
255 // their existing execution contract depends on local tabs and PTYs.265 // their existing execution contract depends on local tabs and PTYs.
256-func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {266+func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
257- return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)267+ return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, ports.RecentRouteSnapshot{})
258 }268 }
259 269
260-func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {270+func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, capturedRouteSnapshot ports.RecentRouteSnapshot) barState {
261- ranked := rankedRecentForHints(hints, recent)271+ routeSnapshot := capturedRouteSnapshot
272+ if routeSnapshot.Generation == 0 && ac != nil {
273+ routeSnapshot = ac.routeSnapshotCopy()
274+ }
275+ ranked := rankedRecentForHintsWithSnapshot(hints, routeSnapshot)
262 state := barState{statusFeedback: statusFeedback}276 state := barState{statusFeedback: statusFeedback}
263 if d != nil {277 if d != nil {
264 state.attentionFrame = d.attentionFrame()278 state.attentionFrame = d.attentionFrame()
@@ -275,16 +289,14 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
275 }289 }
276 if ranked != nil {290 if ranked != nil {
277 state.rankedRecent = ranked291 state.rankedRecent = ranked
278- } else if d != nil {292+ } else if routeSnapshot.Generation != 0 {
279- state.mru = d.recentSessionsForAttachment(cur, ac)293+ state.mru = formatRecentRouteSnapshot(routeSnapshot)
280 }294 }
281 return state295 return state
282 }296 }
283 297
284 func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {298 func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
285- state := d.barStateBase(cur, statusFeedback)299+ return d.barStateBase(cur, statusFeedback)
286- state.mru = d.recentSessions(cur)
287- return state
288 }300 }
289 301
290 func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {302 func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
@@ -383,33 +395,45 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLab
383 return labels395 return labels
384 }396 }
385 397
386-func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent {398+func recentRouteDisplayWidth(entry recentRouteDisplay) int {
387- reserve := 1399+ width := 2 + statusTextWidth(entry.name)
388- if feedback != "" {400+ if entry.attention {
389- reserve = statusTextWidth(feedback) + 2401+ width += 1 + renderer.RuneWidth(ui.AttentionGlyph)
390 }402 }
391- budget := rowLen - leftUsed - reserve403+ return width
392- if budget <= 0 {404+}
405+
406+func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T {
407+ if budget <= 0 || len(entries) == 0 {
393 return nil408 return nil
394 }409 }
395- cost := func(e rankedRecent) int {
396- width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(e.name)
397- if e.attention {
398- width += 1 + renderer.RuneWidth(ui.AttentionGlyph)
399- }
400- return width
401- }
402 used := 0410 used := 0
403 for i, entry := range entries {411 for i, entry := range entries {
404- if used+cost(entry) > budget {
405- return entries[:i] // whole entries only; ranks remain their original MRU ranks.
406- }
407 used += cost(entry)412 used += cost(entry)
413+ if used > budget {
414+ return entries[:i]
415+ }
408 }416 }
409 return entries417 return entries
410 }418 }
411 419
412-func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession {420+func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent {
421+ reserve := 1
422+ if feedback != "" {
423+ reserve = statusTextWidth(feedback) + 2
424+ }
425+ budget := rowLen - leftUsed - reserve
426+ return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int {
427+ return recentRouteDisplayWidth(recentRouteDisplay{
428+ name: entry.name,
429+ kind: entry.kind,
430+ ephemeral: entry.ephemeral,
431+ attention: entry.attention,
432+ }) + statusTextWidth(strconv.Itoa(entry.rank)+":")
433+ })
434+}
435+
436+func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay {
413 // With no feedback, keep one blank trailing cell; with feedback, reserve437 // With no feedback, keep one blank trailing cell; with feedback, reserve
414 // its " text" width plus a one-cell gap so drawRightPlainText always fits.438 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
415 copyReserve := 1439 copyReserve := 1
@@ -420,34 +444,17 @@ func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []re
420 if physicalBudget <= 0 || len(entries) == 0 {444 if physicalBudget <= 0 || len(entries) == 0 {
421 return nil445 return nil
422 }446 }
423- cost := func(e recentSession) int {
424- n := 2 + statusTextWidth(e.name)
425- if e.attention {
426- n += 1 + renderer.RuneWidth(ui.AttentionGlyph)
427- }
428- return n
429- }
430 447
431 budget := physicalBudget448 budget := physicalBudget
432 if feedback == "" {449 if feedback == "" {
433 budget -= mruFutureRightReserve(rowLen)450 budget -= mruFutureRightReserve(rowLen)
434 // Keep at least one recent session when it physically fits; the reserved451 // Keep at least one recent session when it physically fits; the reserved
435 // right side is only a budget preference, not a reason to hide all recents.452 // right side is only a budget preference, not a reason to hide all recents.
436- if firstCost := cost(entries[0]); firstCost <= physicalBudget && budget < firstCost {453+ if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost {
437 budget = firstCost454 budget = firstCost
438 }455 }
439 }456 }
440- if budget <= 0 {457+ return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth)
441- return nil
442- }
443- used := 0
444- for i, e := range entries {
445- used += cost(e)
446- if used > budget {
447- return entries[:i]
448- }
449- }
450- return entries
451 }458 }
452 459
453 const (460 const (
M internal/usecase/daemon/status_test.go +26 -108
@@ -968,32 +968,33 @@ func TestBarStateForPaletteHintsNormalizesTypedNilSession(t *testing.T) {
968 var sess *session968 var sess *session
969 969
970 require.NotPanics(t, func() {970 require.NotPanics(t, func() {
971- state := d.barStateForPaletteHints(sess, "feedback", nil, nil)971+ state := d.barStateForPaletteHints(sess, "feedback", nil)
972 require.Equal(t, "feedback", state.statusFeedback)972 require.Equal(t, "feedback", state.statusFeedback)
973 require.Empty(t, state.status.session)973 require.Empty(t, state.status.session)
974 })974 })
975 }975 }
976 976
977-func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) {977+func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) {
978 p, releasePTY := newBlockingPTY(t)978 p, releasePTY := newBlockingPTY(t)
979- d, sess, _, _ := newManualSessionWithPTYs(t, p)979+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
980 defer releasePTY()980 defer releasePTY()
981- live := &session{sessionCore: sessionCore{id: "live", name: "live"}, tabs: []*tab{{}}}
982- live.mruAt.Store(1)
983- d.sessions[live.id] = live
984 981
982+ snapshot := ports.RecentRouteSnapshot{
983+ Generation: 1,
984+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},
985+ }
985 hints := palette.ContextualHints{986 hints := palette.ContextualHints{
986 Kind: command.ContextHintRecentSessions,987 Kind: command.ContextHintRecentSessions,
987 SelectedRank: 1,988 SelectedRank: 1,
988 Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}},989 Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}},
989 }990 }
990- // Hold the registry lock: a call to recentSessions would block here. The991+ // Hold the registry lock: contextual composition must use the immutable
991- // contextual snapshot must still compose because it has no live MRU lookup.992+ // attachment snapshot and never consult daemon session history.
992 var contextual barState993 var contextual barState
993 done := make(chan struct{})994 done := make(chan struct{})
994 d.mu.Lock()995 d.mu.Lock()
995 go func() {996 go func() {
996- contextual = d.barStateForPaletteHints(sess, "", &hints, []recentSession{{name: "captured", attention: true}})997+ contextual = d.barStateForAttachmentPaletteHintsFor(sess, ac, "", &hints, snapshot)
997 close(done)998 close(done)
998 }()999 }()
999 select {1000 select {
@@ -1006,88 +1007,6 @@ func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.
1006 1007
1007 require.Empty(t, contextual.mru, "contextual composition must not read the live MRU")1008 require.Empty(t, contextual.mru, "contextual composition must not read the live MRU")
1008 require.Equal(t, []rankedRecent{{rank: 1, name: "captured", attention: true, selected: true}}, contextual.rankedRecent)1009 require.Equal(t, []rankedRecent{{rank: 1, name: "captured", attention: true, selected: true}}, contextual.rankedRecent)
1009-
1010- normal := d.barStateFor(sess, "")
1011- require.Len(t, normal.mru, 1)
1012- require.Equal(t, "live", normal.mru[0].name, "normal composition retains canonical live MRU")
1013-}
1014-
1015-func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {
1016- p, releasePTY := newBlockingPTY(t)
1017- d, sess, _, _ := newManualSessionWithPTYs(t, p)
1018- defer releasePTY()
1019- sess.name = "current"
1020- sess.mruAt.Store(100)
1021- for i := range 10 {
1022- other := &session{sessionCore: sessionCore{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i)}, tabs: []*tab{{attention: i == 8}}}
1023- other.mruAt.Store(uint64(i + 1))
1024- d.sessions[other.id] = other
1025- }
1026-
1027- state := d.barStateFor(sess, "")
1028-
1029- require.Len(t, state.mru, 9)
1030- require.Equal(t, "s9", state.mru[0].name)
1031- require.Equal(t, "s1", state.mru[8].name)
1032- for _, got := range state.mru {
1033- require.NotEqual(t, "current", got.name)
1034- }
1035- require.True(t, state.mru[1].attention, "s8 attention should be carried into MRU state")
1036-}
1037-
1038-func TestBarStateForMRUExcludesEphemeralSessions(t *testing.T) {
1039- p, releasePTY := newBlockingPTY(t)
1040- d, current, _, _ := newManualSessionWithPTYs(t, p)
1041- defer releasePTY()
1042-
1043- persistent := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
1044- persistent.mruAt.Store(1)
1045- ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
1046- ephemeral.mruAt.Store(2)
1047- d.sessions[persistent.id] = persistent
1048- d.sessions[ephemeral.id] = ephemeral
1049-
1050- state := d.barStateFor(current, "")
1051-
1052- require.Len(t, state.mru, 1)
1053- require.Equal(t, "named", state.mru[0].name)
1054-}
1055-
1056-func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
1057- p, releasePTY := newBlockingPTY(t)
1058- d, sess, _, _ := newManualSessionWithPTYs(t, p)
1059- defer releasePTY()
1060- for _, name := range []string{"bravo", "alpha", "charlie"} {
1061- d.sessions[domain.SessionID(name)] = &session{sessionCore: sessionCore{id: domain.SessionID(name), name: name}, tabs: []*tab{{}}}
1062- }
1063-
1064- state := d.barStateFor(sess, "")
1065-
1066- require.GreaterOrEqual(t, len(state.mru), 3)
1067- require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})
1068-}
1069-
1070-func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
1071- sz := domain.Size{Cols: 80, Rows: 24}
1072- p1, releasePTY1 := newBlockingPTY(t)
1073- defer releasePTY1()
1074- p2, releasePTY2 := newBlockingPTY(t)
1075- defer releasePTY2()
1076- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1077- d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30}
1078- d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20}
1079- d.mruSeq.Store(30)
1080- cur := &session{sessionCore: sessionCore{id: "cur", name: "current"}, tabs: []*tab{{}}}
1081- d.sessions[cur.id] = cur
1082-
1083- _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv)
1084- require.NoError(t, err)
1085- _, err = createSessionForTest(d, "alpha", false, "/tmp/alpha", sz, terminalEnv{}, d.baseEnv)
1086- require.NoError(t, err)
1087-
1088- state := d.barStateFor(cur, "")
1089- require.GreaterOrEqual(t, len(state.mru), 2)
1090- require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
1091 }1010 }
1092 1011
1093 func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {1012 func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {
@@ -1111,7 +1030,7 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
1111 drawStatusBarState(row, state.bars, resolveStyles(nil))1030 drawStatusBarState(row, state.bars, resolveStyles(nil))
1112 return rowText(row)1031 return rowText(row)
1113 }1032 }
1114- normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}}1033+ normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentRouteDisplay{{name: "vty"}, {name: "misc"}}}
1115 ac.sendMu.Lock()1034 ac.sendMu.Lock()
1116 defer ac.sendMu.Unlock()1035 defer ac.sendMu.Unlock()
1117 1036
@@ -1147,7 +1066,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
1147 state := barState{1066 state := barState{
1148 status: statusSnapshot{session: "cur"},1067 status: statusSnapshot{session: "cur"},
1149 attentionFrame: 1,1068 attentionFrame: 1,
1150- mru: []recentSession{1069+ mru: []recentRouteDisplay{
1151 {name: "fresh"},1070 {name: "fresh"},
1152 {name: "tmp", attention: true},1071 {name: "tmp", attention: true},
1153 },1072 },
@@ -1219,7 +1138,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1219 theme.Palette[10] = theme.Palette[2]1138 theme.Palette[10] = theme.Palette[2]
1220 theme.PaletteKnown = 1<<2 | 1<<101139 theme.PaletteKnown = 1<<2 | 1<<10
1221 resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})1140 resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1222- state := barState{status: statusSnapshot{session: "c"}, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}1141+ state := barState{status: statusSnapshot{session: "c"}, mru: []recentRouteDisplay{{name: "a"}, {name: "b"}, {name: "c"}}}
1223 row := make([]renderer.Cell, 16)1142 row := make([]renderer.Cell, 16)
1224 1143
1225 drawStatusBarState(row, state, resolved.Styles)1144 drawStatusBarState(row, state, resolved.Styles)
@@ -1235,7 +1154,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1235 }1154 }
1236 1155
1237 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {1156 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
1238- state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}1157+ state := barState{status: statusSnapshot{session: "cur"}, mru: []recentRouteDisplay{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
1239 row := make([]renderer.Cell, 21)1158 row := make([]renderer.Cell, 21)
1240 1159
1241 drawStatusBarState(row, state, resolveStyles(nil))1160 drawStatusBarState(row, state, resolveStyles(nil))
@@ -1247,9 +1166,9 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
1247 }1166 }
1248 1167
1249 func TestStatusBarMRUWidthAwareBudget(t *testing.T) {1168 func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
1250- mru := make([]recentSession, 0, maxMRUSessions)1169+ mru := make([]recentRouteDisplay, 0, maxMRUSessions)
1251 for i := 1; i <= maxMRUSessions; i++ {1170 for i := 1; i <= maxMRUSessions; i++ {
1252- mru = append(mru, recentSession{name: "recent" + strconv.Itoa(i)})1171+ mru = append(mru, recentRouteDisplay{name: "recent" + strconv.Itoa(i)})
1253 }1172 }
1254 state := barState{status: statusSnapshot{session: "cur"}, mru: mru}1173 state := barState{status: statusSnapshot{session: "cur"}, mru: mru}
1255 1174
@@ -1309,27 +1228,27 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1309 {1228 {
1310 name: "script text and copy feedback",1229 name: "script text and copy feedback",
1311 width: 32,1230 width: 32,
1312- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentSession{{name: "a"}}},1231+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "a"}}},
1313 wantContains: []string{" a"},1232 wantContains: []string{" a"},
1314 wantSuffix: " main ↑3 * copied",1233 wantSuffix: " main ↑3 * copied",
1315 },1234 },
1316 {1235 {
1317 name: "hide on overlap",1236 name: "hide on overlap",
1318 width: 16,1237 width: 16,
1319- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}},1238+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}},
1320 want: " cur ",1239 want: " cur ",
1321 wantNotContains: []string{"main", "copied"},1240 wantNotContains: []string{"main", "copied"},
1322 },1241 },
1323 {1242 {
1324 name: "empty script keeps copy feedback behavior",1243 name: "empty script keeps copy feedback behavior",
1325 width: 12,1244 width: 12,
1326- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}},1245+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}},
1327 want: " cur copied",1246 want: " cur copied",
1328 },1247 },
1329 {1248 {
1330 name: "mru whole entry fitting with script text",1249 name: "mru whole entry fitting with script text",
1331 width: 24,1250 width: 24,
1332- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},1251+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentRouteDisplay{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
1333 wantContains: []string{" fresh"},1252 wantContains: []string{" fresh"},
1334 wantNotContains: []string{"middle"},1253 wantNotContains: []string{"middle"},
1335 wantSuffix: " git",1254 wantSuffix: " git",
@@ -1337,7 +1256,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1337 {1256 {
1338 name: "mru fitting reserves wide right anchor width",1257 name: "mru fitting reserves wide right anchor width",
1339 width: 12,1258 width: 12,
1340- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentSession{{name: "a"}}},1259+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentRouteDisplay{{name: "a"}}},
1341 wantNotContains: []string{" a "},1260 wantNotContains: []string{" a "},
1342 wantSuffix: " 界 界 ",1261 wantSuffix: " 界 界 ",
1343 continuationCells: []int{9, 11},1262 continuationCells: []int{9, 11},
@@ -1345,7 +1264,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1345 {1264 {
1346 name: "mru fitting counts wide session names",1265 name: "mru fitting counts wide session names",
1347 width: 20,1266 width: 20,
1348- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "界"}, {name: "界"}, {name: "b"}}},1267+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentRouteDisplay{{name: "界"}, {name: "界"}, {name: "b"}}},
1349 wantContains: []string{" 界 "},1268 wantContains: []string{" 界 "},
1350 wantNotContains: []string{" b "},1269 wantNotContains: []string{" b "},
1351 wantSuffix: " git",1270 wantSuffix: " git",
@@ -1378,7 +1297,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1378 }1297 }
1379 1298
1380 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {1299 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1381- state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}1300+ state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentRouteDisplay{{name: "a"}, {name: "b"}, {name: "c"}}}
1382 row := make([]renderer.Cell, 20)1301 row := make([]renderer.Cell, 20)
1383 1302
1384 drawStatusBarState(row, state, resolveStyles(nil))1303 drawStatusBarState(row, state, resolveStyles(nil))
@@ -1391,7 +1310,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1391 }1310 }
1392 1311
1393 func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {1312 func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1394- state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentSession{{name: "fresh"}}}1313+ state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}}
1395 tests := []struct {1314 tests := []struct {
1396 name string1315 name string
1397 cols int1316 cols int
@@ -1448,7 +1367,7 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
1448 state := barState{1367 state := barState{
1449 status: statusSnapshot{session: "current"},1368 status: statusSnapshot{session: "current"},
1450 bottomRight: "script ok",1369 bottomRight: "script ok",
1451- mru: []recentSession{{name: "new"}, {name: "middle"}, {name: "old"}},1370+ mru: []recentRouteDisplay{{name: "new"}, {name: "middle"}, {name: "old"}},
1452 }1371 }
1453 drawStatusBarState(row, state, styles)1372 drawStatusBarState(row, state, styles)
1454 for i := range len(" current ") {1373 for i := range len(" current ") {
@@ -1485,7 +1404,7 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
1485 }1404 }
1486 1405
1487 single := make([]renderer.Cell, 20)1406 single := make([]renderer.Cell, 20)
1488- drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentSession{{name: "only"}}}, styles)1407+ drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles)
1489 only := strings.Index(rowText(single), "only")1408 only := strings.Index(rowText(single), "only")
1490 require.GreaterOrEqual(t, only, 0)1409 require.GreaterOrEqual(t, only, 0)
1491 require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface")1410 require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface")
@@ -1549,5 +1468,4 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1549 releasePTY2()1468 releasePTY2()
1550 hg.Wait()1469 hg.Wait()
1551 d.sessWg.Wait()1470 d.sessWg.Wait()
1552- d.waitNotifies()
1553 }1471 }
M internal/usecase/daemon/test_wait_helpers_test.go +13 -0
@@ -9,6 +9,16 @@ import (
9 9
10 const testWaitTimeout = 2 * time.Second10 const testWaitTimeout = 2 * time.Second
11 11
12+func testFramesOfType(frames []ports.Frame, typ ports.MsgType) []ports.Frame {
13+ matched := make([]ports.Frame, 0, len(frames))
14+ for _, frame := range frames {
15+ if frame.Type == typ {
16+ matched = append(matched, frame)
17+ }
18+ }
19+ return matched
20+}
21+
12 func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T {22 func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T {
13 t.Helper()23 t.Helper()
14 timer := time.NewTimer(testWaitTimeout)24 timer := time.NewTimer(testWaitTimeout)
@@ -41,6 +51,9 @@ func awaitCoordinatorOutput(
41 for {51 for {
42 select {52 select {
43 case frame := <-sends:53 case frame := <-sends:
54+ if frame.Type == ports.MsgRoutePosition {
55+ continue
56+ }
44 if frame.Type != ports.MsgOutput {57 if frame.Type != ports.MsgOutput {
45 t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext)58 t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext)
46 }59 }
M internal/usecase/daemon/wire.go +18 -72
@@ -2,87 +2,33 @@
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, error) {
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+ var identity *ports.CommittedRouteIdentity
62- meta := ports.SessionMeta{12+ if s.incarnation != (domain.SessionLifecycleID{}) {
63- LifecycleID: s.incarnation,13+ identity = &ports.CommittedRouteIdentity{
64- Revision: revision,14+ Target: ports.ExactSessionTarget{LifecycleID: s.incarnation, SessionName: s.name},
65- SessionName: s.name,15+ Ephemeral: s.ephemeral,
66- Tabs: make([]ports.SessionTabMeta, 0, len(s.tabs)),16+ }
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 }17 }
74- if len(meta.Tabs) == 0 {18+ w := ports.Welcome{
75- active = ""19+ SessionID: string(s.id),
76- } else if !activePresent {20+ SessionName: s.name,
77- active = meta.Tabs[0].ID21+ Ephemeral: s.ephemeral,
22+ Capabilities: ports.CapabilityResume,
23+ CommittedIdentity: identity,
78 }24 }
79- meta.ActiveTabID = active
80 s.mu.Unlock()25 s.mu.Unlock()
81- payload, err := ports.MarshalSessionMeta(meta)26+ w.ResumeToken = resumeToken
82- if err != nil {27+ payload := ports.MarshalWelcome(w)
83- return ports.Frame{}, err28+ if payload == nil {
29+ return ports.Frame{}, ports.ErrInvalidRouteWire
84 }30 }
85- return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}, nil31+ return ports.Frame{Type: ports.MsgWelcome, Payload: payload}, nil
86 }32 }
87 33
88 func frameError(code uint16, text string) ports.Frame {34 func frameError(code uint16, text string) ports.Frame {
M internal/usecase/daemon/wire_metadata_test.go +4 -1
@@ -23,7 +23,10 @@ func TestFrameWelcomeSnapshotsSessionMetadataUnderLock(t *testing.T) {
23 })23 })
24 24
25 for range 1000 {25 for range 1000 {
26- frame := frameWelcome(sess, ac)26+ frame, frameErr := frameWelcome(sess, ac.resumeToken)
27+ if frameErr != nil {
28+ t.Fatalf("frameWelcome() error = %v", frameErr)
29+ }
27 welcome, err := ports.UnmarshalWelcome(frame.Payload)30 welcome, err := ports.UnmarshalWelcome(frame.Payload)
28 if err != nil {31 if err != nil {
29 t.Fatalf("UnmarshalWelcome() error = %v", err)32 t.Fatalf("UnmarshalWelcome() error = %v", err)
M internal/usecase/palette/contextual_hints.go +5 -2
@@ -8,8 +8,11 @@ import (
8 8
9 // RecentSessionHint is the immutable display data for one captured MRU rank.9 // RecentSessionHint is the immutable display data for one captured MRU rank.
10 type RecentSessionHint struct {10 type RecentSessionHint struct {
11- Rank int11+ Rank int
12- Name string12+ Name string
13+ SnapshotGeneration uint64
14+ Key uint64
15+ Generation uint64
13 }16 }
14 17
15 // ContextualHints is immutable contextual palette guidance.18 // ContextualHints is immutable contextual palette guidance.
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 +25 -6
@@ -4,6 +4,7 @@ import (
4 "time"4 "time"
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/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
8 )9 )
9 10
@@ -14,6 +15,7 @@ const (
14 ResultKindCommand ResultKind = iota15 ResultKindCommand ResultKind = iota
15 ResultKindActiveSession16 ResultKindActiveSession
16 ResultKindStoppedSession17 ResultKindStoppedSession
18+ ResultKindRecentRoute
17 )19 )
18 20
19 // Result is an immutable palette target. Its kind is the sole discriminator21 // Result is an immutable palette target. Its kind is the sole discriminator
@@ -22,6 +24,7 @@ type Result struct {
22 kind ResultKind24 kind ResultKind
23 command command.Command25 command command.Command
24 session sessionPayload26 session sessionPayload
27+ route routePayload
25 }28 }
26 29
27 type sessionPayload struct {30 type sessionPayload struct {
@@ -30,6 +33,11 @@ type sessionPayload struct {
30 sessionID domain.SessionID33 sessionID domain.SessionID
31 }34 }
32 35
36+type routePayload struct {
37+ label string
38+ action ports.RouteNavigationAction
39+}
40+
33 // NewCommandResult creates a static command palette target.41 // NewCommandResult creates a static command palette target.
34 func NewCommandResult(cmd command.Command) Result {42 func NewCommandResult(cmd command.Command) Result {
35 return Result{kind: ResultKindCommand, command: cmd}43 return Result{kind: ResultKindCommand, command: cmd}
@@ -51,12 +59,20 @@ func NewStoppedSessionResult(name string, createdAt time.Time) Result {
51 }59 }
52 }60 }
53 61
62+// NewRecentRouteResult creates an immutable client-ledger route target.
63+func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Result {
64+ return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}}
65+}
66+
54 func (r Result) Kind() ResultKind { return r.kind }67 func (r Result) Kind() ResultKind { return r.kind }
55 68
56 func (r Result) DisplayText() string {69 func (r Result) DisplayText() string {
57 if r.kind == ResultKindCommand {70 if r.kind == ResultKindCommand {
58 return r.command.Code71 return r.command.Code
59 }72 }
73+ if r.kind == ResultKindRecentRoute {
74+ return activeSessionDisplayPrefix + r.route.label
75+ }
60 return r.sessionDisplayPrefix() + r.session.name76 return r.sessionDisplayPrefix() + r.session.name
61 }77 }
62 78
@@ -68,10 +84,7 @@ func (r Result) sessionDisplayPrefix() string {
68 }84 }
69 85
70 func (r Result) SearchText() string {86 func (r Result) SearchText() string {
71- if r.kind == ResultKindCommand {87+ return r.DisplayText()
72- return r.command.Code
73- }
74- return r.session.name
75 }88 }
76 89
77 // Command returns the command payload only for command results.90 // Command returns the command payload only for command results.
@@ -81,12 +94,12 @@ func (r Result) Command() (command.Command, bool) {
81 94
82 // SessionName returns the session name only for session results.95 // SessionName returns the session name only for session results.
83 func (r Result) SessionName() (string, bool) {96 func (r Result) SessionName() (string, bool) {
84- return r.session.name, r.kind != ResultKindCommand97+ return r.session.name, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
85 }98 }
86 99
87 // SessionCreatedAt returns the session creation time only for session results.100 // SessionCreatedAt returns the session creation time only for session results.
88 func (r Result) SessionCreatedAt() (time.Time, bool) {101 func (r Result) SessionCreatedAt() (time.Time, bool) {
89- return r.session.createdAt, r.kind != ResultKindCommand102+ return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
90 }103 }
91 104
92 // SessionID returns the lifecycle identity only for active session results.105 // SessionID returns the lifecycle identity only for active session results.
@@ -94,6 +107,12 @@ func (r Result) SessionID() (domain.SessionID, bool) {
94 return r.session.sessionID, r.kind == ResultKindActiveSession107 return r.session.sessionID, r.kind == ResultKindActiveSession
95 }108 }
96 109
110+// RouteNavigationAction returns the exact client-ledger target only for a
111+// recent-route result.
112+func (r Result) RouteNavigationAction() (ports.RouteNavigationAction, bool) {
113+ return r.route.action, r.kind == ResultKindRecentRoute
114+}
115+
97 const (116 const (
98 activeSessionDisplayPrefix = "Switch to session "117 activeSessionDisplayPrefix = "Switch to session "
99 stoppedSessionDisplayPrefix = "Resume session "118 stoppedSessionDisplayPrefix = "Resume session "
M internal/usecase/palette/result_test.go +41 -1
@@ -5,6 +5,7 @@ import (
5 "time"5 "time"
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/command"9 "github.com/bnema/vev/internal/usecase/command"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
@@ -30,7 +31,7 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
30 require.True(t, ok)31 require.True(t, ok)
31 require.Equal(t, created, createdAt)32 require.Equal(t, created, createdAt)
32 require.Equal(t, "Switch to session work", active.DisplayText())33 require.Equal(t, "Switch to session work", active.DisplayText())
33- require.Equal(t, "work", active.SearchText())34+ require.Equal(t, "Switch to session work", active.SearchText())
34 id, ok := active.SessionID()35 id, ok := active.SessionID()
35 require.True(t, ok)36 require.True(t, ok)
36 require.Equal(t, domain.SessionID("session-work"), id)37 require.Equal(t, domain.SessionID("session-work"), id)
@@ -43,6 +44,45 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
43 require.True(t, ok)44 require.True(t, ok)
44 require.Equal(t, created, createdAt)45 require.Equal(t, created, createdAt)
45 require.Equal(t, "Resume session archive", stopped.DisplayText())46 require.Equal(t, "Resume session archive", stopped.DisplayText())
47+ require.Equal(t, "Resume session archive", stopped.SearchText())
46 _, ok = stopped.SessionID()48 _, ok = stopped.SessionID()
47 require.False(t, ok)49 require.False(t, ok)
48 }50 }
51+
52+func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) {
53+ action := ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 7, Generation: 3}
54+ result := NewRecentRouteResult("logs@edge", action)
55+
56+ require.Equal(t, ResultKindRecentRoute, result.Kind())
57+ require.Equal(t, "Switch to session logs@edge", result.DisplayText())
58+ require.Equal(t, "Switch to session logs@edge", result.SearchText())
59+ got, ok := result.RouteNavigationAction()
60+ require.True(t, ok)
61+ require.Equal(t, action, got)
62+ _, ok = result.SessionName()
63+ require.False(t, ok)
64+}
65+
66+func TestSessionResultsSearchDisplayedActionText(t *testing.T) {
67+ created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
68+ tests := []struct {
69+ name string
70+ result Result
71+ query string
72+ positions []int
73+ }{
74+ {name: "active prefix", result: NewActiveSessionResult("work", created, "work-id"), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}},
75+ {name: "active name", result: NewActiveSessionResult("work", created, "work-id"), query: "work", positions: []int{1, 8, 20, 21}},
76+ {name: "stopped prefix", result: NewStoppedSessionResult("archive", created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}},
77+ {name: "stopped name", result: NewStoppedSessionResult("archive", created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}},
78+ }
79+
80+ for _, tt := range tests {
81+ t.Run(tt.name, func(t *testing.T) {
82+ matches := Fuzzy([]Result{tt.result}, tt.query)
83+ require.Len(t, matches, 1)
84+ require.Equal(t, tt.result.DisplayText(), tt.result.SearchText())
85+ require.Equal(t, tt.positions, matches[0].Positions)
86+ })
87+ }
88+}
M scripts/remote-picker-harness/main.go +1 -1
@@ -281,7 +281,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
281 if err := sendRawInput(local, "\r"); err != nil {281 if err := sendRawInput(local, "\r"); err != nil {
282 return fmt.Errorf("commit remote picker target: %w", err)282 return fmt.Errorf("commit remote picker target: %w", err)
283 }283 }
284- if err := awaitOutputContains(ctx, local, remoteSession+" at remote"); err != nil {284+ if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
285 return fmt.Errorf("publish remote content on local transport: %w", err)285 return fmt.Errorf("publish remote content on local transport: %w", err)
286 }286 }
287 if !local.probe.contains(previewMarker) {287 if !local.probe.contains(previewMarker) {

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