detached 0f7a788

feat(remote): enforce proxy command and lifecycle ownership

commit
0f7a788d5be0f68a4ff5c8e440aaad235eb3f4ec
author
brice · · 2026-07-30 20:40 UTC
committer
brice · · 2026-07-31 18:59 UTC
parent
7d5c4cb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
30 files · +3400 -173
M internal/usecase/daemon/attachment_transition.go +5 -0
@@ -64,6 +64,7 @@ func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attach
6464 type attachmentTransitionResult struct {
6565 published attachmentRoleToken
6666 displaced attachmentRoleToken
67+ terminalDisplaced detachedAttachmentSnapshot
6768 displacedInterrupted bool
6869 cleanups []renderLifecycleCleanup
6970 }
@@ -233,5 +234,9 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
233234 if result.published.role == attachmentActive {
234235 result.published.ac.clearSnatchedInput()
235236 }
237+ // Warm-proxy timers are external clock operations. Apply them only after
238+ // frozen ownership publication and every architecture lock have completed;
239+ // the lifecycle helper revalidates exact registry pointers and clients.
240+ d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveRole)
236241 return result, nil
237242 }
M internal/usecase/daemon/attachment_transition_publish.go +25 -8
@@ -85,6 +85,7 @@ type attachmentPublication struct {
8585 targetCoordinator *renderCoordinator
8686 releaseCoordinators func()
8787 displacedTransport transportSnapshot
88+ terminalDisplaced bool
8889 nextGeneration uint64
8990 displacedGeneration uint64
9091 }
@@ -176,6 +177,10 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
176177 req := publication.req
177178 if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next {
178179 publication.displacedTransport = publication.old.transportSnapshot()
180+ // A proxied daemon attachment has no local snatched UI and must never
181+ // linger in that role. Replacing it is terminal so its owning local proxy
182+ // receives ReasonReplaced and cannot kick the direct user back later.
183+ publication.terminalDisplaced = publication.old.proxied
179184 }
180185 if publication.source != req.target {
181186 if publication.source.core().client == req.next {
@@ -186,7 +191,11 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
186191 if req.targetRole == attachmentActive {
187192 delete(req.target.core().snatched, req.next)
188193 if publication.old != nil && publication.old != req.next {
189- addSnatchedLocked(req.target, publication.old)
194+ if publication.terminalDisplaced {
195+ delete(req.target.core().snatched, publication.old)
196+ } else {
197+ addSnatchedLocked(req.target, publication.old)
198+ }
190199 }
191200 req.target.core().client = req.next
192201 } else {
@@ -195,6 +204,10 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
195204 publication.nextGeneration = req.next.roleGeneration.Add(1)
196205 if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {
197206 publication.displacedGeneration = publication.old.roleGeneration.Add(1)
207+ if publication.terminalDisplaced {
208+ publication.old.setSession(nil)
209+ publication.old.invalidateFrozenRoleCapability()
210+ }
198211 }
199212 req.next.setSession(req.target)
200213 }
@@ -223,14 +236,18 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
223236 rebase: publication.source != req.target && req.expectedRole == attachmentActive,
224237 }
225238 if publication.displacedGeneration != 0 {
226- result.displaced = attachmentRoleToken{
227- sess: req.target,
228- ac: publication.old,
229- role: attachmentSnatched,
230- generation: publication.displacedGeneration,
231- transport: publication.displacedTransport,
239+ if publication.terminalDisplaced {
240+ result.terminalDisplaced = detachedAttachmentSnapshot{ac: publication.old, transport: publication.displacedTransport}
241+ } else {
242+ result.displaced = attachmentRoleToken{
243+ sess: req.target,
244+ ac: publication.old,
245+ role: attachmentSnatched,
246+ generation: publication.displacedGeneration,
247+ transport: publication.displacedTransport,
248+ }
249+ result.displacedInterrupted = req.expectedTargetTransportInterrupted
232250 }
233- result.displacedInterrupted = req.expectedTargetTransportInterrupted
234251 }
235252 // Membership, currentSession, lease, generation, and both exact admission
236253 // capabilities become visible as one frozen publication. The gates remain
M internal/usecase/daemon/client_frame_routing.go +87 -14
@@ -1,6 +1,11 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/ports"
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/usecase/command"
8+)
49
510 // runConnLoop is the per-connection input router: it pumps client messages
611 // until detach, EOF, or a transport error. Role is resolved after every Recv so
@@ -116,19 +121,7 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
116121 if derr != nil {
117122 return false
118123 }
119- // Attached commands are intentionally admitted only on the proxied
120- // attachment path. Phase 6 supplies execution; until then every
121- // decoded request is rejected with its original correlation ID.
122- valid := request.Version == ports.ProtocolVersion && request.Attached && request.RequestID != 0 &&
123- request.TargetSession == "" && request.TargetTab == "" && request.TargetPane == ""
124- if !valid {
125- d.log.Warn("invalid attached command", "request_id", request.RequestID)
126- }
127- result := ports.CommandResult{
128- RequestID: request.RequestID,
129- Code: ports.ErrNotScriptable,
130- Text: "attached command relay is not enabled",
131- }
124+ result := d.executeAttachedCommand(token, request)
132125 if err := token.sendActiveControl(frameCommandResult(result)); err != nil {
133126 token.endRoleEffect()
134127 d.detachOnRoleSendError(token, token.transport.transport)
@@ -148,6 +141,86 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
148141 return false
149142 }
150143
144+func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports.CommandRequest) ports.CommandResult {
145+ result := ports.CommandResult{RequestID: request.RequestID}
146+ if request.Version != ports.ProtocolVersion {
147+ result.Code = ports.ErrInvalidCommandArgs
148+ result.Text = "unsupported command protocol version"
149+ return result
150+ }
151+ if !request.Attached {
152+ result.Code = ports.ErrInvalidCommandArgs
153+ result.Text = "attached command flag is required"
154+ return result
155+ }
156+ if request.RequestID == 0 {
157+ result.Code = ports.ErrInvalidCommandArgs
158+ result.Text = "command request id is required"
159+ return result
160+ }
161+ if request.Self {
162+ result.Code = ports.ErrInvalidCommandArgs
163+ result.Text = "attached commands cannot target themselves"
164+ return result
165+ }
166+ if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {
167+ result.Code = ports.ErrInvalidCommandArgs
168+ result.Text = "attached commands cannot override their active session target"
169+ return result
170+ }
171+ sess, ok := localSession(token.sess)
172+ if !ok || !token.activeEffect() {
173+ result.Code = ports.ErrNoSuchTarget
174+ result.Text = "attached session is no longer active"
175+ return result
176+ }
177+ cmd, ok := command.BySlug(request.Slug)
178+ if !ok {
179+ result.Code = ports.ErrUnknownCommand
180+ result.Text = "unknown command: " + request.Slug
181+ return result
182+ }
183+ if !cmd.PaletteVisible {
184+ result.Code = ports.ErrNotScriptable
185+ result.Text = request.Slug + " is not available in the palette"
186+ return result
187+ }
188+ if cmd.Run == nil {
189+ result.Code = ports.ErrNotScriptable
190+ result.Text = request.Slug + " is not scriptable"
191+ return result
192+ }
193+ if !proxyAttachedCommandOwnedRemotely(cmd.Slug) {
194+ result.Code = ports.ErrNotScriptable
195+ result.Text = request.Slug + " is owned by the local proxy daemon"
196+ return result
197+ }
198+
199+ // The frame's role token is the target capability. No registry/name lookup is
200+ // performed, so the command cannot escape to another remote session.
201+ sess.dispatchMu.Lock()
202+ err := cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
203+ sess.dispatchMu.Unlock()
204+ if err == nil {
205+ result.OK = true
206+ return result
207+ }
208+ if errors.Is(err, command.ErrInvalidArguments) {
209+ result.Code = ports.ErrInvalidCommandArgs
210+ result.Text = "usage: " + cmd.Usage
211+ return result
212+ }
213+ result.Code = ports.ErrInternal
214+ result.Text = err.Error()
215+ return result
216+}
217+
218+// proxyAttachedCommandOwnedRemotely applies the palette's canonical ownership
219+// policy to command frames received by the remote daemon.
220+func proxyAttachedCommandOwnedRemotely(slug string) bool {
221+ return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote
222+}
223+
151224 // resetActiveOutput rebases only the exact proxied role and transport admitted
152225 // by the frame router. sendMu serializes the rebase with every output send;
153226 // transport revalidation under that lock rejects a link replaced while waiting.
M internal/usecase/daemon/client_transport_errors.go +43 -5
@@ -29,12 +29,23 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
2929 }
3030
3131 func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
32- sess, ok := localSession(token.sess)
33- if !ok || token.effect == nil {
32+ if token.effect == nil {
3433 return false
3534 }
3635 token.effect.bindActionEnd(d, "detach")
3736 token.effect.End()
37+ if proxy, ok := token.sess.(*proxySession); ok {
38+ if !d.detachIfRoleCurrent(token) {
39+ return false
40+ }
41+ d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit)
42+ d.armProxyWarm(proxy)
43+ return true
44+ }
45+ sess, ok := localSession(token.sess)
46+ if !ok {
47+ return false
48+ }
3849 var parkingToken uint64
3950 if !explicit {
4051 parkingToken = d.markParkingInFlight(sess, token.ac)
@@ -116,22 +127,37 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai
116127 return
117128 }
118129 expected := transportSnapshot{}
130+ oldTr := failed
119131 if failed != nil {
120132 expected = ac.transportSnapshot()
121133 if expected.transport != failed {
122134 return
123135 }
136+ } else {
137+ oldTr = ac.transport()
124138 }
125139 if !d.detachProxyIfCurrentTransport(p, ac, expected) {
126140 return
127141 }
142+ d.finishProxyClientGone(p, ac, oldTr, false)
143+}
144+
145+// finishProxyClientGone retires external client ownership after the exact proxy
146+// role has been unpublished. Warm-timer publication remains with the caller so
147+// each detach path can arm once, after p.client is nil.
148+func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {
128149 if rc := p.coordinator.Load(); rc != nil {
129150 rc.noteDetach(ac)
130151 }
131- d.unregisterPreview(ac)
152+ if ac.overlays != nil {
153+ d.closePicker(ac)
154+ }
132155 ac.clearPreviousSession()
156+ if explicit {
157+ d.boundedSend(ac, frameDetached(ports.ReasonDetach))
158+ }
133159 _ = ac.closeCapturedTransport(failed)
134- d.log.Warn("detached proxy client after send error", "host", p.key.Host, "session", p.key.Name)
160+ d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit)
135161 }
136162
137163 func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
@@ -139,8 +165,20 @@ func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.T
139165 }
140166
141167 func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
168+ // A delayed sender may report after the client has rebound. Only the exact
169+ // transport captured by this role is allowed to detach either lifecycle.
170+ if failed == nil || failed != token.transport.transport {
171+ return
172+ }
173+ if proxy, ok := token.sess.(*proxySession); ok {
174+ if d.detachIfRoleCurrentUntil(token, done) {
175+ d.finishProxyClientGone(proxy, token.ac, failed, false)
176+ d.armProxyWarm(proxy)
177+ }
178+ return
179+ }
142180 sess, ok := localSession(token.sess)
143- if !ok || failed != token.transport.transport {
181+ if !ok {
144182 return
145183 }
146184 parkingToken := d.markParkingInFlight(sess, token.ac)
M internal/usecase/daemon/config_test.go +3 -1
@@ -21,7 +21,9 @@ type captureKeyHandler struct {
2121 func (h *captureKeyHandler) Forward(data []byte) {
2222 h.forward = append(h.forward, append([]byte(nil), data...))
2323 }
24-func (h *captureKeyHandler) Action(action keys.Action) { h.actions = append(h.actions, action) }
24+func (h *captureKeyHandler) Action(action keys.Action, _ []byte) {
25+ h.actions = append(h.actions, action)
26+}
2527
2628 func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
2729 d := newTestDaemon(t, nil, stubClock{})
M internal/usecase/daemon/input.go +41 -13
@@ -43,6 +43,23 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
4343 func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {
4444 ac := token.ac
4545 ac.initOverlays()
46+ if proxy, ok := token.sess.(*proxySession); ok {
47+ handler := proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token}
48+ ac.mouseScan.Scan(data,
49+ func(ev mouse.Event) {
50+ if token.activeEffect() && !proxyMouseOwnedLocally(ac.overlays) {
51+ handler.Mouse(ev)
52+ }
53+ },
54+ func(b []byte) {
55+ if !token.activeEffect() || ac.overlays.HandleInput(d, b, token.effect) || !token.activeEffect() {
56+ return
57+ }
58+ ac.keys.RouteWithHandler(b, handler)
59+ },
60+ )
61+ return
62+ }
4663 ac.mouseScan.Scan(data,
4764 func(ev mouse.Event) {
4865 if token.activeEffect() {
@@ -357,7 +374,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
357374 h.d.writeToPane(sess, p, data)
358375 }
359376
360-func (h daemonKeyHandler) Action(action keys.Action) {
377+func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
361378 sess, effect, owned := h.acquireRoleEffect()
362379 if sess == nil {
363380 return
@@ -390,6 +407,13 @@ func (h daemonKeyHandler) Action(action keys.Action) {
390407 case keys.ActionOpenPalette:
391408 h.d.enterPalette(sess, h.ac)
392409 case keys.ActionJumpAttention:
410+ if !proxiedJumpSearchesOtherSessions(h.ac.proxied) {
411+ if idx, ok := oldestAttentionTab(sess); ok && sess.switchTab(idx) {
412+ h.d.activateTab(sess, sess.activeTab())
413+ h.d.invalidateRender(sess, h.ac, true, "input.go")
414+ }
415+ return
416+ }
393417 if effect == nil {
394418 if err := h.d.jumpAttention(sess, h.ac); err != nil {
395419 h.d.reportError(sess, err)
@@ -408,21 +432,13 @@ func (h daemonKeyHandler) Action(action keys.Action) {
408432 h.d.reportError(sess, err)
409433 }
410434 case keys.ActionFocusPaneLeft:
411- if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
412- h.d.reportError(sess, err)
413- }
435+ h.focus(sess, layout.Left, effect)
414436 case keys.ActionFocusPaneRight:
415- if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
416- h.d.reportError(sess, err)
417- }
437+ h.focus(sess, layout.Right, effect)
418438 case keys.ActionFocusPaneUp:
419- if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
420- h.d.reportError(sess, err)
421- }
439+ h.focus(sess, layout.Up, effect)
422440 case keys.ActionFocusPaneDown:
423- if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && !errors.Is(err, errAttachmentTransition) {
424- h.d.reportError(sess, err)
425- }
441+ h.focus(sess, layout.Down, effect)
426442 case keys.ActionGrowPaneWidth:
427443 runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
428444 case keys.ActionShrinkPaneWidth:
@@ -447,3 +463,15 @@ func (h daemonKeyHandler) Action(action keys.Action) {
447463 }
448464 }
449465 }
466+
467+func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *roleEffectTicket) {
468+ var err error
469+ if h.ac.proxied {
470+ err = h.d.focusDirProxied(sess, h.ac, dir)
471+ } else {
472+ err = h.d.focusDir(sess, h.ac, dir, effect)
473+ }
474+ if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) {
475+ h.d.reportError(sess, err)
476+ }
477+}
M internal/usecase/daemon/input_test.go +9 -9
@@ -118,7 +118,7 @@ func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
118118 layout.ErrTooSmall,
119119 )}
120120
121- daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action)
121+ daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action, nil)
122122
123123 history := d.notices.history()
124124 require.Len(t, history, 1)
@@ -154,7 +154,7 @@ func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
154154 paletteSpy := &actionRunnerSpy{}
155155 controlSpy := &actionRunnerSpy{}
156156
157- daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action)
157+ daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action, nil)
158158 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy}))
159159 require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy}))
160160
@@ -238,7 +238,7 @@ func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) {
238238
239239 switch adapter {
240240 case "key":
241- daemonKeyHandler{d: d, ac: ac}.Action(tt.action)
241+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
242242 case "palette":
243243 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac}))
244244 case "control":
@@ -400,7 +400,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
400400 target.mu.Unlock()
401401 // Use the real key action: it requests the mandatory complete repaint for
402402 // the first target-tab frame.
403- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
403+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)
404404 require.Equal(t, 1, activeTabIndex(sess))
405405
406406 second := awaitFrame(t, sends, ports.MsgOutput)
@@ -635,7 +635,7 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
635635 tb.mu.Unlock()
636636 installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
637637
638- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight)
638+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight, nil)
639639 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
640640 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x"))
641641 requirePTYWrite(t, floatingWrites, []byte("x"))
@@ -647,7 +647,7 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
647647 func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) {
648648 d, _, ac, _ := newManualSessionWithPTYs(t, nil)
649649
650- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft)
650+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft, nil)
651651
652652 require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent")
653653 }
@@ -663,7 +663,7 @@ func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) {
663663 tb.tree = nil
664664 tb.mu.Unlock()
665665
666- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft)
666+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft, nil)
667667
668668 history := d.notices.history()
669669 require.Len(t, history, 2, "the layout error and its failed direct display update must both surface")
@@ -683,12 +683,12 @@ func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) {
683683 sess.tabs = append(sess.tabs, second)
684684 sess.mu.Unlock()
685685
686- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
686+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)
687687 second.mu.Lock()
688688 require.Equal(t, floatingHidden, second.floating.state)
689689 second.mu.Unlock()
690690
691- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab1)
691+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab1, nil)
692692 first.mu.Lock()
693693 require.Equal(t, floatingVisible, first.floating.state)
694694 first.mu.Unlock()
M internal/usecase/daemon/navigation_overflow_test.go +6 -6
@@ -69,7 +69,7 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
6969 target.mu.Unlock()
7070 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
7171
72- daemonKeyHandler{d: d, ac: ac}.Action(tt.action)
72+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
7373
7474 require.Equal(t, tt.wantActive, activeTabIndex(sess))
7575 target.mu.Lock()
@@ -124,7 +124,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
124124 }
125125 for _, move := range moves {
126126 t.Run(move.name, func(t *testing.T) {
127- handler.Action(move.action)
127+ handler.Action(move.action, nil)
128128 require.Same(t, move.want, ac.currentSession())
129129 })
130130 }
@@ -168,7 +168,7 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
168168 d.mu.Unlock()
169169 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
170170
171- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown)
171+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown, nil)
172172
173173 require.Same(t, alpha, ac.currentSession())
174174 alpha.mu.Lock()
@@ -296,8 +296,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
296296 close(start)
297297 handler := daemonKeyHandler{d: d, ac: ac}
298298 for range 20 {
299- handler.Action(keys.ActionFocusPaneDown)
300- handler.Action(keys.ActionFocusPaneUp)
299+ handler.Action(keys.ActionFocusPaneDown, nil)
300+ handler.Action(keys.ActionFocusPaneUp, nil)
301301 }
302302 wg.Wait()
303303 close(renameErrors)
@@ -349,7 +349,7 @@ func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t
349349 d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
350350 tt.setup(d, sess)
351351
352- daemonKeyHandler{d: d, ac: ac}.Action(tt.dir)
352+ daemonKeyHandler{d: d, ac: ac}.Action(tt.dir, nil)
353353
354354 require.Equal(t, 0, activeTabIndex(sess))
355355 })
M internal/usecase/daemon/notices_overlay.go +16 -10
@@ -17,7 +17,7 @@ var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight
1717
1818 // enterNotices opens the notification history overlay from the daemon's full
1919 // notice center history, newest first.
20-func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
20+func (d *Daemon) enterNotices(sess attachmentSession, ac *attachedClient) {
2121 history := d.notices.history()
2222 now := d.clock.Now()
2323 ac.overlays.noticeMu.Lock()
@@ -172,16 +172,16 @@ func (d *Daemon) noticesListInputState(ac *attachedClient) listInputState {
172172 rt.noticesOverlay = nil
173173 },
174174 afterClose: func() {
175- if sess := ac.currentSession(); sess != nil {
176- d.invalidateRender(sess, ac, true, "notices_overlay.go")
175+ if entry := ac.currentAttachmentSession(); entry != nil {
176+ d.invalidateRender(entry, ac, true, "notices_overlay.go")
177177 }
178178 },
179179 }
180180 }
181181
182182 func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
183- sess := ac.currentSession()
184- if sess == nil {
183+ entry := ac.currentAttachmentSession()
184+ if entry == nil {
185185 return
186186 }
187187 rt := ac.overlays
@@ -210,10 +210,10 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
210210 // yankNotice sends over the wire and repaints itself, so it must run
211211 // with noticeMu released; a quick yank doesn't close the overlay the
212212 // way copy mode's own 'y' commits and exits.
213- d.yankNotice(sess, ac, yankTarget)
213+ d.yankNotice(entry, ac, yankTarget)
214214 }
215215 if result.exit || result.changed {
216- d.invalidateRender(sess, ac, true, "notices_overlay.go")
216+ d.invalidateRender(entry, ac, true, "notices_overlay.go")
217217 }
218218 }
219219
@@ -221,12 +221,18 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
221221 // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
222222 // send each chunk, then leave a one-shot status-bar confirmation that the
223223 // next repaint clears.
224-func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notification) {
224+func (d *Daemon) yankNotice(entry attachmentSession, ac *attachedClient, n domain.Notification) {
225225 chunks := scopy.OSC52(noticeYankPayload(n))
226226 for _, chunk := range chunks {
227227 failed, err := d.boundedSendOutputErrTransport(ac, chunk)
228228 if err != nil {
229- d.detachOnSendError(sess, ac, failed)
229+ if sess, ok := localSession(entry); ok {
230+ d.detachOnSendError(sess, ac, failed)
231+ } else if proxy, ok := entry.(*proxySession); ok {
232+ d.detachProxyOnSendError(proxy, ac, failed)
233+ } else {
234+ _ = ac.closeCapturedTransport(failed)
235+ }
230236 return
231237 }
232238 }
@@ -237,7 +243,7 @@ func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notifica
237243 ac.overlays.statusFeedback = "notification too large to copy"
238244 }
239245 ac.overlays.copyMu.Unlock()
240- d.invalidateRender(sess, ac, true, "notices_overlay.go")
246+ d.invalidateRender(entry, ac, true, "notices_overlay.go")
241247 }
242248
243249 // noticeYankPayload formats n the way the yank commands put it on the
M internal/usecase/daemon/notices_overlay_test.go +34 -0
@@ -431,6 +431,40 @@ func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
431431 require.Equal(t, "no notifications yet", history[0].Message)
432432 }
433433
434+func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) {
435+ d, proxy, ac, _, handler := newProxyInputHarness(t)
436+ handler.enterPalette()
437+ d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect)
438+
439+ history := d.notices.history()
440+ require.Len(t, history, 1)
441+ require.Equal(t, domain.SessionID(""), history[0].SessionID)
442+ require.Same(t, proxy, ac.currentAttachmentSession())
443+}
444+
445+func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) {
446+ d, proxy, ac, _, handler := newProxyInputHarness(t)
447+ handler.roleToken.effect.End()
448+ client, ok := ac.transport().(*proxyTestTransport)
449+ require.True(t, ok)
450+ client.sendFails.Store(true)
451+
452+ d.yankNotice(proxy, ac, domain.Notification{Code: domain.NoticeClipboard, Message: "copy", Time: time.Unix(1, 0)})
453+
454+ require.Nil(t, ac.currentAttachmentSession())
455+ select {
456+ case <-client.closed:
457+ default:
458+ t.Fatal("proxy client transport was not closed")
459+ }
460+ proxy.mu.Lock()
461+ warm := proxy.warm
462+ proxy.mu.Unlock()
463+ require.NotNil(t, warm, "proxy send failure must retain one warm expiry path")
464+ warm.stop()
465+ awaitTestCompletion(t, warm.done, "warm expiry did not stop during cleanup")
466+}
467+
434468 func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
435469 p, release := newBlockingPTY(t)
436470 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/notify.go +14 -6
@@ -229,14 +229,18 @@ func (d *Daemon) reportError(sess *session, err error) {
229229 if err == nil || benignNoticeError(err) {
230230 return
231231 }
232- var ue *domain.UserError
233- if errors.As(err, &ue) {
232+ if ue, ok := errors.AsType[*domain.UserError](err); ok {
234233 d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err)
235234 return
236235 }
237236 d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
238237 }
239238
239+func (d *Daemon) reportAttachmentError(entry attachmentSession, err error) {
240+ local, _ := localSession(entry)
241+ d.reportError(local, err)
242+}
243+
240244 // notify records a notice and routes it to whoever can see it. A nil sess means
241245 // daemon-global: every attached client gets it, and if none is attached the
242246 // notice waits in the pending queue for the next attach. A detached session's
@@ -293,7 +297,10 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
293297 func (d *Daemon) deliverGlobal(n domain.Notification) {
294298 d.mu.Lock()
295299 d.notices.routingMu.Lock()
296- sessions := localSessionsSnapshot(d.sessions)
300+ sessions := make([]attachmentSession, 0, len(d.sessions))
301+ for _, entry := range d.sessions {
302+ sessions = append(sessions, entry)
303+ }
297304 d.mu.Unlock()
298305
299306 if d.notices.beforeGlobalDelivery != nil {
@@ -301,9 +308,10 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
301308 }
302309 targets := make([]*attachedClient, 0, len(sessions))
303310 for _, s := range sessions {
304- s.mu.Lock()
305- ac := s.client
306- s.mu.Unlock()
311+ core := s.core()
312+ core.mu.Lock()
313+ ac := core.client
314+ core.mu.Unlock()
307315 if ac == nil || !d.publishToast(ac, n) {
308316 continue
309317 }
M internal/usecase/daemon/notify_test.go +15 -0
@@ -18,6 +18,21 @@ import (
1818 "github.com/bnema/vev/pkg/vt"
1919 )
2020
21+func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) {
22+ d := newTestDaemon(t, nil, stubClock{})
23+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}}
24+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
25+ require.NoError(t, err)
26+
27+ d.reportAttachmentError(local, domain.UserWarn(domain.NoticeClipboard, "local failure", nil))
28+ d.reportAttachmentError(proxy, domain.UserWarn(domain.NoticeClipboard, "proxy failure", nil))
29+
30+ history := d.notices.history()
31+ require.Len(t, history, 2, "each attachment error must report exactly once")
32+ require.Equal(t, local.id, history[1].SessionID)
33+ require.Equal(t, domain.SessionID(""), history[0].SessionID)
34+}
35+
2136 func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
2237 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
2338 d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})
M internal/usecase/daemon/palette.go +141 -17
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "context"
45 "errors"
56 "sort"
67 "time"
@@ -20,6 +21,34 @@ const (
2021 paletteRailWidth = 64
2122 )
2223
24+type proxyPaletteOwnership uint8
25+
26+const (
27+ proxyPaletteRejected proxyPaletteOwnership = iota
28+ proxyPaletteLocal
29+ proxyPaletteRemote
30+)
31+
32+// proxyPaletteCommandOwnership is exhaustive by policy: an unknown future
33+// palette command is rejected until its proxy ownership is deliberately added.
34+func proxyPaletteCommandOwnership(slug string) proxyPaletteOwnership {
35+ switch slug {
36+ case "new-tab", "close-tab",
37+ "split-right", "split-left", "split-up", "split-down",
38+ "consume-or-expel-pane-left", "consume-or-expel-pane-right",
39+ "stack-pane", "toggle-stack", "toggle-floating-pane", "close-pane",
40+ "focus-pane-left", "focus-pane-right", "focus-pane-up", "focus-pane-down",
41+ "resize-pane", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
42+ "next-tab", "previous-tab", "rename-tab":
43+ return proxyPaletteRemote
44+ case "new-session", "back-session", "jump-recent-session", "session-picker",
45+ "notifications", "yank-last-notification", "detach":
46+ return proxyPaletteLocal
47+ default:
48+ return proxyPaletteRejected
49+ }
50+}
51+
2352 var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
2453
2554 func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
@@ -146,10 +175,12 @@ func (d *Daemon) recordPaletteUse(code string) {
146175 }
147176
148177 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
149- sess := ac.currentSession()
150- if sess == nil {
178+ entry := ac.currentAttachmentSession()
179+ if entry == nil {
151180 return
152181 }
182+ sess, local := localSession(entry)
183+ proxy, remote := entry.(*proxySession)
153184 var cmd command.Command
154185 var sessionTarget palette.Result
155186 var hasSessionTarget bool
@@ -252,12 +283,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
252283 }
253284 ac.overlays.paletteMu.Unlock()
254285 if cancel {
255- d.invalidateRender(sess, ac, true, "palette.go")
286+ d.invalidateRender(entry, ac, true, "palette.go")
256287 return
257288 }
258289 if !execute {
259290 if changed {
260- d.invalidateRender(sess, ac, true, "palette.go")
291+ d.invalidateRender(entry, ac, true, "palette.go")
261292 }
262293 return
263294 }
@@ -275,19 +306,21 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
275306 var err error
276307 if effect != nil {
277308 err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session")
278- } else {
309+ } else if local {
279310 err = d.switchToTarget(sess, ac, target)
311+ } else {
312+ err = errAttachmentTransition
280313 }
281- if effect != nil && errors.Is(err, errAttachmentTransition) {
314+ if errors.Is(err, errAttachmentTransition) {
282315 return
283316 }
284317 if err != nil {
285318 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
286- d.invalidateRender(sess, ac, true, "palette.go")
319+ d.invalidateRender(entry, ac, true, "palette.go")
287320 return
288321 }
289322 if ac.closeExecutedPalette(generation, rawQuery) {
290- d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
323+ d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")
291324 }
292325 return
293326 }
@@ -296,21 +329,87 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
296329 rank, err := command.ParsePositiveDecimal(args)
297330 if err != nil {
298331 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
299- d.invalidateRender(sess, ac, true, "palette.go")
332+ d.invalidateRender(entry, ac, true, "palette.go")
300333 return
301334 }
302- exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent, effect: effect}
335+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect}
303336 if err := exec.JumpRecentSession(rank); err != nil {
304337 if errors.Is(err, errAttachmentTransition) {
305338 return
306339 }
307340 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
308- d.invalidateRender(sess, ac, true, "palette.go")
341+ d.invalidateRender(entry, ac, true, "palette.go")
309342 return
310343 }
311344 if ac.closeExecutedPalette(generation, rawQuery) {
312345 d.recordPaletteUse(cmd.Code)
313- d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
346+ d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")
347+ }
348+ return
349+ }
350+ ownership := proxyPaletteCommandOwnership(cmd.Slug)
351+ if remote && ownership == proxyPaletteRemote {
352+ if !ac.closeExecutedPalette(generation, rawQuery) {
353+ return
354+ }
355+ ctx := d.serveCtx
356+ if ctx == nil {
357+ ctx = context.Background()
358+ }
359+ result, err := proxy.sendCommand(ctx, d.clock, cmd.Slug, args)
360+ if err != nil {
361+ d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, "remote command failed", err)
362+ return
363+ }
364+ if !result.OK {
365+ text := result.Text
366+ if text == "" {
367+ text = "remote command failed"
368+ }
369+ d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, text, nil)
370+ return
371+ }
372+ d.recordPaletteUse(cmd.Code)
373+ d.invalidateRender(proxy, ac, true, "palette.go")
374+ return
375+ }
376+ if remote && ownership == proxyPaletteLocal {
377+ // Every local action supersedes this exact remote palette generation.
378+ // Close it before role handoff or opening another overlay so a delayed
379+ // action cannot leave the old palette rendered over the new state.
380+ if !ac.closeExecutedPalette(generation, rawQuery) {
381+ return
382+ }
383+ var err error
384+ switch cmd.Slug {
385+ case "new-session", "yank-last-notification":
386+ err = cmd.Run(paletteExec{d: d, ac: ac, effect: effect, attachment: proxy}, args)
387+ if err == nil {
388+ d.recordPaletteUse(cmd.Code)
389+ }
390+ case "back-session":
391+ if effect != nil {
392+ err = d.backSessionForRole(effect.roleToken())
393+ }
394+ case "detach":
395+ if effect != nil && !d.clientGoneForRole(effect.roleToken(), true) {
396+ err = errAttachmentTransition
397+ }
398+ case "session-picker":
399+ d.enterPicker(proxy, ac)
400+ case "notifications":
401+ d.enterNotices(proxy, ac)
402+ default:
403+ err = errors.New(cmd.Name + " is unavailable for a remote session")
404+ }
405+ if err != nil && !errors.Is(err, errAttachmentTransition) {
406+ d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, err.Error(), nil)
407+ }
408+ return
409+ }
410+ if !local {
411+ if ac.closeExecutedPalette(generation, rawQuery) {
412+ d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, cmd.Name+" is unavailable for this attachment", nil)
314413 }
315414 return
316415 }
@@ -392,6 +491,7 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
392491 type paletteExec struct {
393492 d *Daemon
394493 sess *session
494+ attachment attachmentSession
395495 ac *attachedClient
396496 recent []recentSession
397497 actions daemonActionRunner
@@ -428,9 +528,17 @@ func (e paletteExec) CreateTab() error {
428528 }
429529
430530 func (e paletteExec) CreateSession() error {
431- e.d.enterTransitionPrompt(e.sess, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error {
531+ entry := e.attachment
532+ if entry == nil {
533+ entry = e.sess
534+ }
535+ e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error {
432536 if token.ac == nil {
433- return e.d.createSessionAndSwitch(e.sess, e.ac, name)
537+ sess, ok := e.localSession()
538+ if !ok {
539+ return errAttachmentTransition
540+ }
541+ return e.d.createSessionAndSwitch(sess, e.ac, name)
434542 }
435543 return e.d.createSessionAndSwitchForRole(token, name)
436544 })
@@ -588,18 +696,30 @@ func (e paletteExec) OpenNotifications() error {
588696 }
589697
590698 func (e paletteExec) YankLastNotification() error {
699+ entry := e.attachment
700+ if entry == nil {
701+ entry = e.sess
702+ }
591703 n, ok := e.d.notices.latest()
592704 if !ok {
593705 // Reported directly rather than returned: the generic cmd.Run error
594706 // path only logs, so a returned error here would never reach the
595707 // user as the warn toast this no-op is supposed to produce.
596- e.d.reportError(e.sess, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil))
708+ e.d.reportAttachmentError(entry, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil))
597709 return nil
598710 }
599- e.d.yankNotice(e.sess, e.ac, n)
711+ e.d.yankNotice(entry, e.ac, n)
600712 return nil
601713 }
602714
715+func (e paletteExec) localSession() (*session, bool) {
716+ entry := e.attachment
717+ if entry == nil {
718+ entry = e.sess
719+ }
720+ return localSession(entry)
721+}
722+
603723 func (e paletteExec) JumpRecentSession(rank int) error {
604724 if rank < 1 || rank > len(e.recent) {
605725 return command.ErrInvalidArguments
@@ -615,7 +735,11 @@ func (e paletteExec) JumpRecentSession(rank int) error {
615735 if e.effect != nil {
616736 err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
617737 } else {
618- err = e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
738+ sess, ok := e.localSession()
739+ if !ok {
740+ return errAttachmentTransition
741+ }
742+ err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
619743 }
620744 if e.effect != nil && errors.Is(err, errAttachmentTransition) {
621745 return err
M internal/usecase/daemon/palette_test.go +22 -0
@@ -125,6 +125,28 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
125125 // only a log line. The new-tab spawn failure is wrapped as a domain.UserError
126126 // with NoticeTabSpawn (session.go createTab), so it surfaces under that code
127127 // rather than the NoticeInternal catch-all.
128+func TestRemotePaletteClosesBeforeOpeningLocalOverlay(t *testing.T) {
129+ tests := []struct {
130+ name string
131+ code string
132+ active func(*overlayRuntime) bool
133+ }{
134+ {name: "session picker", code: "SSP", active: func(rt *overlayRuntime) bool { return rt.pickerActive() }},
135+ {name: "notifications", code: "NTC", active: func(rt *overlayRuntime) bool { return rt.noticesActive() }},
136+ }
137+ for _, test := range tests {
138+ t.Run(test.name, func(t *testing.T) {
139+ d, _, ac, link, handler := newProxyInputHarness(t)
140+ handler.enterPalette()
141+ d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.roleToken.effect)
142+
143+ require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active")
144+ require.True(t, test.active(ac.overlays), "replacement local overlay was not opened")
145+ requireNoProxyFrame(t, link)
146+ })
147+ }
148+}
149+
128150 func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {
129151 p, release := newBlockingPTY(t)
130152 defer release()
M internal/usecase/daemon/pane_rearrange_test.go +2 -2
@@ -338,7 +338,7 @@ func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *
338338
339339 paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy})
340340 controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy})
341- daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction)
341+ daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction, nil)
342342
343343 if outcome.wantErr == nil {
344344 require.NoError(t, paletteErr)
@@ -394,7 +394,7 @@ func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
394394 {
395395 name: "key remains silent",
396396 invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
397- daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft)
397+ daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft, nil)
398398 return nil
399399 },
400400 },
M internal/usecase/daemon/picker.go +168 -18
@@ -51,12 +51,12 @@ func (d *Daemon) togglePickerSort() {
5151 // enterPicker preserves the existing navigation entry point. Navigation always
5252 // publishes its model, including an empty one; only move entry can fail for a
5353 // missing destination.
54-func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
54+func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) {
5555 model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
5656 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
5757 }
5858
59-func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
59+func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
6060 rt := ac.overlays
6161 rt.pickerMu.Lock()
6262 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
@@ -83,7 +83,7 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
8383
8484 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
8585 // knows nothing about picker intent; picker.New owns all destination policy.
86-func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) {
86+func (d *Daemon) pickerViews(cur attachmentSession) ([]picker.SessionView, picker.SourceFilter) {
8787 d.mu.Lock()
8888 sessions := make([]attachmentSession, 0, len(d.sessions))
8989 proxies := make(map[domain.SessionID]*proxySession)
@@ -120,8 +120,13 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
120120 var current picker.SourceFilter
121121 for _, entry := range sessions {
122122 snap := entry.snapshotView(opts)
123- if entry == cur && snap.active >= 0 && snap.active < len(snap.tabs) {
124- current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}
123+ if entry == cur {
124+ if proxy, ok := entry.(*proxySession); ok {
125+ key := proxy.key
126+ current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, RemoteKey: &key}
127+ } else if snap.active >= 0 && snap.active < len(snap.tabs) {
128+ current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}
129+ }
125130 }
126131 live = append(live, liveSnapshot{entry: entry, view: snap})
127132 }
@@ -196,7 +201,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
196201 return views, current
197202 }
198203
199-func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
204+func (d *Daemon) newPickerModel(cur attachmentSession, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
200205 views, attachedCurrent := d.pickerViews(cur)
201206 if intent == pickerNavigate && current == (picker.SourceFilter{}) {
202207 current = attachedCurrent
@@ -236,7 +241,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
236241 afterClose: func() {
237242 d.clearPreviewGeneration(ac, previewGeneration)
238243 d.remotePickerClosed(instance)
239- if sess := ac.currentSession(); sess != nil {
244+ if sess := ac.currentAttachmentSession(); sess != nil {
240245 d.invalidateRender(sess, ac, true, "picker.go")
241246 }
242247 },
@@ -244,7 +249,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
244249 }
245250
246251 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
247- sess := ac.currentSession()
252+ sess := ac.currentAttachmentSession()
248253 if sess == nil {
249254 return
250255 }
@@ -301,7 +306,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
301306 err = d.killPickerTarget(target)
302307 }
303308 if err != nil {
304- d.reportError(sess, err)
309+ d.reportAttachmentError(sess, err)
305310 }
306311 }
307312 if effect != nil && effect.ended.Load() {
@@ -325,7 +330,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
325330 if committing {
326331 if intent == pickerMovePane || intent == pickerMoveTab {
327332 if err := d.movePickerSourceError(source); err != nil {
328- d.reportError(sess, err)
333+ d.reportAttachmentError(sess, err)
329334 d.invalidateRender(sess, ac, true, "picker.go")
330335 return
331336 }
@@ -335,7 +340,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
335340 d.closePicker(ac)
336341 err := d.commitMovePickerSelection(intent, source, target)
337342 if err != nil {
338- d.reportError(sess, movePickerUserError(err))
343+ d.reportAttachmentError(sess, movePickerUserError(err))
339344 d.invalidateRender(sess, ac, true, "picker.go")
340345 return
341346 }
@@ -345,15 +350,17 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
345350 var err error
346351 if len(effects) != 0 && effects[0] != nil {
347352 err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
348- } else {
353+ } else if local, ok := localSession(sess); ok {
349354 d.closePicker(ac)
350- err = d.switchToTarget(sess, ac, target)
355+ err = d.switchToTarget(local, ac, target)
356+ } else {
357+ err = errAttachmentTransition
351358 }
352359 if errors.Is(err, errAttachmentTransition) {
353360 return
354361 }
355362 if err != nil {
356- d.reportError(sess, err)
363+ d.reportAttachmentError(sess, err)
357364 return
358365 }
359366 return
@@ -403,13 +410,13 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
403410 // A same-session preview is already invalidated by its own coordinator.
404411 // Cross-session previews subscribe to the target's producer wake instead
405412 // of starting a direct paint/timer path.
406- if targetSess == ac.currentSession() {
413+ if targetSess == ac.currentAttachmentSession() {
407414 return
408415 }
409416 rc := d.attachCoordinator(targetSess, nil, nil, false)
410417 rc.subscribePreviewFor(ac, generation, func(renderWake) {
411418 if pickerPreviewCurrent(ac, targetSess, next, generation) {
412- if owner := ac.currentSession(); owner != nil {
419+ if owner := ac.currentAttachmentSession(); owner != nil {
413420 d.invalidateRender(owner, ac, false, "picker preview")
414421 }
415422 }
@@ -497,7 +504,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
497504 observed := ac.overlays.pickerPreview == tb
498505 ac.overlays.pickerMu.Unlock()
499506 if observed && d.clearPreviewGeneration(ac, generation) {
500- if sess := ac.currentSession(); sess != nil {
507+ if sess := ac.currentAttachmentSession(); sess != nil {
501508 d.invalidateRender(sess, ac, true, "picker.go")
502509 }
503510 }
@@ -535,7 +542,8 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
535542
536543 func pickerTargetsEqual(left, right picker.Target) bool {
537544 if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
538- left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped {
545+ left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped ||
546+ !remoteKeysEqual(left.RemoteKey, right.RemoteKey) {
539547 return false
540548 }
541549 if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
@@ -544,6 +552,13 @@ func pickerTargetsEqual(left, right picker.Target) bool {
544552 return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
545553 }
546554
555+func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
556+ if left == nil || right == nil {
557+ return left == nil && right == nil
558+ }
559+ return *left == *right
560+}
561+
547562 func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession {
548563 d.mu.Lock()
549564 defer d.mu.Unlock()
@@ -615,6 +630,9 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.
615630 }
616631 token.effect.bindActionEnd(d, action)
617632 token.effect.End()
633+ if target.RemoteKey != nil {
634+ return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)
635+ }
618636 sess, ok := localSession(token.sess)
619637 if !ok {
620638 return errAttachmentTransition
@@ -622,6 +640,134 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.
622640 return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action)
623641 }
624642
643+// switchToRemoteTargetForRole performs dialing after the initiating effect has
644+// ended and without role or architecture locks. transitionAttachment then
645+// freezes and revalidates the exact source role and exact structured-key proxy
646+// before publishing ownership.
647+func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error {
648+ if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {
649+ return errAttachmentTransition
650+ }
651+ ctx := d.serveCtx
652+ if ctx == nil {
653+ ctx = context.Background()
654+ }
655+ // Only the caller that observed neither a published proxy nor an in-flight
656+ // constructor may own failure cleanup. Waiters and existing-proxy users must
657+ // never tear down the shared link when their source transition goes stale.
658+ d.mu.Lock()
659+ _, alreadyPublished := d.sessions[key.ID()]
660+ constructionInFlight := d.proxyConstructions[key] != nil
661+ d.mu.Unlock()
662+ ownsCandidate := !alreadyPublished && !constructionInFlight
663+ proxy, err := d.openProxySession(ctx, key, token.ac.size)
664+ if err != nil {
665+ if !token.activeCurrent() {
666+ return errAttachmentTransition
667+ }
668+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't connect to that remote session", err)
669+ }
670+ if proxy == nil || proxy.key != key || proxy.id != key.ID() {
671+ return errAttachmentTransition
672+ }
673+ proxy.mu.Lock()
674+ candidateGeneration := proxy.generation
675+ proxy.mu.Unlock()
676+
677+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
678+ source: token.sess, target: proxy, next: token.ac,
679+ expectedRole: attachmentActive, targetRole: attachmentActive,
680+ expectedTransport: token.transport, sourceToken: &token, action: action,
681+ preserveRole: proxy == token.sess, ready: true,
682+ })
683+ if err != nil {
684+ if ownsCandidate {
685+ d.discardUnattachedProxyGeneration(proxy, candidateGeneration)
686+ }
687+ return errAttachmentTransition
688+ }
689+ if guard.closePicker {
690+ fresh, admitted := token.ac.beginRoleEffect(transition.published)
691+ if admitted {
692+ d.closePicker(token.ac)
693+ fresh.End()
694+ }
695+ }
696+ if proxy != token.sess {
697+ token.ac.recordPreviousSession(token.sess)
698+ }
699+ d.deferAttachmentTransitionCleanups(transition)
700+ if proxy == token.sess {
701+ d.invalidateRender(proxy, token.ac, true, "picker.go")
702+ } else {
703+ d.firstPaintForTransition(transition.published)
704+ }
705+ return nil
706+}
707+
708+// discardUnattachedProxyGeneration removes only a transition-owned candidate
709+// that never acquired a client. It mirrors normal proxy teardown fencing but
710+// deliberately does not arm the five-minute warm lifetime for an attachment
711+// that was never published.
712+func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generation uint64) bool {
713+ if d == nil || proxy == nil {
714+ return false
715+ }
716+ d.mu.Lock()
717+ if d.sessions[proxy.id] != proxy {
718+ d.mu.Unlock()
719+ return false
720+ }
721+ proxy.sessionCore.mu.Lock()
722+ if proxy.client != nil {
723+ proxy.sessionCore.mu.Unlock()
724+ d.mu.Unlock()
725+ return false
726+ }
727+ proxy.mu.Lock()
728+ if proxy.generation != generation || proxy.expired || proxy.warm != nil {
729+ proxy.mu.Unlock()
730+ proxy.sessionCore.mu.Unlock()
731+ d.mu.Unlock()
732+ return false
733+ }
734+ proxy.generation++
735+ proxy.expired = true
736+ cancel := proxy.cancel
737+ transport := proxy.transport
738+ coordinator := proxy.coordinator.Load()
739+ proxy.mu.Unlock()
740+ if !d.unregisterSessionLocked(proxy) {
741+ proxy.sessionCore.mu.Unlock()
742+ d.mu.Unlock()
743+ return false
744+ }
745+ proxy.sessionCore.mu.Unlock()
746+ empty := len(d.sessions) == 0
747+ if empty {
748+ d.closing = true
749+ }
750+ d.mu.Unlock()
751+
752+ if cancel != nil {
753+ cancel()
754+ }
755+ if transport != nil {
756+ _ = transport.Close()
757+ }
758+ if coordinator != nil {
759+ coordinator.beginSessionTeardown().finish()
760+ coordinator.waitForTimerWorkers()
761+ }
762+ proxy.finish()
763+ if empty {
764+ d.doneOnce.Do(func() { close(d.done) })
765+ } else {
766+ d.refreshRemoteOpenPickers()
767+ }
768+ return true
769+}
770+
625771 // switchToTargetGuarded is retained for daemon-internal and headless callers.
626772 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
627773 return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "")
@@ -920,6 +1066,10 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
9201066 if token.ac == nil {
9211067 return d.killPickerTarget(target)
9221068 }
1069+ if target.RemoteKey != nil {
1070+ d.enterRemoteKillConfirmation(token.sess, token.ac, target)
1071+ return nil
1072+ }
9231073 if target.Stopped {
9241074 token.effect.bindActionEnd(d, "picker-delete")
9251075 token.effect.End()
M internal/usecase/daemon/prompt.go +7 -6
@@ -13,7 +13,7 @@ func promptModalFor(title string) ui.Modal {
1313 return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
1414 }
1515
16-func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {
16+func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string) error) {
1717 d.closePrompt(ac)
1818 ac.overlays.promptMu.Lock()
1919 ac.overlays.prompt = promptui.New(title, initial)
@@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
2323 d.invalidateRender(sess, ac, true, "prompt.go")
2424 }
2525
26-func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) {
26+func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) {
2727 d.closePrompt(ac)
2828 ac.overlays.promptMu.Lock()
2929 ac.overlays.prompt = promptui.New(title, initial)
@@ -47,11 +47,12 @@ func (d *Daemon) closePrompt(ac *attachedClient) {
4747 func promptValidationError(err error) bool {
4848 return errors.Is(err, domain.ErrInvalidSessionName) ||
4949 errors.Is(err, errSessionNameRequired) ||
50- errors.Is(err, errSessionNameInUse)
50+ errors.Is(err, errSessionNameInUse) ||
51+ errors.Is(err, errProxyKillConfirmation)
5152 }
5253
5354 func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
54- sess := ac.currentSession()
55+ sess := ac.currentAttachmentSession()
5556 if sess == nil {
5657 return
5758 }
@@ -112,13 +113,13 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
112113 }
113114 ac.overlays.promptMu.Unlock()
114115 if !promptValidationError(err) {
115- d.reportError(sess, err)
116+ d.reportAttachmentError(sess, err)
116117 }
117118 d.invalidateRender(sess, ac, true, "prompt.go")
118119 return
119120 }
120121 d.closePrompt(ac)
121- if current := ac.currentSession(); current != nil {
122+ if current := ac.currentAttachmentSession(); current != nil {
122123 d.invalidateRender(current, ac, true, "prompt.go")
123124 }
124125 return
A internal/usecase/daemon/proxy_input.go +262 -0
@@ -0,0 +1,262 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/keys"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/usecase/mouse"
11+ "github.com/bnema/vev/internal/usecase/palette"
12+)
13+
14+// sendInput is the sole proxy input sender. Sequence assignment and transport
15+// Send are serialized with every other proxy link write; raw is never rebuilt
16+// from an interpreted key action.
17+func (p *proxySession) sendInput(raw []byte) error {
18+ if p == nil || len(raw) == 0 {
19+ return nil
20+ }
21+ data := append([]byte(nil), raw...)
22+ p.sendMu.Lock()
23+ defer p.sendMu.Unlock()
24+
25+ p.mu.Lock()
26+ transport := p.transport
27+ p.mu.Unlock()
28+ if transport == nil {
29+ return errors.New("proxy session: input link is unavailable")
30+ }
31+ p.inputNext++
32+ return transport.Send(ports.Frame{
33+ Type: ports.MsgInput,
34+ Payload: ports.MarshalInput(ports.Input{
35+ InputSeq: p.inputNext,
36+ Data: data,
37+ }),
38+ })
39+}
40+
41+// proxyKeyHandler pins input ownership while a local client is attached to a
42+// proxy. Local UI actions never reach the link. Every remote-owned action is
43+// sent using the exact bytes supplied by keys.Router.
44+type proxyKeyHandler struct {
45+ d *Daemon
46+ proxy *proxySession
47+ ac *attachedClient
48+ roleToken attachmentRoleToken
49+}
50+
51+func (h proxyKeyHandler) acquireRoleEffect() (*roleEffectTicket, bool, bool) {
52+ if h.proxy == nil || h.ac == nil {
53+ return nil, false, false
54+ }
55+ if h.roleToken.ac == nil {
56+ return nil, false, false
57+ }
58+ if h.roleToken.sess != h.proxy || h.roleToken.ac != h.ac {
59+ return nil, false, false
60+ }
61+ if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {
62+ return effect, false, true
63+ }
64+ effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)
65+ if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil {
66+ h.d.afterDelayedKeyEffectAttempt(admitted)
67+ }
68+ if !admitted {
69+ return nil, false, false
70+ }
71+ if h.d != nil && h.d.afterRoleEffectAdmitted != nil {
72+ token := h.roleToken
73+ token.effect = effect
74+ h.d.afterRoleEffectAdmitted(token)
75+ }
76+ return effect, true, true
77+}
78+
79+func (h proxyKeyHandler) sendOwned(raw []byte) {
80+ if len(raw) == 0 {
81+ return
82+ }
83+ if err := h.proxy.sendInput(raw); err != nil && h.d != nil {
84+ h.d.log.Warn("proxy input send failed", "host", h.proxy.key.Host, "session", h.proxy.key.Name, "err", err)
85+ }
86+}
87+
88+func (h proxyKeyHandler) send(raw []byte) {
89+ effect, owned, ok := h.acquireRoleEffect()
90+ if !ok {
91+ return
92+ }
93+ if owned {
94+ defer effect.End()
95+ }
96+ h.sendOwned(raw)
97+}
98+
99+func (h proxyKeyHandler) Forward(raw []byte) { h.send(raw) }
100+
101+func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) }
102+
103+func (h proxyKeyHandler) Action(action keys.Action, raw []byte) {
104+ effect, owned, ok := h.acquireRoleEffect()
105+ if !ok {
106+ return
107+ }
108+ if owned {
109+ defer effect.End()
110+ }
111+ h.roleToken.effect = effect
112+ switch action {
113+ case keys.ActionOpenPalette:
114+ h.enterPalette()
115+ case keys.ActionJumpAttention:
116+ if h.proxy.hasRemoteAttention() {
117+ h.sendOwned(raw)
118+ return
119+ }
120+ h.jumpLocalAttention()
121+ case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
122+ keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
123+ keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9,
124+ keys.ActionFocusPaneLeft, keys.ActionFocusPaneRight, keys.ActionFocusPaneUp, keys.ActionFocusPaneDown,
125+ keys.ActionToggleFloatingPane,
126+ keys.ActionGrowPaneWidth, keys.ActionShrinkPaneWidth,
127+ keys.ActionGrowPaneHeight, keys.ActionShrinkPaneHeight,
128+ keys.ActionEqualizePanes,
129+ keys.ActionConsumeOrExpelPaneLeft, keys.ActionConsumeOrExpelPaneRight:
130+ h.sendOwned(raw)
131+ }
132+}
133+
134+func (p *proxySession) hasRemoteAttention() bool {
135+ if p == nil {
136+ return false
137+ }
138+ p.mu.Lock()
139+ defer p.mu.Unlock()
140+ for _, tab := range p.meta.Tabs {
141+ if tab.Attention {
142+ return true
143+ }
144+ }
145+ return false
146+}
147+
148+func (h proxyKeyHandler) enterPalette() {
149+ if h.d == nil || h.ac == nil || h.proxy == nil {
150+ return
151+ }
152+ // paletteResults still accepts a local current session. Build its immutable
153+ // snapshot without one and remove this proxy by its structured opaque ID.
154+ recent := h.d.recentSessions(h.proxy)
155+ commands := h.d.paletteCommands()
156+ results := h.d.paletteResults(nil, commands)
157+ filtered := results[:0]
158+ for _, result := range results {
159+ if id, ok := result.SessionID(); ok && id == h.proxy.id {
160+ continue
161+ }
162+ filtered = append(filtered, result)
163+ }
164+ h.ac.overlays.paletteMu.Lock()
165+ h.ac.overlays.paletteGeneration++
166+ h.ac.overlays.palette = palette.New(filtered)
167+ h.ac.overlays.paletteRecent = recent
168+ h.ac.overlays.paletteHints = palette.ContextualHints{}
169+ h.ac.overlays.paletteFeedback = ""
170+ h.ac.overlays.palettePending = nil
171+ h.ac.overlays.paletteMu.Unlock()
172+ h.d.invalidateRender(h.proxy, h.ac, true, "proxy_input.go")
173+}
174+
175+// jumpLocalAttention handles only the selected-session part of attention
176+// navigation. A pending tab on the proxy remains remote-owned; without one,
177+// the local daemon may move the client to another local session.
178+func (h proxyKeyHandler) jumpLocalAttention() {
179+ if h.d == nil || h.roleToken.ac == nil || h.roleToken.effect == nil {
180+ return
181+ }
182+ target, ok := h.d.oldestOtherSessionAttention(nil)
183+ if !ok {
184+ return
185+ }
186+ h.d.mu.Lock()
187+ targetSession, ok := localSession(h.d.sessions[target.sessionID])
188+ h.d.mu.Unlock()
189+ if !ok {
190+ return
191+ }
192+
193+ token := h.roleToken
194+ token.effect.bindActionEnd(h.d, "proxy-jump-attention")
195+ token.effect.End()
196+ transition, err := h.d.transitionAttachment(attachmentTransitionRequest{
197+ source: h.proxy, target: targetSession, next: h.ac,
198+ expectedRole: attachmentActive, targetRole: attachmentActive,
199+ expectedTransport: token.transport, sourceToken: &token,
200+ action: "proxy-jump-attention", activateTargetTab: true,
201+ targetTabIndex: target.tabIndex, ready: true,
202+ })
203+ if err != nil {
204+ return
205+ }
206+ h.d.touchMRU(targetSession)
207+ h.ac.recordPreviousSession(h.proxy)
208+ h.d.deferAttachmentTransitionCleanups(transition)
209+ h.d.firstPaintForTransition(transition.published)
210+}
211+
212+func proxyMouseOwnedLocally(rt *overlayRuntime) bool {
213+ // copyActive cannot be reached for proxy attachments through production input
214+ // routing: copy mode is local-session-only and proxy keys never enter it.
215+ return rt != nil && (rt.promptActive() || rt.paletteActive() || rt.pickerActive() ||
216+ rt.noticesActive() || rt.resizeModeActive())
217+}
218+
219+func proxiedNavConfig(cfg domain.NavConfig) domain.NavConfig {
220+ cfg.OverflowSessions = false
221+ return cfg
222+}
223+
224+func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied }
225+
226+// focusDirProxied performs ordinary pane and tab overflow on the remote daemon,
227+// but never follows OverflowSessions. The local proxy daemon is the only owner
228+// allowed to change the selected session.
229+func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error {
230+ target := resolveDaemonActionTarget(sess)
231+ oldFocus := layout.PaneID("")
232+ if target.pane != nil {
233+ oldFocus = target.pane.id
234+ }
235+ span, err := d.focusDirAt(sess, target.tab, target.pane, dir)
236+ if err == nil {
237+ if ac != nil {
238+ d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "proxy_input.go")
239+ }
240+ return nil
241+ }
242+ if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, target.tab) {
243+ return err
244+ }
245+
246+ sess.mu.Lock()
247+ position, count := sess.active, len(sess.tabs)
248+ sess.mu.Unlock()
249+ step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count)
250+ if step.kind != overflowTabs {
251+ return errNoNeighbor
252+ }
253+ candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
254+ if !ok || !d.commitTabOverflow(sess, candidate) {
255+ return errNoNeighbor
256+ }
257+ d.activateTab(sess, candidate.target)
258+ if ac != nil {
259+ d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go")
260+ }
261+ return nil
262+}
A internal/usecase/daemon/proxy_input_test.go +683 -0
@@ -0,0 +1,683 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/command"
19+ "github.com/bnema/vev/internal/usecase/keys"
20+ "github.com/bnema/vev/internal/usecase/mouse"
21+ "github.com/bnema/vev/internal/usecase/picker"
22+)
23+
24+func newProxyInputHarness(t *testing.T) (*Daemon, *proxySession, *attachedClient, *proxyTestTransport, proxyKeyHandler) {
25+ t.Helper()
26+ d := newTestDaemon(t, nil, stubClock{})
27+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
28+ require.NoError(t, err)
29+ link := newProxyTestTransport()
30+ proxy.mu.Lock()
31+ proxy.transport = link
32+ proxy.linkGeneration = 1
33+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build"}}}
34+ proxy.mu.Unlock()
35+ ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()}
36+ ac.initOverlays()
37+ token := roleEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))
38+ return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token}
39+}
40+
41+func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *attachedClient) attachmentRoleToken {
42+ t.Helper()
43+ clientTransport := newProxyTestTransport()
44+ ac.replaceTransport(clientTransport)
45+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
46+ d.mu.Lock()
47+ require.True(t, d.registerSessionLocked(proxy))
48+ d.mu.Unlock()
49+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
50+ target: proxy, next: ac,
51+ expectedRole: attachmentDetached, targetRole: attachmentActive,
52+ expectedTransport: ac.transportSnapshot(), ready: true,
53+ })
54+ require.NoError(t, err)
55+ d.deferAttachmentTransitionCleanups(transition)
56+ return transition.published
57+}
58+
59+func roleEffectForTest(t *testing.T, token attachmentRoleToken) attachmentRoleToken {
60+ t.Helper()
61+ effect, admitted := token.ac.beginRoleEffect(token)
62+ require.True(t, admitted)
63+ token.effect = effect
64+ t.Cleanup(effect.End)
65+ return token
66+}
67+
68+func requireProxyInputFrame(t *testing.T, link *proxyTestTransport, wantSeq uint64, want []byte) {
69+ t.Helper()
70+ select {
71+ case frame := <-link.sent:
72+ require.Equal(t, ports.MsgInput, frame.Type)
73+ input, err := ports.UnmarshalInput(frame.Payload)
74+ require.NoError(t, err)
75+ require.Equal(t, wantSeq, input.InputSeq)
76+ require.Equal(t, want, input.Data)
77+ default:
78+ t.Fatal("missing proxy input frame")
79+ }
80+ select {
81+ case frame := <-link.sent:
82+ t.Fatalf("input was sent more than once: %#v", frame)
83+ default:
84+ }
85+}
86+
87+func requireNoProxyFrame(t *testing.T, link *proxyTestTransport) {
88+ t.Helper()
89+ select {
90+ case frame := <-link.sent:
91+ t.Fatalf("unexpected proxy frame: %#v", frame)
92+ default:
93+ }
94+}
95+
96+func TestProxyInputForwardsOrdinaryPasteMouseAndEscapeBytesExactlyOnce(t *testing.T) {
97+ _, _, _, link, handler := newProxyInputHarness(t)
98+ paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), []byte("a\x1bj\x00")...), ports.BracketedPasteCloseMarker...)
99+ inputs := []struct {
100+ name string
101+ raw []byte
102+ }{
103+ {name: "ordinary keyboard", raw: []byte("hello")},
104+ {name: "bracketed paste", raw: paste},
105+ {name: "terminal escape", raw: []byte("\x1b[15~")},
106+ }
107+ for i, input := range inputs {
108+ t.Run(input.name, func(t *testing.T) {
109+ handler.Forward(input.raw)
110+ requireProxyInputFrame(t, link, uint64(i+1), input.raw)
111+ })
112+ }
113+
114+ mouseRaw := []byte("\x1b[<0;12;8M")
115+ handler.Mouse(mouse.Event{Raw: mouseRaw})
116+ requireProxyInputFrame(t, link, 4, mouseRaw)
117+}
118+
119+func TestProxyInputRemoteActionsForwardOriginalChordWithoutLocalExecution(t *testing.T) {
120+ remoteActions := []keys.Action{
121+ keys.ActionSwitchTab1, keys.ActionSwitchTab9,
122+ keys.ActionFocusPaneLeft, keys.ActionFocusPaneRight, keys.ActionFocusPaneUp, keys.ActionFocusPaneDown,
123+ keys.ActionToggleFloatingPane,
124+ keys.ActionGrowPaneWidth, keys.ActionShrinkPaneWidth, keys.ActionGrowPaneHeight, keys.ActionShrinkPaneHeight,
125+ keys.ActionEqualizePanes, keys.ActionConsumeOrExpelPaneLeft, keys.ActionConsumeOrExpelPaneRight,
126+ }
127+ for _, action := range remoteActions {
128+ t.Run(action.Name(), func(t *testing.T) {
129+ _, proxy, _, link, handler := newProxyInputHarness(t)
130+ before := proxy.snapshotView(viewOptions{tabDetails: true})
131+ raw := []byte{keys.ESC, byte('A' + action%26), 0x00}
132+ handler.Action(action, raw)
133+ requireProxyInputFrame(t, link, 1, raw)
134+ require.Equal(t, before, proxy.snapshotView(viewOptions{tabDetails: true}), "remote action must not mutate local proxy metadata")
135+ })
136+ }
137+}
138+
139+func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
140+ t.Run("router action bytes", func(t *testing.T) {
141+ _, _, _, link, handler := newProxyInputHarness(t)
142+ bindings := keys.DefaultBindings()
143+ var published atomic.Pointer[keys.Bindings]
144+ published.Store(bindings)
145+ router := keys.NewRouter(stubClock{}, handler, &published)
146+
147+ router.Route([]byte{keys.ESC, 'j'})
148+ requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})
149+ })
150+
151+ t.Run("retained escape reacquires its original role", func(t *testing.T) {
152+ d, proxy, ac, link, initial := newProxyInputHarness(t)
153+ base := initial.roleToken
154+ base.effect.End()
155+ base.effect = nil
156+
157+ firstEffect, admitted := ac.beginRoleEffect(base)
158+ require.True(t, admitted)
159+ first := base
160+ first.effect = firstEffect
161+ ac.keys.RouteWithHandler([]byte{keys.ESC}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: first})
162+ firstEffect.End()
163+
164+ secondEffect, admitted := ac.beginRoleEffect(base)
165+ require.True(t, admitted)
166+ second := base
167+ second.effect = secondEffect
168+ ac.keys.RouteWithHandler([]byte{'j'}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: second})
169+ secondEffect.End()
170+
171+ requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})
172+ })
173+}
174+
175+func TestProxyInputLocalPaletteNeverForwards(t *testing.T) {
176+ d, _, ac, link, handler := newProxyInputHarness(t)
177+ token := handler.roleToken
178+ handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '})
179+ require.True(t, ac.overlays.paletteActive())
180+ requireNoProxyFrame(t, link)
181+
182+ // Keyboard and mouse navigation are consumed by the local overlay before
183+ // either the proxy router or proxy mouse sender can observe them.
184+ d.handleInputForRole(token, []byte("x"))
185+ d.handleInputForRole(token, []byte("\x1b[<0;12;8M"))
186+ requireNoProxyFrame(t, link)
187+}
188+
189+func TestProxyInputAttentionOwnership(t *testing.T) {
190+ t.Run("pending remote tab attention forwards original chord", func(t *testing.T) {
191+ _, proxy, _, link, handler := newProxyInputHarness(t)
192+ proxy.mu.Lock()
193+ proxy.meta.Tabs[1].Attention = true
194+ proxy.mu.Unlock()
195+ raw := []byte{keys.ESC, 'a'}
196+ handler.Action(keys.ActionJumpAttention, raw)
197+ requireProxyInputFrame(t, link, 1, raw)
198+ })
199+
200+ t.Run("without remote attention changes selected session locally", func(t *testing.T) {
201+ d, _, ac, link, handler := newProxyInputHarness(t)
202+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
203+ tb.attention = true
204+ tb.attentionAt = time.Unix(1, 0)
205+ local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
206+ publishTiledPaneOwners(local, tb)
207+ d.mu.Lock()
208+ require.True(t, d.registerSessionLocked(local))
209+ d.mu.Unlock()
210+
211+ handler.Action(keys.ActionJumpAttention, []byte{keys.ESC, 'a'})
212+
213+ require.Same(t, local, ac.currentSession())
214+ requireNoProxyFrame(t, link)
215+ })
216+}
217+
218+func TestProxyInputProxiedRemoteOwnershipGuards(t *testing.T) {
219+ _, proxy, _, link, _ := newProxyInputHarness(t)
220+ require.True(t, proxy.caps.cannotAcceptMoves, "a proxy must reject move destinations locally")
221+ require.True(t, proxy.caps.cannotYieldMoves, "a proxy must reject move sources locally")
222+ requireNoProxyFrame(t, link)
223+
224+ cfg := domain.NavConfig{OverflowTabs: true, OverflowSessions: true}
225+ require.Equal(t, domain.NavConfig{OverflowTabs: true}, proxiedNavConfig(cfg), "focus overflow must not cross remote-daemon sessions")
226+ require.False(t, proxiedJumpSearchesOtherSessions(true), "proxied jump attention must stay in its attached session")
227+ require.True(t, proxiedJumpSearchesOtherSessions(false))
228+}
229+
230+func TestProxyPaletteCommandOwnershipIsComplete(t *testing.T) {
231+ expected := map[string]proxyPaletteOwnership{
232+ "new-tab": proxyPaletteRemote, "new-session": proxyPaletteLocal, "close-tab": proxyPaletteRemote,
233+ "split-right": proxyPaletteRemote, "split-left": proxyPaletteRemote, "split-up": proxyPaletteRemote, "split-down": proxyPaletteRemote,
234+ "consume-or-expel-pane-left": proxyPaletteRemote, "consume-or-expel-pane-right": proxyPaletteRemote,
235+ "stack-pane": proxyPaletteRemote, "toggle-stack": proxyPaletteRemote, "toggle-floating-pane": proxyPaletteRemote, "close-pane": proxyPaletteRemote,
236+ "move-pane": proxyPaletteRejected, "move-tab": proxyPaletteRejected,
237+ "focus-pane-left": proxyPaletteRemote, "focus-pane-right": proxyPaletteRemote, "focus-pane-up": proxyPaletteRemote, "focus-pane-down": proxyPaletteRemote,
238+ "resize-pane": proxyPaletteRemote, "grow-pane-width": proxyPaletteRemote, "shrink-pane-width": proxyPaletteRemote,
239+ "grow-pane-height": proxyPaletteRemote, "shrink-pane-height": proxyPaletteRemote, "equalize-panes": proxyPaletteRemote,
240+ "next-tab": proxyPaletteRemote, "previous-tab": proxyPaletteRemote,
241+ "back-session": proxyPaletteLocal, "jump-recent-session": proxyPaletteLocal, "session-picker": proxyPaletteLocal,
242+ "notifications": proxyPaletteLocal, "yank-last-notification": proxyPaletteLocal,
243+ "visual-mode": proxyPaletteRejected, "rename-session": proxyPaletteRejected, "rename-tab": proxyPaletteRemote,
244+ "detach": proxyPaletteLocal,
245+ }
246+
247+ commands := command.PaletteRegistry()
248+ require.Len(t, expected, len(commands), "ownership table must change whenever the palette registry changes")
249+ for _, cmd := range commands {
250+ want, covered := expected[cmd.Slug]
251+ require.True(t, covered, "palette-visible command %q has no ownership assertion", cmd.Slug)
252+ require.Equal(t, want, proxyPaletteCommandOwnership(cmd.Slug), cmd.Slug)
253+ require.Equal(t, want == proxyPaletteRemote, proxyAttachedCommandOwnedRemotely(cmd.Slug), cmd.Slug)
254+ }
255+}
256+
257+func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
258+ t.Run("new session opens local transition prompt", func(t *testing.T) {
259+ d, proxy, ac, link, handler := newProxyInputHarness(t)
260+ handler.enterPalette()
261+ d.handlePaletteInput(ac, []byte("CNS\r"), handler.roleToken.effect)
262+
263+ require.False(t, ac.overlays.paletteActive())
264+ require.True(t, ac.overlays.promptActive())
265+ require.Same(t, proxy, ac.currentAttachmentSession())
266+ requireNoProxyFrame(t, link)
267+ })
268+
269+ t.Run("notification yank uses local notice center and clipboard", func(t *testing.T) {
270+ d, proxy, ac, link, handler := newProxyInputHarness(t)
271+ d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "local notice", Time: time.Unix(1, 0)})
272+ handler.enterPalette()
273+ client, ok := ac.transport().(*proxyTestTransport)
274+ require.True(t, ok)
275+ for len(client.sent) > 0 {
276+ <-client.sent
277+ }
278+ d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect)
279+
280+ require.False(t, ac.overlays.paletteActive())
281+ require.Same(t, proxy, ac.currentAttachmentSession())
282+ frame := awaitTestValue(t, client.sent, "local notification yank did not reach the client clipboard")
283+ require.Equal(t, ports.MsgOutput, frame.Type)
284+ output, err := ports.UnmarshalOutput(frame.Payload)
285+ require.NoError(t, err)
286+ require.Contains(t, string(output.Data), "\x1b]52;")
287+ requireNoProxyFrame(t, link)
288+ })
289+}
290+
291+type proxyCommandTestTimer struct {
292+ ch chan time.Time
293+ once sync.Once
294+}
295+
296+func newProxyCommandTestTimer() *proxyCommandTestTimer {
297+ return &proxyCommandTestTimer{ch: make(chan time.Time, 1)}
298+}
299+
300+func (t *proxyCommandTestTimer) C() <-chan time.Time { return t.ch }
301+func (t *proxyCommandTestTimer) Reset(time.Duration) bool { return false }
302+func (t *proxyCommandTestTimer) Stop() bool { return true }
303+func (t *proxyCommandTestTimer) Fire() {
304+ t.once.Do(func() { t.ch <- time.Time{} })
305+}
306+
307+type proxyCommandTestClock struct{ timers chan *proxyCommandTestTimer }
308+
309+func newProxyCommandTestClock() *proxyCommandTestClock {
310+ return &proxyCommandTestClock{timers: make(chan *proxyCommandTestTimer, 8)}
311+}
312+
313+func (*proxyCommandTestClock) Now() time.Time { return time.Time{} }
314+func (c *proxyCommandTestClock) NewTimer(delay time.Duration) ports.Timer {
315+ if delay != proxyAttachedCommandTimeout {
316+ return stubTimer{}
317+ }
318+ timer := newProxyCommandTestTimer()
319+ c.timers <- timer
320+ return timer
321+}
322+
323+func requireProxyCommandRequest(t *testing.T, link *proxyTestTransport) ports.CommandRequest {
324+ t.Helper()
325+ frame := awaitTestValue(t, link.sent, "proxy command was not sent")
326+ require.Equal(t, ports.MsgCommand, frame.Type)
327+ request, err := ports.UnmarshalCommandRequest(frame.Payload)
328+ require.NoError(t, err)
329+ return request
330+}
331+
332+func commandResultFrame(result ports.CommandResult) ports.Frame {
333+ return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
334+}
335+
336+func TestProxyAttachedCommandCorrelatesInterleavedWrongAndLateResults(t *testing.T) {
337+ d, proxy, _, link, _ := newProxyInputHarness(t)
338+ clock := newProxyCommandTestClock()
339+
340+ firstDone := make(chan ports.CommandResult, 1)
341+ firstErr := make(chan error, 1)
342+ go func() {
343+ result, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
344+ firstDone <- result
345+ firstErr <- err
346+ }()
347+ first := requireProxyCommandRequest(t, link)
348+ require.Equal(t, uint64(1), first.RequestID)
349+ require.True(t, first.Attached)
350+ _ = awaitTestValue(t, clock.timers, "first command did not arm timeout")
351+
352+ generation := proxy.linkGeneration
353+ result, err := d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})})
354+ require.NoError(t, err)
355+ require.Equal(t, proxyLinkResume, result)
356+ require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type)
357+ result, err = d.handleLinkFrame(proxy, generation, proxyMeta(proxy.key.Name))
358+ require.NoError(t, err)
359+ require.Equal(t, proxyLinkResume, result)
360+ result, err = d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})})
361+ require.NoError(t, err)
362+ require.Equal(t, proxyLinkResume, result)
363+ result, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: 999, OK: true}))
364+ require.NoError(t, err)
365+ require.Equal(t, proxyLinkResume, result)
366+ select {
367+ case <-firstDone:
368+ t.Fatal("wrong request ID completed command")
369+ default:
370+ }
371+ require.NoError(t, proxy.sendInput([]byte("x")))
372+ requireProxyInputFrame(t, link, 1, []byte("x"))
373+
374+ _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true}))
375+ require.NoError(t, err)
376+ require.True(t, (<-firstDone).OK)
377+ require.NoError(t, <-firstErr)
378+
379+ secondDone := make(chan ports.CommandResult, 1)
380+ go func() {
381+ result, _ := proxy.sendCommand(context.Background(), clock, "previous-tab", nil)
382+ secondDone <- result
383+ }()
384+ second := requireProxyCommandRequest(t, link)
385+ require.Equal(t, uint64(2), second.RequestID)
386+ _ = awaitTestValue(t, clock.timers, "second command did not arm timeout")
387+ _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true}))
388+ require.NoError(t, err)
389+ select {
390+ case <-secondDone:
391+ t.Fatal("late request ID completed replacement command")
392+ default:
393+ }
394+ _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: second.RequestID, Code: ports.ErrInternal, Text: "remote failed"}))
395+ require.NoError(t, err)
396+ remoteFailure := <-secondDone
397+ require.False(t, remoteFailure.OK)
398+ require.Equal(t, "remote failed", remoteFailure.Text)
399+}
400+
401+func TestProxyAttachedCommandSharesSerializedSender(t *testing.T) {
402+ d, proxy, _, link, _ := newProxyInputHarness(t)
403+ clock := newProxyCommandTestClock()
404+ gate := make(chan struct{})
405+ entered := make(chan struct{}, 2)
406+ link.sendGate = gate
407+ link.sendEntered = entered
408+
409+ commandDone := make(chan error, 1)
410+ go func() {
411+ _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
412+ commandDone <- err
413+ }()
414+ awaitTestCompletion(t, entered, "command sender did not enter transport")
415+ inputDone := make(chan error, 1)
416+ go func() { inputDone <- proxy.sendInput([]byte("x")) }()
417+ select {
418+ case <-entered:
419+ t.Fatal("input entered Transport.Send while command send held the shared sender")
420+ default:
421+ }
422+ close(gate)
423+ request := requireProxyCommandRequest(t, link)
424+ require.NoError(t, awaitTestValue(t, inputDone, "input send did not finish"))
425+ requireProxyInputFrame(t, link, 1, []byte("x"))
426+ _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
427+ _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
428+ require.NoError(t, err)
429+ require.NoError(t, awaitTestValue(t, commandDone, "command did not finish"))
430+ require.False(t, link.concurrent.Load(), "transport observed concurrent Send calls")
431+}
432+
433+func TestProxyAttachedCommandAllowsOnlyOneOutstandingInteractiveRequest(t *testing.T) {
434+ d, proxy, _, link, _ := newProxyInputHarness(t)
435+ clock := newProxyCommandTestClock()
436+ firstDone := make(chan struct{})
437+ go func() {
438+ _, _ = proxy.sendCommand(context.Background(), clock, "next-tab", nil)
439+ close(firstDone)
440+ }()
441+ first := requireProxyCommandRequest(t, link)
442+ _ = awaitTestValue(t, clock.timers, "first command did not arm timeout")
443+
444+ secondDone := make(chan struct{})
445+ go func() {
446+ _, _ = proxy.sendCommand(context.Background(), clock, "previous-tab", nil)
447+ close(secondDone)
448+ }()
449+ requireNoProxyFrame(t, link)
450+ _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true}))
451+ require.NoError(t, err)
452+ awaitTestCompletion(t, firstDone, "first command did not complete")
453+ second := requireProxyCommandRequest(t, link)
454+ _ = awaitTestValue(t, clock.timers, "second command did not arm timeout")
455+ _, err = d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: second.RequestID, OK: true}))
456+ require.NoError(t, err)
457+ awaitTestCompletion(t, secondDone, "second command did not complete")
458+}
459+
460+func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T) {
461+ t.Run("timeout", func(t *testing.T) {
462+ _, proxy, _, link, _ := newProxyInputHarness(t)
463+ clock := newProxyCommandTestClock()
464+ done := make(chan error, 1)
465+ go func() {
466+ _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
467+ done <- err
468+ }()
469+ _ = requireProxyCommandRequest(t, link)
470+ timer := awaitTestValue(t, clock.timers, "command did not arm timeout")
471+ timer.Fire()
472+ require.ErrorIs(t, awaitTestValue(t, done, "command did not time out"), errProxyCommandTimeout)
473+ })
474+
475+ t.Run("context cancellation", func(t *testing.T) {
476+ _, proxy, _, link, _ := newProxyInputHarness(t)
477+ clock := newProxyCommandTestClock()
478+ ctx, cancel := context.WithCancel(context.Background())
479+ done := make(chan error, 1)
480+ go func() {
481+ _, err := proxy.sendCommand(ctx, clock, "next-tab", nil)
482+ done <- err
483+ }()
484+ _ = requireProxyCommandRequest(t, link)
485+ _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
486+ cancel()
487+ require.ErrorIs(t, awaitTestValue(t, done, "command did not cancel"), context.Canceled)
488+ })
489+
490+ t.Run("link generation replacement", func(t *testing.T) {
491+ d, proxy, _, link, _ := newProxyInputHarness(t)
492+ clock := newProxyCommandTestClock()
493+ done := make(chan error, 1)
494+ go func() {
495+ _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
496+ done <- err
497+ }()
498+ request := requireProxyCommandRequest(t, link)
499+ _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
500+ oldGeneration := proxy.linkGeneration
501+ newGeneration, _ := proxy.installTransport(newProxyTestTransport())
502+ require.Greater(t, newGeneration, oldGeneration)
503+ require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))
504+ _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
505+ require.NoError(t, err, "late old-generation result must be harmless")
506+ })
507+}
508+
509+func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *testing.T) {
510+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
511+ ac.proxied = true
512+ second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
513+ publishTiledPaneOwners(sess, second)
514+ sess.mu.Lock()
515+ sess.tabs = append(sess.tabs, second)
516+ sess.active = 0
517+ sess.mu.Unlock()
518+ d.attachCoordinator(sess, nil, ac, true)
519+
520+ send := func(request ports.CommandRequest) ports.CommandResult {
521+ payload, err := ports.MarshalCommandRequest(request)
522+ require.NoError(t, err)
523+ token := sess.attachmentToken(ac, ac.transport())
524+ require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
525+ frame := awaitFrame(t, sends, ports.MsgCommandResult)
526+ result, err := ports.UnmarshalCommandResult(frame.Payload)
527+ require.NoError(t, err)
528+ return result
529+ }
530+
531+ result := send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 1, Attached: true, Slug: "next-tab"})
532+ require.True(t, result.OK)
533+ sess.mu.Lock()
534+ require.Equal(t, 1, sess.active)
535+ sess.mu.Unlock()
536+
537+ result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 2, Attached: true, Slug: "previous-tab", TargetSession: "other"})
538+ require.False(t, result.OK)
539+ sess.mu.Lock()
540+ require.Equal(t, 1, sess.active, "target override must not execute against any session")
541+ sess.mu.Unlock()
542+
543+ result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 3, Attached: true, Slug: "rename-tab"})
544+ require.True(t, result.OK)
545+ require.True(t, ac.overlays.promptActive(), "attached interactive command must be able to open the remote prompt")
546+}
547+
548+func TestProxyAttachedCommandRemoteErrorCreatesLocalNotice(t *testing.T) {
549+ d, proxy, ac, link, handler := newProxyInputHarness(t)
550+ clock := newProxyCommandTestClock()
551+ d.clock = clock
552+ handler.enterPalette()
553+ done := make(chan struct{})
554+ go func() {
555+ d.handlePaletteInput(ac, []byte("NXT\r"), handler.roleToken.effect)
556+ close(done)
557+ }()
558+ request := requireProxyCommandRequest(t, link)
559+ _ = awaitTestValue(t, clock.timers, "palette command did not arm timeout")
560+ _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, Code: ports.ErrInternal, Text: "remote failed"}))
561+ require.NoError(t, err)
562+ awaitTestCompletion(t, done, "palette command did not finish")
563+ notice, ok := d.notices.latest()
564+ require.True(t, ok)
565+ require.Equal(t, domain.NoticeSessionUnavailable, notice.Code)
566+ require.Contains(t, notice.Message, "remote failed")
567+}
568+
569+func TestProxyAttachedCommandMalformedAndUnavailableResultsAreSafe(t *testing.T) {
570+ d, proxy, _, _, _ := newProxyInputHarness(t)
571+ result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{Type: ports.MsgCommandResult, Payload: []byte{1}})
572+ require.Equal(t, proxyLinkStop, result)
573+ require.Error(t, err)
574+
575+ proxy.mu.Lock()
576+ proxy.transport = nil
577+ proxy.mu.Unlock()
578+ _, err = proxy.sendCommand(context.Background(), stubClock{}, "next-tab", nil)
579+ require.True(t, errors.Is(err, errProxyCommandUnavailable))
580+}
581+
582+func TestProxyOwnershipMakesCompatibleRemoteRowsSelectable(t *testing.T) {
583+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
584+ for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} {
585+ view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running", Tabs: 2}, status, time.Unix(1, 0))
586+ require.True(t, view.RemoteAttachReady, "compatible status %d must be enabled after ownership is complete", status)
587+ model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
588+ target, selectable := model.Selected()
589+ require.True(t, selectable)
590+ require.Equal(t, key, *target.RemoteKey)
591+ }
592+ mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{})
593+ require.False(t, mismatch.RemoteAttachReady)
594+ _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
595+ require.False(t, selectable)
596+}
597+
598+func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *testing.T) {
599+ d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
600+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
601+ transport := newProxyTestTransport()
602+ transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
603+ transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}
604+ factory := portsmocks.NewMockRemoteDialerFactory(t)
605+ dialer := portsmocks.NewMockDialer(t)
606+ var daemonLockAvailable, sourceLockAvailable bool
607+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).
608+ Run(func(string, string, ports.RemoteTransportMode, *slog.Logger) {
609+ // If picker selection retained either architecture lock while dialing,
610+ // this synchronous callback would deadlock.
611+ d.mu.Lock()
612+ daemonLockAvailable = true
613+ d.mu.Unlock()
614+ source.mu.Lock()
615+ sourceLockAvailable = true
616+ source.mu.Unlock()
617+ }).Return(dialer, nil).Once()
618+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
619+ d.remoteDialerFactory = factory
620+ d.remoteTransportMode = ports.RemoteTransportUDP
621+ d.attachCoordinator(source, nil, ac, true)
622+
623+ token := source.attachmentToken(ac, ac.transport())
624+ effect, admitted := ac.beginRoleEffect(token)
625+ require.True(t, admitted)
626+ token.effect = effect
627+ err := d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
628+ require.NoError(t, err)
629+ require.True(t, daemonLockAvailable)
630+ require.True(t, sourceLockAvailable)
631+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
632+ require.True(t, ok)
633+ require.Equal(t, key, proxy.key)
634+ t.Cleanup(func() { stopProxy(t, proxy) })
635+
636+ wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"}
637+ currentToken := attachmentToken(proxy, ac, ac.transport())
638+ currentEffect, admitted := ac.beginRoleEffect(currentToken)
639+ require.True(t, admitted)
640+ currentToken.effect = currentEffect
641+ err = d.switchToTargetForRole(currentToken, picker.Target{Session: key.ID(), RemoteKey: &wrong, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
642+ require.ErrorIs(t, err, errAttachmentTransition)
643+ require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment")
644+
645+ staleToken := attachmentToken(proxy, ac, ac.transport())
646+ staleEffect, admitted := ac.beginRoleEffect(staleToken)
647+ require.True(t, admitted)
648+ staleToken.effect = staleEffect
649+ ac.roleGeneration.Add(1) // model a concurrent role replacement after selection
650+ err = d.switchToTargetForRole(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
651+ require.ErrorIs(t, err, errAttachmentTransition)
652+ require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment")
653+}
654+
655+func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) {
656+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
657+ d.attachCoordinator(local, nil, ac, true)
658+ key := domain.RemoteSessionKey{Host: "arch", Name: "enter"}
659+ transport := newProxyTestTransport()
660+ transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
661+ transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}
662+ d.remoteDialerFactory = newProxyConstructionFactory(transport)
663+ d.remoteTransportMode = ports.RemoteTransportUDP
664+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
665+ Host: key.Host, FetchedAt: time.Unix(1, 0),
666+ Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
667+ }})
668+
669+ model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
670+ d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
671+ target, selectable := model.Selected()
672+ require.True(t, selectable)
673+ require.Equal(t, key, *target.RemoteKey)
674+
675+ token := local.attachmentToken(ac, ac.transport())
676+ effect, admitted := ac.beginRoleEffect(token)
677+ require.True(t, admitted)
678+ d.handlePickerInput(ac, []byte("\r"), effect)
679+ proxy, ok := ac.currentAttachmentSession().(*proxySession)
680+ require.True(t, ok, "Enter must route the row instead of only marking it selectable")
681+ require.Equal(t, key, proxy.key)
682+ t.Cleanup(func() { stopProxy(t, proxy) })
683+}
A internal/usecase/daemon/proxy_kill.go +276 -0
@@ -0,0 +1,276 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/picker"
12+)
13+
14+const proxyKillTimeout = 10 * time.Second
15+
16+var (
17+ errProxyKillTimeout = errors.New("proxy session: remote kill timed out")
18+ errProxyKillGeneration = errors.New("proxy session: remote kill lifecycle changed")
19+ errProxyKillConfirmation = errors.New("type the full remote session label exactly")
20+)
21+
22+type proxyKillIOResult struct {
23+ frame ports.Frame
24+ err error
25+}
26+
27+// enterRemoteKillConfirmation turns a remote picker deletion into an exact,
28+// typed lifecycle capability. The proxy pointer is resolved when the prompt is
29+// opened, while its generation is deliberately captured only after the exact
30+// label is submitted.
31+func (d *Daemon) enterRemoteKillConfirmation(owner attachmentSession, ac *attachedClient, target picker.Target) bool {
32+ if d == nil || owner == nil || ac == nil || target.RemoteKey == nil || target.Session != target.RemoteKey.ID() {
33+ return false
34+ }
35+ key := *target.RemoteKey
36+ d.mu.Lock()
37+ proxy, ok := d.sessions[target.Session].(*proxySession)
38+ ok = ok && proxy.key == key
39+ d.mu.Unlock()
40+ if !ok {
41+ return false
42+ }
43+ title := " Type " + key.Display() + " to kill "
44+ proxy.mu.Lock()
45+ unreachable := proxy.expired || proxy.linkState == ports.LinkStateOffline || proxy.linkState == ports.LinkStateDead
46+ proxy.mu.Unlock()
47+ if unreachable {
48+ title = " Type " + key.Display() + " to kill (remote unreachable) "
49+ }
50+ d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentRoleToken) error {
51+ if value != key.Display() {
52+ return errProxyKillConfirmation
53+ }
54+ if token.ac != nil {
55+ if !token.activeCurrent() {
56+ return errAttachmentTransition
57+ }
58+ token.effect.bindActionEnd(d, "remote-picker-delete")
59+ token.effect.End()
60+ }
61+ d.mu.Lock()
62+ if d.sessions[proxy.id] != proxy {
63+ d.mu.Unlock()
64+ return errProxyKillGeneration
65+ }
66+ proxy.mu.Lock()
67+ generation := proxy.generation
68+ proxy.mu.Unlock()
69+ d.mu.Unlock()
70+ ctx := d.serveCtx
71+ if ctx == nil {
72+ ctx = context.Background()
73+ }
74+ return d.killRemoteProxy(ctx, proxy, generation)
75+ })
76+ return true
77+}
78+
79+// killRemoteProxy uses a fresh control transport and accepts only EOF as the
80+// remote daemon's authoritative kill acknowledgement. No live or cached state
81+// is changed before that acknowledgement and exact generation publication.
82+func (d *Daemon) killRemoteProxy(ctx context.Context, proxy *proxySession, generation uint64) error {
83+ if d == nil || proxy == nil || d.remoteDialerFactory == nil || d.clock == nil {
84+ return errors.New("proxy session: remote kill is unavailable")
85+ }
86+ if ctx == nil {
87+ ctx = context.Background()
88+ }
89+ if err := ctx.Err(); err != nil {
90+ return err
91+ }
92+
93+ d.mu.Lock()
94+ current := d.sessions[proxy.id] == proxy
95+ if current {
96+ proxy.mu.Lock()
97+ current = proxy.generation == generation
98+ proxy.mu.Unlock()
99+ }
100+ d.mu.Unlock()
101+ if !current {
102+ return errProxyKillGeneration
103+ }
104+
105+ timer := d.clock.NewTimer(proxyKillTimeout)
106+ if timer == nil {
107+ return errProxyKillTimeout
108+ }
109+ defer timer.Stop()
110+ boundedCtx, cancel := context.WithCancel(ctx)
111+ defer cancel()
112+ timedOut := make(chan struct{})
113+ stopTimeout := make(chan struct{})
114+ defer close(stopTimeout)
115+ go func() {
116+ select {
117+ case <-timer.C():
118+ close(timedOut)
119+ cancel()
120+ case <-stopTimeout:
121+ }
122+ }()
123+
124+ dialer, err := d.remoteDialerFactory.DialerForRemote(proxy.key.Host, proxy.key.Name, d.remoteTransportMode, d.log)
125+ if err != nil {
126+ return fmt.Errorf("proxy session: select remote kill dialer: %w", err)
127+ }
128+ if dialer == nil {
129+ return errors.New("proxy session: remote kill dialer is nil")
130+ }
131+ transport, err := dialer.Dial(boundedCtx)
132+ if err != nil {
133+ return proxyKillContextError(ctx, timedOut, fmt.Errorf("proxy session: remote kill dial: %w", err))
134+ }
135+ if transport == nil {
136+ return errors.New("proxy session: remote kill transport is nil")
137+ }
138+ defer func() { _ = transport.Close() }()
139+
140+ killFrame := ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: proxy.key.Name})}
141+ sendDone := make(chan error, 1)
142+ go func() { sendDone <- transport.Send(killFrame) }()
143+ select {
144+ case err := <-sendDone:
145+ if err != nil {
146+ return fmt.Errorf("proxy session: send remote kill: %w", err)
147+ }
148+ case <-boundedCtx.Done():
149+ _ = transport.Close()
150+ // Transport promises Close unblocks Send, but this boundary cannot let a
151+ // broken adapter defeat the caller's bound. The buffered result lets the
152+ // caller return; a non-cooperative adapter may retain this one transport
153+ // goroutine until Send eventually exits. It owns no daemon locks or state.
154+ return proxyKillContextError(ctx, timedOut, boundedCtx.Err())
155+ }
156+
157+ recvDone := make(chan proxyKillIOResult, 1)
158+ go func() {
159+ frame, err := transport.Recv()
160+ recvDone <- proxyKillIOResult{frame: frame, err: err}
161+ }()
162+ var received proxyKillIOResult
163+ select {
164+ case received = <-recvDone:
165+ case <-boundedCtx.Done():
166+ _ = transport.Close()
167+ // Apply the same bounded boundary defensively to Recv. A conforming
168+ // transport exits on Close; a broken one may retain this transport-owned
169+ // goroutine, but cannot delay or mutate the failed kill operation.
170+ return proxyKillContextError(ctx, timedOut, boundedCtx.Err())
171+ }
172+ if !errors.Is(received.err, io.EOF) {
173+ if received.err != nil {
174+ return fmt.Errorf("proxy session: receive remote kill result: %w", received.err)
175+ }
176+ if received.frame.Type == ports.MsgError {
177+ remoteErr, decodeErr := ports.UnmarshalErrorMsg(received.frame.Payload)
178+ if decodeErr != nil {
179+ return fmt.Errorf("proxy session: malformed remote kill error: %w", decodeErr)
180+ }
181+ return fmt.Errorf("proxy session: remote kill failed (%d): %s", remoteErr.Code, remoteErr.Text)
182+ }
183+ return fmt.Errorf("proxy session: unexpected remote kill reply type %d", received.frame.Type)
184+ }
185+ return d.publishRemoteProxyKill(proxy, generation)
186+}
187+
188+func proxyKillContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error {
189+ select {
190+ case <-timedOut:
191+ return errProxyKillTimeout
192+ default:
193+ }
194+ if err := parent.Err(); err != nil {
195+ return err
196+ }
197+ return fallback
198+}
199+
200+// publishRemoteProxyKill atomically removes only the acknowledged proxy
201+// generation and matching discovery row. Teardown and persistence are external
202+// operations and therefore happen after every architecture lock is released.
203+func (d *Daemon) publishRemoteProxyKill(proxy *proxySession, generation uint64) error {
204+ d.mu.Lock()
205+ if d.sessions[proxy.id] != proxy {
206+ d.mu.Unlock()
207+ return errProxyKillGeneration
208+ }
209+ proxy.sessionCore.mu.Lock()
210+ proxy.mu.Lock()
211+ if proxy.generation != generation {
212+ proxy.mu.Unlock()
213+ proxy.sessionCore.mu.Unlock()
214+ d.mu.Unlock()
215+ return errProxyKillGeneration
216+ }
217+ proxy.generation++
218+ proxy.expired = true
219+ warm := proxy.warm
220+ proxy.warm = nil
221+ cancel := proxy.cancel
222+ transport := proxy.transport
223+ coordinator := proxy.coordinator.Load()
224+ proxy.mu.Unlock()
225+ if !d.unregisterSessionLocked(proxy) {
226+ proxy.sessionCore.mu.Unlock()
227+ d.mu.Unlock()
228+ return errProxyKillGeneration
229+ }
230+ proxy.sessionCore.mu.Unlock()
231+
232+ cacheChanged := false
233+ d.remoteCatalog.mu.Lock()
234+ if entry, ok := d.remoteCatalog.cache[proxy.key.Host]; ok {
235+ sessions := make([]ports.RemoteCatalogSession, 0, len(entry.Sessions))
236+ for _, session := range entry.Sessions {
237+ if session.Name == proxy.key.Name {
238+ cacheChanged = true
239+ continue
240+ }
241+ sessions = append(sessions, session)
242+ }
243+ if cacheChanged {
244+ entry.Sessions = sessions
245+ d.remoteCatalog.cache[proxy.key.Host] = entry
246+ }
247+ }
248+ d.remoteCatalog.mu.Unlock()
249+ empty := len(d.sessions) == 0
250+ if empty {
251+ d.closing = true
252+ }
253+ d.mu.Unlock()
254+
255+ warm.stop()
256+ if cancel != nil {
257+ cancel()
258+ }
259+ if transport != nil {
260+ _ = transport.Close()
261+ }
262+ if coordinator != nil {
263+ coordinator.beginSessionTeardown().finish()
264+ coordinator.waitForTimerWorkers()
265+ }
266+ proxy.finish()
267+ if cacheChanged {
268+ d.persistRemoteCatalogCache()
269+ }
270+ if empty {
271+ d.doneOnce.Do(func() { close(d.done) })
272+ } else {
273+ d.refreshRemoteOpenPickers()
274+ }
275+ return nil
276+}
M internal/usecase/daemon/proxy_lifecycle.go +46 -0
@@ -36,6 +36,52 @@ func (t *proxyWarmTimer) stop() {
3636 t.cancelOnce.Do(func() { close(t.cancel) })
3737 }
3838
39+// proxyAttachmentTransitionCommitted applies lifecycle policy only after the
40+// attachment publication has released every architecture lock. Each helper
41+// performs its own exact pointer/client revalidation before changing a timer.
42+func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveRole bool) {
43+ if d == nil || preserveRole || source == target {
44+ return
45+ }
46+ if proxy, ok := source.(*proxySession); ok {
47+ d.armProxyWarm(proxy)
48+ }
49+ if proxy, ok := target.(*proxySession); ok {
50+ if d.cancelProxyWarmForAttachment(proxy, ac) {
51+ d.touchMRU(proxy)
52+ }
53+ }
54+}
55+
56+// cancelProxyWarmForAttachment marks an exact published attachment as active.
57+// Timer operations occur only after d.mu, sessionCore.mu, and proxy.mu have all
58+// been released.
59+func (d *Daemon) cancelProxyWarmForAttachment(p *proxySession, ac *attachedClient) bool {
60+ if d == nil || p == nil || ac == nil {
61+ return false
62+ }
63+ d.mu.Lock()
64+ if d.sessions[p.id] != p {
65+ d.mu.Unlock()
66+ return false
67+ }
68+ p.sessionCore.mu.Lock()
69+ if p.client != ac {
70+ p.sessionCore.mu.Unlock()
71+ d.mu.Unlock()
72+ return false
73+ }
74+ p.mu.Lock()
75+ p.generation++
76+ old := p.warm
77+ p.warm = nil
78+ p.mu.Unlock()
79+ p.sessionCore.mu.Unlock()
80+ d.mu.Unlock()
81+ old.stop()
82+ return true
83+}
84+
3985 // armProxyWarm reserves and publishes a fresh five-minute timer only while the
4086 // exact proxy remains registered and headless. Clock and Timer methods are
4187 // external operations and therefore run outside architecture locks.
M internal/usecase/daemon/proxy_lifecycle_test.go +1334 -3
@@ -1,18 +1,1237 @@
11 package daemon
22
33 import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "log/slog"
49 "sync"
510 "testing"
611 "time"
712
813 "github.com/bnema/vev/internal/domain"
914 "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/picker"
1016 "github.com/stretchr/testify/require"
1117 )
1218
13-// warmHookClock runs a one-shot hook while armProxyWarm sits between its
14-// reservation and its publication, so a lifecycle race can be interleaved
15-// deterministically instead of being waited for.
19+type proxyLifecycleClock struct {
20+ mu sync.RWMutex
21+ now time.Time
22+ timers chan *proxyLifecycleTimer
23+}
24+
25+func newProxyLifecycleClock() *proxyLifecycleClock {
26+ return &proxyLifecycleClock{
27+ now: time.Unix(1_800_000_000, 0),
28+ timers: make(chan *proxyLifecycleTimer, 16),
29+ }
30+}
31+
32+func (c *proxyLifecycleClock) Now() time.Time {
33+ c.mu.RLock()
34+ defer c.mu.RUnlock()
35+ return c.now
36+}
37+
38+func (c *proxyLifecycleClock) Advance(delay time.Duration) {
39+ c.mu.Lock()
40+ c.now = c.now.Add(delay)
41+ c.mu.Unlock()
42+}
43+
44+func (c *proxyLifecycleClock) NewTimer(delay time.Duration) ports.Timer {
45+ timer := &proxyLifecycleTimer{clock: c, delay: delay, ticks: make(chan time.Time, 1)}
46+ select {
47+ case c.timers <- timer:
48+ default:
49+ }
50+ return timer
51+}
52+
53+type proxyLifecycleTimer struct {
54+ mu sync.Mutex
55+ clock *proxyLifecycleClock
56+ delay time.Duration
57+ ticks chan time.Time
58+ stopped bool
59+}
60+
61+func (t *proxyLifecycleTimer) C() <-chan time.Time { return t.ticks }
62+func (t *proxyLifecycleTimer) Reset(delay time.Duration) bool {
63+ t.mu.Lock()
64+ wasActive := !t.stopped
65+ t.delay = delay
66+ t.stopped = false
67+ t.mu.Unlock()
68+ return wasActive
69+}
70+func (t *proxyLifecycleTimer) Stop() bool {
71+ t.mu.Lock()
72+ wasActive := !t.stopped
73+ t.stopped = true
74+ t.mu.Unlock()
75+ return wasActive
76+}
77+func (t *proxyLifecycleTimer) fire() {
78+ t.clock.Advance(t.delay)
79+ select {
80+ case t.ticks <- t.clock.Now():
81+ default:
82+ }
83+}
84+
85+func TestProxyLifecycleClockTimerRegistrationDoesNotBlock(t *testing.T) {
86+ clock := newProxyLifecycleClock()
87+ for range cap(clock.timers) {
88+ clock.NewTimer(time.Minute)
89+ }
90+
91+ registered := make(chan struct{})
92+ go func() {
93+ clock.NewTimer(time.Minute)
94+ close(registered)
95+ }()
96+ awaitTestCompletion(t, registered, "timer registration blocked on observer capacity")
97+ for range cap(clock.timers) {
98+ _ = awaitTestValue(t, clock.timers, "registered timer was not observable")
99+ }
100+}
101+
102+type proxyLifecycleTransport struct {
103+ d *Daemon
104+ proxy *proxySession
105+ closed chan struct{}
106+ closeOne sync.Once
107+ unlocked bool
108+}
109+
110+func (t *proxyLifecycleTransport) Send(ports.Frame) error { return nil }
111+func (t *proxyLifecycleTransport) Recv() (ports.Frame, error) {
112+ <-t.closed
113+ return ports.Frame{}, nil
114+}
115+func (t *proxyLifecycleTransport) Close() error {
116+ t.closeOne.Do(func() {
117+ daemonUnlocked := t.d.mu.TryLock()
118+ if daemonUnlocked {
119+ t.d.mu.Unlock()
120+ }
121+ coreUnlocked := t.proxy.sessionCore.mu.TryLock()
122+ if coreUnlocked {
123+ t.proxy.sessionCore.mu.Unlock()
124+ }
125+ proxyUnlocked := t.proxy.mu.TryLock()
126+ if proxyUnlocked {
127+ t.proxy.mu.Unlock()
128+ }
129+ t.unlocked = daemonUnlocked && coreUnlocked && proxyUnlocked
130+ close(t.closed)
131+ })
132+ return nil
133+}
134+
135+func newRegisteredLifecycleProxy(t *testing.T, clock ports.Clock) (*Daemon, *proxySession, *proxyLifecycleTransport) {
136+ t.Helper()
137+ d := newTestDaemon(t, nil, clock)
138+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
139+ require.NoError(t, err)
140+ transport := &proxyLifecycleTransport{d: d, proxy: proxy, closed: make(chan struct{})}
141+ proxy.transport = transport
142+ proxy.linkGeneration = 1
143+ d.mu.Lock()
144+ require.True(t, d.registerSessionLocked(proxy))
145+ d.mu.Unlock()
146+ return d, proxy, transport
147+}
148+
149+func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
150+ clock := newProxyLifecycleClock()
151+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
152+ d.clock = clock
153+ localCoordinator := d.attachCoordinator(local, nil, ac, true)
154+ localToken := attachmentToken(local, ac, ac.transport())
155+ localToken.lease = localCoordinator.attachmentLease(ac)
156+
157+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ac.size)
158+ require.NoError(t, err)
159+ d.mu.Lock()
160+ require.True(t, d.registerSessionLocked(proxy))
161+ d.mu.Unlock()
162+
163+ toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
164+ source: local, target: proxy, next: ac,
165+ expectedRole: attachmentActive, targetRole: attachmentActive,
166+ expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
167+ action: "lifecycle test", ready: true,
168+ })
169+ require.NoError(t, err)
170+ require.NotZero(t, proxy.mruAt.Load(), "proxy activation must participate in unified MRU ordering")
171+ proxy.mu.Lock()
172+ require.Nil(t, proxy.warm, "attachment must leave no warm timer published")
173+ proxy.mu.Unlock()
174+
175+ _, err = d.transitionAttachment(attachmentTransitionRequest{
176+ source: proxy, target: local, next: ac,
177+ expectedRole: attachmentActive, targetRole: attachmentActive,
178+ expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
179+ action: "lifecycle test", ready: true,
180+ })
181+ require.NoError(t, err)
182+ timer := awaitTestValue(t, clock.timers, "proxy timer was not registered")
183+ require.Equal(t, proxyWarmDuration, timer.delay)
184+ proxy.mu.Lock()
185+ require.NotNil(t, proxy.warm, "switch-away must arm the warm lifecycle")
186+ proxy.mu.Unlock()
187+}
188+
189+func TestProxyWarmLifecycleUsesExactFiveMinuteGeneration(t *testing.T) {
190+ clock := newProxyLifecycleClock()
191+ d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
192+ d.remoteCatalog.mu.Lock()
193+ d.remoteCatalog.cache[proxy.key.Host] = ports.RemoteCatalogCacheEntry{Host: proxy.key.Host, FetchedAt: clock.Now()}
194+ d.remoteCatalog.mu.Unlock()
195+
196+ d.armProxyWarm(proxy)
197+ timer := awaitTestValue(t, clock.timers, "proxy timer was not registered")
198+ require.Equal(t, 5*time.Minute, timer.delay)
199+ proxy.mu.Lock()
200+ token := proxy.warm
201+ generation := proxy.generation
202+ proxy.mu.Unlock()
203+ require.NotNil(t, token)
204+
205+ timer.fire()
206+ awaitTestCompletion(t, token.done, "proxy warm lifecycle did not complete")
207+
208+ d.mu.Lock()
209+ require.NotContains(t, d.sessions, proxy.id)
210+ require.True(t, d.closing, "the last warm proxy expiry must begin daemon shutdown")
211+ d.mu.Unlock()
212+ require.True(t, transport.unlocked, "transport close must run after daemon/core/proxy locks are released")
213+ proxy.mu.Lock()
214+ require.Greater(t, proxy.generation, generation)
215+ proxy.mu.Unlock()
216+ d.remoteCatalog.mu.Lock()
217+ require.Contains(t, d.remoteCatalog.cache, proxy.key.Host, "warm expiry must preserve discovery cache")
218+ d.remoteCatalog.mu.Unlock()
219+}
220+
221+func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
222+ clock := newProxyLifecycleClock()
223+ d, proxy, _ := newRegisteredLifecycleProxy(t, clock)
224+
225+ d.armProxyWarm(proxy)
226+ firstTimer := awaitTestValue(t, clock.timers, "first proxy timer was not registered")
227+ proxy.mu.Lock()
228+ firstToken := proxy.warm
229+ proxy.mu.Unlock()
230+ require.NotNil(t, firstToken)
231+
232+ ac := &attachedClient{}
233+ proxy.sessionCore.mu.Lock()
234+ proxy.client = ac
235+ proxy.sessionCore.mu.Unlock()
236+ d.cancelProxyWarmForAttachment(proxy, ac)
237+ awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete")
238+ firstTimer.fire()
239+
240+ d.mu.Lock()
241+ require.Same(t, proxy, d.sessions[proxy.id])
242+ d.mu.Unlock()
243+
244+ proxy.sessionCore.mu.Lock()
245+ proxy.client = nil
246+ proxy.sessionCore.mu.Unlock()
247+ d.armProxyWarm(proxy)
248+ secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered")
249+ proxy.mu.Lock()
250+ secondToken := proxy.warm
251+ proxy.mu.Unlock()
252+ require.NotNil(t, secondToken)
253+ require.NotSame(t, firstToken, secondToken)
254+
255+ secondTimer.fire()
256+ awaitTestCompletion(t, secondToken.done, "second proxy warm lifecycle did not complete")
257+ d.mu.Lock()
258+ require.NotContains(t, d.sessions, proxy.id)
259+ d.mu.Unlock()
260+}
261+
262+func TestProxyWarmTimerRejectsReplacementPointer(t *testing.T) {
263+ clock := newProxyLifecycleClock()
264+ d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
265+ d.armProxyWarm(proxy)
266+ timer := awaitTestValue(t, clock.timers, "proxy timer was not registered")
267+ proxy.mu.Lock()
268+ token := proxy.warm
269+ proxy.mu.Unlock()
270+
271+ replacement, err := newProxySession(proxy.key, defaultSize)
272+ require.NoError(t, err)
273+ d.mu.Lock()
274+ d.sessions[proxy.id] = replacement
275+ d.mu.Unlock()
276+
277+ timer.fire()
278+ awaitTestCompletion(t, token.done, "proxy warm lifecycle did not complete")
279+ d.mu.Lock()
280+ require.Same(t, replacement, d.sessions[proxy.id])
281+ d.mu.Unlock()
282+ select {
283+ case <-transport.closed:
284+ t.Fatal("stale pointer timer closed the old transport")
285+ default:
286+ }
287+}
288+
289+type proxyResumeTestFactory struct {
290+ mu sync.Mutex
291+ transports []ports.Transport
292+ callCount int
293+ calls chan int
294+}
295+
296+func newProxyResumeTestFactory(transports ...ports.Transport) *proxyResumeTestFactory {
297+ return &proxyResumeTestFactory{transports: transports, calls: make(chan int, proxyResumeMaxAttempts+2)}
298+}
299+
300+func (f *proxyResumeTestFactory) DialerForRemote(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error) {
301+ f.mu.Lock()
302+ f.callCount++
303+ callNumber := f.callCount
304+ var selected ports.Transport
305+ for i, transport := range f.transports {
306+ if transport != nil {
307+ selected = transport
308+ f.transports[i] = nil
309+ break
310+ }
311+ }
312+ f.mu.Unlock()
313+ f.calls <- callNumber
314+ if selected == nil {
315+ return nil, errors.New("resume dial unavailable")
316+ }
317+ return proxyConstructionDialer{transport: selected}, nil
318+}
319+
320+func newProxyResumeHarness(t *testing.T, clock *proxyLifecycleClock, factory ports.RemoteDialerFactory) (*Daemon, *proxySession, *proxyTestTransport) {
321+ t.Helper()
322+ d := newTestDaemon(t, nil, clock)
323+ d.remoteDialerFactory = factory
324+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
325+ require.NoError(t, err)
326+ initial := newProxyTestTransport()
327+ proxy.mu.Lock()
328+ proxy.transport = initial
329+ proxy.linkGeneration = 1
330+ proxy.mu.Unlock()
331+ d.mu.Lock()
332+ require.True(t, d.registerSessionLocked(proxy))
333+ d.mu.Unlock()
334+ return d, proxy, initial
335+}
336+
337+func TestProxyResumeBackoffIsFakeClockBoundedAndParksOffline(t *testing.T) {
338+ clock := newProxyLifecycleClock()
339+ factory := newProxyResumeTestFactory()
340+ d, proxy, initial := newProxyResumeHarness(t, clock, factory)
341+ initial.recv <- proxyRecv{err: io.ErrUnexpectedEOF}
342+
343+ go d.runProxyLink(context.Background(), proxy)
344+ for attempt := range proxyResumeMaxAttempts {
345+ timer := awaitTestValue(t, clock.timers, "proxy resume did not arm fake-clock backoff")
346+ require.Equal(t, proxyResumeBackoff(attempt), timer.delay)
347+ timer.fire()
348+ require.Equal(t, attempt+1, awaitTestValue(t, factory.calls, "proxy resume dial was not attempted"))
349+ }
350+ awaitTestCompletion(t, proxy.done, "proxy resume did not stop after its attempt cap")
351+
352+ proxy.mu.Lock()
353+ require.Nil(t, proxy.transport)
354+ require.Equal(t, ports.LinkStateOffline, proxy.linkState)
355+ proxy.mu.Unlock()
356+ d.mu.Lock()
357+ require.Same(t, proxy, d.sessions[proxy.id], "exhaustion must park the proxy instead of unregistering it")
358+ d.mu.Unlock()
359+ select {
360+ case timer := <-clock.timers:
361+ t.Fatalf("attempt cap armed an extra retry: %v", timer.delay)
362+ default:
363+ }
364+}
365+
366+func TestProxyResumeCancellationDuringBackoffDoesNotRedial(t *testing.T) {
367+ clock := newProxyLifecycleClock()
368+ factory := newProxyResumeTestFactory()
369+ d, proxy, initial := newProxyResumeHarness(t, clock, factory)
370+ initial.recv <- proxyRecv{err: io.EOF}
371+ ctx, cancel := context.WithCancel(context.Background())
372+ go d.runProxyLink(ctx, proxy)
373+ _ = awaitTestValue(t, clock.timers, "proxy resume did not arm backoff")
374+ cancel()
375+ awaitTestCompletion(t, proxy.done, "canceled proxy resume did not stop")
376+ select {
377+ case <-factory.calls:
378+ t.Fatal("canceled backoff dialed a replacement link")
379+ default:
380+ }
381+}
382+
383+func TestProxyResumeBackoffResetsAfterStableConnection(t *testing.T) {
384+ clock := newProxyLifecycleClock()
385+ resumed := newProxyTestTransport()
386+ factory := newProxyResumeTestFactory(resumed)
387+ d, proxy, initial := newProxyResumeHarness(t, clock, factory)
388+ initial.recv <- proxyRecv{err: io.EOF}
389+ ctx, cancel := context.WithCancel(context.Background())
390+ defer cancel()
391+ go d.runProxyLink(ctx, proxy)
392+
393+ first := awaitTestValue(t, clock.timers, "initial resume did not arm backoff")
394+ require.Equal(t, proxyResumeInitialBackoff, first.delay)
395+ first.fire()
396+ _ = awaitTestValue(t, factory.calls, "stable resume did not dial")
397+ _ = requireProxyHello(t, resumed)
398+ resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume|ports.CapabilityProxied)}
399+ resumed.recv <- proxyRecv{frame: proxyMeta(proxy.key.Name)}
400+ require.Eventually(t, func() bool {
401+ proxy.mu.Lock()
402+ defer proxy.mu.Unlock()
403+ return proxy.transport == resumed && proxy.resumeToken == 2
404+ }, time.Second, time.Millisecond)
405+
406+ clock.Advance(proxyResumeStableDuration)
407+ resumed.recv <- proxyRecv{err: io.EOF}
408+ second := awaitTestValue(t, clock.timers, "stable link loss did not arm another backoff")
409+ require.Equal(t, proxyResumeInitialBackoff, second.delay, "stable connection must reset exponential backoff")
410+ cancel()
411+ awaitTestCompletion(t, proxy.done, "stable-reset proxy did not stop after cancellation")
412+}
413+
414+func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) {
415+ clock := newProxyLifecycleClock()
416+ d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
417+
418+ tests := []struct {
419+ state ports.LinkState
420+ want string
421+ }{
422+ {ports.LinkStateConnected, proxy.name},
423+ {ports.LinkStateDegraded, proxy.name + " [degraded]"},
424+ {ports.LinkStateProbing, proxy.name + " [probing]"},
425+ {ports.LinkStateOffline, proxy.name + " [offline]"},
426+ {ports.LinkStateDead, proxy.name + " [dead]"},
427+ }
428+ for _, test := range tests {
429+ require.True(t, d.updateProxyLinkState(proxy, 1, transport, test.state))
430+ require.Equal(t, test.want, proxy.statusSegments(false).session)
431+ }
432+ require.False(t, d.updateProxyLinkState(proxy, 2, transport, ports.LinkStateConnected))
433+ require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)
434+}
435+
436+func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {
437+ d := newTestDaemon(t, nil, newProxyLifecycleClock())
438+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}
439+ oldTransport := newProxyTestTransport()
440+ old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true}
441+ old.initOverlays()
442+ old.setSession(sess)
443+ sess.client = old
444+ d.mu.Lock()
445+ require.True(t, d.registerSessionLocked(sess))
446+ d.mu.Unlock()
447+ d.attachCoordinator(sess, nil, old, true)
448+
449+ newTransport := newProxyTestTransport()
450+ next := &attachedClient{tr: newTransport, output: newOutputStateStream()}
451+ next.initOverlays()
452+ result, err := d.transitionAttachment(attachmentTransitionRequest{
453+ target: sess, next: next,
454+ expectedRole: attachmentDetached, targetRole: attachmentActive,
455+ expectedTransport: next.transportSnapshot(), ready: true,
456+ })
457+ require.NoError(t, err)
458+ require.Nil(t, result.displaced.ac, "a proxied attachment must never enter the snatched role")
459+ require.Same(t, next, sess.client)
460+ require.Nil(t, old.currentAttachmentSession())
461+
462+ d.deferAttachmentTransitionCleanups(result)
463+ d.waitNotifies()
464+ frame := awaitTestValue(t, oldTransport.sent, "replacement did not notify old transport")
465+ detached, err := ports.UnmarshalDetached(frame.Payload)
466+ require.NoError(t, err)
467+ require.Equal(t, ports.ReasonReplaced, detached.Reason)
468+ select {
469+ case <-oldTransport.closed:
470+ default:
471+ t.Fatal("terminal proxied replacement did not close its transport")
472+ }
473+}
474+
475+func TestProxyReplacedIsTerminalExpired(t *testing.T) {
476+ clock := newProxyLifecycleClock()
477+ d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
478+
479+ require.True(t, d.markProxyReplaced(proxy, 1, transport))
480+ proxy.mu.Lock()
481+ require.True(t, proxy.expired)
482+ proxy.mu.Unlock()
483+ require.Equal(t, proxy.name+" [expired]", proxy.statusSegments(false).session)
484+ require.False(t, d.updateProxyLinkState(proxy, 1, transport, ports.LinkStateConnected), "replacement expiry must reject later link recovery")
485+}
486+
487+func TestRemoteProxyPickerUsesExplicitExpiredLifecycleState(t *testing.T) {
488+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
489+ spoofed := remoteProxyPickerView(key, sessionView{name: key.Display() + " [expired]", tabCount: 1})
490+ require.Equal(t, key.Display(), spoofed.Name)
491+ require.Equal(t, picker.RemoteFresh, spoofed.RemoteAvailability)
492+ require.True(t, spoofed.RemoteAttachReady)
493+
494+ expired := remoteProxyPickerView(key, sessionView{name: "unrelated presentation", tabCount: 1, expired: true})
495+ require.Equal(t, key.Display()+" [expired]", expired.Name)
496+ require.Equal(t, picker.RemoteStale, expired.RemoteAvailability)
497+ require.False(t, expired.RemoteAttachReady)
498+}
499+
500+func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) {
501+ clock := newProxyLifecycleClock()
502+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
503+ d.clock = clock
504+ coordinator := d.attachCoordinator(local, nil, ac, true)
505+ localToken := attachmentToken(local, ac, ac.transport())
506+ localToken.lease = coordinator.attachmentLease(ac)
507+
508+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
509+ proxy, err := newProxySession(key, ac.size)
510+ require.NoError(t, err)
511+ transport := &proxyLifecycleTransport{d: d, proxy: proxy, closed: make(chan struct{})}
512+ proxy.transport = transport
513+ proxy.linkGeneration = 1
514+ factory := newProxyConstructionFactory(newProxyTestTransport())
515+ d.remoteDialerFactory = factory
516+ d.mu.Lock()
517+ require.True(t, d.registerSessionLocked(proxy))
518+ d.mu.Unlock()
519+
520+ toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
521+ source: local, target: proxy, next: ac,
522+ expectedRole: attachmentActive, targetRole: attachmentActive,
523+ expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
524+ action: "replaced picker regression", ready: true,
525+ })
526+ require.NoError(t, err)
527+
528+ result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{
529+ Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced}),
530+ })
531+ require.NoError(t, err)
532+ require.Equal(t, proxyLinkReplaced, result)
533+ require.True(t, d.markProxyReplaced(proxy, proxy.linkGeneration, transport))
534+
535+ _, err = d.transitionAttachment(attachmentTransitionRequest{
536+ source: proxy, target: local, next: ac,
537+ expectedRole: attachmentActive, targetRole: attachmentActive,
538+ expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
539+ action: "replaced picker regression", ready: true,
540+ })
541+ require.NoError(t, err)
542+ require.Same(t, local, ac.currentAttachmentSession())
543+ proxy.mu.Lock()
544+ warm := proxy.warm
545+ proxy.mu.Unlock()
546+ require.NotNil(t, warm, "switch-away did not retain warm expiry")
547+ warmTimer, ok := warm.timer.(*proxyLifecycleTimer)
548+ require.True(t, ok)
549+ require.Equal(t, proxyWarmDuration, warmTimer.delay)
550+
551+ views, _ := d.pickerViews(local)
552+ var expiredView picker.SessionView
553+ for _, view := range views {
554+ if view.RemoteKey != nil && *view.RemoteKey == key {
555+ expiredView = view
556+ break
557+ }
558+ }
559+ require.Equal(t, key.ID(), expiredView.ID, "exact registered proxy was absent from unified picker")
560+ require.Equal(t, key.Display()+" [expired]", expiredView.Name)
561+ require.Equal(t, picker.RemoteStale, expiredView.RemoteAvailability)
562+ require.Equal(t, "expired", expiredView.RemoteDetail)
563+ require.False(t, expiredView.RemoteAttachReady)
564+ require.Equal(t, key.Display()+" [expired]", proxy.lifecycleDisplayName())
565+
566+ d.enterPicker(local, ac)
567+ forced := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{
568+ Session: key.ID(), RemoteKey: &key,
569+ })
570+ ac.overlays.pickerMu.Lock()
571+ ac.overlays.picker = forced
572+ cursor, cursorOK := forced.Cursor()
573+ _, selectable := forced.Selected()
574+ ac.overlays.pickerMu.Unlock()
575+ require.True(t, cursorOK)
576+ require.Equal(t, key, *cursor.RemoteKey)
577+ require.False(t, selectable, "terminal proxy row must not be activatable")
578+ d.handlePickerInput(ac, []byte("\r"))
579+ require.False(t, ac.overlays.pickerActive())
580+ require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy")
581+
582+ token := attachmentToken(local, ac, ac.transport())
583+ effect, admitted := ac.beginRoleEffect(token)
584+ require.True(t, admitted)
585+ token.effect = effect
586+ err = d.switchToTargetForRole(token, cursor, sessionHandoffGuard{}, "replaced picker regression")
587+ require.Error(t, err)
588+ require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy")
589+
590+ reopened, err := d.openProxySession(context.Background(), key, ac.size)
591+ require.Error(t, err)
592+ require.Nil(t, reopened)
593+ require.Equal(t, 0, factory.callCount(), "terminal proxy triggered an automatic IntentAttach/redial")
594+ d.mu.Lock()
595+ require.Same(t, proxy, d.sessions[key.ID()], "terminal proxy registration changed before warm expiry")
596+ d.mu.Unlock()
597+
598+ proxy.mu.Lock()
599+ require.Same(t, warm, proxy.warm)
600+ proxy.mu.Unlock()
601+ warmTimer.fire()
602+ awaitTestCompletion(t, warm.done, "terminal proxy warm expiry did not complete")
603+}
604+
605+func TestDormantProxyReplacedRetainsAutomaticWarmExpiry(t *testing.T) {
606+ clock := newProxyLifecycleClock()
607+ d, proxy, _ := newRegisteredLifecycleProxy(t, clock)
608+ link := newProxyKillControlTransport(d, proxy)
609+ proxy.mu.Lock()
610+ proxy.transport = link
611+ proxy.mu.Unlock()
612+
613+ require.True(t, d.armProxyWarm(proxy))
614+ timer := awaitTestValue(t, clock.timers, "dormant proxy did not arm its warm deadline")
615+ proxy.mu.Lock()
616+ token := proxy.warm
617+ generation := proxy.generation
618+ proxy.mu.Unlock()
619+ require.NotNil(t, token)
620+ require.Equal(t, proxyWarmDuration, timer.delay)
621+
622+ link.recv <- proxyKillRecv{frame: ports.Frame{
623+ Type: ports.MsgDetached,
624+ Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced}),
625+ }}
626+ go d.runProxyLink(context.Background(), proxy)
627+ awaitTestCompletion(t, proxy.done, "replacement did not terminate the proxy link")
628+
629+ proxy.mu.Lock()
630+ require.True(t, proxy.expired)
631+ require.Same(t, token, proxy.warm, "terminal replacement cleared the only expiry capability")
632+ require.Greater(t, proxy.generation, generation, "terminal replacement did not invalidate stale lifecycle observers")
633+ require.Equal(t, proxy.generation, token.generation, "preserved expiry capability was not rekeyed to the terminal generation")
634+ proxy.mu.Unlock()
635+ select {
636+ case extra := <-clock.timers:
637+ t.Fatalf("replacement unexpectedly rearmed the original deadline: %v", extra.delay)
638+ default:
639+ }
640+
641+ timer.fire()
642+ awaitTestCompletion(t, token.done, "original warm deadline did not complete")
643+ d.mu.Lock()
644+ require.NotContains(t, d.sessions, proxy.id, "exact replaced proxy was not automatically unregistered")
645+ d.mu.Unlock()
646+}
647+
648+type proxyKillRecv struct {
649+ frame ports.Frame
650+ err error
651+}
652+
653+type proxyKillControlTransport struct {
654+ d *Daemon
655+ proxy *proxySession
656+ sent chan ports.Frame
657+ recv chan proxyKillRecv
658+ recvLocksChecked chan struct{}
659+ closed chan struct{}
660+ close sync.Once
661+ recvCheck sync.Once
662+ sendErr error
663+}
664+
665+func newProxyKillControlTransport(d *Daemon, proxy *proxySession) *proxyKillControlTransport {
666+ return &proxyKillControlTransport{
667+ d: d, proxy: proxy, sent: make(chan ports.Frame, 2), recv: make(chan proxyKillRecv, 1),
668+ recvLocksChecked: make(chan struct{}), closed: make(chan struct{}),
669+ }
670+}
671+
672+func (t *proxyKillControlTransport) locksAvailable() bool {
673+ daemonUnlocked := t.d.mu.TryLock()
674+ if daemonUnlocked {
675+ t.d.mu.Unlock()
676+ }
677+ coreUnlocked := t.proxy.sessionCore.mu.TryLock()
678+ if coreUnlocked {
679+ t.proxy.sessionCore.mu.Unlock()
680+ }
681+ proxyUnlocked := t.proxy.mu.TryLock()
682+ if proxyUnlocked {
683+ t.proxy.mu.Unlock()
684+ }
685+ catalogUnlocked := t.d.remoteCatalog.mu.TryLock()
686+ if catalogUnlocked {
687+ t.d.remoteCatalog.mu.Unlock()
688+ }
689+ return daemonUnlocked && coreUnlocked && proxyUnlocked && catalogUnlocked
690+}
691+
692+func (t *proxyKillControlTransport) Send(frame ports.Frame) error {
693+ if !t.locksAvailable() {
694+ return errors.New("architecture lock held during control send")
695+ }
696+ t.sent <- frame
697+ return t.sendErr
698+}
699+
700+func (t *proxyKillControlTransport) Recv() (ports.Frame, error) {
701+ if !t.locksAvailable() {
702+ return ports.Frame{}, errors.New("architecture lock held during control receive")
703+ }
704+ t.recvCheck.Do(func() { close(t.recvLocksChecked) })
705+ select {
706+ case result := <-t.recv:
707+ return result.frame, result.err
708+ case <-t.closed:
709+ return ports.Frame{}, io.EOF
710+ }
711+}
712+
713+func (t *proxyKillControlTransport) Close() error {
714+ if !t.locksAvailable() {
715+ return errors.New("architecture lock held during control close")
716+ }
717+ t.close.Do(func() { close(t.closed) })
718+ return nil
719+}
720+
721+type proxyKillBlockedSendTransport struct {
722+ *proxyKillControlTransport
723+ started chan struct{}
724+ release chan struct{}
725+ finished chan struct{}
726+ start sync.Once
727+ finish sync.Once
728+}
729+
730+func (t *proxyKillBlockedSendTransport) Send(ports.Frame) error {
731+ if !t.locksAvailable() {
732+ return errors.New("architecture lock held during blocked control send")
733+ }
734+ t.start.Do(func() { close(t.started) })
735+ <-t.release
736+ t.finish.Do(func() { close(t.finished) })
737+ return nil
738+}
739+
740+type proxyKillDialer struct {
741+ d *Daemon
742+ proxy *proxySession
743+ transport ports.Transport
744+ err error
745+}
746+
747+func (d proxyKillDialer) Dial(ctx context.Context) (ports.Transport, error) {
748+ if ctx == nil {
749+ return nil, errors.New("nil kill context")
750+ }
751+ checker := newProxyKillControlTransport(d.d, d.proxy)
752+ if !checker.locksAvailable() {
753+ return nil, errors.New("architecture lock held during control dial")
754+ }
755+ return d.transport, d.err
756+}
757+
758+type proxyKillDialerFactory struct {
759+ d *Daemon
760+ proxy *proxySession
761+ dialer ports.Dialer
762+ err error
763+ targets chan domain.RemoteSessionKey
764+}
765+
766+func (f *proxyKillDialerFactory) DialerForRemote(target, session string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
767+ checker := newProxyKillControlTransport(f.d, f.proxy)
768+ if !checker.locksAvailable() {
769+ return nil, errors.New("architecture lock held during dialer selection")
770+ }
771+ if f.targets != nil {
772+ f.targets <- domain.RemoteSessionKey{Host: target, Name: session}
773+ }
774+ return f.dialer, f.err
775+}
776+
777+type proxyKillCache struct {
778+ d *Daemon
779+ proxy *proxySession
780+ stores chan []ports.RemoteCatalogCacheEntry
781+}
782+
783+func (*proxyKillCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil }
784+func (c *proxyKillCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
785+ checker := newProxyKillControlTransport(c.d, c.proxy)
786+ if !checker.locksAvailable() {
787+ return errors.New("architecture lock held during cache persistence")
788+ }
789+ copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries))
790+ for i, entry := range entries {
791+ copyEntries[i] = cloneRemoteCatalogEntry(entry)
792+ }
793+ c.stores <- copyEntries
794+ return nil
795+}
796+
797+func newProxyKillHarness(t *testing.T) (*Daemon, *proxySession, *proxyLifecycleClock, *proxyKillControlTransport, *proxyKillCache) {
798+ t.Helper()
799+ clock := newProxyLifecycleClock()
800+ d, proxy, _ := newRegisteredLifecycleProxy(t, clock)
801+ control := newProxyKillControlTransport(d, proxy)
802+ factory := &proxyKillDialerFactory{d: d, proxy: proxy, targets: make(chan domain.RemoteSessionKey, 1)}
803+ factory.dialer = proxyKillDialer{d: d, proxy: proxy, transport: control}
804+ d.remoteDialerFactory = factory
805+ d.remoteTransportMode = ports.RemoteTransportUDP
806+ cache := &proxyKillCache{d: d, proxy: proxy, stores: make(chan []ports.RemoteCatalogCacheEntry, 2)}
807+ d.remoteCatalogCache = cache
808+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
809+ Host: proxy.key.Host, FetchedAt: clock.Now(), Sessions: []ports.RemoteCatalogSession{
810+ {Name: proxy.key.Name, State: "running"}, {Name: "other", State: "running"},
811+ },
812+ }})
813+ return d, proxy, clock, control, cache
814+}
815+
816+func proxyKillGeneration(p *proxySession) uint64 {
817+ p.mu.Lock()
818+ defer p.mu.Unlock()
819+ return p.generation
820+}
821+
822+func requireProxyKillState(t *testing.T, d *Daemon, proxy *proxySession, present bool) {
823+ t.Helper()
824+ d.mu.Lock()
825+ require.Equal(t, present, d.sessions[proxy.id] == proxy)
826+ d.mu.Unlock()
827+ d.remoteCatalog.mu.Lock()
828+ entry := d.remoteCatalog.cache[proxy.key.Host]
829+ d.remoteCatalog.mu.Unlock()
830+ found := false
831+ for _, session := range entry.Sessions {
832+ found = found || session.Name == proxy.key.Name
833+ }
834+ require.Equal(t, present, found)
835+}
836+
837+func TestProxyKillEOFSuccessRemovesExactLiveAndCacheWithoutLockedIO(t *testing.T) {
838+ d, proxy, _, control, cache := newProxyKillHarness(t)
839+ proxy.mu.Lock()
840+ liveTransport, ok := proxy.transport.(*proxyLifecycleTransport)
841+ liveGeneration := proxy.generation
842+ proxy.mu.Unlock()
843+ require.True(t, ok)
844+ control.recv <- proxyKillRecv{err: fmt.Errorf("ssh stream closed: %w", io.EOF)}
845+
846+ require.NoError(t, d.killRemoteProxy(context.Background(), proxy, liveGeneration))
847+ frame := awaitTestValue(t, control.sent, "remote control kill was not sent")
848+ require.Equal(t, ports.MsgKill, frame.Type)
849+ kill, err := ports.UnmarshalKill(frame.Payload)
850+ require.NoError(t, err)
851+ require.Equal(t, ports.Kill{Name: proxy.key.Name}, kill)
852+ requireProxyKillState(t, d, proxy, false)
853+ awaitTestCompletion(t, liveTransport.closed, "successful remote kill did not close the exact live transport generation")
854+ require.True(t, liveTransport.unlocked, "exact live transport close ran under an architecture lock")
855+ proxy.mu.Lock()
856+ require.Greater(t, proxy.generation, liveGeneration)
857+ require.Same(t, liveTransport, proxy.transport, "teardown must not substitute a different link generation before closing it")
858+ proxy.mu.Unlock()
859+ stored := awaitTestValue(t, cache.stores, "successful remote kill did not persist cache removal")
860+ require.Len(t, stored, 1)
861+ require.Equal(t, []ports.RemoteCatalogSession{{Name: "other", State: "running"}}, stored[0].Sessions)
862+}
863+
864+func TestProxyKillFailuresPreserveLiveAndCache(t *testing.T) {
865+ remoteFailure := ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrInternal, Text: "no"})}
866+ tests := []struct {
867+ name string
868+ setup func(*Daemon, *proxySession, *proxyKillControlTransport)
869+ }{
870+ {name: "dialer selection", setup: func(d *Daemon, proxy *proxySession, _ *proxyKillControlTransport) {
871+ d.remoteDialerFactory = &proxyKillDialerFactory{d: d, proxy: proxy, err: errors.New("factory")}
872+ }},
873+ {name: "dial", setup: func(d *Daemon, proxy *proxySession, _ *proxyKillControlTransport) {
874+ d.remoteDialerFactory = &proxyKillDialerFactory{d: d, proxy: proxy, dialer: proxyKillDialer{d: d, proxy: proxy, err: errors.New("dial")}}
875+ }},
876+ {name: "send", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
877+ control.sendErr = errors.New("send")
878+ }},
879+ {name: "receive error", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
880+ control.recv <- proxyKillRecv{err: errors.New("truncated control reply")}
881+ }},
882+ {name: "remote error", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
883+ control.recv <- proxyKillRecv{frame: remoteFailure}
884+ }},
885+ {name: "unexpected reply", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
886+ control.recv <- proxyKillRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
887+ }},
888+ }
889+ for _, test := range tests {
890+ t.Run(test.name, func(t *testing.T) {
891+ d, proxy, _, control, cache := newProxyKillHarness(t)
892+ test.setup(d, proxy, control)
893+ require.Error(t, d.killRemoteProxy(context.Background(), proxy, proxyKillGeneration(proxy)))
894+ requireProxyKillState(t, d, proxy, true)
895+ select {
896+ case <-cache.stores:
897+ t.Fatal("failed remote kill persisted a cache mutation")
898+ default:
899+ }
900+ })
901+ }
902+}
903+
904+func TestProxyKillTimeoutPreservesState(t *testing.T) {
905+ d, proxy, clock, _, cache := newProxyKillHarness(t)
906+ done := make(chan error, 1)
907+ go func() { done <- d.killRemoteProxy(context.Background(), proxy, proxyKillGeneration(proxy)) }()
908+ timer := awaitTestValue(t, clock.timers, "remote kill did not arm a bounded timeout")
909+ require.Equal(t, proxyKillTimeout, timer.delay)
910+ timer.fire()
911+ require.ErrorIs(t, awaitTestValue(t, done, "remote kill timeout did not unblock receive"), errProxyKillTimeout)
912+ requireProxyKillState(t, d, proxy, true)
913+ select {
914+ case <-cache.stores:
915+ t.Fatal("timed out remote kill persisted a cache mutation")
916+ default:
917+ }
918+}
919+
920+func TestProxyKillBlockedSendReturnsAtBoundWithoutMutatingState(t *testing.T) {
921+ d, proxy, clock, control, cache := newProxyKillHarness(t)
922+ blocked := &proxyKillBlockedSendTransport{
923+ proxyKillControlTransport: control,
924+ started: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{}),
925+ }
926+ defer func() {
927+ close(blocked.release)
928+ awaitTestCompletion(t, blocked.finished, "abandoned send did not finish after test release")
929+ }()
930+ d.remoteDialerFactory = &proxyKillDialerFactory{
931+ d: d, proxy: proxy,
932+ dialer: proxyKillDialer{d: d, proxy: proxy, transport: blocked},
933+ }
934+
935+ d.mu.Lock()
936+ beforeLive := d.sessions[proxy.id]
937+ beforeClosing := d.closing
938+ d.mu.Unlock()
939+ proxy.mu.Lock()
940+ beforeGeneration, beforeExpired, beforeWarm, beforeTransport := proxy.generation, proxy.expired, proxy.warm, proxy.transport
941+ proxy.mu.Unlock()
942+ d.remoteCatalog.mu.Lock()
943+ beforeCache := cloneRemoteCatalogEntry(d.remoteCatalog.cache[proxy.key.Host])
944+ d.remoteCatalog.mu.Unlock()
945+
946+ done := make(chan error, 1)
947+ go func() { done <- d.killRemoteProxy(context.Background(), proxy, beforeGeneration) }()
948+ awaitTestCompletion(t, blocked.started, "remote kill did not enter adversarial Send")
949+ timer := awaitTestValue(t, clock.timers, "remote kill did not arm a bounded timeout")
950+ require.Equal(t, proxyKillTimeout, timer.delay)
951+ timer.fire()
952+ require.ErrorIs(t, awaitTestValue(t, done, "remote kill waited for Send after its bound"), errProxyKillTimeout)
953+
954+ d.mu.Lock()
955+ require.Same(t, beforeLive, d.sessions[proxy.id])
956+ require.Equal(t, beforeClosing, d.closing)
957+ d.mu.Unlock()
958+ proxy.mu.Lock()
959+ require.Equal(t, beforeGeneration, proxy.generation)
960+ require.Equal(t, beforeExpired, proxy.expired)
961+ require.Same(t, beforeWarm, proxy.warm)
962+ require.Same(t, beforeTransport, proxy.transport)
963+ proxy.mu.Unlock()
964+ d.remoteCatalog.mu.Lock()
965+ require.Equal(t, beforeCache, d.remoteCatalog.cache[proxy.key.Host])
966+ d.remoteCatalog.mu.Unlock()
967+ select {
968+ case <-cache.stores:
969+ t.Fatal("blocked remote kill persisted a cache mutation")
970+ default:
971+ }
972+ select {
973+ case <-blocked.finished:
974+ t.Fatal("adversarial Send unexpectedly returned when transport closed")
975+ default:
976+ }
977+}
978+
979+func TestProxyKillStaleSuccessPreservesReplacementGeneration(t *testing.T) {
980+ tests := []struct {
981+ name string
982+ win func(*Daemon, *proxySession)
983+ }{
984+ {name: "reattach generation", win: func(d *Daemon, proxy *proxySession) {
985+ d.mu.Lock()
986+ proxy.sessionCore.mu.Lock()
987+ proxy.mu.Lock()
988+ proxy.generation++
989+ proxy.mu.Unlock()
990+ proxy.sessionCore.mu.Unlock()
991+ d.mu.Unlock()
992+ }},
993+ {name: "replacement pointer", win: func(d *Daemon, proxy *proxySession) {
994+ replacement, err := newProxySession(proxy.key, defaultSize)
995+ require.NoError(t, err)
996+ d.mu.Lock()
997+ d.sessions[proxy.id] = replacement
998+ d.mu.Unlock()
999+ }},
1000+ }
1001+ for _, test := range tests {
1002+ t.Run(test.name, func(t *testing.T) {
1003+ d, proxy, _, control, cache := newProxyKillHarness(t)
1004+ generation := proxyKillGeneration(proxy)
1005+ done := make(chan error, 1)
1006+ go func() { done <- d.killRemoteProxy(context.Background(), proxy, generation) }()
1007+ _ = awaitTestValue(t, control.sent, "remote kill did not reach the control transport")
1008+ awaitTestCompletion(t, control.recvLocksChecked, "remote kill did not verify unlocked architecture before generation replacement")
1009+ test.win(d, proxy)
1010+ control.recv <- proxyKillRecv{err: io.EOF}
1011+ require.ErrorIs(t, awaitTestValue(t, done, "stale remote kill did not finish"), errProxyKillGeneration)
1012+ d.remoteCatalog.mu.Lock()
1013+ entry := d.remoteCatalog.cache[proxy.key.Host]
1014+ d.remoteCatalog.mu.Unlock()
1015+ require.Len(t, entry.Sessions, 2, "stale success removed cached discovery")
1016+ select {
1017+ case <-cache.stores:
1018+ t.Fatal("stale remote success persisted a cache mutation")
1019+ default:
1020+ }
1021+ })
1022+ }
1023+}
1024+
1025+func TestProxyKillCurrentLinkDetachedCanRaceControlEOF(t *testing.T) {
1026+ d, proxy, _, control, _ := newProxyKillHarness(t)
1027+ generation := proxyKillGeneration(proxy)
1028+ done := make(chan error, 1)
1029+ go func() { done <- d.killRemoteProxy(context.Background(), proxy, generation) }()
1030+ _ = awaitTestValue(t, control.sent, "remote kill did not reach the control transport")
1031+ result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{
1032+ Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}),
1033+ })
1034+ require.NoError(t, err)
1035+ require.Equal(t, proxyLinkStop, result)
1036+ control.recv <- proxyKillRecv{err: io.EOF}
1037+ require.NoError(t, awaitTestValue(t, done, "remote kill did not accept EOF after current-link detach"))
1038+ requireProxyKillState(t, d, proxy, false)
1039+}
1040+
1041+func TestProxyKillConfirmationRequiresExactRemoteDisplay(t *testing.T) {
1042+ p, release := newBlockingPTY(t)
1043+ defer release()
1044+ d, local, ac, _ := newManualSessionWithPTYs(t, p)
1045+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
1046+ require.NoError(t, err)
1047+ d.mu.Lock()
1048+ require.True(t, d.registerSessionLocked(proxy))
1049+ d.mu.Unlock()
1050+
1051+ target := picker.Target{Session: proxy.id, RemoteKey: &proxy.key, TabIndex: -1}
1052+ require.True(t, d.enterRemoteKillConfirmation(local, ac, target))
1053+ ac.overlays.promptMu.Lock()
1054+ require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title())
1055+ submit := ac.overlays.promptTransitionSubmit
1056+ ac.overlays.promptMu.Unlock()
1057+ require.Error(t, submit("work", attachmentRoleToken{}))
1058+ d.mu.Lock()
1059+ require.Same(t, proxy, d.sessions[proxy.id])
1060+ d.mu.Unlock()
1061+}
1062+
1063+func TestProxyKillConfirmationWorksWhileAttachedToProxyAndCancelsWithoutIO(t *testing.T) {
1064+ d, proxy, ac, _, _, _, _ := newProxyRoleDetachFixture(t)
1065+ target := picker.Target{Session: proxy.id, RemoteKey: &proxy.key, TabIndex: -1}
1066+ require.True(t, d.enterRemoteKillConfirmation(proxy, ac, target))
1067+ d.handlePromptInput(ac, []byte("\x03"))
1068+ require.False(t, ac.overlays.promptActive())
1069+ d.mu.Lock()
1070+ require.Same(t, proxy, d.sessions[proxy.id])
1071+ d.mu.Unlock()
1072+}
1073+
1074+type proxyConstructionDialer struct{ transport ports.Transport }
1075+
1076+func (d proxyConstructionDialer) Dial(context.Context) (ports.Transport, error) {
1077+ return d.transport, nil
1078+}
1079+
1080+type proxyConstructionFactory struct {
1081+ mu sync.Mutex
1082+ transports []ports.Transport
1083+ calls chan int
1084+ secondAttempt chan struct{}
1085+ releaseSecond chan struct{}
1086+ secondCallOnce sync.Once
1087+}
1088+
1089+func newProxyConstructionFactory(transports ...ports.Transport) *proxyConstructionFactory {
1090+ return &proxyConstructionFactory{
1091+ transports: transports, calls: make(chan int, len(transports)+1),
1092+ secondAttempt: make(chan struct{}), releaseSecond: make(chan struct{}),
1093+ }
1094+}
1095+
1096+func (f *proxyConstructionFactory) DialerForRemote(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error) {
1097+ f.mu.Lock()
1098+ call := len(f.transports)
1099+ for i := range f.transports {
1100+ if f.transports[i] != nil {
1101+ call = i
1102+ break
1103+ }
1104+ }
1105+ if call == len(f.transports) {
1106+ f.mu.Unlock()
1107+ return nil, errors.New("unexpected extra proxy construction")
1108+ }
1109+ transport := f.transports[call]
1110+ f.transports[call] = nil
1111+ f.mu.Unlock()
1112+ f.calls <- call + 1
1113+ if call == 1 {
1114+ f.secondCallOnce.Do(func() { close(f.secondAttempt) })
1115+ <-f.releaseSecond
1116+ }
1117+ return proxyConstructionDialer{transport: transport}, nil
1118+}
1119+
1120+func (f *proxyConstructionFactory) callCount() int {
1121+ f.mu.Lock()
1122+ defer f.mu.Unlock()
1123+ count := 0
1124+ for _, transport := range f.transports {
1125+ if transport == nil {
1126+ count++
1127+ }
1128+ }
1129+ return count
1130+}
1131+
1132+type proxyConstructionWaitContext struct {
1133+ context.Context
1134+ observed chan struct{}
1135+ once sync.Once
1136+}
1137+
1138+func (c *proxyConstructionWaitContext) Done() <-chan struct{} {
1139+ c.once.Do(func() { close(c.observed) })
1140+ return c.Context.Done()
1141+}
1142+
1143+type proxyOpenResult struct {
1144+ proxy *proxySession
1145+ err error
1146+}
1147+
1148+func openProxyForTest(d *Daemon, ctx context.Context, key domain.RemoteSessionKey) <-chan proxyOpenResult {
1149+ result := make(chan proxyOpenResult, 1)
1150+ go func() {
1151+ proxy, err := d.openProxySession(ctx, key, defaultSize)
1152+ result <- proxyOpenResult{proxy: proxy, err: err}
1153+ }()
1154+ return result
1155+}
1156+
1157+func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
1158+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1159+ first := newProxyTestTransport()
1160+ second := newProxyTestTransport()
1161+ second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityProxied)}
1162+ second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1163+ factory := newProxyConstructionFactory(first, second)
1164+ d := newProxyTestDaemon(t, factory, nil)
1165+
1166+ leader := openProxyForTest(d, context.Background(), key)
1167+ require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
1168+ _ = requireProxyHello(t, first)
1169+
1170+ waitCtx, cancelWait := context.WithCancel(context.Background())
1171+ defer cancelWait()
1172+ observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})}
1173+ waiter := openProxyForTest(d, observed, key)
1174+
1175+ secondStarted := false
1176+ select {
1177+ case <-observed.observed:
1178+ // The waiter reached the cancellable construction reservation.
1179+ case <-factory.secondAttempt:
1180+ // This is the adversarial pre-fix path: the later remote IntentAttach
1181+ // would replace the leader before its otherwise-valid reply arrives.
1182+ secondStarted = true
1183+ close(factory.releaseSecond)
1184+ _ = requireProxyHello(t, second)
1185+ }
1186+
1187+ first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
1188+ first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1189+ leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
1190+ waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish")
1191+ require.NoError(t, leaderResult.err)
1192+ require.NoError(t, waiterResult.err)
1193+ require.False(t, secondStarted, "a second same-key IntentAttach handshake started")
1194+ require.Equal(t, 1, factory.callCount())
1195+ require.Same(t, leaderResult.proxy, waiterResult.proxy, "all callers must receive the exact registered winner")
1196+ d.mu.Lock()
1197+ require.Same(t, leaderResult.proxy, d.sessions[key.ID()])
1198+ d.mu.Unlock()
1199+ stopProxy(t, leaderResult.proxy)
1200+}
1201+
1202+func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
1203+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1204+ first := newProxyTestTransport()
1205+ second := newProxyTestTransport()
1206+ factory := newProxyConstructionFactory(first, second)
1207+ d := newProxyTestDaemon(t, factory, nil)
1208+
1209+ leader := openProxyForTest(d, context.Background(), key)
1210+ require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
1211+ _ = requireProxyHello(t, first)
1212+
1213+ base, cancel := context.WithCancel(context.Background())
1214+ observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})}
1215+ waiter := openProxyForTest(d, observed, key)
1216+ select {
1217+ case <-observed.observed:
1218+ case <-factory.secondAttempt:
1219+ close(factory.releaseSecond)
1220+ t.Fatal("cancelable waiter started another same-key handshake")
1221+ }
1222+ cancel()
1223+ waiterResult := awaitTestValue(t, waiter, "canceled waiter did not finish")
1224+ require.ErrorIs(t, waiterResult.err, context.Canceled)
1225+ require.Nil(t, waiterResult.proxy)
1226+ require.Equal(t, 1, factory.callCount())
1227+
1228+ first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
1229+ first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1230+ leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
1231+ require.NoError(t, leaderResult.err)
1232+ stopProxy(t, leaderResult.proxy)
1233+}
1234+
161235 type warmHookClock struct {
171236 timers chan *signalTimer
181237
@@ -290,3 +1509,115 @@ func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) {
2901509 awaitTestCompletion(t, current.done, "current warm timer never ran to completion")
2911510 require.False(t, proxyRegistered(d, p))
2921511 }
1512+
1513+func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedClient, *closeTrackingTransport, *warmHookClock, attachmentRoleToken, *renderCoordinator) {
1514+ t.Helper()
1515+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1516+ clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
1517+ d.clock = clock
1518+ clientTransport := &closeTrackingTransport{}
1519+ ac.replaceTransport(clientTransport)
1520+ localCoordinator := d.attachCoordinator(local, nil, ac, true)
1521+ localToken := attachmentToken(local, ac, clientTransport)
1522+ localToken.lease = localCoordinator.attachmentLease(ac)
1523+ proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})
1524+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
1525+ source: local, target: proxy, next: ac,
1526+ expectedRole: attachmentActive, targetRole: attachmentActive,
1527+ expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
1528+ action: "proxy detach fixture", ready: true,
1529+ })
1530+ require.NoError(t, err)
1531+ return d, proxy, ac, clientTransport, clock, transition.published, proxy.coordinator.Load()
1532+}
1533+
1534+func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
1535+ tests := []struct {
1536+ name string
1537+ detach func(*Daemon, attachmentRoleToken, ports.Transport) bool
1538+ }{
1539+ {
1540+ name: "explicit",
1541+ detach: func(d *Daemon, token attachmentRoleToken, _ ports.Transport) bool {
1542+ effect, admitted := token.ac.beginRoleEffect(token)
1543+ require.True(t, admitted)
1544+ token.effect = effect
1545+ return d.clientGoneForRole(token, true)
1546+ },
1547+ },
1548+ {
1549+ name: "send error",
1550+ detach: func(d *Daemon, token attachmentRoleToken, failed ports.Transport) bool {
1551+ d.detachOnRoleSendError(token, failed)
1552+ return token.ac.currentAttachmentSession() == nil
1553+ },
1554+ },
1555+ }
1556+ for _, tt := range tests {
1557+ t.Run(tt.name, func(t *testing.T) {
1558+ d, proxy, ac, tr, clock, token, coordinator := newProxyRoleDetachFixture(t)
1559+ require.True(t, tt.detach(d, token, tr))
1560+ require.Nil(t, ac.currentAttachmentSession())
1561+ require.True(t, tr.Closed())
1562+ require.Nil(t, coordinator.attachmentLease(ac), "coordinator must retire the detached client")
1563+ var warmTimers []*signalTimer
1564+ for len(warmTimers) == 0 {
1565+ timer := awaitTestValue(t, clock.timers, "proxy detach did not arm a warm timer")
1566+ if timer.duration == proxyWarmDuration {
1567+ warmTimers = append(warmTimers, timer)
1568+ }
1569+ }
1570+ select {
1571+ case timer := <-clock.timers:
1572+ if timer.duration == proxyWarmDuration {
1573+ warmTimers = append(warmTimers, timer)
1574+ }
1575+ default:
1576+ }
1577+ require.Len(t, warmTimers, 1, "proxy detach must arm exactly one warm timer")
1578+ warm, _ := proxyWarmToken(proxy)
1579+ require.NotNil(t, warm)
1580+ warm.stop()
1581+ awaitTestCompletion(t, warm.done, "warm timer did not stop")
1582+ })
1583+ }
1584+}
1585+
1586+func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
1587+ tests := []struct {
1588+ name string
1589+ stale func(*attachedClient, *attachmentRoleToken) *closeTrackingTransport
1590+ }{
1591+ {
1592+ name: "transport",
1593+ stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport {
1594+ current := &closeTrackingTransport{}
1595+ ac.replaceTransport(current)
1596+ return current
1597+ },
1598+ },
1599+ {
1600+ name: "role generation",
1601+ stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport {
1602+ ac.roleGeneration.Add(1)
1603+ return nil
1604+ },
1605+ },
1606+ }
1607+ for _, tt := range tests {
1608+ t.Run(tt.name, func(t *testing.T) {
1609+ d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t)
1610+ current := tt.stale(ac, &token)
1611+ d.detachOnRoleSendError(token, failed)
1612+ require.Same(t, proxy, ac.currentAttachmentSession())
1613+ if current != nil {
1614+ require.False(t, current.Closed(), "stale transport error closed the rebound client")
1615+ }
1616+ select {
1617+ case <-clock.timers:
1618+ t.Fatal("stale proxy detach armed a warm timer")
1619+ default:
1620+ }
1621+ })
1622+ }
1623+}
M internal/usecase/daemon/remote_picker.go +2 -2
@@ -122,7 +122,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
122122 RemoteAvailability: remotePickerAvailability(status),
123123 RemoteDetail: detail,
124124 ConnectOnly: true,
125- RemoteAttachReady: false,
125+ RemoteAttachReady: status != remoteHostVersionMismatch,
126126 CannotAcceptMoves: true,
127127 }
128128 }
@@ -155,7 +155,7 @@ func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker
155155 view.RemoteAvailability = picker.RemoteFresh
156156 view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
157157 view.ConnectOnly = true
158- view.RemoteAttachReady = false
158+ view.RemoteAttachReady = true
159159 if snap.expired {
160160 view.RemoteAvailability = picker.RemoteStale
161161 view.RemoteDetail = "expired"
M internal/usecase/daemon/remote_picker_test.go +12 -11
@@ -759,7 +759,7 @@ func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.
759759 owner.overlays.pickerMu.Unlock()
760760 require.True(t, ok)
761761 require.Equal(t, &key, before.RemoteKey)
762- require.False(t, beforeSelectable, "cache-derived remote row must stay gated until proxy activation lands")
762+ require.True(t, beforeSelectable, "ownership activation makes a compatible cached row selectable")
763763
764764 proxy := &proxySession{
765765 sessionCore: sessionCore{id: key.ID(), name: key.Display()},
@@ -779,7 +779,7 @@ func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.
779779 require.True(t, ok)
780780 require.Equal(t, before.Session, after.Session)
781781 require.Equal(t, before.RemoteKey, after.RemoteKey)
782- require.False(t, selectable, "cache-to-live upgrade must preserve the proxy activation gate")
782+ require.True(t, selectable, "completed ownership must keep the upgraded proxy row selectable")
783783 }
784784
785785 func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
@@ -951,14 +951,15 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
951951 ac.overlays.pickerMu.Unlock()
952952 }
953953
954-func TestRemoteRowsRemainNonSelectableDuringRefreshPhase(t *testing.T) {
954+func TestRemoteRowsBecomeSelectableOnlyAfterOwnershipCompletion(t *testing.T) {
955955 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
956- for _, availability := range []picker.RemoteAvailability{picker.RemoteCached, picker.RemoteFresh, picker.RemoteStale, picker.RemoteVersionMismatch} {
957- model := picker.New([]picker.SessionView{{
958- ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: availability,
959- ConnectOnly: true, RemoteAttachReady: false,
960- }}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
961- _, selectable := model.Selected()
962- require.False(t, selectable, "availability %d became remotely attachable in phase 5", availability)
963- }
956+ for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} {
957+ view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, status, time.Unix(1, 0))
958+ _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
959+ require.True(t, selectable, "status %d is ready after ownership completion", status)
960+ }
961+ mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{})
962+ require.False(t, mismatch.RemoteAttachReady)
963+ _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
964+ require.False(t, selectable)
964965 }
M internal/usecase/daemon/render_coordinator_test.go +16 -9
@@ -1084,10 +1084,11 @@ var producerFiles = []string{
10841084
10851085 func TestProducerInvalidations(t *testing.T) {
10861086 cases := []struct {
1087- file string
1088- name string
1089- tabs int
1090- run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
1087+ file string
1088+ name string
1089+ tabs int
1090+ producer string
1091+ run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
10911092 }{
10921093 {
10931094 file: "attention.go",
@@ -1125,11 +1126,14 @@ func TestProducerInvalidations(t *testing.T) {
11251126 },
11261127 },
11271128 {
1128- file: "input.go",
1129- name: "switch tab key action",
1130- tabs: 2,
1129+ file: "input.go",
1130+ name: "proxied jump attention switches a local tab",
1131+ tabs: 2,
1132+ producer: "input.go",
11311133 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1132- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
1134+ ac.proxied = true
1135+ sess.tabs[1].attention = true
1136+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil)
11331137 require.Equal(t, 1, activeTabIndex(sess))
11341138 },
11351139 },
@@ -1222,7 +1226,10 @@ func TestProducerInvalidations(t *testing.T) {
12221226
12231227 tc.run(t, d, sess, ac)
12241228
1225- awaitInvalidation(t, invs)
1229+ inv := awaitInvalidation(t, invs)
1230+ if tc.producer != "" {
1231+ require.Equal(t, tc.producer, inv.producer)
1232+ }
12261233 requireNoInvalidation(t, invs)
12271234 requireNoCoordinatorOutputFrame(t, sends)
12281235 })
M internal/usecase/daemon/session.go +11 -4
@@ -156,20 +156,27 @@ type tab struct {
156156 // what it has actually seen). sendMu serialises the two senders — the render
157157 // coordinator and the connection handler — so the transport's single-writer
158158
159-func (d *Daemon) touchMRU(sess *session) {
160- if d == nil || sess == nil {
159+func (d *Daemon) touchMRU(entry attachmentSession) {
160+ if d == nil || entry == nil || entry.core() == nil {
161161 return
162162 }
163+ core := entry.core()
163164 seq := d.mruSeq.Add(1)
164165 for {
165- old := sess.mruAt.Load()
166+ old := core.mruAt.Load()
166167 if old >= seq {
167168 return
168169 }
169- if sess.mruAt.CompareAndSwap(old, seq) {
170+ if core.mruAt.CompareAndSwap(old, seq) {
170171 break
171172 }
172173 }
174+ // Proxy recency is live derived state only. Local named sessions retain the
175+ // existing catalogue metadata publication semantics.
176+ sess, ok := localSession(entry)
177+ if !ok {
178+ return
179+ }
173180 sess.mu.Lock()
174181 if !sess.ephemeral {
175182 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
M internal/usecase/daemon/session_meta_test.go +58 -19
@@ -106,36 +106,76 @@ func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) {
106106 require.False(t, ac.sessionMetaSent)
107107 }
108108
109-func TestProxiedAttachedCommandRejectionPreservesRequestID(t *testing.T) {
109+func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
110110 tests := []struct {
111- name string
112- request ports.CommandRequest
113- wantCode uint16
114- wantText string
111+ name string
112+ request ports.CommandRequest
113+ ok bool
114+ code uint16
115+ text string
115116 }{
116117 {
117118 name: "valid attached command",
118119 request: ports.CommandRequest{
119- Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "new-tab",
120+ Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "next-tab",
120121 },
121- wantCode: ports.ErrNotScriptable,
122- wantText: "attached command relay is not enabled",
122+ ok: true,
123123 },
124124 {
125125 name: "target override is rejected",
126126 request: ports.CommandRequest{
127- Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "new-tab", TargetSession: "other",
127+ Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "next-tab", TargetSession: "other",
128128 },
129- wantCode: ports.ErrNotScriptable,
130- wantText: "attached command relay is not enabled",
129+ code: ports.ErrInvalidCommandArgs,
130+ text: "attached commands cannot override their active session target",
131131 },
132132 {
133133 name: "missing attached flag is rejected",
134134 request: ports.CommandRequest{
135- Version: ports.ProtocolVersion, RequestID: 43, Slug: "new-tab",
135+ Version: ports.ProtocolVersion, RequestID: 43, Slug: "next-tab",
136136 },
137- wantCode: ports.ErrNotScriptable,
138- wantText: "attached command relay is not enabled",
137+ code: ports.ErrInvalidCommandArgs,
138+ text: "attached command flag is required",
139+ },
140+ {
141+ name: "unsupported version is rejected",
142+ request: ports.CommandRequest{
143+ Version: ports.ProtocolVersion + 1, RequestID: 44, Attached: true, Slug: "next-tab",
144+ },
145+ code: ports.ErrInvalidCommandArgs,
146+ text: "unsupported command protocol version",
147+ },
148+ {
149+ name: "missing request id is rejected",
150+ request: ports.CommandRequest{
151+ Version: ports.ProtocolVersion, Attached: true, Slug: "next-tab",
152+ },
153+ code: ports.ErrInvalidCommandArgs,
154+ text: "command request id is required",
155+ },
156+ {
157+ name: "self target is rejected",
158+ request: ports.CommandRequest{
159+ Version: ports.ProtocolVersion, RequestID: 45, Attached: true, Self: true, Slug: "next-tab",
160+ },
161+ code: ports.ErrInvalidCommandArgs,
162+ text: "attached commands cannot target themselves",
163+ },
164+ {
165+ name: "unknown slug is rejected",
166+ request: ports.CommandRequest{
167+ Version: ports.ProtocolVersion, RequestID: 46, Attached: true, Slug: "missing",
168+ },
169+ code: ports.ErrUnknownCommand,
170+ text: "unknown command: missing",
171+ },
172+ {
173+ name: "local detach is not scriptable",
174+ request: ports.CommandRequest{
175+ Version: ports.ProtocolVersion, RequestID: 47, Attached: true, Slug: "detach",
176+ },
177+ code: ports.ErrNotScriptable,
178+ text: "detach is owned by the local proxy daemon",
139179 },
140180 }
141181 for _, tt := range tests {
@@ -149,14 +189,13 @@ func TestProxiedAttachedCommandRejectionPreservesRequestID(t *testing.T) {
149189 token := sess.attachmentToken(ac, ac.transport())
150190 require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
151191
152- reply := <-sends
153- require.Equal(t, ports.MsgCommandResult, reply.Type)
192+ reply := awaitFrame(t, sends, ports.MsgCommandResult)
154193 result, err := ports.UnmarshalCommandResult(reply.Payload)
155194 require.NoError(t, err)
156195 require.Equal(t, tt.request.RequestID, result.RequestID)
157- require.False(t, result.OK)
158- require.Equal(t, tt.wantCode, result.Code)
159- require.Equal(t, tt.wantText, result.Text)
196+ require.Equal(t, tt.ok, result.OK)
197+ require.Equal(t, tt.code, result.Code)
198+ require.Equal(t, tt.text, result.Text)
160199 })
161200 }
162201 }
M internal/usecase/daemon/snatched_cleanup.go +9 -0
@@ -1,5 +1,7 @@
11 package daemon
22
3+import "github.com/bnema/vev/internal/ports"
4+
35 // removeSnatchedAttachment removes exactly one role generation and transport
46 // from session routing. It never touches the active attachment or closes a
57 // transport, allowing callers that already interrupted a blocked send to avoid
@@ -176,6 +178,13 @@ func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool {
176178 // captured transport; an idle healthy snatched transport remains connected and
177179 // receives a dependency-free reset panel.
178180 func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
181+ if terminal := result.terminalDisplaced; terminal.ac != nil && terminal.transport.transport != nil {
182+ terminal.ac.clearSnatchedInput()
183+ d.unregisterPreview(terminal.ac)
184+ terminal.ac.clearPreviousSession()
185+ terminal.ac.clearCaptureFrames()
186+ d.notifyDetachedSnapshotAsync(terminal, ports.ReasonReplaced)
187+ }
179188 if token := result.displaced; token.ac != nil && token.transport.transport != nil {
180189 blockedRender := result.displacedInterrupted
181190 if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
M internal/usecase/keys/router.go +12 -6
@@ -48,10 +48,11 @@ const (
4848 )
4949
5050 // Handler receives router outputs. Forward is called only for bytes that should
51-// reach the PTY; Action is called for intercepted bindings.
51+// reach the PTY; Action receives intercepted bindings and the exact consumed
52+// terminal bytes. Both byte slices are owned copies.
5253 type Handler interface {
5354 Forward([]byte)
54- Action(Action)
55+ Action(Action, []byte)
5556 }
5657
5758 // Router implements vev's Alt/ESC input routing. It retains a trailing ESC for
@@ -151,7 +152,7 @@ func (r *Router) route(data []byte, h Handler) {
151152 }
152153 if action, size, ok, partial := r.altArrowCSI(remaining); ok {
153154 flush()
154- h.Action(action)
155+ r.action(action, data[i:i+1+size], h)
155156 i += 1 + size
156157 continue
157158 } else if partial {
@@ -167,7 +168,7 @@ func (r *Router) route(data []byte, h Handler) {
167168 }
168169 if action, size, ok := r.binding(remaining); ok {
169170 flush()
170- h.Action(action)
171+ r.action(action, data[i:i+1+size], h)
171172 i += 1 + size
172173 continue
173174 }
@@ -184,7 +185,7 @@ func (r *Router) route(data []byte, h Handler) {
184185
185186 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int {
186187 if action, size, ok, partial := r.altArrowCSI(data); ok {
187- h.Action(action)
188+ r.action(action, append([]byte{ESC}, data[:size]...), h)
188189 return size
189190 } else if partial {
190191 r.retainESC(h, data)
@@ -192,7 +193,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler)
192193 }
193194 next := data[0]
194195 if action, size, ok := r.binding(data); ok {
195- h.Action(action)
196+ r.action(action, append([]byte{ESC}, data[:size]...), h)
196197 return size
197198 }
198199 if partialUTF8Rune(data) {
@@ -279,6 +280,11 @@ func (r *Router) forward(data []byte, h Handler) {
279280 h.Forward(cp)
280281 }
281282
283+func (r *Router) action(action Action, raw []byte, h Handler) {
284+ cp := append([]byte(nil), raw...)
285+ h.Action(action, cp)
286+}
287+
282288 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
283289
284290 func (r *Router) currentBindings() *Bindings {
M internal/usecase/keys/router_test.go +41 -4
@@ -33,9 +33,10 @@ func (t *fakeTimer) Stop() bool { t.stopped = true; return true
3333 func (t *fakeTimer) fire() { t.ch <- time.Time{} }
3434
3535 type captureHandler struct {
36- forwards [][]byte
37- actions []Action
38- notify chan struct{}
36+ forwards [][]byte
37+ actions []Action
38+ actionRaw [][]byte
39+ notify chan struct{}
3940 }
4041
4142 func (h *captureHandler) Forward(data []byte) {
@@ -44,7 +45,43 @@ func (h *captureHandler) Forward(data []byte) {
4445 h.notify <- struct{}{}
4546 }
4647 }
47-func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) }
48+func (h *captureHandler) Action(a Action, raw []byte) {
49+ h.actions = append(h.actions, a)
50+ h.actionRaw = append(h.actionRaw, append([]byte(nil), raw...))
51+}
52+
53+func TestRouterRawActionPreservesExactConsumedBytes(t *testing.T) {
54+ custom := DefaultBindings().clone()
55+ custom.bind(ActionEqualizePanes, KeySpec{altRunes: []rune{'ø'}})
56+ var bindings atomic.Pointer[Bindings]
57+ bindings.Store(custom)
58+
59+ tests := []struct {
60+ name string
61+ reads [][]byte
62+ action Action
63+ raw []byte
64+ }{
65+ {name: "single frame", reads: [][]byte{{ESC, 'j'}}, action: ActionFocusPaneDown, raw: []byte{ESC, 'j'}},
66+ {name: "delayed escape", reads: [][]byte{{ESC}, {'1'}}, action: ActionSwitchTab1, raw: []byte{ESC, '1'}},
67+ {name: "split UTF-8 custom binding", reads: [][]byte{{ESC, 0xc3}, {0xb8}}, action: ActionEqualizePanes, raw: []byte{ESC, 0xc3, 0xb8}},
68+ {name: "split Alt arrow", reads: [][]byte{{ESC, '[', '1'}, {';', '9', 'D'}}, action: ActionFocusPaneLeft, raw: []byte{ESC, '[', '1', ';', '9', 'D'}},
69+ }
70+
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ clk := &fakeClock{}
74+ h := &captureHandler{}
75+ router := NewRouter(clk, h, &bindings)
76+ for _, read := range tt.reads {
77+ router.Route(read)
78+ }
79+ require.Equal(t, []Action{tt.action}, h.actions)
80+ require.Equal(t, [][]byte{tt.raw}, h.actionRaw)
81+ require.Empty(t, h.forwards)
82+ })
83+ }
84+}
4885
4986 func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) {
5087 cases := []struct {

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