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
64 type attachmentTransitionResult struct {64 type attachmentTransitionResult struct {
65 published attachmentRoleToken65 published attachmentRoleToken
66 displaced attachmentRoleToken66 displaced attachmentRoleToken
67+ terminalDisplaced detachedAttachmentSnapshot
67 displacedInterrupted bool68 displacedInterrupted bool
68 cleanups []renderLifecycleCleanup69 cleanups []renderLifecycleCleanup
69 }70 }
@@ -233,5 +234,9 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
233 if result.published.role == attachmentActive {234 if result.published.role == attachmentActive {
234 result.published.ac.clearSnatchedInput()235 result.published.ac.clearSnatchedInput()
235 }236 }
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)
236 return result, nil241 return result, nil
237 }242 }
M internal/usecase/daemon/attachment_transition_publish.go +25 -8
@@ -85,6 +85,7 @@ type attachmentPublication struct {
85 targetCoordinator *renderCoordinator85 targetCoordinator *renderCoordinator
86 releaseCoordinators func()86 releaseCoordinators func()
87 displacedTransport transportSnapshot87 displacedTransport transportSnapshot
88+ terminalDisplaced bool
88 nextGeneration uint6489 nextGeneration uint64
89 displacedGeneration uint6490 displacedGeneration uint64
90 }91 }
@@ -176,6 +177,10 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
176 req := publication.req177 req := publication.req
177 if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next {178 if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next {
178 publication.displacedTransport = publication.old.transportSnapshot()179 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
179 }184 }
180 if publication.source != req.target {185 if publication.source != req.target {
181 if publication.source.core().client == req.next {186 if publication.source.core().client == req.next {
@@ -186,7 +191,11 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
186 if req.targetRole == attachmentActive {191 if req.targetRole == attachmentActive {
187 delete(req.target.core().snatched, req.next)192 delete(req.target.core().snatched, req.next)
188 if publication.old != nil && publication.old != req.next {193 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+ }
190 }199 }
191 req.target.core().client = req.next200 req.target.core().client = req.next
192 } else {201 } else {
@@ -195,6 +204,10 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
195 publication.nextGeneration = req.next.roleGeneration.Add(1)204 publication.nextGeneration = req.next.roleGeneration.Add(1)
196 if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {205 if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {
197 publication.displacedGeneration = publication.old.roleGeneration.Add(1)206 publication.displacedGeneration = publication.old.roleGeneration.Add(1)
207+ if publication.terminalDisplaced {
208+ publication.old.setSession(nil)
209+ publication.old.invalidateFrozenRoleCapability()
210+ }
198 }211 }
199 req.next.setSession(req.target)212 req.next.setSession(req.target)
200 }213 }
@@ -223,14 +236,18 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
223 rebase: publication.source != req.target && req.expectedRole == attachmentActive,236 rebase: publication.source != req.target && req.expectedRole == attachmentActive,
224 }237 }
225 if publication.displacedGeneration != 0 {238 if publication.displacedGeneration != 0 {
226- result.displaced = attachmentRoleToken{239+ if publication.terminalDisplaced {
227- sess: req.target,240+ result.terminalDisplaced = detachedAttachmentSnapshot{ac: publication.old, transport: publication.displacedTransport}
228- ac: publication.old,241+ } else {
229- role: attachmentSnatched,242+ result.displaced = attachmentRoleToken{
230- generation: publication.displacedGeneration,243+ sess: req.target,
231- transport: publication.displacedTransport,244+ ac: publication.old,
245+ role: attachmentSnatched,
246+ generation: publication.displacedGeneration,
247+ transport: publication.displacedTransport,
248+ }
249+ result.displacedInterrupted = req.expectedTargetTransportInterrupted
232 }250 }
233- result.displacedInterrupted = req.expectedTargetTransportInterrupted
234 }251 }
235 // Membership, currentSession, lease, generation, and both exact admission252 // Membership, currentSession, lease, generation, and both exact admission
236 // capabilities become visible as one frozen publication. The gates remain253 // capabilities become visible as one frozen publication. The gates remain
M internal/usecase/daemon/client_frame_routing.go +87 -14
@@ -1,6 +1,11 @@
1 package daemon1 package daemon
2 2
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+)
4 9
5 // runConnLoop is the per-connection input router: it pumps client messages10 // runConnLoop is the per-connection input router: it pumps client messages
6 // until detach, EOF, or a transport error. Role is resolved after every Recv so11 // 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
116 if derr != nil {121 if derr != nil {
117 return false122 return false
118 }123 }
119- // Attached commands are intentionally admitted only on the proxied124+ result := d.executeAttachedCommand(token, request)
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- }
132 if err := token.sendActiveControl(frameCommandResult(result)); err != nil {125 if err := token.sendActiveControl(frameCommandResult(result)); err != nil {
133 token.endRoleEffect()126 token.endRoleEffect()
134 d.detachOnRoleSendError(token, token.transport.transport)127 d.detachOnRoleSendError(token, token.transport.transport)
@@ -148,6 +141,86 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
148 return false141 return false
149 }142 }
150 143
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+
151 // resetActiveOutput rebases only the exact proxied role and transport admitted224 // resetActiveOutput rebases only the exact proxied role and transport admitted
152 // by the frame router. sendMu serializes the rebase with every output send;225 // by the frame router. sendMu serializes the rebase with every output send;
153 // transport revalidation under that lock rejects a link replaced while waiting.226 // 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
29 }29 }
30 30
31 func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {31 func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
32- sess, ok := localSession(token.sess)32+ if token.effect == nil {
33- if !ok || token.effect == nil {
34 return false33 return false
35 }34 }
36 token.effect.bindActionEnd(d, "detach")35 token.effect.bindActionEnd(d, "detach")
37 token.effect.End()36 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+ }
38 var parkingToken uint6449 var parkingToken uint64
39 if !explicit {50 if !explicit {
40 parkingToken = d.markParkingInFlight(sess, token.ac)51 parkingToken = d.markParkingInFlight(sess, token.ac)
@@ -116,22 +127,37 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai
116 return127 return
117 }128 }
118 expected := transportSnapshot{}129 expected := transportSnapshot{}
130+ oldTr := failed
119 if failed != nil {131 if failed != nil {
120 expected = ac.transportSnapshot()132 expected = ac.transportSnapshot()
121 if expected.transport != failed {133 if expected.transport != failed {
122 return134 return
123 }135 }
136+ } else {
137+ oldTr = ac.transport()
124 }138 }
125 if !d.detachProxyIfCurrentTransport(p, ac, expected) {139 if !d.detachProxyIfCurrentTransport(p, ac, expected) {
126 return140 return
127 }141 }
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) {
128 if rc := p.coordinator.Load(); rc != nil {149 if rc := p.coordinator.Load(); rc != nil {
129 rc.noteDetach(ac)150 rc.noteDetach(ac)
130 }151 }
131- d.unregisterPreview(ac)152+ if ac.overlays != nil {
153+ d.closePicker(ac)
154+ }
132 ac.clearPreviousSession()155 ac.clearPreviousSession()
156+ if explicit {
157+ d.boundedSend(ac, frameDetached(ports.ReasonDetach))
158+ }
133 _ = ac.closeCapturedTransport(failed)159 _ = 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)
135 }161 }
136 162
137 func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {163 func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
@@ -139,8 +165,20 @@ func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.T
139 }165 }
140 166
141 func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {167 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+ }
142 sess, ok := localSession(token.sess)180 sess, ok := localSession(token.sess)
143- if !ok || failed != token.transport.transport {181+ if !ok {
144 return182 return
145 }183 }
146 parkingToken := d.markParkingInFlight(sess, token.ac)184 parkingToken := d.markParkingInFlight(sess, token.ac)
M internal/usecase/daemon/config_test.go +3 -1
@@ -21,7 +21,9 @@ type captureKeyHandler struct {
21 func (h *captureKeyHandler) Forward(data []byte) {21 func (h *captureKeyHandler) Forward(data []byte) {
22 h.forward = append(h.forward, append([]byte(nil), data...))22 h.forward = append(h.forward, append([]byte(nil), data...))
23 }23 }
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+}
25 27
26 func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {28 func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
27 d := newTestDaemon(t, nil, stubClock{})29 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) {
43 func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {43 func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {
44 ac := token.ac44 ac := token.ac
45 ac.initOverlays()45 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+ }
46 ac.mouseScan.Scan(data,63 ac.mouseScan.Scan(data,
47 func(ev mouse.Event) {64 func(ev mouse.Event) {
48 if token.activeEffect() {65 if token.activeEffect() {
@@ -357,7 +374,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
357 h.d.writeToPane(sess, p, data)374 h.d.writeToPane(sess, p, data)
358 }375 }
359 376
360-func (h daemonKeyHandler) Action(action keys.Action) {377+func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
361 sess, effect, owned := h.acquireRoleEffect()378 sess, effect, owned := h.acquireRoleEffect()
362 if sess == nil {379 if sess == nil {
363 return380 return
@@ -390,6 +407,13 @@ func (h daemonKeyHandler) Action(action keys.Action) {
390 case keys.ActionOpenPalette:407 case keys.ActionOpenPalette:
391 h.d.enterPalette(sess, h.ac)408 h.d.enterPalette(sess, h.ac)
392 case keys.ActionJumpAttention:409 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+ }
393 if effect == nil {417 if effect == nil {
394 if err := h.d.jumpAttention(sess, h.ac); err != nil {418 if err := h.d.jumpAttention(sess, h.ac); err != nil {
395 h.d.reportError(sess, err)419 h.d.reportError(sess, err)
@@ -408,21 +432,13 @@ func (h daemonKeyHandler) Action(action keys.Action) {
408 h.d.reportError(sess, err)432 h.d.reportError(sess, err)
409 }433 }
410 case keys.ActionFocusPaneLeft:434 case keys.ActionFocusPaneLeft:
411- if err := h.d.focusDir(sess, h.ac, layout.Left, effect); err != nil && !errors.Is(err, errAttachmentTransition) {435+ h.focus(sess, layout.Left, effect)
412- h.d.reportError(sess, err)
413- }
414 case keys.ActionFocusPaneRight:436 case keys.ActionFocusPaneRight:
415- if err := h.d.focusDir(sess, h.ac, layout.Right, effect); err != nil && !errors.Is(err, errAttachmentTransition) {437+ h.focus(sess, layout.Right, effect)
416- h.d.reportError(sess, err)
417- }
418 case keys.ActionFocusPaneUp:438 case keys.ActionFocusPaneUp:
419- if err := h.d.focusDir(sess, h.ac, layout.Up, effect); err != nil && !errors.Is(err, errAttachmentTransition) {439+ h.focus(sess, layout.Up, effect)
420- h.d.reportError(sess, err)
421- }
422 case keys.ActionFocusPaneDown:440 case keys.ActionFocusPaneDown:
423- if err := h.d.focusDir(sess, h.ac, layout.Down, effect); err != nil && !errors.Is(err, errAttachmentTransition) {441+ h.focus(sess, layout.Down, effect)
424- h.d.reportError(sess, err)
425- }
426 case keys.ActionGrowPaneWidth:442 case keys.ActionGrowPaneWidth:
427 runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})443 runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
428 case keys.ActionShrinkPaneWidth:444 case keys.ActionShrinkPaneWidth:
@@ -447,3 +463,15 @@ func (h daemonKeyHandler) Action(action keys.Action) {
447 }463 }
448 }464 }
449 }465 }
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) {
118 layout.ErrTooSmall,118 layout.ErrTooSmall,
119 )}119 )}
120 120
121- daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action)121+ daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action, nil)
122 122
123 history := d.notices.history()123 history := d.notices.history()
124 require.Len(t, history, 1)124 require.Len(t, history, 1)
@@ -154,7 +154,7 @@ func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
154 paletteSpy := &actionRunnerSpy{}154 paletteSpy := &actionRunnerSpy{}
155 controlSpy := &actionRunnerSpy{}155 controlSpy := &actionRunnerSpy{}
156 156
157- daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action)157+ daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action, nil)
158 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy}))158 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy}))
159 require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy}))159 require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy}))
160 160
@@ -238,7 +238,7 @@ func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) {
238 238
239 switch adapter {239 switch adapter {
240 case "key":240 case "key":
241- daemonKeyHandler{d: d, ac: ac}.Action(tt.action)241+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
242 case "palette":242 case "palette":
243 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac}))243 require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac}))
244 case "control":244 case "control":
@@ -400,7 +400,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
400 target.mu.Unlock()400 target.mu.Unlock()
401 // Use the real key action: it requests the mandatory complete repaint for401 // Use the real key action: it requests the mandatory complete repaint for
402 // the first target-tab frame.402 // the first target-tab frame.
403- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)403+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)
404 require.Equal(t, 1, activeTabIndex(sess))404 require.Equal(t, 1, activeTabIndex(sess))
405 405
406 second := awaitFrame(t, sends, ports.MsgOutput)406 second := awaitFrame(t, sends, ports.MsgOutput)
@@ -635,7 +635,7 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
635 tb.mu.Unlock()635 tb.mu.Unlock()
636 installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)636 installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
637 637
638- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight)638+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight, nil)
639 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)639 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
640 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x"))640 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x"))
641 requirePTYWrite(t, floatingWrites, []byte("x"))641 requirePTYWrite(t, floatingWrites, []byte("x"))
@@ -647,7 +647,7 @@ func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
647 func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) {647 func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) {
648 d, _, ac, _ := newManualSessionWithPTYs(t, nil)648 d, _, ac, _ := newManualSessionWithPTYs(t, nil)
649 649
650- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft)650+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft, nil)
651 651
652 require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent")652 require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent")
653 }653 }
@@ -663,7 +663,7 @@ func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) {
663 tb.tree = nil663 tb.tree = nil
664 tb.mu.Unlock()664 tb.mu.Unlock()
665 665
666- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft)666+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft, nil)
667 667
668 history := d.notices.history()668 history := d.notices.history()
669 require.Len(t, history, 2, "the layout error and its failed direct display update must both surface")669 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) {
683 sess.tabs = append(sess.tabs, second)683 sess.tabs = append(sess.tabs, second)
684 sess.mu.Unlock()684 sess.mu.Unlock()
685 685
686- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)686+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)
687 second.mu.Lock()687 second.mu.Lock()
688 require.Equal(t, floatingHidden, second.floating.state)688 require.Equal(t, floatingHidden, second.floating.state)
689 second.mu.Unlock()689 second.mu.Unlock()
690 690
691- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab1)691+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab1, nil)
692 first.mu.Lock()692 first.mu.Lock()
693 require.Equal(t, floatingVisible, first.floating.state)693 require.Equal(t, floatingVisible, first.floating.state)
694 first.mu.Unlock()694 first.mu.Unlock()
M internal/usecase/daemon/navigation_overflow_test.go +6 -6
@@ -69,7 +69,7 @@ func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) {
69 target.mu.Unlock()69 target.mu.Unlock()
70 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})70 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}})
71 71
72- daemonKeyHandler{d: d, ac: ac}.Action(tt.action)72+ daemonKeyHandler{d: d, ac: ac}.Action(tt.action, nil)
73 73
74 require.Equal(t, tt.wantActive, activeTabIndex(sess))74 require.Equal(t, tt.wantActive, activeTabIndex(sess))
75 target.mu.Lock()75 target.mu.Lock()
@@ -124,7 +124,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
124 }124 }
125 for _, move := range moves {125 for _, move := range moves {
126 t.Run(move.name, func(t *testing.T) {126 t.Run(move.name, func(t *testing.T) {
127- handler.Action(move.action)127+ handler.Action(move.action, nil)
128 require.Same(t, move.want, ac.currentSession())128 require.Same(t, move.want, ac.currentSession())
129 })129 })
130 }130 }
@@ -168,7 +168,7 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
168 d.mu.Unlock()168 d.mu.Unlock()
169 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})169 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
170 170
171- daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown)171+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown, nil)
172 172
173 require.Same(t, alpha, ac.currentSession())173 require.Same(t, alpha, ac.currentSession())
174 alpha.mu.Lock()174 alpha.mu.Lock()
@@ -296,8 +296,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
296 close(start)296 close(start)
297 handler := daemonKeyHandler{d: d, ac: ac}297 handler := daemonKeyHandler{d: d, ac: ac}
298 for range 20 {298 for range 20 {
299- handler.Action(keys.ActionFocusPaneDown)299+ handler.Action(keys.ActionFocusPaneDown, nil)
300- handler.Action(keys.ActionFocusPaneUp)300+ handler.Action(keys.ActionFocusPaneUp, nil)
301 }301 }
302 wg.Wait()302 wg.Wait()
303 close(renameErrors)303 close(renameErrors)
@@ -349,7 +349,7 @@ func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t
349 d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)349 d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
350 tt.setup(d, sess)350 tt.setup(d, sess)
351 351
352- daemonKeyHandler{d: d, ac: ac}.Action(tt.dir)352+ daemonKeyHandler{d: d, ac: ac}.Action(tt.dir, nil)
353 353
354 require.Equal(t, 0, activeTabIndex(sess))354 require.Equal(t, 0, activeTabIndex(sess))
355 })355 })
M internal/usecase/daemon/notices_overlay.go +16 -10
@@ -17,7 +17,7 @@ var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight
17 17
18 // enterNotices opens the notification history overlay from the daemon's full18 // enterNotices opens the notification history overlay from the daemon's full
19 // notice center history, newest first.19 // notice center history, newest first.
20-func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {20+func (d *Daemon) enterNotices(sess attachmentSession, ac *attachedClient) {
21 history := d.notices.history()21 history := d.notices.history()
22 now := d.clock.Now()22 now := d.clock.Now()
23 ac.overlays.noticeMu.Lock()23 ac.overlays.noticeMu.Lock()
@@ -172,16 +172,16 @@ func (d *Daemon) noticesListInputState(ac *attachedClient) listInputState {
172 rt.noticesOverlay = nil172 rt.noticesOverlay = nil
173 },173 },
174 afterClose: func() {174 afterClose: func() {
175- if sess := ac.currentSession(); sess != nil {175+ if entry := ac.currentAttachmentSession(); entry != nil {
176- d.invalidateRender(sess, ac, true, "notices_overlay.go")176+ d.invalidateRender(entry, ac, true, "notices_overlay.go")
177 }177 }
178 },178 },
179 }179 }
180 }180 }
181 181
182 func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {182 func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
183- sess := ac.currentSession()183+ entry := ac.currentAttachmentSession()
184- if sess == nil {184+ if entry == nil {
185 return185 return
186 }186 }
187 rt := ac.overlays187 rt := ac.overlays
@@ -210,10 +210,10 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
210 // yankNotice sends over the wire and repaints itself, so it must run210 // yankNotice sends over the wire and repaints itself, so it must run
211 // with noticeMu released; a quick yank doesn't close the overlay the211 // with noticeMu released; a quick yank doesn't close the overlay the
212 // way copy mode's own 'y' commits and exits.212 // way copy mode's own 'y' commits and exits.
213- d.yankNotice(sess, ac, yankTarget)213+ d.yankNotice(entry, ac, yankTarget)
214 }214 }
215 if result.exit || result.changed {215 if result.exit || result.changed {
216- d.invalidateRender(sess, ac, true, "notices_overlay.go")216+ d.invalidateRender(entry, ac, true, "notices_overlay.go")
217 }217 }
218 }218 }
219 219
@@ -221,12 +221,18 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
221 // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):221 // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
222 // send each chunk, then leave a one-shot status-bar confirmation that the222 // send each chunk, then leave a one-shot status-bar confirmation that the
223 // next repaint clears.223 // 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) {
225 chunks := scopy.OSC52(noticeYankPayload(n))225 chunks := scopy.OSC52(noticeYankPayload(n))
226 for _, chunk := range chunks {226 for _, chunk := range chunks {
227 failed, err := d.boundedSendOutputErrTransport(ac, chunk)227 failed, err := d.boundedSendOutputErrTransport(ac, chunk)
228 if err != nil {228 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+ }
230 return236 return
231 }237 }
232 }238 }
@@ -237,7 +243,7 @@ func (d *Daemon) yankNotice(sess *session, ac *attachedClient, n domain.Notifica
237 ac.overlays.statusFeedback = "notification too large to copy"243 ac.overlays.statusFeedback = "notification too large to copy"
238 }244 }
239 ac.overlays.copyMu.Unlock()245 ac.overlays.copyMu.Unlock()
240- d.invalidateRender(sess, ac, true, "notices_overlay.go")246+ d.invalidateRender(entry, ac, true, "notices_overlay.go")
241 }247 }
242 248
243 // noticeYankPayload formats n the way the yank commands put it on the249 // 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) {
431 require.Equal(t, "no notifications yet", history[0].Message)431 require.Equal(t, "no notifications yet", history[0].Message)
432 }432 }
433 433
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+
434 func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {468 func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
435 p, release := newBlockingPTY(t)469 p, release := newBlockingPTY(t)
436 d, sess, ac, sends := newManualSessionWithPTYs(t, p)470 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) {
229 if err == nil || benignNoticeError(err) {229 if err == nil || benignNoticeError(err) {
230 return230 return
231 }231 }
232- var ue *domain.UserError232+ if ue, ok := errors.AsType[*domain.UserError](err); ok {
233- if errors.As(err, &ue) {
234 d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err)233 d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err)
235 return234 return
236 }235 }
237 d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)236 d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
238 }237 }
239 238
239+func (d *Daemon) reportAttachmentError(entry attachmentSession, err error) {
240+ local, _ := localSession(entry)
241+ d.reportError(local, err)
242+}
243+
240 // notify records a notice and routes it to whoever can see it. A nil sess means244 // notify records a notice and routes it to whoever can see it. A nil sess means
241 // daemon-global: every attached client gets it, and if none is attached the245 // daemon-global: every attached client gets it, and if none is attached the
242 // notice waits in the pending queue for the next attach. A detached session's246 // 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
293 func (d *Daemon) deliverGlobal(n domain.Notification) {297 func (d *Daemon) deliverGlobal(n domain.Notification) {
294 d.mu.Lock()298 d.mu.Lock()
295 d.notices.routingMu.Lock()299 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+ }
297 d.mu.Unlock()304 d.mu.Unlock()
298 305
299 if d.notices.beforeGlobalDelivery != nil {306 if d.notices.beforeGlobalDelivery != nil {
@@ -301,9 +308,10 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
301 }308 }
302 targets := make([]*attachedClient, 0, len(sessions))309 targets := make([]*attachedClient, 0, len(sessions))
303 for _, s := range sessions {310 for _, s := range sessions {
304- s.mu.Lock()311+ core := s.core()
305- ac := s.client312+ core.mu.Lock()
306- s.mu.Unlock()313+ ac := core.client
314+ core.mu.Unlock()
307 if ac == nil || !d.publishToast(ac, n) {315 if ac == nil || !d.publishToast(ac, n) {
308 continue316 continue
309 }317 }
M internal/usecase/daemon/notify_test.go +15 -0
@@ -18,6 +18,21 @@ import (
18 "github.com/bnema/vev/pkg/vt"18 "github.com/bnema/vev/pkg/vt"
19 )19 )
20 20
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+
21 func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {36 func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
22 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})37 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
23 d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})38 d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})
M internal/usecase/daemon/palette.go +141 -17
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "sort"6 "sort"
6 "time"7 "time"
@@ -20,6 +21,34 @@ const (
20 paletteRailWidth = 6421 paletteRailWidth = 64
21 )22 )
22 23
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+
23 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}}52 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}}
24 53
25 func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {54 func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
@@ -146,10 +175,12 @@ func (d *Daemon) recordPaletteUse(code string) {
146 }175 }
147 176
148 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {177 func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
149- sess := ac.currentSession()178+ entry := ac.currentAttachmentSession()
150- if sess == nil {179+ if entry == nil {
151 return180 return
152 }181 }
182+ sess, local := localSession(entry)
183+ proxy, remote := entry.(*proxySession)
153 var cmd command.Command184 var cmd command.Command
154 var sessionTarget palette.Result185 var sessionTarget palette.Result
155 var hasSessionTarget bool186 var hasSessionTarget bool
@@ -252,12 +283,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
252 }283 }
253 ac.overlays.paletteMu.Unlock()284 ac.overlays.paletteMu.Unlock()
254 if cancel {285 if cancel {
255- d.invalidateRender(sess, ac, true, "palette.go")286+ d.invalidateRender(entry, ac, true, "palette.go")
256 return287 return
257 }288 }
258 if !execute {289 if !execute {
259 if changed {290 if changed {
260- d.invalidateRender(sess, ac, true, "palette.go")291+ d.invalidateRender(entry, ac, true, "palette.go")
261 }292 }
262 return293 return
263 }294 }
@@ -275,19 +306,21 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
275 var err error306 var err error
276 if effect != nil {307 if effect != nil {
277 err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session")308 err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session")
278- } else {309+ } else if local {
279 err = d.switchToTarget(sess, ac, target)310 err = d.switchToTarget(sess, ac, target)
311+ } else {
312+ err = errAttachmentTransition
280 }313 }
281- if effect != nil && errors.Is(err, errAttachmentTransition) {314+ if errors.Is(err, errAttachmentTransition) {
282 return315 return
283 }316 }
284 if err != nil {317 if err != nil {
285 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")318 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
286- d.invalidateRender(sess, ac, true, "palette.go")319+ d.invalidateRender(entry, ac, true, "palette.go")
287 return320 return
288 }321 }
289 if ac.closeExecutedPalette(generation, rawQuery) {322 if ac.closeExecutedPalette(generation, rawQuery) {
290- d.invalidateRender(ac.currentSession(), ac, true, "palette.go")323+ d.invalidateRender(ac.currentAttachmentSession(), ac, true, "palette.go")
291 }324 }
292 return325 return
293 }326 }
@@ -296,21 +329,87 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
296 rank, err := command.ParsePositiveDecimal(args)329 rank, err := command.ParsePositiveDecimal(args)
297 if err != nil {330 if err != nil {
298 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")331 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")
300 return333 return
301 }334 }
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}
303 if err := exec.JumpRecentSession(rank); err != nil {336 if err := exec.JumpRecentSession(rank); err != nil {
304 if errors.Is(err, errAttachmentTransition) {337 if errors.Is(err, errAttachmentTransition) {
305 return338 return
306 }339 }
307 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")340 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")
309 return342 return
310 }343 }
311 if ac.closeExecutedPalette(generation, rawQuery) {344 if ac.closeExecutedPalette(generation, rawQuery) {
312 d.recordPaletteUse(cmd.Code)345 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)
314 }413 }
315 return414 return
316 }415 }
@@ -392,6 +491,7 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
392 type paletteExec struct {491 type paletteExec struct {
393 d *Daemon492 d *Daemon
394 sess *session493 sess *session
494+ attachment attachmentSession
395 ac *attachedClient495 ac *attachedClient
396 recent []recentSession496 recent []recentSession
397 actions daemonActionRunner497 actions daemonActionRunner
@@ -428,9 +528,17 @@ func (e paletteExec) CreateTab() error {
428 }528 }
429 529
430 func (e paletteExec) CreateSession() error {530 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 {
432 if token.ac == nil {536 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)
434 }542 }
435 return e.d.createSessionAndSwitchForRole(token, name)543 return e.d.createSessionAndSwitchForRole(token, name)
436 })544 })
@@ -588,18 +696,30 @@ func (e paletteExec) OpenNotifications() error {
588 }696 }
589 697
590 func (e paletteExec) YankLastNotification() error {698 func (e paletteExec) YankLastNotification() error {
699+ entry := e.attachment
700+ if entry == nil {
701+ entry = e.sess
702+ }
591 n, ok := e.d.notices.latest()703 n, ok := e.d.notices.latest()
592 if !ok {704 if !ok {
593 // Reported directly rather than returned: the generic cmd.Run error705 // Reported directly rather than returned: the generic cmd.Run error
594 // path only logs, so a returned error here would never reach the706 // path only logs, so a returned error here would never reach the
595 // user as the warn toast this no-op is supposed to produce.707 // 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))
597 return nil709 return nil
598 }710 }
599- e.d.yankNotice(e.sess, e.ac, n)711+ e.d.yankNotice(entry, e.ac, n)
600 return nil712 return nil
601 }713 }
602 714
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+
603 func (e paletteExec) JumpRecentSession(rank int) error {723 func (e paletteExec) JumpRecentSession(rank int) error {
604 if rank < 1 || rank > len(e.recent) {724 if rank < 1 || rank > len(e.recent) {
605 return command.ErrInvalidArguments725 return command.ErrInvalidArguments
@@ -615,7 +735,11 @@ func (e paletteExec) JumpRecentSession(rank int) error {
615 if e.effect != nil {735 if e.effect != nil {
616 err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")736 err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
617 } else {737 } 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})
619 }743 }
620 if e.effect != nil && errors.Is(err, errAttachmentTransition) {744 if e.effect != nil && errors.Is(err, errAttachmentTransition) {
621 return err745 return err
M internal/usecase/daemon/palette_test.go +22 -0
@@ -125,6 +125,28 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
125 // only a log line. The new-tab spawn failure is wrapped as a domain.UserError125 // only a log line. The new-tab spawn failure is wrapped as a domain.UserError
126 // with NoticeTabSpawn (session.go createTab), so it surfaces under that code126 // with NoticeTabSpawn (session.go createTab), so it surfaces under that code
127 // rather than the NoticeInternal catch-all.127 // 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+
128 func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {150 func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {
129 p, release := newBlockingPTY(t)151 p, release := newBlockingPTY(t)
130 defer release()152 defer release()
M internal/usecase/daemon/pane_rearrange_test.go +2 -2
@@ -338,7 +338,7 @@ func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *
338 338
339 paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy})339 paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy})
340 controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy})340 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)
342 342
343 if outcome.wantErr == nil {343 if outcome.wantErr == nil {
344 require.NoError(t, paletteErr)344 require.NoError(t, paletteErr)
@@ -394,7 +394,7 @@ func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
394 {394 {
395 name: "key remains silent",395 name: "key remains silent",
396 invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {396 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)
398 return nil398 return nil
399 },399 },
400 },400 },
M internal/usecase/daemon/picker.go +168 -18
@@ -51,12 +51,12 @@ func (d *Daemon) togglePickerSort() {
51 // enterPicker preserves the existing navigation entry point. Navigation always51 // enterPicker preserves the existing navigation entry point. Navigation always
52 // publishes its model, including an empty one; only move entry can fail for a52 // publishes its model, including an empty one; only move entry can fail for a
53 // missing destination.53 // missing destination.
54-func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {54+func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) {
55 model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})55 model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
56 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})56 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
57 }57 }
58 58
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) {
60 rt := ac.overlays60 rt := ac.overlays
61 rt.pickerMu.Lock()61 rt.pickerMu.Lock()
62 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration62 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
@@ -83,7 +83,7 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
83 83
84 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally84 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
85 // knows nothing about picker intent; picker.New owns all destination policy.85 // 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) {
87 d.mu.Lock()87 d.mu.Lock()
88 sessions := make([]attachmentSession, 0, len(d.sessions))88 sessions := make([]attachmentSession, 0, len(d.sessions))
89 proxies := make(map[domain.SessionID]*proxySession)89 proxies := make(map[domain.SessionID]*proxySession)
@@ -120,8 +120,13 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
120 var current picker.SourceFilter120 var current picker.SourceFilter
121 for _, entry := range sessions {121 for _, entry := range sessions {
122 snap := entry.snapshotView(opts)122 snap := entry.snapshotView(opts)
123- if entry == cur && snap.active >= 0 && snap.active < len(snap.tabs) {123+ if entry == cur {
124- current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.active].id}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+ }
125 }130 }
126 live = append(live, liveSnapshot{entry: entry, view: snap})131 live = append(live, liveSnapshot{entry: entry, view: snap})
127 }132 }
@@ -196,7 +201,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
196 return views, current201 return views, current
197 }202 }
198 203
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 {
200 views, attachedCurrent := d.pickerViews(cur)205 views, attachedCurrent := d.pickerViews(cur)
201 if intent == pickerNavigate && current == (picker.SourceFilter{}) {206 if intent == pickerNavigate && current == (picker.SourceFilter{}) {
202 current = attachedCurrent207 current = attachedCurrent
@@ -236,7 +241,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
236 afterClose: func() {241 afterClose: func() {
237 d.clearPreviewGeneration(ac, previewGeneration)242 d.clearPreviewGeneration(ac, previewGeneration)
238 d.remotePickerClosed(instance)243 d.remotePickerClosed(instance)
239- if sess := ac.currentSession(); sess != nil {244+ if sess := ac.currentAttachmentSession(); sess != nil {
240 d.invalidateRender(sess, ac, true, "picker.go")245 d.invalidateRender(sess, ac, true, "picker.go")
241 }246 }
242 },247 },
@@ -244,7 +249,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
244 }249 }
245 250
246 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {251 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
247- sess := ac.currentSession()252+ sess := ac.currentAttachmentSession()
248 if sess == nil {253 if sess == nil {
249 return254 return
250 }255 }
@@ -301,7 +306,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
301 err = d.killPickerTarget(target)306 err = d.killPickerTarget(target)
302 }307 }
303 if err != nil {308 if err != nil {
304- d.reportError(sess, err)309+ d.reportAttachmentError(sess, err)
305 }310 }
306 }311 }
307 if effect != nil && effect.ended.Load() {312 if effect != nil && effect.ended.Load() {
@@ -325,7 +330,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
325 if committing {330 if committing {
326 if intent == pickerMovePane || intent == pickerMoveTab {331 if intent == pickerMovePane || intent == pickerMoveTab {
327 if err := d.movePickerSourceError(source); err != nil {332 if err := d.movePickerSourceError(source); err != nil {
328- d.reportError(sess, err)333+ d.reportAttachmentError(sess, err)
329 d.invalidateRender(sess, ac, true, "picker.go")334 d.invalidateRender(sess, ac, true, "picker.go")
330 return335 return
331 }336 }
@@ -335,7 +340,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
335 d.closePicker(ac)340 d.closePicker(ac)
336 err := d.commitMovePickerSelection(intent, source, target)341 err := d.commitMovePickerSelection(intent, source, target)
337 if err != nil {342 if err != nil {
338- d.reportError(sess, movePickerUserError(err))343+ d.reportAttachmentError(sess, movePickerUserError(err))
339 d.invalidateRender(sess, ac, true, "picker.go")344 d.invalidateRender(sess, ac, true, "picker.go")
340 return345 return
341 }346 }
@@ -345,15 +350,17 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
345 var err error350 var err error
346 if len(effects) != 0 && effects[0] != nil {351 if len(effects) != 0 && effects[0] != nil {
347 err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")352 err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
348- } else {353+ } else if local, ok := localSession(sess); ok {
349 d.closePicker(ac)354 d.closePicker(ac)
350- err = d.switchToTarget(sess, ac, target)355+ err = d.switchToTarget(local, ac, target)
356+ } else {
357+ err = errAttachmentTransition
351 }358 }
352 if errors.Is(err, errAttachmentTransition) {359 if errors.Is(err, errAttachmentTransition) {
353 return360 return
354 }361 }
355 if err != nil {362 if err != nil {
356- d.reportError(sess, err)363+ d.reportAttachmentError(sess, err)
357 return364 return
358 }365 }
359 return366 return
@@ -403,13 +410,13 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
403 // A same-session preview is already invalidated by its own coordinator.410 // A same-session preview is already invalidated by its own coordinator.
404 // Cross-session previews subscribe to the target's producer wake instead411 // Cross-session previews subscribe to the target's producer wake instead
405 // of starting a direct paint/timer path.412 // of starting a direct paint/timer path.
406- if targetSess == ac.currentSession() {413+ if targetSess == ac.currentAttachmentSession() {
407 return414 return
408 }415 }
409 rc := d.attachCoordinator(targetSess, nil, nil, false)416 rc := d.attachCoordinator(targetSess, nil, nil, false)
410 rc.subscribePreviewFor(ac, generation, func(renderWake) {417 rc.subscribePreviewFor(ac, generation, func(renderWake) {
411 if pickerPreviewCurrent(ac, targetSess, next, generation) {418 if pickerPreviewCurrent(ac, targetSess, next, generation) {
412- if owner := ac.currentSession(); owner != nil {419+ if owner := ac.currentAttachmentSession(); owner != nil {
413 d.invalidateRender(owner, ac, false, "picker preview")420 d.invalidateRender(owner, ac, false, "picker preview")
414 }421 }
415 }422 }
@@ -497,7 +504,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
497 observed := ac.overlays.pickerPreview == tb504 observed := ac.overlays.pickerPreview == tb
498 ac.overlays.pickerMu.Unlock()505 ac.overlays.pickerMu.Unlock()
499 if observed && d.clearPreviewGeneration(ac, generation) {506 if observed && d.clearPreviewGeneration(ac, generation) {
500- if sess := ac.currentSession(); sess != nil {507+ if sess := ac.currentAttachmentSession(); sess != nil {
501 d.invalidateRender(sess, ac, true, "picker.go")508 d.invalidateRender(sess, ac, true, "picker.go")
502 }509 }
503 }510 }
@@ -535,7 +542,8 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
535 542
536 func pickerTargetsEqual(left, right picker.Target) bool {543 func pickerTargetsEqual(left, right picker.Target) bool {
537 if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||544 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) {
539 return false547 return false
540 }548 }
541 if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {549 if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
@@ -544,6 +552,13 @@ func pickerTargetsEqual(left, right picker.Target) bool {
544 return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt552 return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
545 }553 }
546 554
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+
547 func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession {562 func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession {
548 d.mu.Lock()563 d.mu.Lock()
549 defer d.mu.Unlock()564 defer d.mu.Unlock()
@@ -615,6 +630,9 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.
615 }630 }
616 token.effect.bindActionEnd(d, action)631 token.effect.bindActionEnd(d, action)
617 token.effect.End()632 token.effect.End()
633+ if target.RemoteKey != nil {
634+ return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)
635+ }
618 sess, ok := localSession(token.sess)636 sess, ok := localSession(token.sess)
619 if !ok {637 if !ok {
620 return errAttachmentTransition638 return errAttachmentTransition
@@ -622,6 +640,134 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.
622 return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action)640 return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action)
623 }641 }
624 642
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+
625 // switchToTargetGuarded is retained for daemon-internal and headless callers.771 // switchToTargetGuarded is retained for daemon-internal and headless callers.
626 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {772 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
627 return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "")773 return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "")
@@ -920,6 +1066,10 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
920 if token.ac == nil {1066 if token.ac == nil {
921 return d.killPickerTarget(target)1067 return d.killPickerTarget(target)
922 }1068 }
1069+ if target.RemoteKey != nil {
1070+ d.enterRemoteKillConfirmation(token.sess, token.ac, target)
1071+ return nil
1072+ }
923 if target.Stopped {1073 if target.Stopped {
924 token.effect.bindActionEnd(d, "picker-delete")1074 token.effect.bindActionEnd(d, "picker-delete")
925 token.effect.End()1075 token.effect.End()
M internal/usecase/daemon/prompt.go +7 -6
@@ -13,7 +13,7 @@ func promptModalFor(title string) ui.Modal {
13 return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}13 return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
14 }14 }
15 15
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) {
17 d.closePrompt(ac)17 d.closePrompt(ac)
18 ac.overlays.promptMu.Lock()18 ac.overlays.promptMu.Lock()
19 ac.overlays.prompt = promptui.New(title, initial)19 ac.overlays.prompt = promptui.New(title, initial)
@@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
23 d.invalidateRender(sess, ac, true, "prompt.go")23 d.invalidateRender(sess, ac, true, "prompt.go")
24 }24 }
25 25
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) {
27 d.closePrompt(ac)27 d.closePrompt(ac)
28 ac.overlays.promptMu.Lock()28 ac.overlays.promptMu.Lock()
29 ac.overlays.prompt = promptui.New(title, initial)29 ac.overlays.prompt = promptui.New(title, initial)
@@ -47,11 +47,12 @@ func (d *Daemon) closePrompt(ac *attachedClient) {
47 func promptValidationError(err error) bool {47 func promptValidationError(err error) bool {
48 return errors.Is(err, domain.ErrInvalidSessionName) ||48 return errors.Is(err, domain.ErrInvalidSessionName) ||
49 errors.Is(err, errSessionNameRequired) ||49 errors.Is(err, errSessionNameRequired) ||
50- errors.Is(err, errSessionNameInUse)50+ errors.Is(err, errSessionNameInUse) ||
51+ errors.Is(err, errProxyKillConfirmation)
51 }52 }
52 53
53 func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {54 func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
54- sess := ac.currentSession()55+ sess := ac.currentAttachmentSession()
55 if sess == nil {56 if sess == nil {
56 return57 return
57 }58 }
@@ -112,13 +113,13 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
112 }113 }
113 ac.overlays.promptMu.Unlock()114 ac.overlays.promptMu.Unlock()
114 if !promptValidationError(err) {115 if !promptValidationError(err) {
115- d.reportError(sess, err)116+ d.reportAttachmentError(sess, err)
116 }117 }
117 d.invalidateRender(sess, ac, true, "prompt.go")118 d.invalidateRender(sess, ac, true, "prompt.go")
118 return119 return
119 }120 }
120 d.closePrompt(ac)121 d.closePrompt(ac)
121- if current := ac.currentSession(); current != nil {122+ if current := ac.currentAttachmentSession(); current != nil {
122 d.invalidateRender(current, ac, true, "prompt.go")123 d.invalidateRender(current, ac, true, "prompt.go")
123 }124 }
124 return125 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() {
36 t.cancelOnce.Do(func() { close(t.cancel) })36 t.cancelOnce.Do(func() { close(t.cancel) })
37 }37 }
38 38
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+
39 // armProxyWarm reserves and publishes a fresh five-minute timer only while the85 // armProxyWarm reserves and publishes a fresh five-minute timer only while the
40 // exact proxy remains registered and headless. Clock and Timer methods are86 // exact proxy remains registered and headless. Clock and Timer methods are
41 // external operations and therefore run outside architecture locks.87 // external operations and therefore run outside architecture locks.
M internal/usecase/daemon/proxy_lifecycle_test.go +1334 -3
@@ -1,18 +1,1237 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "log/slog"
4 "sync"9 "sync"
5 "testing"10 "testing"
6 "time"11 "time"
7 12
8 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/picker"
10 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
11 )17 )
12 18
13-// warmHookClock runs a one-shot hook while armProxyWarm sits between its19+type proxyLifecycleClock struct {
14-// reservation and its publication, so a lifecycle race can be interleaved20+ mu sync.RWMutex
15-// deterministically instead of being waited for.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+
16 type warmHookClock struct {1235 type warmHookClock struct {
17 timers chan *signalTimer1236 timers chan *signalTimer
18 1237
@@ -290,3 +1509,115 @@ func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) {
290 awaitTestCompletion(t, current.done, "current warm timer never ran to completion")1509 awaitTestCompletion(t, current.done, "current warm timer never ran to completion")
291 require.False(t, proxyRegistered(d, p))1510 require.False(t, proxyRegistered(d, p))
292 }1511 }
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
122 RemoteAvailability: remotePickerAvailability(status),122 RemoteAvailability: remotePickerAvailability(status),
123 RemoteDetail: detail,123 RemoteDetail: detail,
124 ConnectOnly: true,124 ConnectOnly: true,
125- RemoteAttachReady: false,125+ RemoteAttachReady: status != remoteHostVersionMismatch,
126 CannotAcceptMoves: true,126 CannotAcceptMoves: true,
127 }127 }
128 }128 }
@@ -155,7 +155,7 @@ func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker
155 view.RemoteAvailability = picker.RemoteFresh155 view.RemoteAvailability = picker.RemoteFresh
156 view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))156 view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
157 view.ConnectOnly = true157 view.ConnectOnly = true
158- view.RemoteAttachReady = false158+ view.RemoteAttachReady = true
159 if snap.expired {159 if snap.expired {
160 view.RemoteAvailability = picker.RemoteStale160 view.RemoteAvailability = picker.RemoteStale
161 view.RemoteDetail = "expired"161 view.RemoteDetail = "expired"
M internal/usecase/daemon/remote_picker_test.go +12 -11
@@ -759,7 +759,7 @@ func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.
759 owner.overlays.pickerMu.Unlock()759 owner.overlays.pickerMu.Unlock()
760 require.True(t, ok)760 require.True(t, ok)
761 require.Equal(t, &key, before.RemoteKey)761 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")
763 763
764 proxy := &proxySession{764 proxy := &proxySession{
765 sessionCore: sessionCore{id: key.ID(), name: key.Display()},765 sessionCore: sessionCore{id: key.ID(), name: key.Display()},
@@ -779,7 +779,7 @@ func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.
779 require.True(t, ok)779 require.True(t, ok)
780 require.Equal(t, before.Session, after.Session)780 require.Equal(t, before.Session, after.Session)
781 require.Equal(t, before.RemoteKey, after.RemoteKey)781 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")
783 }783 }
784 784
785 func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {785 func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
@@ -951,14 +951,15 @@ func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
951 ac.overlays.pickerMu.Unlock()951 ac.overlays.pickerMu.Unlock()
952 }952 }
953 953
954-func TestRemoteRowsRemainNonSelectableDuringRefreshPhase(t *testing.T) {954+func TestRemoteRowsBecomeSelectableOnlyAfterOwnershipCompletion(t *testing.T) {
955 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}955 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
956- for _, availability := range []picker.RemoteAvailability{picker.RemoteCached, picker.RemoteFresh, picker.RemoteStale, picker.RemoteVersionMismatch} {956+ for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} {
957- model := picker.New([]picker.SessionView{{957+ view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, status, time.Unix(1, 0))
958- ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: availability,958+ _, selectable := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
959- ConnectOnly: true, RemoteAttachReady: false,959+ require.True(t, selectable, "status %d is ready after ownership completion", status)
960- }}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})960+ }
961- _, selectable := model.Selected()961+ mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{})
962- require.False(t, selectable, "availability %d became remotely attachable in phase 5", availability)962+ require.False(t, mismatch.RemoteAttachReady)
963- }963+ _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
964+ require.False(t, selectable)
964 }965 }
M internal/usecase/daemon/render_coordinator_test.go +16 -9
@@ -1084,10 +1084,11 @@ var producerFiles = []string{
1084 1084
1085 func TestProducerInvalidations(t *testing.T) {1085 func TestProducerInvalidations(t *testing.T) {
1086 cases := []struct {1086 cases := []struct {
1087- file string1087+ file string
1088- name string1088+ name string
1089- tabs int1089+ tabs int
1090- run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)1090+ producer string
1091+ run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
1091 }{1092 }{
1092 {1093 {
1093 file: "attention.go",1094 file: "attention.go",
@@ -1125,11 +1126,14 @@ func TestProducerInvalidations(t *testing.T) {
1125 },1126 },
1126 },1127 },
1127 {1128 {
1128- file: "input.go",1129+ file: "input.go",
1129- name: "switch tab key action",1130+ name: "proxied jump attention switches a local tab",
1130- tabs: 2,1131+ tabs: 2,
1132+ producer: "input.go",
1131 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {1133 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)
1133 require.Equal(t, 1, activeTabIndex(sess))1137 require.Equal(t, 1, activeTabIndex(sess))
1134 },1138 },
1135 },1139 },
@@ -1222,7 +1226,10 @@ func TestProducerInvalidations(t *testing.T) {
1222 1226
1223 tc.run(t, d, sess, ac)1227 tc.run(t, d, sess, ac)
1224 1228
1225- awaitInvalidation(t, invs)1229+ inv := awaitInvalidation(t, invs)
1230+ if tc.producer != "" {
1231+ require.Equal(t, tc.producer, inv.producer)
1232+ }
1226 requireNoInvalidation(t, invs)1233 requireNoInvalidation(t, invs)
1227 requireNoCoordinatorOutputFrame(t, sends)1234 requireNoCoordinatorOutputFrame(t, sends)
1228 })1235 })
M internal/usecase/daemon/session.go +11 -4
@@ -156,20 +156,27 @@ type tab struct {
156 // what it has actually seen). sendMu serialises the two senders — the render156 // what it has actually seen). sendMu serialises the two senders — the render
157 // coordinator and the connection handler — so the transport's single-writer157 // coordinator and the connection handler — so the transport's single-writer
158 158
159-func (d *Daemon) touchMRU(sess *session) {159+func (d *Daemon) touchMRU(entry attachmentSession) {
160- if d == nil || sess == nil {160+ if d == nil || entry == nil || entry.core() == nil {
161 return161 return
162 }162 }
163+ core := entry.core()
163 seq := d.mruSeq.Add(1)164 seq := d.mruSeq.Add(1)
164 for {165 for {
165- old := sess.mruAt.Load()166+ old := core.mruAt.Load()
166 if old >= seq {167 if old >= seq {
167 return168 return
168 }169 }
169- if sess.mruAt.CompareAndSwap(old, seq) {170+ if core.mruAt.CompareAndSwap(old, seq) {
170 break171 break
171 }172 }
172 }173 }
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+ }
173 sess.mu.Lock()180 sess.mu.Lock()
174 if !sess.ephemeral {181 if !sess.ephemeral {
175 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())182 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) {
106 require.False(t, ac.sessionMetaSent)106 require.False(t, ac.sessionMetaSent)
107 }107 }
108 108
109-func TestProxiedAttachedCommandRejectionPreservesRequestID(t *testing.T) {109+func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
110 tests := []struct {110 tests := []struct {
111- name string111+ name string
112- request ports.CommandRequest112+ request ports.CommandRequest
113- wantCode uint16113+ ok bool
114- wantText string114+ code uint16
115+ text string
115 }{116 }{
116 {117 {
117 name: "valid attached command",118 name: "valid attached command",
118 request: ports.CommandRequest{119 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",
120 },121 },
121- wantCode: ports.ErrNotScriptable,122+ ok: true,
122- wantText: "attached command relay is not enabled",
123 },123 },
124 {124 {
125 name: "target override is rejected",125 name: "target override is rejected",
126 request: ports.CommandRequest{126 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",
128 },128 },
129- wantCode: ports.ErrNotScriptable,129+ code: ports.ErrInvalidCommandArgs,
130- wantText: "attached command relay is not enabled",130+ text: "attached commands cannot override their active session target",
131 },131 },
132 {132 {
133 name: "missing attached flag is rejected",133 name: "missing attached flag is rejected",
134 request: ports.CommandRequest{134 request: ports.CommandRequest{
135- Version: ports.ProtocolVersion, RequestID: 43, Slug: "new-tab",135+ Version: ports.ProtocolVersion, RequestID: 43, Slug: "next-tab",
136 },136 },
137- wantCode: ports.ErrNotScriptable,137+ code: ports.ErrInvalidCommandArgs,
138- wantText: "attached command relay is not enabled",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",
139 },179 },
140 }180 }
141 for _, tt := range tests {181 for _, tt := range tests {
@@ -149,14 +189,13 @@ func TestProxiedAttachedCommandRejectionPreservesRequestID(t *testing.T) {
149 token := sess.attachmentToken(ac, ac.transport())189 token := sess.attachmentToken(ac, ac.transport())
150 require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))190 require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
151 191
152- reply := <-sends192+ reply := awaitFrame(t, sends, ports.MsgCommandResult)
153- require.Equal(t, ports.MsgCommandResult, reply.Type)
154 result, err := ports.UnmarshalCommandResult(reply.Payload)193 result, err := ports.UnmarshalCommandResult(reply.Payload)
155 require.NoError(t, err)194 require.NoError(t, err)
156 require.Equal(t, tt.request.RequestID, result.RequestID)195 require.Equal(t, tt.request.RequestID, result.RequestID)
157- require.False(t, result.OK)196+ require.Equal(t, tt.ok, result.OK)
158- require.Equal(t, tt.wantCode, result.Code)197+ require.Equal(t, tt.code, result.Code)
159- require.Equal(t, tt.wantText, result.Text)198+ require.Equal(t, tt.text, result.Text)
160 })199 })
161 }200 }
162 }201 }
M internal/usecase/daemon/snatched_cleanup.go +9 -0
@@ -1,5 +1,7 @@
1 package daemon1 package daemon
2 2
3+import "github.com/bnema/vev/internal/ports"
4+
3 // removeSnatchedAttachment removes exactly one role generation and transport5 // removeSnatchedAttachment removes exactly one role generation and transport
4 // from session routing. It never touches the active attachment or closes a6 // from session routing. It never touches the active attachment or closes a
5 // transport, allowing callers that already interrupted a blocked send to avoid7 // transport, allowing callers that already interrupted a blocked send to avoid
@@ -176,6 +178,13 @@ func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool {
176 // captured transport; an idle healthy snatched transport remains connected and178 // captured transport; an idle healthy snatched transport remains connected and
177 // receives a dependency-free reset panel.179 // receives a dependency-free reset panel.
178 func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {180 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+ }
179 if token := result.displaced; token.ac != nil && token.transport.transport != nil {188 if token := result.displaced; token.ac != nil && token.transport.transport != nil {
180 blockedRender := result.displacedInterrupted189 blockedRender := result.displacedInterrupted
181 if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {190 if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
M internal/usecase/keys/router.go +12 -6
@@ -48,10 +48,11 @@ const (
48 )48 )
49 49
50 // Handler receives router outputs. Forward is called only for bytes that should50 // 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.
52 type Handler interface {53 type Handler interface {
53 Forward([]byte)54 Forward([]byte)
54- Action(Action)55+ Action(Action, []byte)
55 }56 }
56 57
57 // Router implements vev's Alt/ESC input routing. It retains a trailing ESC for58 // 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) {
151 }152 }
152 if action, size, ok, partial := r.altArrowCSI(remaining); ok {153 if action, size, ok, partial := r.altArrowCSI(remaining); ok {
153 flush()154 flush()
154- h.Action(action)155+ r.action(action, data[i:i+1+size], h)
155 i += 1 + size156 i += 1 + size
156 continue157 continue
157 } else if partial {158 } else if partial {
@@ -167,7 +168,7 @@ func (r *Router) route(data []byte, h Handler) {
167 }168 }
168 if action, size, ok := r.binding(remaining); ok {169 if action, size, ok := r.binding(remaining); ok {
169 flush()170 flush()
170- h.Action(action)171+ r.action(action, data[i:i+1+size], h)
171 i += 1 + size172 i += 1 + size
172 continue173 continue
173 }174 }
@@ -184,7 +185,7 @@ func (r *Router) route(data []byte, h Handler) {
184 185
185 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int {186 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler) int {
186 if action, size, ok, partial := r.altArrowCSI(data); ok {187 if action, size, ok, partial := r.altArrowCSI(data); ok {
187- h.Action(action)188+ r.action(action, append([]byte{ESC}, data[:size]...), h)
188 return size189 return size
189 } else if partial {190 } else if partial {
190 r.retainESC(h, data)191 r.retainESC(h, data)
@@ -192,7 +193,7 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int, h Handler)
192 }193 }
193 next := data[0]194 next := data[0]
194 if action, size, ok := r.binding(data); ok {195 if action, size, ok := r.binding(data); ok {
195- h.Action(action)196+ r.action(action, append([]byte{ESC}, data[:size]...), h)
196 return size197 return size
197 }198 }
198 if partialUTF8Rune(data) {199 if partialUTF8Rune(data) {
@@ -279,6 +280,11 @@ func (r *Router) forward(data []byte, h Handler) {
279 h.Forward(cp)280 h.Forward(cp)
280 }281 }
281 282
283+func (r *Router) action(action Action, raw []byte, h Handler) {
284+ cp := append([]byte(nil), raw...)
285+ h.Action(action, cp)
286+}
287+
282 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }288 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
283 289
284 func (r *Router) currentBindings() *Bindings {290 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
33 func (t *fakeTimer) fire() { t.ch <- time.Time{} }33 func (t *fakeTimer) fire() { t.ch <- time.Time{} }
34 34
35 type captureHandler struct {35 type captureHandler struct {
36- forwards [][]byte36+ forwards [][]byte
37- actions []Action37+ actions []Action
38- notify chan struct{}38+ actionRaw [][]byte
39+ notify chan struct{}
39 }40 }
40 41
41 func (h *captureHandler) Forward(data []byte) {42 func (h *captureHandler) Forward(data []byte) {
@@ -44,7 +45,43 @@ func (h *captureHandler) Forward(data []byte) {
44 h.notify <- struct{}{}45 h.notify <- struct{}{}
45 }46 }
46 }47 }
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+}
48 85
49 func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) {86 func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) {
50 cases := []struct {87 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