detached 09a2f38

feat(daemon): let snatched clients reclaim sessions

commit
09a2f38cd41a74ce749ed28a411a251f029d0ec8
author
brice · · 2026-07-27 23:48 UTC
parent
ddc019c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +1174 -14
M internal/usecase/daemon/client.go +14 -12
@@ -74,8 +74,15 @@ type attachedClient struct {
7474 // previousSession is guarded independently. It is retained through temporary
7575 // setSession(nil) hand-offs and cleared only on terminal teardown.
7676 previousSession Guarded[*session]
77- linkMu sync.Mutex
78- sendMu sync.Mutex
77+ // snatchedInputMu serializes the restricted input parser with its delayed
78+ // standalone-ESC callback. It is independent of routing and transport locks;
79+ // role activation and terminal close clear it only after releasing those locks.
80+ snatchedInputMu sync.Mutex
81+ snatchedInputPending []byte
82+ snatchedInputDrain bool
83+ snatchedInputESC pendingByteTimer
84+ linkMu sync.Mutex
85+ sendMu sync.Mutex
7986 }
8087
8188 type cursorOut struct {
@@ -215,12 +222,14 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
215222 return nil
216223 }
217224 ac.linkMu.Lock()
218- defer ac.linkMu.Unlock()
219225 if ac.tr != tr {
226+ ac.linkMu.Unlock()
220227 return nil
221228 }
222229 ac.tr = nil
223230 ac.transportIncarnation++
231+ ac.linkMu.Unlock()
232+ ac.clearSnatchedInput()
224233 return tr
225234 }
226235
@@ -819,8 +828,8 @@ func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Fr
819828 }
820829 switch f.Type {
821830 case ports.MsgInput:
822- if in, err := ports.UnmarshalInput(f.Payload); err == nil {
823- d.handleSnatchedInput(token, in)
831+ if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) {
832+ return true
824833 }
825834 case ports.MsgResize:
826835 if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {
@@ -875,13 +884,6 @@ func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Fr
875884 return false
876885 }
877886
878-// handleSnatchedInput is the narrow seam for the future strict snatched-action
879-// parser. Ordinary terminal input is deliberately ignored and never enters the
880-// key router or PTY path.
881-func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, _ ports.Input) {
882- _ = token
883-}
884-
885887 // clientGone detaches ac if it is still the session's current client. The
886888 // session remains registered and headless after the client is gone.
887889 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
@@ -74,8 +74,15 @@ type attachedClient struct {
74 // previousSession is guarded independently. It is retained through temporary74 // previousSession is guarded independently. It is retained through temporary
75 // setSession(nil) hand-offs and cleared only on terminal teardown.75 // setSession(nil) hand-offs and cleared only on terminal teardown.
76 previousSession Guarded[*session]76 previousSession Guarded[*session]
77- linkMu sync.Mutex77+ // snatchedInputMu serializes the restricted input parser with its delayed
78- sendMu sync.Mutex78+ // standalone-ESC callback. It is independent of routing and transport locks;
79+ // role activation and terminal close clear it only after releasing those locks.
80+ snatchedInputMu sync.Mutex
81+ snatchedInputPending []byte
82+ snatchedInputDrain bool
83+ snatchedInputESC pendingByteTimer
84+ linkMu sync.Mutex
85+ sendMu sync.Mutex
79 }86 }
80 87
81 type cursorOut struct {88 type cursorOut struct {
@@ -215,12 +222,14 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
215 return nil222 return nil
216 }223 }
217 ac.linkMu.Lock()224 ac.linkMu.Lock()
218- defer ac.linkMu.Unlock()
219 if ac.tr != tr {225 if ac.tr != tr {
226+ ac.linkMu.Unlock()
220 return nil227 return nil
221 }228 }
222 ac.tr = nil229 ac.tr = nil
223 ac.transportIncarnation++230 ac.transportIncarnation++
231+ ac.linkMu.Unlock()
232+ ac.clearSnatchedInput()
224 return tr233 return tr
225 }234 }
226 235
@@ -819,8 +828,8 @@ func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Fr
819 }828 }
820 switch f.Type {829 switch f.Type {
821 case ports.MsgInput:830 case ports.MsgInput:
822- if in, err := ports.UnmarshalInput(f.Payload); err == nil {831+ if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) {
823- d.handleSnatchedInput(token, in)832+ return true
824 }833 }
825 case ports.MsgResize:834 case ports.MsgResize:
826 if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {835 if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {
@@ -875,13 +884,6 @@ func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Fr
875 return false884 return false
876 }885 }
877 886
878-// handleSnatchedInput is the narrow seam for the future strict snatched-action
879-// parser. Ordinary terminal input is deliberately ignored and never enters the
880-// key router or PTY path.
881-func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, _ ports.Input) {
882- _ = token
883-}
884-
885 // clientGone detaches ac if it is still the session's current client. The887 // clientGone detaches ac if it is still the session's current client. The
886 // session remains registered and headless after the client is gone.888 // session remains registered and headless after the client is gone.
887 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {889 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
M internal/usecase/daemon/daemon.go +9 -0
@@ -115,6 +115,12 @@ type Daemon struct {
115115 // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
116116 // after one overlay-family mutex is released and before the next is taken.
117117 afterSnatchOverlayFamily func(string)
118+ // afterSnatchedEscapeAccepted observes a standalone ESC after parser state
119+ // is consumed and before its captured role and transport are revalidated.
120+ afterSnatchedEscapeAccepted func()
121+ // afterSnatchedEscapeAttempt reports whether that callback acquired its exact
122+ // role capability after revalidation.
123+ afterSnatchedEscapeAttempt func(bool)
118124 // afterActiveFrameDispatch is a deterministic test seam after the connection
119125 // loop's first role check and before a decoded active frame takes effect.
120126 afterActiveFrameDispatch func(attachmentRoleToken)
@@ -134,6 +140,9 @@ type Daemon struct {
134140 // afterDisplacedCleanupStarted observes deferred displaced cleanup before it
135141 // synchronizes with the attachment gate.
136142 afterDisplacedCleanupStarted func()
143+ // beforeReclaimFirstPaint observes a committed reclaim after displaced cleanup
144+ // is queued and before its generation-bound reset first paint is admitted.
145+ beforeReclaimFirstPaint func(attachmentRoleToken)
137146 // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint
138147 // reserves its exact capability and before its first observable mutation.
139148 afterRoleEffectAdmitted func(attachmentRoleToken)
@@ -115,6 +115,12 @@ type Daemon struct {
115 // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs115 // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
116 // after one overlay-family mutex is released and before the next is taken.116 // after one overlay-family mutex is released and before the next is taken.
117 afterSnatchOverlayFamily func(string)117 afterSnatchOverlayFamily func(string)
118+ // afterSnatchedEscapeAccepted observes a standalone ESC after parser state
119+ // is consumed and before its captured role and transport are revalidated.
120+ afterSnatchedEscapeAccepted func()
121+ // afterSnatchedEscapeAttempt reports whether that callback acquired its exact
122+ // role capability after revalidation.
123+ afterSnatchedEscapeAttempt func(bool)
118 // afterActiveFrameDispatch is a deterministic test seam after the connection124 // afterActiveFrameDispatch is a deterministic test seam after the connection
119 // loop's first role check and before a decoded active frame takes effect.125 // loop's first role check and before a decoded active frame takes effect.
120 afterActiveFrameDispatch func(attachmentRoleToken)126 afterActiveFrameDispatch func(attachmentRoleToken)
@@ -134,6 +140,9 @@ type Daemon struct {
134 // afterDisplacedCleanupStarted observes deferred displaced cleanup before it140 // afterDisplacedCleanupStarted observes deferred displaced cleanup before it
135 // synchronizes with the attachment gate.141 // synchronizes with the attachment gate.
136 afterDisplacedCleanupStarted func()142 afterDisplacedCleanupStarted func()
143+ // beforeReclaimFirstPaint observes a committed reclaim after displaced cleanup
144+ // is queued and before its generation-bound reset first paint is admitted.
145+ beforeReclaimFirstPaint func(attachmentRoleToken)
137 // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint146 // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint
138 // reserves its exact capability and before its first observable mutation.147 // reserves its exact capability and before its first observable mutation.
139 afterRoleEffectAdmitted func(attachmentRoleToken)148 afterRoleEffectAdmitted func(attachmentRoleToken)
M internal/usecase/daemon/snatched_client.go +74 -2
@@ -76,6 +76,10 @@ type attachmentTransitionRequest struct {
7676 preflighted bool
7777 roleEffectsFrozen bool
7878 expectedTargetTransportInterrupted bool
79+ // activationBarrier is used when promoting an already connected snatched
80+ // attachment. Its role gate is deadline-drained before sendMu is acquired,
81+ // preserving the gate -> sendMu -> daemon/routing/session publication order.
82+ activationBarrier bool
7983 }
8084
8185 func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {
@@ -235,7 +239,9 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
235239 source = req.target
236240 }
237241 if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
238- d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target {
242+ d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target ||
243+ req.activationBarrier && (req.sourceToken == nil || source != req.target ||
244+ req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
239245 d.mu.Unlock()
240246 return attachmentTransitionResult{}, errAttachmentTransition
241247 }
@@ -265,17 +271,47 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
265271 interrupts := []roleTransportInterrupt{{
266272 ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
267273 }}
268- frozen := freezeRoleEffectGatesInterruptingObserved(interrupts, func(ac *attachedClient) {
274+ var drainDeadline *roleEffectDrainDeadline
275+ var drainDone func() <-chan struct{}
276+ if req.activationBarrier {
277+ drainDeadline = newRoleEffectDrainDeadline(d.clock)
278+ drainDone = drainDeadline.Done
279+ }
280+ frozen := freezeRoleEffectGatesInterruptingObservedUntil(interrupts, drainDone, func(ac *attachedClient) {
269281 if d.afterRoleEffectGateFrozen != nil {
270282 d.afterRoleEffectGateFrozen(req.action, ac)
271283 }
272284 }, participants...)
285+ if drainDeadline != nil {
286+ drainDeadline.stop()
287+ }
273288 defer frozen.unfreeze()
289+ if !frozen.acquired || !frozen.drained {
290+ if req.activationBarrier {
291+ _ = req.next.closeCapturedTransport(req.expectedTransport.transport)
292+ return attachmentTransitionResult{}, errSendTimedOut
293+ }
294+ return attachmentTransitionResult{}, errAttachmentTransition
295+ }
274296 req.expectedTargetTransportInterrupted = frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
275297 if d.afterRoleEffectsFrozen != nil {
276298 d.afterRoleEffectsFrozen()
277299 }
278300
301+ var releaseActivation func()
302+ if req.activationBarrier {
303+ var err error
304+ releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken)
305+ if err != nil {
306+ return attachmentTransitionResult{}, err
307+ }
308+ defer func() {
309+ if releaseActivation != nil {
310+ releaseActivation()
311+ }
312+ }()
313+ }
314+
279315 d.mu.Lock()
280316 req.roleEffectsFrozen = true
281317 if req.target == nil {
@@ -293,6 +329,24 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
293329 }
294330 result, err := d.transitionAttachmentLocked(req)
295331 d.mu.Unlock()
332+ if err == nil && req.activationBarrier && result.published.role == attachmentActive {
333+ ac := result.published.ac
334+ if ac.output != nil {
335+ ac.output.rebase()
336+ }
337+ ac.captureFrames = nil
338+ d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false)
339+ // The first paint still requests a reset, but the old panel dependency
340+ // chain has already been rebased atomically under the activation barrier.
341+ result.published.rebase = false
342+ }
343+ if releaseActivation != nil {
344+ releaseActivation()
345+ releaseActivation = nil
346+ }
347+ if err == nil && result.published.role == attachmentActive {
348+ result.published.ac.clearSnatchedInput()
349+ }
296350 return result, err
297351 }
298352
@@ -543,6 +597,22 @@ func (d *Daemon) sendSnatchedControl(token attachmentRoleToken, frame ports.Fram
543597 return err
544598 }
545599
600+// boundedSendSnatchedControl keeps a best-effort control acknowledgement bound
601+// to the exact admitted role and link incarnation. On deadline it closes only
602+// that captured link, releasing either the transport Send or a wait on sendMu.
603+func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error {
604+ if token.ac == nil || token.effect == nil || token.transport.transport == nil {
605+ return errAttachmentTransition
606+ }
607+ _, err := d.boundedSendWith(token.transport.transport, func() error {
608+ return d.sendSnatchedControl(token, frame)
609+ })
610+ if errors.Is(err, errSendTimedOut) {
611+ _ = token.ac.closeCapturedTransport(token.transport.transport)
612+ }
613+ return err
614+}
615+
546616 // removeSnatchedAttachment removes exactly one role generation and transport
547617 // from session routing. It never touches the active attachment or closes a
548618 // transport, allowing callers that already interrupted a blocked send to avoid
@@ -569,6 +639,7 @@ func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool {
569639 return false
570640 }
571641
642+ token.ac.clearSnatchedInput()
572643 d.unregisterPreview(token.ac)
573644 token.ac.clearPreviousSession()
574645 token.ac.setSession(nil)
@@ -620,6 +691,7 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
620691 }
621692
622693 _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
694+ token.ac.clearSnatchedInput()
623695 d.unregisterPreview(token.ac)
624696 token.ac.clearPreviousSession()
625697 token.ac.setSession(nil)
@@ -76,6 +76,10 @@ type attachmentTransitionRequest struct {
76 preflighted bool76 preflighted bool
77 roleEffectsFrozen bool77 roleEffectsFrozen bool
78 expectedTargetTransportInterrupted bool78 expectedTargetTransportInterrupted bool
79+ // activationBarrier is used when promoting an already connected snatched
80+ // attachment. Its role gate is deadline-drained before sendMu is acquired,
81+ // preserving the gate -> sendMu -> daemon/routing/session publication order.
82+ activationBarrier bool
79 }83 }
80 84
81 func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {85 func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {
@@ -235,7 +239,9 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
235 source = req.target239 source = req.target
236 }240 }
237 if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||241 if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
238- d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target {242+ d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target ||
243+ req.activationBarrier && (req.sourceToken == nil || source != req.target ||
244+ req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
239 d.mu.Unlock()245 d.mu.Unlock()
240 return attachmentTransitionResult{}, errAttachmentTransition246 return attachmentTransitionResult{}, errAttachmentTransition
241 }247 }
@@ -265,17 +271,47 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
265 interrupts := []roleTransportInterrupt{{271 interrupts := []roleTransportInterrupt{{
266 ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,272 ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
267 }}273 }}
268- frozen := freezeRoleEffectGatesInterruptingObserved(interrupts, func(ac *attachedClient) {274+ var drainDeadline *roleEffectDrainDeadline
275+ var drainDone func() <-chan struct{}
276+ if req.activationBarrier {
277+ drainDeadline = newRoleEffectDrainDeadline(d.clock)
278+ drainDone = drainDeadline.Done
279+ }
280+ frozen := freezeRoleEffectGatesInterruptingObservedUntil(interrupts, drainDone, func(ac *attachedClient) {
269 if d.afterRoleEffectGateFrozen != nil {281 if d.afterRoleEffectGateFrozen != nil {
270 d.afterRoleEffectGateFrozen(req.action, ac)282 d.afterRoleEffectGateFrozen(req.action, ac)
271 }283 }
272 }, participants...)284 }, participants...)
285+ if drainDeadline != nil {
286+ drainDeadline.stop()
287+ }
273 defer frozen.unfreeze()288 defer frozen.unfreeze()
289+ if !frozen.acquired || !frozen.drained {
290+ if req.activationBarrier {
291+ _ = req.next.closeCapturedTransport(req.expectedTransport.transport)
292+ return attachmentTransitionResult{}, errSendTimedOut
293+ }
294+ return attachmentTransitionResult{}, errAttachmentTransition
295+ }
274 req.expectedTargetTransportInterrupted = frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)296 req.expectedTargetTransportInterrupted = frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
275 if d.afterRoleEffectsFrozen != nil {297 if d.afterRoleEffectsFrozen != nil {
276 d.afterRoleEffectsFrozen()298 d.afterRoleEffectsFrozen()
277 }299 }
278 300
301+ var releaseActivation func()
302+ if req.activationBarrier {
303+ var err error
304+ releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken)
305+ if err != nil {
306+ return attachmentTransitionResult{}, err
307+ }
308+ defer func() {
309+ if releaseActivation != nil {
310+ releaseActivation()
311+ }
312+ }()
313+ }
314+
279 d.mu.Lock()315 d.mu.Lock()
280 req.roleEffectsFrozen = true316 req.roleEffectsFrozen = true
281 if req.target == nil {317 if req.target == nil {
@@ -293,6 +329,24 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
293 }329 }
294 result, err := d.transitionAttachmentLocked(req)330 result, err := d.transitionAttachmentLocked(req)
295 d.mu.Unlock()331 d.mu.Unlock()
332+ if err == nil && req.activationBarrier && result.published.role == attachmentActive {
333+ ac := result.published.ac
334+ if ac.output != nil {
335+ ac.output.rebase()
336+ }
337+ ac.captureFrames = nil
338+ d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false)
339+ // The first paint still requests a reset, but the old panel dependency
340+ // chain has already been rebased atomically under the activation barrier.
341+ result.published.rebase = false
342+ }
343+ if releaseActivation != nil {
344+ releaseActivation()
345+ releaseActivation = nil
346+ }
347+ if err == nil && result.published.role == attachmentActive {
348+ result.published.ac.clearSnatchedInput()
349+ }
296 return result, err350 return result, err
297 }351 }
298 352
@@ -543,6 +597,22 @@ func (d *Daemon) sendSnatchedControl(token attachmentRoleToken, frame ports.Fram
543 return err597 return err
544 }598 }
545 599
600+// boundedSendSnatchedControl keeps a best-effort control acknowledgement bound
601+// to the exact admitted role and link incarnation. On deadline it closes only
602+// that captured link, releasing either the transport Send or a wait on sendMu.
603+func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error {
604+ if token.ac == nil || token.effect == nil || token.transport.transport == nil {
605+ return errAttachmentTransition
606+ }
607+ _, err := d.boundedSendWith(token.transport.transport, func() error {
608+ return d.sendSnatchedControl(token, frame)
609+ })
610+ if errors.Is(err, errSendTimedOut) {
611+ _ = token.ac.closeCapturedTransport(token.transport.transport)
612+ }
613+ return err
614+}
615+
546 // removeSnatchedAttachment removes exactly one role generation and transport616 // removeSnatchedAttachment removes exactly one role generation and transport
547 // from session routing. It never touches the active attachment or closes a617 // from session routing. It never touches the active attachment or closes a
548 // transport, allowing callers that already interrupted a blocked send to avoid618 // transport, allowing callers that already interrupted a blocked send to avoid
@@ -569,6 +639,7 @@ func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool {
569 return false639 return false
570 }640 }
571 641
642+ token.ac.clearSnatchedInput()
572 d.unregisterPreview(token.ac)643 d.unregisterPreview(token.ac)
573 token.ac.clearPreviousSession()644 token.ac.clearPreviousSession()
574 token.ac.setSession(nil)645 token.ac.setSession(nil)
@@ -620,6 +691,7 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
620 }691 }
621 692
622 _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))693 _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
694+ token.ac.clearSnatchedInput()
623 d.unregisterPreview(token.ac)695 d.unregisterPreview(token.ac)
624 token.ac.clearPreviousSession()696 token.ac.clearPreviousSession()
625 token.ac.setSession(nil)697 token.ac.setSession(nil)
A internal/usecase/daemon/snatched_input.go +261 -0
@@ -0,0 +1,261 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/usecase/keys"
8+)
9+
10+const snatchedInputSequenceLimit = 64
11+
12+type snatchedInputAction uint8
13+
14+const (
15+ snatchedInputNone snatchedInputAction = iota
16+ snatchedInputReclaim
17+ snatchedInputQuit
18+)
19+
20+// clearSnatchedInput invalidates every parser continuation before stopping its
21+// timer. Callers must not hold daemon, routing, session, coordinator, send, or
22+// transport locks because Timer.Stop is an external call.
23+func (ac *attachedClient) clearSnatchedInput() {
24+ if ac == nil {
25+ return
26+ }
27+ ac.snatchedInputMu.Lock()
28+ ac.snatchedInputPending = nil
29+ ac.snatchedInputDrain = false
30+ pendingESC := ac.snatchedInputESC
31+ ac.snatchedInputESC = pendingByteTimer{}
32+ ac.snatchedInputMu.Unlock()
33+ pendingESC.stop()
34+}
35+
36+// handleSnatchedInput accepts actions only as exact one-byte input events.
37+// Terminal escape sequences and arbitrary coalesced input are consumed as
38+// indivisible events so a byte in a paste, sequence final, or frame tail can
39+// never become an action.
40+func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, input ports.Input) bool {
41+ action := d.parseSnatchedInput(token, input.Data)
42+ switch action {
43+ case snatchedInputReclaim:
44+ d.reclaimSnatchedAttachment(token)
45+ case snatchedInputQuit:
46+ return d.quitSnatchedAttachment(token)
47+ }
48+ return false
49+}
50+
51+func (d *Daemon) parseSnatchedInput(token attachmentRoleToken, data []byte) snatchedInputAction {
52+ ac := token.ac
53+ if ac == nil || len(data) == 0 {
54+ return snatchedInputNone
55+ }
56+
57+ ac.snatchedInputMu.Lock()
58+
59+ if ac.snatchedInputDrain {
60+ if snatchedSequenceFinal(data) >= 0 {
61+ ac.snatchedInputDrain = false
62+ }
63+ ac.snatchedInputMu.Unlock()
64+ return snatchedInputNone
65+ }
66+
67+ if len(ac.snatchedInputPending) != 0 {
68+ pendingESC := ac.snatchedInputESC
69+ ac.snatchedInputESC = pendingByteTimer{}
70+ combined := make([]byte, 0, len(ac.snatchedInputPending)+len(data))
71+ combined = append(combined, ac.snatchedInputPending...)
72+ combined = append(combined, data...)
73+ ac.snatchedInputPending = nil
74+ d.retainSnatchedSequenceLocked(ac, combined)
75+ ac.snatchedInputMu.Unlock()
76+ pendingESC.stop()
77+ return snatchedInputNone
78+ }
79+
80+ if len(data) == 1 {
81+ var action snatchedInputAction
82+ switch data[0] {
83+ case 'r', 'R':
84+ action = snatchedInputReclaim
85+ case 'q', 'Q':
86+ action = snatchedInputQuit
87+ case keys.ESC:
88+ ac.snatchedInputPending = append(ac.snatchedInputPending[:0], keys.ESC)
89+ ac.snatchedInputESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
90+ d.fireSnatchedEscape(token, timer)
91+ })
92+ }
93+ ac.snatchedInputMu.Unlock()
94+ return action
95+ }
96+
97+ // A complete/coalesced event is always ignored. Only an incomplete CSI or
98+ // SS3 prefix is retained to prevent its future final byte from acting.
99+ d.retainSnatchedSequenceLocked(ac, data)
100+ ac.snatchedInputMu.Unlock()
101+ return snatchedInputNone
102+}
103+
104+func (d *Daemon) retainSnatchedSequenceLocked(ac *attachedClient, data []byte) {
105+ if !snatchedSequencePrefix(data) || snatchedSequenceFinal(data[2:]) >= 0 {
106+ return
107+ }
108+ if len(data) > snatchedInputSequenceLimit {
109+ ac.snatchedInputDrain = true
110+ return
111+ }
112+ ac.snatchedInputPending = append(ac.snatchedInputPending[:0], data...)
113+}
114+
115+func snatchedSequencePrefix(data []byte) bool {
116+ return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O')
117+}
118+
119+func snatchedSequenceFinal(data []byte) int {
120+ for i, b := range data {
121+ if b >= 0x40 && b <= 0x7e {
122+ return i
123+ }
124+ }
125+ return -1
126+}
127+
128+func (d *Daemon) fireSnatchedEscape(token attachmentRoleToken, timer ports.Timer) {
129+ ac := token.ac
130+ if ac == nil {
131+ return
132+ }
133+ ac.snatchedInputMu.Lock()
134+ if ac.snatchedInputESC.timer != timer || len(ac.snatchedInputPending) != 1 || ac.snatchedInputPending[0] != keys.ESC {
135+ ac.snatchedInputMu.Unlock()
136+ return
137+ }
138+ ac.snatchedInputPending = nil
139+ ac.snatchedInputESC.timer = nil
140+ ac.snatchedInputESC.done = nil
141+ ac.snatchedInputMu.Unlock()
142+
143+ if d.afterSnatchedEscapeAccepted != nil {
144+ d.afterSnatchedEscapeAccepted()
145+ }
146+ if !token.current() {
147+ if d.afterSnatchedEscapeAttempt != nil {
148+ d.afterSnatchedEscapeAttempt(false)
149+ }
150+ return
151+ }
152+ ticket, admitted := ac.beginRoleEffect(token)
153+ if d.afterSnatchedEscapeAttempt != nil {
154+ d.afterSnatchedEscapeAttempt(admitted)
155+ }
156+ if !admitted {
157+ return
158+ }
159+ token.effect = ticket
160+ defer token.endRoleEffect()
161+ d.quitSnatchedAttachment(token)
162+}
163+
164+// acquireActivationBarrier waits for the requester's serialized output path
165+// without retaining role, daemon, routing, session, coordinator, or pane locks.
166+// The caller has already frozen and drained the requester's role gate, so no new
167+// role-bound sender can race this acquisition. A deadline closes only the exact
168+// captured link, which releases a transport Send currently holding sendMu.
169+func (d *Daemon) acquireActivationBarrier(token attachmentRoleToken) (func(), error) {
170+ if token.ac == nil || token.transport.transport == nil || !token.current() {
171+ return nil, errAttachmentTransition
172+ }
173+
174+ acquired := make(chan struct{})
175+ cancel := make(chan struct{})
176+ go func() {
177+ token.ac.sendMu.Lock()
178+ select {
179+ case acquired <- struct{}{}:
180+ // The receiver now owns the matching Unlock obligation.
181+ case <-cancel:
182+ token.ac.sendMu.Unlock()
183+ }
184+ }()
185+
186+ timer := d.clock.NewTimer(detachNotifyTimeout)
187+ select {
188+ case <-acquired:
189+ timer.Stop()
190+ if token.ac.roleGeneration.Load() != token.generation ||
191+ !token.ac.transportSnapshotCurrent(token.transport) {
192+ token.ac.sendMu.Unlock()
193+ return nil, errAttachmentTransition
194+ }
195+ return token.ac.sendMu.Unlock, nil
196+ case <-timer.C():
197+ close(cancel)
198+ _ = token.ac.closeCapturedTransport(token.transport.transport)
199+ return nil, errSendTimedOut
200+ }
201+}
202+
203+func (d *Daemon) reclaimSnatchedAttachment(token attachmentRoleToken) bool {
204+ if token.role != attachmentSnatched || token.effect == nil {
205+ return false
206+ }
207+ token.effect.bindActionEnd(d, "reclaim-snatched")
208+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
209+ source: token.sess, target: token.sess, next: token.ac,
210+ expectedRole: attachmentSnatched, targetRole: attachmentActive,
211+ expectedTransport: token.transport, sourceToken: &token,
212+ action: "reclaim-snatched", ready: true, activationBarrier: true,
213+ })
214+ if err != nil {
215+ if errors.Is(err, errSendTimedOut) {
216+ d.attachmentCleanupWg.Go(func() { d.dropSnatchedAttachment(token) })
217+ } else {
218+ d.showSnatchedUnavailable(token)
219+ }
220+ return false
221+ }
222+ d.deferAttachmentTransitionCleanups(transition)
223+ if d.beforeReclaimFirstPaint != nil {
224+ d.beforeReclaimFirstPaint(transition.published)
225+ }
226+ d.firstPaintForTransition(transition.published)
227+ return true
228+}
229+
230+// showSnatchedUnavailable is a best-effort precommit failure panel. Fresh role
231+// admission and the generation-aware sender make it inert after a concurrent
232+// reclaim, quit, transport replacement, or session teardown.
233+func (d *Daemon) showSnatchedUnavailable(token attachmentRoleToken) {
234+ if !token.current() {
235+ return
236+ }
237+ fresh := token
238+ fresh.effect = nil
239+ ticket, admitted := token.ac.beginRoleEffect(fresh)
240+ if !admitted {
241+ return
242+ }
243+ fresh.effect = ticket
244+ defer fresh.endRoleEffect()
245+ if err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "Session is no longer available.", ticket); err != nil {
246+ fresh.endRoleEffect()
247+ d.dropSnatchedAttachment(token)
248+ }
249+}
250+
251+func (d *Daemon) quitSnatchedAttachment(token attachmentRoleToken) bool {
252+ if token.role != attachmentSnatched || token.effect == nil {
253+ return false
254+ }
255+ token.effect.bindActionEnd(d, "quit-snatched")
256+ _ = d.boundedSendSnatchedControl(token, frameDetached(ports.ReasonDetach))
257+ // Terminal cleanup freezes the same role gate, so release this action's
258+ // admission before attempting the generation-bound removal.
259+ token.endRoleEffect()
260+ return d.dropSnatchedAttachment(token)
261+}
@@ -0,0 +1,261 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/usecase/keys"
8+)
9+
10+const snatchedInputSequenceLimit = 64
11+
12+type snatchedInputAction uint8
13+
14+const (
15+ snatchedInputNone snatchedInputAction = iota
16+ snatchedInputReclaim
17+ snatchedInputQuit
18+)
19+
20+// clearSnatchedInput invalidates every parser continuation before stopping its
21+// timer. Callers must not hold daemon, routing, session, coordinator, send, or
22+// transport locks because Timer.Stop is an external call.
23+func (ac *attachedClient) clearSnatchedInput() {
24+ if ac == nil {
25+ return
26+ }
27+ ac.snatchedInputMu.Lock()
28+ ac.snatchedInputPending = nil
29+ ac.snatchedInputDrain = false
30+ pendingESC := ac.snatchedInputESC
31+ ac.snatchedInputESC = pendingByteTimer{}
32+ ac.snatchedInputMu.Unlock()
33+ pendingESC.stop()
34+}
35+
36+// handleSnatchedInput accepts actions only as exact one-byte input events.
37+// Terminal escape sequences and arbitrary coalesced input are consumed as
38+// indivisible events so a byte in a paste, sequence final, or frame tail can
39+// never become an action.
40+func (d *Daemon) handleSnatchedInput(token attachmentRoleToken, input ports.Input) bool {
41+ action := d.parseSnatchedInput(token, input.Data)
42+ switch action {
43+ case snatchedInputReclaim:
44+ d.reclaimSnatchedAttachment(token)
45+ case snatchedInputQuit:
46+ return d.quitSnatchedAttachment(token)
47+ }
48+ return false
49+}
50+
51+func (d *Daemon) parseSnatchedInput(token attachmentRoleToken, data []byte) snatchedInputAction {
52+ ac := token.ac
53+ if ac == nil || len(data) == 0 {
54+ return snatchedInputNone
55+ }
56+
57+ ac.snatchedInputMu.Lock()
58+
59+ if ac.snatchedInputDrain {
60+ if snatchedSequenceFinal(data) >= 0 {
61+ ac.snatchedInputDrain = false
62+ }
63+ ac.snatchedInputMu.Unlock()
64+ return snatchedInputNone
65+ }
66+
67+ if len(ac.snatchedInputPending) != 0 {
68+ pendingESC := ac.snatchedInputESC
69+ ac.snatchedInputESC = pendingByteTimer{}
70+ combined := make([]byte, 0, len(ac.snatchedInputPending)+len(data))
71+ combined = append(combined, ac.snatchedInputPending...)
72+ combined = append(combined, data...)
73+ ac.snatchedInputPending = nil
74+ d.retainSnatchedSequenceLocked(ac, combined)
75+ ac.snatchedInputMu.Unlock()
76+ pendingESC.stop()
77+ return snatchedInputNone
78+ }
79+
80+ if len(data) == 1 {
81+ var action snatchedInputAction
82+ switch data[0] {
83+ case 'r', 'R':
84+ action = snatchedInputReclaim
85+ case 'q', 'Q':
86+ action = snatchedInputQuit
87+ case keys.ESC:
88+ ac.snatchedInputPending = append(ac.snatchedInputPending[:0], keys.ESC)
89+ ac.snatchedInputESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
90+ d.fireSnatchedEscape(token, timer)
91+ })
92+ }
93+ ac.snatchedInputMu.Unlock()
94+ return action
95+ }
96+
97+ // A complete/coalesced event is always ignored. Only an incomplete CSI or
98+ // SS3 prefix is retained to prevent its future final byte from acting.
99+ d.retainSnatchedSequenceLocked(ac, data)
100+ ac.snatchedInputMu.Unlock()
101+ return snatchedInputNone
102+}
103+
104+func (d *Daemon) retainSnatchedSequenceLocked(ac *attachedClient, data []byte) {
105+ if !snatchedSequencePrefix(data) || snatchedSequenceFinal(data[2:]) >= 0 {
106+ return
107+ }
108+ if len(data) > snatchedInputSequenceLimit {
109+ ac.snatchedInputDrain = true
110+ return
111+ }
112+ ac.snatchedInputPending = append(ac.snatchedInputPending[:0], data...)
113+}
114+
115+func snatchedSequencePrefix(data []byte) bool {
116+ return len(data) >= 2 && data[0] == keys.ESC && (data[1] == '[' || data[1] == 'O')
117+}
118+
119+func snatchedSequenceFinal(data []byte) int {
120+ for i, b := range data {
121+ if b >= 0x40 && b <= 0x7e {
122+ return i
123+ }
124+ }
125+ return -1
126+}
127+
128+func (d *Daemon) fireSnatchedEscape(token attachmentRoleToken, timer ports.Timer) {
129+ ac := token.ac
130+ if ac == nil {
131+ return
132+ }
133+ ac.snatchedInputMu.Lock()
134+ if ac.snatchedInputESC.timer != timer || len(ac.snatchedInputPending) != 1 || ac.snatchedInputPending[0] != keys.ESC {
135+ ac.snatchedInputMu.Unlock()
136+ return
137+ }
138+ ac.snatchedInputPending = nil
139+ ac.snatchedInputESC.timer = nil
140+ ac.snatchedInputESC.done = nil
141+ ac.snatchedInputMu.Unlock()
142+
143+ if d.afterSnatchedEscapeAccepted != nil {
144+ d.afterSnatchedEscapeAccepted()
145+ }
146+ if !token.current() {
147+ if d.afterSnatchedEscapeAttempt != nil {
148+ d.afterSnatchedEscapeAttempt(false)
149+ }
150+ return
151+ }
152+ ticket, admitted := ac.beginRoleEffect(token)
153+ if d.afterSnatchedEscapeAttempt != nil {
154+ d.afterSnatchedEscapeAttempt(admitted)
155+ }
156+ if !admitted {
157+ return
158+ }
159+ token.effect = ticket
160+ defer token.endRoleEffect()
161+ d.quitSnatchedAttachment(token)
162+}
163+
164+// acquireActivationBarrier waits for the requester's serialized output path
165+// without retaining role, daemon, routing, session, coordinator, or pane locks.
166+// The caller has already frozen and drained the requester's role gate, so no new
167+// role-bound sender can race this acquisition. A deadline closes only the exact
168+// captured link, which releases a transport Send currently holding sendMu.
169+func (d *Daemon) acquireActivationBarrier(token attachmentRoleToken) (func(), error) {
170+ if token.ac == nil || token.transport.transport == nil || !token.current() {
171+ return nil, errAttachmentTransition
172+ }
173+
174+ acquired := make(chan struct{})
175+ cancel := make(chan struct{})
176+ go func() {
177+ token.ac.sendMu.Lock()
178+ select {
179+ case acquired <- struct{}{}:
180+ // The receiver now owns the matching Unlock obligation.
181+ case <-cancel:
182+ token.ac.sendMu.Unlock()
183+ }
184+ }()
185+
186+ timer := d.clock.NewTimer(detachNotifyTimeout)
187+ select {
188+ case <-acquired:
189+ timer.Stop()
190+ if token.ac.roleGeneration.Load() != token.generation ||
191+ !token.ac.transportSnapshotCurrent(token.transport) {
192+ token.ac.sendMu.Unlock()
193+ return nil, errAttachmentTransition
194+ }
195+ return token.ac.sendMu.Unlock, nil
196+ case <-timer.C():
197+ close(cancel)
198+ _ = token.ac.closeCapturedTransport(token.transport.transport)
199+ return nil, errSendTimedOut
200+ }
201+}
202+
203+func (d *Daemon) reclaimSnatchedAttachment(token attachmentRoleToken) bool {
204+ if token.role != attachmentSnatched || token.effect == nil {
205+ return false
206+ }
207+ token.effect.bindActionEnd(d, "reclaim-snatched")
208+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
209+ source: token.sess, target: token.sess, next: token.ac,
210+ expectedRole: attachmentSnatched, targetRole: attachmentActive,
211+ expectedTransport: token.transport, sourceToken: &token,
212+ action: "reclaim-snatched", ready: true, activationBarrier: true,
213+ })
214+ if err != nil {
215+ if errors.Is(err, errSendTimedOut) {
216+ d.attachmentCleanupWg.Go(func() { d.dropSnatchedAttachment(token) })
217+ } else {
218+ d.showSnatchedUnavailable(token)
219+ }
220+ return false
221+ }
222+ d.deferAttachmentTransitionCleanups(transition)
223+ if d.beforeReclaimFirstPaint != nil {
224+ d.beforeReclaimFirstPaint(transition.published)
225+ }
226+ d.firstPaintForTransition(transition.published)
227+ return true
228+}
229+
230+// showSnatchedUnavailable is a best-effort precommit failure panel. Fresh role
231+// admission and the generation-aware sender make it inert after a concurrent
232+// reclaim, quit, transport replacement, or session teardown.
233+func (d *Daemon) showSnatchedUnavailable(token attachmentRoleToken) {
234+ if !token.current() {
235+ return
236+ }
237+ fresh := token
238+ fresh.effect = nil
239+ ticket, admitted := token.ac.beginRoleEffect(fresh)
240+ if !admitted {
241+ return
242+ }
243+ fresh.effect = ticket
244+ defer fresh.endRoleEffect()
245+ if err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "Session is no longer available.", ticket); err != nil {
246+ fresh.endRoleEffect()
247+ d.dropSnatchedAttachment(token)
248+ }
249+}
250+
251+func (d *Daemon) quitSnatchedAttachment(token attachmentRoleToken) bool {
252+ if token.role != attachmentSnatched || token.effect == nil {
253+ return false
254+ }
255+ token.effect.bindActionEnd(d, "quit-snatched")
256+ _ = d.boundedSendSnatchedControl(token, frameDetached(ports.ReasonDetach))
257+ // Terminal cleanup freezes the same role gate, so release this action's
258+ // admission before attempting the generation-bound removal.
259+ token.endRoleEffect()
260+ return d.dropSnatchedAttachment(token)
261+}
A internal/usecase/daemon/snatched_input_test.go +365 -0
@@ -0,0 +1,365 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/keys"
16+)
17+
18+type snatchedInputTimer struct {
19+ ch chan time.Time
20+}
21+
22+func newSnatchedInputTimer() *snatchedInputTimer {
23+ return &snatchedInputTimer{ch: make(chan time.Time, 1)}
24+}
25+
26+func (t *snatchedInputTimer) C() <-chan time.Time { return t.ch }
27+func (*snatchedInputTimer) Reset(time.Duration) bool { return false }
28+func (*snatchedInputTimer) Stop() bool { return true }
29+func (t *snatchedInputTimer) Fire() { t.ch <- time.Time{} }
30+
31+type snatchedInputClock struct {
32+ timers chan *snatchedInputTimer
33+}
34+
35+func newSnatchedInputClock() *snatchedInputClock {
36+ return &snatchedInputClock{timers: make(chan *snatchedInputTimer, 8)}
37+}
38+
39+func (*snatchedInputClock) Now() time.Time { return time.Time{} }
40+func (c *snatchedInputClock) NewTimer(delay time.Duration) ports.Timer {
41+ if delay != keys.ESCDelay {
42+ return stubTimer{}
43+ }
44+ timer := newSnatchedInputTimer()
45+ c.timers <- timer
46+ return timer
47+}
48+
49+type snatchedCloseSignalTransport struct {
50+ mu sync.Mutex
51+ closed bool
52+ closedCh chan struct{}
53+ closeOnce sync.Once
54+}
55+
56+func newSnatchedCloseSignalTransport() *snatchedCloseSignalTransport {
57+ return &snatchedCloseSignalTransport{closedCh: make(chan struct{})}
58+}
59+
60+func (*snatchedCloseSignalTransport) Send(ports.Frame) error { return nil }
61+func (*snatchedCloseSignalTransport) Recv() (ports.Frame, error) {
62+ return ports.Frame{}, errors.New("closed")
63+}
64+func (t *snatchedCloseSignalTransport) Close() error {
65+ t.mu.Lock()
66+ t.closed = true
67+ t.mu.Unlock()
68+ t.closeOnce.Do(func() { close(t.closedCh) })
69+ return nil
70+}
71+func (t *snatchedCloseSignalTransport) Closed() bool {
72+ t.mu.Lock()
73+ defer t.mu.Unlock()
74+ return t.closed
75+}
76+
77+type blockedSnatchedQuitTransport struct {
78+ recv chan ports.Frame
79+ sendStarted chan struct{}
80+ sendDone chan struct{}
81+ closed chan struct{}
82+ closeOnce sync.Once
83+ sendOnce sync.Once
84+ mu sync.Mutex
85+ isClosed bool
86+}
87+
88+func newBlockedSnatchedQuitTransport() *blockedSnatchedQuitTransport {
89+ return &blockedSnatchedQuitTransport{
90+ recv: make(chan ports.Frame, 1),
91+ sendStarted: make(chan struct{}),
92+ sendDone: make(chan struct{}),
93+ closed: make(chan struct{}),
94+ }
95+}
96+
97+func (t *blockedSnatchedQuitTransport) Send(frame ports.Frame) error {
98+ if frame.Type != ports.MsgDetached {
99+ return nil
100+ }
101+ t.sendOnce.Do(func() { close(t.sendStarted) })
102+ <-t.closed
103+ close(t.sendDone)
104+ return io.ErrClosedPipe
105+}
106+
107+func (t *blockedSnatchedQuitTransport) Recv() (ports.Frame, error) {
108+ select {
109+ case frame := <-t.recv:
110+ return frame, nil
111+ case <-t.closed:
112+ return ports.Frame{}, io.ErrClosedPipe
113+ }
114+}
115+
116+func (t *blockedSnatchedQuitTransport) Close() error {
117+ t.mu.Lock()
118+ t.isClosed = true
119+ t.mu.Unlock()
120+ t.closeOnce.Do(func() { close(t.closed) })
121+ return nil
122+}
123+
124+func (t *blockedSnatchedQuitTransport) Closed() bool {
125+ t.mu.Lock()
126+ defer t.mu.Unlock()
127+ return t.isClosed
128+}
129+
130+func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session, *attachedClient, *attachedClient, *snatchedCloseSignalTransport) {
131+ t.Helper()
132+ d := newTestDaemon(t, nil, clock)
133+ waitingTransport := newSnatchedCloseSignalTransport()
134+ waiting := &attachedClient{
135+ tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24},
136+ }
137+ active := &attachedClient{
138+ tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: waiting.size,
139+ }
140+ waiting.initOverlays()
141+ active.initOverlays()
142+ sess := &session{
143+ id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}},
144+ }
145+ waiting.setSession(sess)
146+ active.setSession(sess)
147+ d.sessions[sess.id] = sess
148+ d.attachCoordinator(sess, nil, active, true)
149+ return d, sess, waiting, active, waitingTransport
150+}
151+
152+func sendSnatchedInput(d *Daemon, sess *session, ac *attachedClient, data string) bool {
153+ tr := ac.transport()
154+ token := sess.attachmentToken(ac, tr)
155+ return d.handleSnatchedClientFrame(token, ports.Frame{
156+ Type: ports.MsgInput,
157+ Payload: ports.MarshalInput(ports.Input{Data: []byte(data)}),
158+ })
159+}
160+
161+func TestSnatchedInputStrictActions(t *testing.T) {
162+ tests := []struct {
163+ name string
164+ chunks []string
165+ fireEscape bool
166+ wantRole attachmentRole
167+ wantClosed bool
168+ }{
169+ {name: "lowercase reclaim", chunks: []string{"r"}, wantRole: attachmentActive},
170+ {name: "uppercase reclaim", chunks: []string{"R"}, wantRole: attachmentActive},
171+ {name: "lowercase quit", chunks: []string{"q"}, wantRole: attachmentDetached, wantClosed: true},
172+ {name: "uppercase quit", chunks: []string{"Q"}, wantRole: attachmentDetached, wantClosed: true},
173+ {name: "standalone escape quits after delay", chunks: []string{"\x1b"}, fireEscape: true, wantRole: attachmentDetached, wantClosed: true},
174+ {name: "coalesced reclaim ignored", chunks: []string{"rr"}, wantRole: attachmentSnatched},
175+ {name: "paste containing quit ignored", chunks: []string{"paste q"}, wantRole: attachmentSnatched},
176+ {name: "complete CSI ignored", chunks: []string{"\x1b[r"}, wantRole: attachmentSnatched},
177+ {name: "complete SS3 ignored", chunks: []string{"\x1bOR"}, wantRole: attachmentSnatched},
178+ {name: "complete sequence tail ignored", chunks: []string{"\x1b[Aq"}, wantRole: attachmentSnatched},
179+ {name: "split CSI final reclaim ignored", chunks: []string{"\x1b[", "r"}, wantRole: attachmentSnatched},
180+ {name: "split SS3 final reclaim ignored", chunks: []string{"\x1bO", "R"}, wantRole: attachmentSnatched},
181+ {name: "split sequence frame tail ignored", chunks: []string{"\x1b[", "Aq"}, wantRole: attachmentSnatched},
182+ {name: "escape followed by action ignored as alt chord", chunks: []string{"\x1b", "q"}, wantRole: attachmentSnatched},
183+ {name: "CSI prefix remains inert", chunks: []string{"\x1b[", "12;"}, wantRole: attachmentSnatched},
184+ {name: "overflow drain consumes final and frame tail", chunks: []string{"\x1b[", strings.Repeat(";", 80), "rQ"}, wantRole: attachmentSnatched},
185+ }
186+
187+ for _, tt := range tests {
188+ t.Run(tt.name, func(t *testing.T) {
189+ clock := newSnatchedInputClock()
190+ d, sess, waiting, active, waitingTransport := newSnatchedInputFixture(t, clock)
191+ for _, chunk := range tt.chunks {
192+ sendSnatchedInput(d, sess, waiting, chunk)
193+ }
194+ if tt.fireEscape {
195+ require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting), "escape acted before its delay")
196+ require.False(t, waitingTransport.Closed(), "escape closed the transport before its delay")
197+ timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer")
198+ timer.Fire()
199+ awaitTestCompletion(t, waitingTransport.closedCh, "standalone escape did not quit")
200+ }
201+ d.attachmentCleanupWg.Wait()
202+
203+ require.Equal(t, tt.wantRole, sess.attachmentRole(waiting))
204+ require.Equal(t, tt.wantClosed, waitingTransport.Closed())
205+ if tt.wantRole == attachmentActive {
206+ require.Same(t, waiting, sess.client)
207+ require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
208+ } else {
209+ require.Same(t, active, sess.client)
210+ }
211+ })
212+ }
213+}
214+
215+func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) {
216+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
217+ waitingTransport := newBlockedSnatchedQuitTransport()
218+ defer waitingTransport.Close()
219+ activeTransport := &closeTrackingTransport{}
220+ d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
221+
222+ otherTransport := &closeTrackingTransport{}
223+ other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: waiting.size}
224+ other.initOverlays()
225+ other.setSession(sess)
226+ sess.mu.Lock()
227+ sess.addSnatchedLocked(other)
228+ sess.mu.Unlock()
229+ otherToken := sess.attachmentToken(other, otherTransport)
230+ require.Equal(t, attachmentSnatched, otherToken.role)
231+
232+ ownerLease := sess.renderCoordinator().attachmentLease(active)
233+ require.NotNil(t, ownerLease)
234+ activeGeneration := active.roleGeneration.Load()
235+ otherGeneration := other.roleGeneration.Load()
236+
237+ loopDone := make(chan struct{})
238+ go func() {
239+ d.runConnLoop(waiting)
240+ close(loopDone)
241+ }()
242+ waitingTransport.recv <- ports.Frame{
243+ Type: ports.MsgInput,
244+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}),
245+ }
246+ awaitTestCompletion(t, waitingTransport.sendStarted, "snatched quit did not attempt its detach acknowledgement")
247+ select {
248+ case <-loopDone:
249+ t.Fatal("snatched quit returned before its acknowledgement deadline")
250+ default:
251+ }
252+
253+ timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline")
254+ require.Equal(t, detachNotifyTimeout, timer.duration)
255+ timer.ch <- time.Time{}
256+ awaitTestCompletion(t, waitingTransport.sendDone, "closing the captured link did not release its blocked send")
257+ awaitTestCompletion(t, loopDone, "snatched connection parser did not retire after the acknowledgement deadline")
258+
259+ require.True(t, waitingTransport.Closed())
260+ requireRoleGateRetired(t, waiting)
261+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
262+ require.Nil(t, waiting.currentSession())
263+ require.Nil(t, waiting.transport())
264+ require.Same(t, active, sess.client)
265+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
266+ require.Same(t, ownerLease, sess.renderCoordinator().attachmentLease(active))
267+ require.Equal(t, activeGeneration, active.roleGeneration.Load())
268+ require.False(t, activeTransport.Closed())
269+ require.Equal(t, attachmentSnatched, sess.attachmentRole(other))
270+ require.Equal(t, otherGeneration, other.roleGeneration.Load())
271+ require.Same(t, otherTransport, other.transport())
272+ require.False(t, otherTransport.Closed())
273+}
274+
275+func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) {
276+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
277+ staleTransport := newBlockedSnatchedQuitTransport()
278+ defer staleTransport.Close()
279+ ownerTransport := &closeTrackingTransport{}
280+ d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport)
281+
282+ ticketEnded := make(chan struct{})
283+ releaseQuit := make(chan struct{})
284+ d.afterActionRoleEffectEnded = func(action string) {
285+ if action == "quit-snatched" {
286+ close(ticketEnded)
287+ <-releaseQuit
288+ }
289+ }
290+ loopDone := make(chan struct{})
291+ go func() {
292+ d.runConnLoop(waiting)
293+ close(loopDone)
294+ }()
295+ staleTransport.recv <- ports.Frame{
296+ Type: ports.MsgInput,
297+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'Q'}}),
298+ }
299+ awaitTestCompletion(t, staleTransport.sendStarted, "snatched quit did not block in its captured transport")
300+ timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline")
301+ timer.ch <- time.Time{}
302+ awaitTestCompletion(t, staleTransport.sendDone, "deadline close did not retire the captured send")
303+ awaitTestCompletion(t, ticketEnded, "snatched quit did not end its role ticket before cleanup")
304+
305+ freshTransport := &closeTrackingTransport{}
306+ waiting.replaceTransport(freshTransport)
307+ reclaimed, err := d.transitionAttachment(attachmentTransitionRequest{
308+ target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive,
309+ expectedTransport: waiting.transportSnapshot(), ready: true,
310+ })
311+ require.NoError(t, err)
312+ d.deferAttachmentTransitionCleanups(reclaimed)
313+ freshGeneration := reclaimed.published.generation
314+ freshLease := reclaimed.published.lease
315+
316+ close(releaseQuit)
317+ awaitTestCompletion(t, loopDone, "stale quit parser did not retire after reclaim")
318+ d.attachmentCleanupWg.Wait()
319+
320+ require.True(t, staleTransport.Closed())
321+ requireRoleGateRetired(t, waiting)
322+ require.Same(t, waiting, sess.client)
323+ require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
324+ require.Equal(t, freshGeneration, waiting.roleGeneration.Load())
325+ require.Same(t, freshTransport, waiting.transport())
326+ require.False(t, freshTransport.Closed(), "stale quit cleanup closed the reclaimed transport")
327+ require.Same(t, freshLease, sess.renderCoordinator().attachmentLease(waiting))
328+ require.Equal(t, attachmentSnatched, sess.attachmentRole(owner))
329+ require.False(t, ownerTransport.Closed(), "stale quit cleanup affected the displaced owner")
330+}
331+
332+func TestSnatchedInputTimerCannotAffectNewRoleOrTransport(t *testing.T) {
333+ clock := newSnatchedInputClock()
334+ d, sess, waiting, _, oldTransport := newSnatchedInputFixture(t, clock)
335+
336+ sendSnatchedInput(d, sess, waiting, "\x1b")
337+ timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer")
338+ accepted := make(chan struct{})
339+ release := make(chan struct{})
340+ attempted := make(chan bool, 1)
341+ d.afterSnatchedEscapeAccepted = func() {
342+ close(accepted)
343+ <-release
344+ }
345+ d.afterSnatchedEscapeAttempt = func(admitted bool) { attempted <- admitted }
346+ timer.Fire()
347+ awaitTestCompletion(t, accepted, "escape timer callback did not consume parser state")
348+
349+ result, err := d.transitionAttachment(attachmentTransitionRequest{
350+ target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive,
351+ expectedTransport: waiting.transportSnapshot(), ready: true,
352+ })
353+ require.NoError(t, err)
354+ d.deferAttachmentTransitionCleanups(result)
355+ newTransport := newSnatchedCloseSignalTransport()
356+ waiting.replaceTransport(newTransport)
357+ close(release)
358+ require.False(t, awaitTestValue(t, attempted, "escape callback did not revalidate its stale role"))
359+ d.attachmentCleanupWg.Wait()
360+
361+ require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
362+ require.Same(t, newTransport, waiting.transport())
363+ require.False(t, newTransport.Closed(), "stale timer closed the replacement transport")
364+ require.False(t, oldTransport.Closed(), "stale timer closed the old transport after role activation")
365+}
@@ -0,0 +1,365 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/keys"
16+)
17+
18+type snatchedInputTimer struct {
19+ ch chan time.Time
20+}
21+
22+func newSnatchedInputTimer() *snatchedInputTimer {
23+ return &snatchedInputTimer{ch: make(chan time.Time, 1)}
24+}
25+
26+func (t *snatchedInputTimer) C() <-chan time.Time { return t.ch }
27+func (*snatchedInputTimer) Reset(time.Duration) bool { return false }
28+func (*snatchedInputTimer) Stop() bool { return true }
29+func (t *snatchedInputTimer) Fire() { t.ch <- time.Time{} }
30+
31+type snatchedInputClock struct {
32+ timers chan *snatchedInputTimer
33+}
34+
35+func newSnatchedInputClock() *snatchedInputClock {
36+ return &snatchedInputClock{timers: make(chan *snatchedInputTimer, 8)}
37+}
38+
39+func (*snatchedInputClock) Now() time.Time { return time.Time{} }
40+func (c *snatchedInputClock) NewTimer(delay time.Duration) ports.Timer {
41+ if delay != keys.ESCDelay {
42+ return stubTimer{}
43+ }
44+ timer := newSnatchedInputTimer()
45+ c.timers <- timer
46+ return timer
47+}
48+
49+type snatchedCloseSignalTransport struct {
50+ mu sync.Mutex
51+ closed bool
52+ closedCh chan struct{}
53+ closeOnce sync.Once
54+}
55+
56+func newSnatchedCloseSignalTransport() *snatchedCloseSignalTransport {
57+ return &snatchedCloseSignalTransport{closedCh: make(chan struct{})}
58+}
59+
60+func (*snatchedCloseSignalTransport) Send(ports.Frame) error { return nil }
61+func (*snatchedCloseSignalTransport) Recv() (ports.Frame, error) {
62+ return ports.Frame{}, errors.New("closed")
63+}
64+func (t *snatchedCloseSignalTransport) Close() error {
65+ t.mu.Lock()
66+ t.closed = true
67+ t.mu.Unlock()
68+ t.closeOnce.Do(func() { close(t.closedCh) })
69+ return nil
70+}
71+func (t *snatchedCloseSignalTransport) Closed() bool {
72+ t.mu.Lock()
73+ defer t.mu.Unlock()
74+ return t.closed
75+}
76+
77+type blockedSnatchedQuitTransport struct {
78+ recv chan ports.Frame
79+ sendStarted chan struct{}
80+ sendDone chan struct{}
81+ closed chan struct{}
82+ closeOnce sync.Once
83+ sendOnce sync.Once
84+ mu sync.Mutex
85+ isClosed bool
86+}
87+
88+func newBlockedSnatchedQuitTransport() *blockedSnatchedQuitTransport {
89+ return &blockedSnatchedQuitTransport{
90+ recv: make(chan ports.Frame, 1),
91+ sendStarted: make(chan struct{}),
92+ sendDone: make(chan struct{}),
93+ closed: make(chan struct{}),
94+ }
95+}
96+
97+func (t *blockedSnatchedQuitTransport) Send(frame ports.Frame) error {
98+ if frame.Type != ports.MsgDetached {
99+ return nil
100+ }
101+ t.sendOnce.Do(func() { close(t.sendStarted) })
102+ <-t.closed
103+ close(t.sendDone)
104+ return io.ErrClosedPipe
105+}
106+
107+func (t *blockedSnatchedQuitTransport) Recv() (ports.Frame, error) {
108+ select {
109+ case frame := <-t.recv:
110+ return frame, nil
111+ case <-t.closed:
112+ return ports.Frame{}, io.ErrClosedPipe
113+ }
114+}
115+
116+func (t *blockedSnatchedQuitTransport) Close() error {
117+ t.mu.Lock()
118+ t.isClosed = true
119+ t.mu.Unlock()
120+ t.closeOnce.Do(func() { close(t.closed) })
121+ return nil
122+}
123+
124+func (t *blockedSnatchedQuitTransport) Closed() bool {
125+ t.mu.Lock()
126+ defer t.mu.Unlock()
127+ return t.isClosed
128+}
129+
130+func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session, *attachedClient, *attachedClient, *snatchedCloseSignalTransport) {
131+ t.Helper()
132+ d := newTestDaemon(t, nil, clock)
133+ waitingTransport := newSnatchedCloseSignalTransport()
134+ waiting := &attachedClient{
135+ tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24},
136+ }
137+ active := &attachedClient{
138+ tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: waiting.size,
139+ }
140+ waiting.initOverlays()
141+ active.initOverlays()
142+ sess := &session{
143+ id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}},
144+ }
145+ waiting.setSession(sess)
146+ active.setSession(sess)
147+ d.sessions[sess.id] = sess
148+ d.attachCoordinator(sess, nil, active, true)
149+ return d, sess, waiting, active, waitingTransport
150+}
151+
152+func sendSnatchedInput(d *Daemon, sess *session, ac *attachedClient, data string) bool {
153+ tr := ac.transport()
154+ token := sess.attachmentToken(ac, tr)
155+ return d.handleSnatchedClientFrame(token, ports.Frame{
156+ Type: ports.MsgInput,
157+ Payload: ports.MarshalInput(ports.Input{Data: []byte(data)}),
158+ })
159+}
160+
161+func TestSnatchedInputStrictActions(t *testing.T) {
162+ tests := []struct {
163+ name string
164+ chunks []string
165+ fireEscape bool
166+ wantRole attachmentRole
167+ wantClosed bool
168+ }{
169+ {name: "lowercase reclaim", chunks: []string{"r"}, wantRole: attachmentActive},
170+ {name: "uppercase reclaim", chunks: []string{"R"}, wantRole: attachmentActive},
171+ {name: "lowercase quit", chunks: []string{"q"}, wantRole: attachmentDetached, wantClosed: true},
172+ {name: "uppercase quit", chunks: []string{"Q"}, wantRole: attachmentDetached, wantClosed: true},
173+ {name: "standalone escape quits after delay", chunks: []string{"\x1b"}, fireEscape: true, wantRole: attachmentDetached, wantClosed: true},
174+ {name: "coalesced reclaim ignored", chunks: []string{"rr"}, wantRole: attachmentSnatched},
175+ {name: "paste containing quit ignored", chunks: []string{"paste q"}, wantRole: attachmentSnatched},
176+ {name: "complete CSI ignored", chunks: []string{"\x1b[r"}, wantRole: attachmentSnatched},
177+ {name: "complete SS3 ignored", chunks: []string{"\x1bOR"}, wantRole: attachmentSnatched},
178+ {name: "complete sequence tail ignored", chunks: []string{"\x1b[Aq"}, wantRole: attachmentSnatched},
179+ {name: "split CSI final reclaim ignored", chunks: []string{"\x1b[", "r"}, wantRole: attachmentSnatched},
180+ {name: "split SS3 final reclaim ignored", chunks: []string{"\x1bO", "R"}, wantRole: attachmentSnatched},
181+ {name: "split sequence frame tail ignored", chunks: []string{"\x1b[", "Aq"}, wantRole: attachmentSnatched},
182+ {name: "escape followed by action ignored as alt chord", chunks: []string{"\x1b", "q"}, wantRole: attachmentSnatched},
183+ {name: "CSI prefix remains inert", chunks: []string{"\x1b[", "12;"}, wantRole: attachmentSnatched},
184+ {name: "overflow drain consumes final and frame tail", chunks: []string{"\x1b[", strings.Repeat(";", 80), "rQ"}, wantRole: attachmentSnatched},
185+ }
186+
187+ for _, tt := range tests {
188+ t.Run(tt.name, func(t *testing.T) {
189+ clock := newSnatchedInputClock()
190+ d, sess, waiting, active, waitingTransport := newSnatchedInputFixture(t, clock)
191+ for _, chunk := range tt.chunks {
192+ sendSnatchedInput(d, sess, waiting, chunk)
193+ }
194+ if tt.fireEscape {
195+ require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting), "escape acted before its delay")
196+ require.False(t, waitingTransport.Closed(), "escape closed the transport before its delay")
197+ timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer")
198+ timer.Fire()
199+ awaitTestCompletion(t, waitingTransport.closedCh, "standalone escape did not quit")
200+ }
201+ d.attachmentCleanupWg.Wait()
202+
203+ require.Equal(t, tt.wantRole, sess.attachmentRole(waiting))
204+ require.Equal(t, tt.wantClosed, waitingTransport.Closed())
205+ if tt.wantRole == attachmentActive {
206+ require.Same(t, waiting, sess.client)
207+ require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
208+ } else {
209+ require.Same(t, active, sess.client)
210+ }
211+ })
212+ }
213+}
214+
215+func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) {
216+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
217+ waitingTransport := newBlockedSnatchedQuitTransport()
218+ defer waitingTransport.Close()
219+ activeTransport := &closeTrackingTransport{}
220+ d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
221+
222+ otherTransport := &closeTrackingTransport{}
223+ other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: waiting.size}
224+ other.initOverlays()
225+ other.setSession(sess)
226+ sess.mu.Lock()
227+ sess.addSnatchedLocked(other)
228+ sess.mu.Unlock()
229+ otherToken := sess.attachmentToken(other, otherTransport)
230+ require.Equal(t, attachmentSnatched, otherToken.role)
231+
232+ ownerLease := sess.renderCoordinator().attachmentLease(active)
233+ require.NotNil(t, ownerLease)
234+ activeGeneration := active.roleGeneration.Load()
235+ otherGeneration := other.roleGeneration.Load()
236+
237+ loopDone := make(chan struct{})
238+ go func() {
239+ d.runConnLoop(waiting)
240+ close(loopDone)
241+ }()
242+ waitingTransport.recv <- ports.Frame{
243+ Type: ports.MsgInput,
244+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}),
245+ }
246+ awaitTestCompletion(t, waitingTransport.sendStarted, "snatched quit did not attempt its detach acknowledgement")
247+ select {
248+ case <-loopDone:
249+ t.Fatal("snatched quit returned before its acknowledgement deadline")
250+ default:
251+ }
252+
253+ timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline")
254+ require.Equal(t, detachNotifyTimeout, timer.duration)
255+ timer.ch <- time.Time{}
256+ awaitTestCompletion(t, waitingTransport.sendDone, "closing the captured link did not release its blocked send")
257+ awaitTestCompletion(t, loopDone, "snatched connection parser did not retire after the acknowledgement deadline")
258+
259+ require.True(t, waitingTransport.Closed())
260+ requireRoleGateRetired(t, waiting)
261+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
262+ require.Nil(t, waiting.currentSession())
263+ require.Nil(t, waiting.transport())
264+ require.Same(t, active, sess.client)
265+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
266+ require.Same(t, ownerLease, sess.renderCoordinator().attachmentLease(active))
267+ require.Equal(t, activeGeneration, active.roleGeneration.Load())
268+ require.False(t, activeTransport.Closed())
269+ require.Equal(t, attachmentSnatched, sess.attachmentRole(other))
270+ require.Equal(t, otherGeneration, other.roleGeneration.Load())
271+ require.Same(t, otherTransport, other.transport())
272+ require.False(t, otherTransport.Closed())
273+}
274+
275+func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) {
276+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
277+ staleTransport := newBlockedSnatchedQuitTransport()
278+ defer staleTransport.Close()
279+ ownerTransport := &closeTrackingTransport{}
280+ d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport)
281+
282+ ticketEnded := make(chan struct{})
283+ releaseQuit := make(chan struct{})
284+ d.afterActionRoleEffectEnded = func(action string) {
285+ if action == "quit-snatched" {
286+ close(ticketEnded)
287+ <-releaseQuit
288+ }
289+ }
290+ loopDone := make(chan struct{})
291+ go func() {
292+ d.runConnLoop(waiting)
293+ close(loopDone)
294+ }()
295+ staleTransport.recv <- ports.Frame{
296+ Type: ports.MsgInput,
297+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'Q'}}),
298+ }
299+ awaitTestCompletion(t, staleTransport.sendStarted, "snatched quit did not block in its captured transport")
300+ timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline")
301+ timer.ch <- time.Time{}
302+ awaitTestCompletion(t, staleTransport.sendDone, "deadline close did not retire the captured send")
303+ awaitTestCompletion(t, ticketEnded, "snatched quit did not end its role ticket before cleanup")
304+
305+ freshTransport := &closeTrackingTransport{}
306+ waiting.replaceTransport(freshTransport)
307+ reclaimed, err := d.transitionAttachment(attachmentTransitionRequest{
308+ target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive,
309+ expectedTransport: waiting.transportSnapshot(), ready: true,
310+ })
311+ require.NoError(t, err)
312+ d.deferAttachmentTransitionCleanups(reclaimed)
313+ freshGeneration := reclaimed.published.generation
314+ freshLease := reclaimed.published.lease
315+
316+ close(releaseQuit)
317+ awaitTestCompletion(t, loopDone, "stale quit parser did not retire after reclaim")
318+ d.attachmentCleanupWg.Wait()
319+
320+ require.True(t, staleTransport.Closed())
321+ requireRoleGateRetired(t, waiting)
322+ require.Same(t, waiting, sess.client)
323+ require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
324+ require.Equal(t, freshGeneration, waiting.roleGeneration.Load())
325+ require.Same(t, freshTransport, waiting.transport())
326+ require.False(t, freshTransport.Closed(), "stale quit cleanup closed the reclaimed transport")
327+ require.Same(t, freshLease, sess.renderCoordinator().attachmentLease(waiting))
328+ require.Equal(t, attachmentSnatched, sess.attachmentRole(owner))
329+ require.False(t, ownerTransport.Closed(), "stale quit cleanup affected the displaced owner")
330+}
331+
332+func TestSnatchedInputTimerCannotAffectNewRoleOrTransport(t *testing.T) {
333+ clock := newSnatchedInputClock()
334+ d, sess, waiting, _, oldTransport := newSnatchedInputFixture(t, clock)
335+
336+ sendSnatchedInput(d, sess, waiting, "\x1b")
337+ timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer")
338+ accepted := make(chan struct{})
339+ release := make(chan struct{})
340+ attempted := make(chan bool, 1)
341+ d.afterSnatchedEscapeAccepted = func() {
342+ close(accepted)
343+ <-release
344+ }
345+ d.afterSnatchedEscapeAttempt = func(admitted bool) { attempted <- admitted }
346+ timer.Fire()
347+ awaitTestCompletion(t, accepted, "escape timer callback did not consume parser state")
348+
349+ result, err := d.transitionAttachment(attachmentTransitionRequest{
350+ target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive,
351+ expectedTransport: waiting.transportSnapshot(), ready: true,
352+ })
353+ require.NoError(t, err)
354+ d.deferAttachmentTransitionCleanups(result)
355+ newTransport := newSnatchedCloseSignalTransport()
356+ waiting.replaceTransport(newTransport)
357+ close(release)
358+ require.False(t, awaitTestValue(t, attempted, "escape callback did not revalidate its stale role"))
359+ d.attachmentCleanupWg.Wait()
360+
361+ require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
362+ require.Same(t, newTransport, waiting.transport())
363+ require.False(t, newTransport.Closed(), "stale timer closed the replacement transport")
364+ require.False(t, oldTransport.Closed(), "stale timer closed the old transport after role activation")
365+}
A internal/usecase/daemon/snatched_reclaim_test.go +443 -0
@@ -0,0 +1,443 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ themeui "github.com/bnema/vev/internal/usecase/theme"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+type failingReclaimTransport struct {
18+ mu sync.Mutex
19+ closed bool
20+}
21+
22+func (*failingReclaimTransport) Send(ports.Frame) error { return errors.New("send failed") }
23+func (*failingReclaimTransport) Recv() (ports.Frame, error) {
24+ return ports.Frame{}, errors.New("closed")
25+}
26+func (t *failingReclaimTransport) Close() error {
27+ t.mu.Lock()
28+ t.closed = true
29+ t.mu.Unlock()
30+ return nil
31+}
32+func (t *failingReclaimTransport) Closed() bool {
33+ t.mu.Lock()
34+ defer t.mu.Unlock()
35+ return t.closed
36+}
37+
38+func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport, activeTransport ports.Transport) (*Daemon, *session, *attachedClient, *attachedClient) {
39+ t.Helper()
40+ d := newTestDaemon(t, nil, clock)
41+ waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
42+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
43+ waiting.initOverlays()
44+ active.initOverlays()
45+ sess := &session{
46+ id: "atomic-reclaim", client: active,
47+ snatched: map[*attachedClient]struct{}{waiting: {}},
48+ }
49+ waiting.setSession(sess)
50+ active.setSession(sess)
51+ d.sessions[sess.id] = sess
52+ d.attachCoordinator(sess, nil, active, true)
53+ return d, sess, waiting, active
54+}
55+
56+func requireSingleOwnerAndLease(t *testing.T, sess *session, clients ...*attachedClient) {
57+ t.Helper()
58+ sess.mu.Lock()
59+ owner := sess.client
60+ snatched := make(map[*attachedClient]struct{}, len(sess.snatched))
61+ for ac := range sess.snatched {
62+ snatched[ac] = struct{}{}
63+ }
64+ sess.mu.Unlock()
65+ require.NotNil(t, owner)
66+
67+ activeCount := 0
68+ for _, ac := range clients {
69+ if ac == owner {
70+ activeCount++
71+ continue
72+ }
73+ _, waiting := snatched[ac]
74+ require.True(t, waiting, "every non-owner must remain snatched")
75+ }
76+ require.Equal(t, 1, activeCount)
77+
78+ rc := sess.renderCoordinator()
79+ rc.mu.Lock()
80+ lease := rc.lease
81+ rc.mu.Unlock()
82+ require.NotNil(t, lease)
83+ require.True(t, lease.active)
84+ require.Same(t, owner, lease.attachment)
85+}
86+
87+func TestReclaimSnatchedSwapsOwnerLeaseRebasesAndAppliesTheme(t *testing.T) {
88+ d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
89+ requesterTransport := &closeTrackingTransport{}
90+ requester.replaceTransport(requesterTransport)
91+ d.attachCoordinator(sess, nil, requester, true)
92+
93+ active := &attachedClient{
94+ tr: &closeTrackingTransport{},
95+ output: newOutputStateStream(),
96+ size: requester.size,
97+ }
98+ active.initOverlays()
99+ displaced, err := d.transitionAttachment(attachmentTransitionRequest{
100+ target: sess, next: active,
101+ expectedRole: attachmentDetached, targetRole: attachmentActive,
102+ expectedTransport: active.transportSnapshot(), ready: true,
103+ })
104+ require.NoError(t, err)
105+ d.deferAttachmentTransitionCleanups(displaced)
106+ d.attachmentCleanupWg.Wait()
107+
108+ requester.sendMu.Lock()
109+ beforeReclaimState := requester.output.next
110+ requester.sendMu.Unlock()
111+ requesterTheme := themeui.Theme{
112+ Foreground: renderer.RGB{R: 11, G: 22, B: 33},
113+ Background: renderer.RGB{R: 44, G: 55, B: 66},
114+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
115+ }
116+ requester.setClientTheme(requesterTheme)
117+
118+ token := sess.attachmentToken(requester, requesterTransport)
119+ require.Equal(t, attachmentSnatched, token.role)
120+ require.False(t, d.handleSnatchedClientFrame(token, ports.Frame{
121+ Type: ports.MsgInput,
122+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
123+ }))
124+
125+ sess.mu.Lock()
126+ require.Same(t, requester, sess.client)
127+ _, activeIsSnatched := sess.snatched[active]
128+ _, requesterIsSnatched := sess.snatched[requester]
129+ sess.mu.Unlock()
130+ require.True(t, activeIsSnatched)
131+ require.False(t, requesterIsSnatched)
132+ rc := sess.renderCoordinator()
133+ lease := rc.attachmentLease(requester)
134+ require.NotNil(t, lease)
135+ require.Nil(t, rc.attachmentLease(active))
136+ require.True(t, lease.active)
137+
138+ requester.sendMu.Lock()
139+ require.Greater(t, requester.output.next, beforeReclaimState, "reclaim must emit a reset first paint")
140+ require.GreaterOrEqual(t, requester.output.acked, beforeReclaimState, "reclaim must rebase the old panel output chain")
141+ requester.sendMu.Unlock()
142+ assertSessionDefaultColors(t, sess, requesterTheme.Foreground, requesterTheme.Background)
143+
144+ d.attachmentCleanupWg.Wait()
145+ require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
146+ require.False(t, active.transport().(*closeTrackingTransport).Closed())
147+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size)
148+}
149+
150+func TestFailedReclaimBeforeCommitPreservesOwnerAndShowsUnavailable(t *testing.T) {
151+ waitingTransport := &closeTrackingTransport{}
152+ activeTransport := &closeTrackingTransport{}
153+ d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
154+ token := sess.attachmentToken(waiting, waitingTransport)
155+
156+ d.mu.Lock()
157+ delete(d.sessions, sess.id)
158+ d.mu.Unlock()
159+ d.handleSnatchedClientFrame(token, ports.Frame{
160+ Type: ports.MsgInput,
161+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
162+ })
163+
164+ requireSingleOwnerAndLease(t, sess, waiting, active)
165+ require.Same(t, active, sess.client)
166+ require.False(t, waitingTransport.Closed())
167+ frames := waitingTransport.Sends()
168+ require.Len(t, frames, 1)
169+ output, err := ports.UnmarshalOutput(frames[0].Payload)
170+ require.NoError(t, err)
171+ require.Contains(t, string(output.Data), "Session is no longer available.")
172+}
173+
174+func TestPostcommitPanelFailureRetiresOnlyDisplacedClient(t *testing.T) {
175+ waitingTransport := &closeTrackingTransport{}
176+ activeTransport := &failingReclaimTransport{}
177+ d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
178+ token := sess.attachmentToken(waiting, waitingTransport)
179+
180+ d.handleSnatchedClientFrame(token, ports.Frame{
181+ Type: ports.MsgInput,
182+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
183+ })
184+ d.attachmentCleanupWg.Wait()
185+
186+ require.Same(t, waiting, sess.client)
187+ require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
188+ require.Equal(t, attachmentDetached, sess.attachmentRole(active))
189+ require.NotNil(t, sess.renderCoordinator().attachmentLease(waiting))
190+ require.Nil(t, sess.renderCoordinator().attachmentLease(active))
191+ require.False(t, waitingTransport.Closed())
192+ require.True(t, activeTransport.Closed())
193+}
194+
195+func TestBlockedRequesterActivationDeadlinePreservesOwnerLease(t *testing.T) {
196+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
197+ waitingTransport := newSnatchedCloseSignalTransport()
198+ activeTransport := &closeTrackingTransport{}
199+ d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
200+
201+ waiting.sendMu.Lock()
202+ locked := true
203+ defer func() {
204+ if locked {
205+ waiting.sendMu.Unlock()
206+ }
207+ }()
208+ done := make(chan struct{})
209+ go func() {
210+ token := sess.attachmentToken(waiting, waitingTransport)
211+ d.handleSnatchedClientFrame(token, ports.Frame{
212+ Type: ports.MsgInput,
213+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
214+ })
215+ close(done)
216+ }()
217+
218+ timer := awaitTestValue(t, clock.timers, "reclaim did not install its activation deadline")
219+ require.Equal(t, detachNotifyTimeout, timer.duration)
220+ timer.ch <- time.Time{}
221+ awaitTestCompletion(t, done, "deadline did not release blocked reclaim")
222+ require.Same(t, active, sess.client)
223+ require.Same(t, active, sess.renderCoordinator().attachmentLease(active).attachment)
224+ require.True(t, waitingTransport.Closed())
225+
226+ waiting.sendMu.Unlock()
227+ locked = false
228+ d.attachmentCleanupWg.Wait()
229+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
230+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
231+}
232+
233+func TestQuitInvalidatesPausedSnatchedFinalizerWithoutLatePanel(t *testing.T) {
234+ waitingTransport := &closeTrackingTransport{}
235+ activeTransport := &closeTrackingTransport{}
236+ d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
237+
238+ finalizerStarted := make(chan struct{})
239+ releaseFinalizer := make(chan struct{})
240+ d.afterDisplacedCleanupStarted = func() {
241+ close(finalizerStarted)
242+ <-releaseFinalizer
243+ }
244+ waitingToken := sess.attachmentToken(waiting, waitingTransport)
245+ d.handleSnatchedClientFrame(waitingToken, ports.Frame{
246+ Type: ports.MsgInput,
247+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
248+ })
249+ awaitTestCompletion(t, finalizerStarted, "displaced finalizer did not pause")
250+
251+ activeToken := sess.attachmentToken(active, activeTransport)
252+ require.Equal(t, attachmentSnatched, activeToken.role)
253+ require.True(t, d.handleSnatchedClientFrame(activeToken, ports.Frame{
254+ Type: ports.MsgInput,
255+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}),
256+ }))
257+ close(releaseFinalizer)
258+ d.attachmentCleanupWg.Wait()
259+
260+ require.Equal(t, attachmentDetached, sess.attachmentRole(active))
261+ frames := activeTransport.Sends()
262+ require.Len(t, frames, 1, "stale finalizer emitted output after quit")
263+ require.Equal(t, ports.MsgDetached, frames[0].Type)
264+}
265+
266+func TestReclaimOrdersDisplacedPanelAfterAdmittedOldPaint(t *testing.T) {
267+ d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
268+ requesterTransport := &closeTrackingTransport{}
269+ requester.replaceTransport(requesterTransport)
270+ d.attachCoordinator(sess, nil, requester, true)
271+
272+ activeTransport := &closeTrackingTransport{}
273+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: requester.size}
274+ active.initOverlays()
275+ initial, err := d.transitionAttachment(attachmentTransitionRequest{
276+ target: sess, next: active,
277+ expectedRole: attachmentDetached, targetRole: attachmentActive,
278+ expectedTransport: active.transportSnapshot(), ready: true,
279+ })
280+ require.NoError(t, err)
281+ d.deferAttachmentTransitionCleanups(initial)
282+ d.attachmentCleanupWg.Wait()
283+ baselineActiveFrames := len(activeTransport.Sends())
284+
285+ paintAdmitted := make(chan struct{})
286+ releasePaint := make(chan struct{})
287+ d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
288+ if token.ac == active && token.role == attachmentActive {
289+ close(paintAdmitted)
290+ <-releasePaint
291+ }
292+ }
293+ activeToken := sess.attachmentToken(active, activeTransport)
294+ paintDone := make(chan bool, 1)
295+ go func() { paintDone <- d.firstPaintForTransition(activeToken) }()
296+ awaitTestCompletion(t, paintAdmitted, "old active paint was not admitted")
297+
298+ reclaimFrozen := make(chan struct{})
299+ var frozenOnce sync.Once
300+ d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
301+ if action == "reclaim-snatched" {
302+ frozenOnce.Do(func() { close(reclaimFrozen) })
303+ }
304+ }
305+ reclaimDone := make(chan struct{})
306+ go func() {
307+ token := sess.attachmentToken(requester, requesterTransport)
308+ d.handleSnatchedClientFrame(token, ports.Frame{
309+ Type: ports.MsgInput,
310+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
311+ })
312+ close(reclaimDone)
313+ }()
314+ awaitTestCompletion(t, reclaimFrozen, "reclaim did not freeze role effects behind old paint")
315+ close(releasePaint)
316+ require.True(t, awaitTestValue(t, paintDone, "old paint did not finish"))
317+ awaitTestCompletion(t, reclaimDone, "reclaim did not finish after old paint")
318+ d.attachmentCleanupWg.Wait()
319+
320+ frames := activeTransport.Sends()[baselineActiveFrames:]
321+ require.NotEmpty(t, frames)
322+ last := frames[len(frames)-1]
323+ require.Equal(t, ports.MsgOutput, last.Type)
324+ output, err := ports.UnmarshalOutput(last.Payload)
325+ require.NoError(t, err)
326+ require.Contains(t, string(output.Data), "Session snatched", "old paint overtook the displaced reset panel")
327+ requireSingleOwnerAndLease(t, sess, requester, active)
328+}
329+
330+func TestLaterReclaimMakesPausedFirstPaintStale(t *testing.T) {
331+ d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
332+ firstTransport := &closeTrackingTransport{}
333+ first.replaceTransport(firstTransport)
334+ d.attachCoordinator(sess, nil, first, true)
335+
336+ secondTransport := &closeTrackingTransport{}
337+ second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
338+ second.initOverlays()
339+ initial, err := d.transitionAttachment(attachmentTransitionRequest{
340+ target: sess, next: second,
341+ expectedRole: attachmentDetached, targetRole: attachmentActive,
342+ expectedTransport: second.transportSnapshot(), ready: true,
343+ })
344+ require.NoError(t, err)
345+ d.deferAttachmentTransitionCleanups(initial)
346+ d.attachmentCleanupWg.Wait()
347+ baselineFirstFrames := len(firstTransport.Sends())
348+
349+ firstPaintPaused := make(chan struct{})
350+ releaseFirstPaint := make(chan struct{})
351+ d.beforeReclaimFirstPaint = func(token attachmentRoleToken) {
352+ if token.ac != first {
353+ return
354+ }
355+ close(firstPaintPaused)
356+ <-releaseFirstPaint
357+ }
358+ firstDone := make(chan struct{})
359+ go func() {
360+ token := sess.attachmentToken(first, firstTransport)
361+ d.handleSnatchedClientFrame(token, ports.Frame{
362+ Type: ports.MsgInput,
363+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
364+ })
365+ close(firstDone)
366+ }()
367+ awaitTestCompletion(t, firstPaintPaused, "first reclaim did not pause before first paint")
368+ require.Same(t, first, sess.client, "first reclaim was not committed before its paint")
369+
370+ secondToken := sess.attachmentToken(second, secondTransport)
371+ require.Equal(t, attachmentSnatched, secondToken.role)
372+ d.handleSnatchedClientFrame(secondToken, ports.Frame{
373+ Type: ports.MsgInput,
374+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
375+ })
376+ require.Same(t, second, sess.client)
377+ close(releaseFirstPaint)
378+ awaitTestCompletion(t, firstDone, "stale first paint did not return")
379+ d.attachmentCleanupWg.Wait()
380+
381+ newFirstFrames := firstTransport.Sends()[baselineFirstFrames:]
382+ require.Len(t, newFirstFrames, 1, "stale first paint emitted after a later reclaim")
383+ require.Equal(t, ports.MsgOutput, newFirstFrames[0].Type)
384+ output, err := ports.UnmarshalOutput(newFirstFrames[0].Payload)
385+ require.NoError(t, err)
386+ require.Contains(t, string(output.Data), "Session snatched")
387+ requireSingleOwnerAndLease(t, sess, first, second)
388+}
389+
390+func TestAnyWaiterCanReclaimAndConcurrentRequestsKeepSingleOwnerLease(t *testing.T) {
391+ d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
392+ firstTransport := &closeTrackingTransport{}
393+ first.replaceTransport(firstTransport)
394+ d.attachCoordinator(sess, nil, first, true)
395+
396+ secondTransport := &closeTrackingTransport{}
397+ second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
398+ second.initOverlays()
399+ toSecond, err := d.transitionAttachment(attachmentTransitionRequest{
400+ target: sess, next: second,
401+ expectedRole: attachmentDetached, targetRole: attachmentActive,
402+ expectedTransport: second.transportSnapshot(), ready: true,
403+ })
404+ require.NoError(t, err)
405+ d.deferAttachmentTransitionCleanups(toSecond)
406+ d.attachmentCleanupWg.Wait()
407+
408+ thirdTransport := &closeTrackingTransport{}
409+ third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size}
410+ third.initOverlays()
411+ toThird, err := d.transitionAttachment(attachmentTransitionRequest{
412+ target: sess, next: third,
413+ expectedRole: attachmentDetached, targetRole: attachmentActive,
414+ expectedTransport: third.transportSnapshot(), ready: true,
415+ })
416+ require.NoError(t, err)
417+ d.deferAttachmentTransitionCleanups(toThird)
418+ d.attachmentCleanupWg.Wait()
419+ requireSingleOwnerAndLease(t, sess, first, second, third)
420+
421+ firstToken := sess.attachmentToken(first, firstTransport)
422+ secondToken := sess.attachmentToken(second, secondTransport)
423+ require.Equal(t, attachmentSnatched, firstToken.role)
424+ require.Equal(t, attachmentSnatched, secondToken.role)
425+ start := make(chan struct{})
426+ done := make(chan struct{}, 2)
427+ for _, token := range []attachmentRoleToken{firstToken, secondToken} {
428+ go func() {
429+ <-start
430+ d.handleSnatchedClientFrame(token, ports.Frame{
431+ Type: ports.MsgInput,
432+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
433+ })
434+ done <- struct{}{}
435+ }()
436+ }
437+ close(start)
438+ awaitTestCompletion(t, done, "first concurrent reclaim did not return")
439+ awaitTestCompletion(t, done, "second concurrent reclaim did not return")
440+ d.attachmentCleanupWg.Wait()
441+
442+ requireSingleOwnerAndLease(t, sess, first, second, third)
443+}
@@ -0,0 +1,443 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ themeui "github.com/bnema/vev/internal/usecase/theme"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+type failingReclaimTransport struct {
18+ mu sync.Mutex
19+ closed bool
20+}
21+
22+func (*failingReclaimTransport) Send(ports.Frame) error { return errors.New("send failed") }
23+func (*failingReclaimTransport) Recv() (ports.Frame, error) {
24+ return ports.Frame{}, errors.New("closed")
25+}
26+func (t *failingReclaimTransport) Close() error {
27+ t.mu.Lock()
28+ t.closed = true
29+ t.mu.Unlock()
30+ return nil
31+}
32+func (t *failingReclaimTransport) Closed() bool {
33+ t.mu.Lock()
34+ defer t.mu.Unlock()
35+ return t.closed
36+}
37+
38+func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport, activeTransport ports.Transport) (*Daemon, *session, *attachedClient, *attachedClient) {
39+ t.Helper()
40+ d := newTestDaemon(t, nil, clock)
41+ waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
42+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
43+ waiting.initOverlays()
44+ active.initOverlays()
45+ sess := &session{
46+ id: "atomic-reclaim", client: active,
47+ snatched: map[*attachedClient]struct{}{waiting: {}},
48+ }
49+ waiting.setSession(sess)
50+ active.setSession(sess)
51+ d.sessions[sess.id] = sess
52+ d.attachCoordinator(sess, nil, active, true)
53+ return d, sess, waiting, active
54+}
55+
56+func requireSingleOwnerAndLease(t *testing.T, sess *session, clients ...*attachedClient) {
57+ t.Helper()
58+ sess.mu.Lock()
59+ owner := sess.client
60+ snatched := make(map[*attachedClient]struct{}, len(sess.snatched))
61+ for ac := range sess.snatched {
62+ snatched[ac] = struct{}{}
63+ }
64+ sess.mu.Unlock()
65+ require.NotNil(t, owner)
66+
67+ activeCount := 0
68+ for _, ac := range clients {
69+ if ac == owner {
70+ activeCount++
71+ continue
72+ }
73+ _, waiting := snatched[ac]
74+ require.True(t, waiting, "every non-owner must remain snatched")
75+ }
76+ require.Equal(t, 1, activeCount)
77+
78+ rc := sess.renderCoordinator()
79+ rc.mu.Lock()
80+ lease := rc.lease
81+ rc.mu.Unlock()
82+ require.NotNil(t, lease)
83+ require.True(t, lease.active)
84+ require.Same(t, owner, lease.attachment)
85+}
86+
87+func TestReclaimSnatchedSwapsOwnerLeaseRebasesAndAppliesTheme(t *testing.T) {
88+ d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
89+ requesterTransport := &closeTrackingTransport{}
90+ requester.replaceTransport(requesterTransport)
91+ d.attachCoordinator(sess, nil, requester, true)
92+
93+ active := &attachedClient{
94+ tr: &closeTrackingTransport{},
95+ output: newOutputStateStream(),
96+ size: requester.size,
97+ }
98+ active.initOverlays()
99+ displaced, err := d.transitionAttachment(attachmentTransitionRequest{
100+ target: sess, next: active,
101+ expectedRole: attachmentDetached, targetRole: attachmentActive,
102+ expectedTransport: active.transportSnapshot(), ready: true,
103+ })
104+ require.NoError(t, err)
105+ d.deferAttachmentTransitionCleanups(displaced)
106+ d.attachmentCleanupWg.Wait()
107+
108+ requester.sendMu.Lock()
109+ beforeReclaimState := requester.output.next
110+ requester.sendMu.Unlock()
111+ requesterTheme := themeui.Theme{
112+ Foreground: renderer.RGB{R: 11, G: 22, B: 33},
113+ Background: renderer.RGB{R: 44, G: 55, B: 66},
114+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
115+ }
116+ requester.setClientTheme(requesterTheme)
117+
118+ token := sess.attachmentToken(requester, requesterTransport)
119+ require.Equal(t, attachmentSnatched, token.role)
120+ require.False(t, d.handleSnatchedClientFrame(token, ports.Frame{
121+ Type: ports.MsgInput,
122+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
123+ }))
124+
125+ sess.mu.Lock()
126+ require.Same(t, requester, sess.client)
127+ _, activeIsSnatched := sess.snatched[active]
128+ _, requesterIsSnatched := sess.snatched[requester]
129+ sess.mu.Unlock()
130+ require.True(t, activeIsSnatched)
131+ require.False(t, requesterIsSnatched)
132+ rc := sess.renderCoordinator()
133+ lease := rc.attachmentLease(requester)
134+ require.NotNil(t, lease)
135+ require.Nil(t, rc.attachmentLease(active))
136+ require.True(t, lease.active)
137+
138+ requester.sendMu.Lock()
139+ require.Greater(t, requester.output.next, beforeReclaimState, "reclaim must emit a reset first paint")
140+ require.GreaterOrEqual(t, requester.output.acked, beforeReclaimState, "reclaim must rebase the old panel output chain")
141+ requester.sendMu.Unlock()
142+ assertSessionDefaultColors(t, sess, requesterTheme.Foreground, requesterTheme.Background)
143+
144+ d.attachmentCleanupWg.Wait()
145+ require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
146+ require.False(t, active.transport().(*closeTrackingTransport).Closed())
147+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size)
148+}
149+
150+func TestFailedReclaimBeforeCommitPreservesOwnerAndShowsUnavailable(t *testing.T) {
151+ waitingTransport := &closeTrackingTransport{}
152+ activeTransport := &closeTrackingTransport{}
153+ d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
154+ token := sess.attachmentToken(waiting, waitingTransport)
155+
156+ d.mu.Lock()
157+ delete(d.sessions, sess.id)
158+ d.mu.Unlock()
159+ d.handleSnatchedClientFrame(token, ports.Frame{
160+ Type: ports.MsgInput,
161+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
162+ })
163+
164+ requireSingleOwnerAndLease(t, sess, waiting, active)
165+ require.Same(t, active, sess.client)
166+ require.False(t, waitingTransport.Closed())
167+ frames := waitingTransport.Sends()
168+ require.Len(t, frames, 1)
169+ output, err := ports.UnmarshalOutput(frames[0].Payload)
170+ require.NoError(t, err)
171+ require.Contains(t, string(output.Data), "Session is no longer available.")
172+}
173+
174+func TestPostcommitPanelFailureRetiresOnlyDisplacedClient(t *testing.T) {
175+ waitingTransport := &closeTrackingTransport{}
176+ activeTransport := &failingReclaimTransport{}
177+ d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
178+ token := sess.attachmentToken(waiting, waitingTransport)
179+
180+ d.handleSnatchedClientFrame(token, ports.Frame{
181+ Type: ports.MsgInput,
182+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
183+ })
184+ d.attachmentCleanupWg.Wait()
185+
186+ require.Same(t, waiting, sess.client)
187+ require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
188+ require.Equal(t, attachmentDetached, sess.attachmentRole(active))
189+ require.NotNil(t, sess.renderCoordinator().attachmentLease(waiting))
190+ require.Nil(t, sess.renderCoordinator().attachmentLease(active))
191+ require.False(t, waitingTransport.Closed())
192+ require.True(t, activeTransport.Closed())
193+}
194+
195+func TestBlockedRequesterActivationDeadlinePreservesOwnerLease(t *testing.T) {
196+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
197+ waitingTransport := newSnatchedCloseSignalTransport()
198+ activeTransport := &closeTrackingTransport{}
199+ d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
200+
201+ waiting.sendMu.Lock()
202+ locked := true
203+ defer func() {
204+ if locked {
205+ waiting.sendMu.Unlock()
206+ }
207+ }()
208+ done := make(chan struct{})
209+ go func() {
210+ token := sess.attachmentToken(waiting, waitingTransport)
211+ d.handleSnatchedClientFrame(token, ports.Frame{
212+ Type: ports.MsgInput,
213+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
214+ })
215+ close(done)
216+ }()
217+
218+ timer := awaitTestValue(t, clock.timers, "reclaim did not install its activation deadline")
219+ require.Equal(t, detachNotifyTimeout, timer.duration)
220+ timer.ch <- time.Time{}
221+ awaitTestCompletion(t, done, "deadline did not release blocked reclaim")
222+ require.Same(t, active, sess.client)
223+ require.Same(t, active, sess.renderCoordinator().attachmentLease(active).attachment)
224+ require.True(t, waitingTransport.Closed())
225+
226+ waiting.sendMu.Unlock()
227+ locked = false
228+ d.attachmentCleanupWg.Wait()
229+ require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
230+ require.Equal(t, attachmentActive, sess.attachmentRole(active))
231+}
232+
233+func TestQuitInvalidatesPausedSnatchedFinalizerWithoutLatePanel(t *testing.T) {
234+ waitingTransport := &closeTrackingTransport{}
235+ activeTransport := &closeTrackingTransport{}
236+ d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
237+
238+ finalizerStarted := make(chan struct{})
239+ releaseFinalizer := make(chan struct{})
240+ d.afterDisplacedCleanupStarted = func() {
241+ close(finalizerStarted)
242+ <-releaseFinalizer
243+ }
244+ waitingToken := sess.attachmentToken(waiting, waitingTransport)
245+ d.handleSnatchedClientFrame(waitingToken, ports.Frame{
246+ Type: ports.MsgInput,
247+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
248+ })
249+ awaitTestCompletion(t, finalizerStarted, "displaced finalizer did not pause")
250+
251+ activeToken := sess.attachmentToken(active, activeTransport)
252+ require.Equal(t, attachmentSnatched, activeToken.role)
253+ require.True(t, d.handleSnatchedClientFrame(activeToken, ports.Frame{
254+ Type: ports.MsgInput,
255+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}),
256+ }))
257+ close(releaseFinalizer)
258+ d.attachmentCleanupWg.Wait()
259+
260+ require.Equal(t, attachmentDetached, sess.attachmentRole(active))
261+ frames := activeTransport.Sends()
262+ require.Len(t, frames, 1, "stale finalizer emitted output after quit")
263+ require.Equal(t, ports.MsgDetached, frames[0].Type)
264+}
265+
266+func TestReclaimOrdersDisplacedPanelAfterAdmittedOldPaint(t *testing.T) {
267+ d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
268+ requesterTransport := &closeTrackingTransport{}
269+ requester.replaceTransport(requesterTransport)
270+ d.attachCoordinator(sess, nil, requester, true)
271+
272+ activeTransport := &closeTrackingTransport{}
273+ active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: requester.size}
274+ active.initOverlays()
275+ initial, err := d.transitionAttachment(attachmentTransitionRequest{
276+ target: sess, next: active,
277+ expectedRole: attachmentDetached, targetRole: attachmentActive,
278+ expectedTransport: active.transportSnapshot(), ready: true,
279+ })
280+ require.NoError(t, err)
281+ d.deferAttachmentTransitionCleanups(initial)
282+ d.attachmentCleanupWg.Wait()
283+ baselineActiveFrames := len(activeTransport.Sends())
284+
285+ paintAdmitted := make(chan struct{})
286+ releasePaint := make(chan struct{})
287+ d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
288+ if token.ac == active && token.role == attachmentActive {
289+ close(paintAdmitted)
290+ <-releasePaint
291+ }
292+ }
293+ activeToken := sess.attachmentToken(active, activeTransport)
294+ paintDone := make(chan bool, 1)
295+ go func() { paintDone <- d.firstPaintForTransition(activeToken) }()
296+ awaitTestCompletion(t, paintAdmitted, "old active paint was not admitted")
297+
298+ reclaimFrozen := make(chan struct{})
299+ var frozenOnce sync.Once
300+ d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
301+ if action == "reclaim-snatched" {
302+ frozenOnce.Do(func() { close(reclaimFrozen) })
303+ }
304+ }
305+ reclaimDone := make(chan struct{})
306+ go func() {
307+ token := sess.attachmentToken(requester, requesterTransport)
308+ d.handleSnatchedClientFrame(token, ports.Frame{
309+ Type: ports.MsgInput,
310+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
311+ })
312+ close(reclaimDone)
313+ }()
314+ awaitTestCompletion(t, reclaimFrozen, "reclaim did not freeze role effects behind old paint")
315+ close(releasePaint)
316+ require.True(t, awaitTestValue(t, paintDone, "old paint did not finish"))
317+ awaitTestCompletion(t, reclaimDone, "reclaim did not finish after old paint")
318+ d.attachmentCleanupWg.Wait()
319+
320+ frames := activeTransport.Sends()[baselineActiveFrames:]
321+ require.NotEmpty(t, frames)
322+ last := frames[len(frames)-1]
323+ require.Equal(t, ports.MsgOutput, last.Type)
324+ output, err := ports.UnmarshalOutput(last.Payload)
325+ require.NoError(t, err)
326+ require.Contains(t, string(output.Data), "Session snatched", "old paint overtook the displaced reset panel")
327+ requireSingleOwnerAndLease(t, sess, requester, active)
328+}
329+
330+func TestLaterReclaimMakesPausedFirstPaintStale(t *testing.T) {
331+ d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
332+ firstTransport := &closeTrackingTransport{}
333+ first.replaceTransport(firstTransport)
334+ d.attachCoordinator(sess, nil, first, true)
335+
336+ secondTransport := &closeTrackingTransport{}
337+ second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
338+ second.initOverlays()
339+ initial, err := d.transitionAttachment(attachmentTransitionRequest{
340+ target: sess, next: second,
341+ expectedRole: attachmentDetached, targetRole: attachmentActive,
342+ expectedTransport: second.transportSnapshot(), ready: true,
343+ })
344+ require.NoError(t, err)
345+ d.deferAttachmentTransitionCleanups(initial)
346+ d.attachmentCleanupWg.Wait()
347+ baselineFirstFrames := len(firstTransport.Sends())
348+
349+ firstPaintPaused := make(chan struct{})
350+ releaseFirstPaint := make(chan struct{})
351+ d.beforeReclaimFirstPaint = func(token attachmentRoleToken) {
352+ if token.ac != first {
353+ return
354+ }
355+ close(firstPaintPaused)
356+ <-releaseFirstPaint
357+ }
358+ firstDone := make(chan struct{})
359+ go func() {
360+ token := sess.attachmentToken(first, firstTransport)
361+ d.handleSnatchedClientFrame(token, ports.Frame{
362+ Type: ports.MsgInput,
363+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
364+ })
365+ close(firstDone)
366+ }()
367+ awaitTestCompletion(t, firstPaintPaused, "first reclaim did not pause before first paint")
368+ require.Same(t, first, sess.client, "first reclaim was not committed before its paint")
369+
370+ secondToken := sess.attachmentToken(second, secondTransport)
371+ require.Equal(t, attachmentSnatched, secondToken.role)
372+ d.handleSnatchedClientFrame(secondToken, ports.Frame{
373+ Type: ports.MsgInput,
374+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
375+ })
376+ require.Same(t, second, sess.client)
377+ close(releaseFirstPaint)
378+ awaitTestCompletion(t, firstDone, "stale first paint did not return")
379+ d.attachmentCleanupWg.Wait()
380+
381+ newFirstFrames := firstTransport.Sends()[baselineFirstFrames:]
382+ require.Len(t, newFirstFrames, 1, "stale first paint emitted after a later reclaim")
383+ require.Equal(t, ports.MsgOutput, newFirstFrames[0].Type)
384+ output, err := ports.UnmarshalOutput(newFirstFrames[0].Payload)
385+ require.NoError(t, err)
386+ require.Contains(t, string(output.Data), "Session snatched")
387+ requireSingleOwnerAndLease(t, sess, first, second)
388+}
389+
390+func TestAnyWaiterCanReclaimAndConcurrentRequestsKeepSingleOwnerLease(t *testing.T) {
391+ d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
392+ firstTransport := &closeTrackingTransport{}
393+ first.replaceTransport(firstTransport)
394+ d.attachCoordinator(sess, nil, first, true)
395+
396+ secondTransport := &closeTrackingTransport{}
397+ second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
398+ second.initOverlays()
399+ toSecond, err := d.transitionAttachment(attachmentTransitionRequest{
400+ target: sess, next: second,
401+ expectedRole: attachmentDetached, targetRole: attachmentActive,
402+ expectedTransport: second.transportSnapshot(), ready: true,
403+ })
404+ require.NoError(t, err)
405+ d.deferAttachmentTransitionCleanups(toSecond)
406+ d.attachmentCleanupWg.Wait()
407+
408+ thirdTransport := &closeTrackingTransport{}
409+ third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size}
410+ third.initOverlays()
411+ toThird, err := d.transitionAttachment(attachmentTransitionRequest{
412+ target: sess, next: third,
413+ expectedRole: attachmentDetached, targetRole: attachmentActive,
414+ expectedTransport: third.transportSnapshot(), ready: true,
415+ })
416+ require.NoError(t, err)
417+ d.deferAttachmentTransitionCleanups(toThird)
418+ d.attachmentCleanupWg.Wait()
419+ requireSingleOwnerAndLease(t, sess, first, second, third)
420+
421+ firstToken := sess.attachmentToken(first, firstTransport)
422+ secondToken := sess.attachmentToken(second, secondTransport)
423+ require.Equal(t, attachmentSnatched, firstToken.role)
424+ require.Equal(t, attachmentSnatched, secondToken.role)
425+ start := make(chan struct{})
426+ done := make(chan struct{}, 2)
427+ for _, token := range []attachmentRoleToken{firstToken, secondToken} {
428+ go func() {
429+ <-start
430+ d.handleSnatchedClientFrame(token, ports.Frame{
431+ Type: ports.MsgInput,
432+ Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
433+ })
434+ done <- struct{}{}
435+ }()
436+ }
437+ close(start)
438+ awaitTestCompletion(t, done, "first concurrent reclaim did not return")
439+ awaitTestCompletion(t, done, "second concurrent reclaim did not return")
440+ d.attachmentCleanupWg.Wait()
441+
442+ requireSingleOwnerAndLease(t, sess, first, second, third)
443+}
M internal/usecase/daemon/theme_resolve.go +8 -0
@@ -12,6 +12,14 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
1212 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
1313 }
1414
15+// applyHostThemeSendLocked applies a theme when the caller already owns
16+// ac.sendMu, preserving the usual sendMu -> themeMu lock order.
17+func (d *Daemon) applyHostThemeSendLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
18+ sess.themeMu.Lock()
19+ defer sess.themeMu.Unlock()
20+ return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
21+}
22+
1523 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
1624 // Resolve while applying, never while composing. The theme mutex only
1725 // publishes the completed value and is not held across session/tab/pane
@@ -12,6 +12,14 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
12 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)12 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
13 }13 }
14 14
15+// applyHostThemeSendLocked applies a theme when the caller already owns
16+// ac.sendMu, preserving the usual sendMu -> themeMu lock order.
17+func (d *Daemon) applyHostThemeSendLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
18+ sess.themeMu.Lock()
19+ defer sess.themeMu.Unlock()
20+ return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
21+}
22+
15 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {23 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
16 // Resolve while applying, never while composing. The theme mutex only24 // Resolve while applying, never while composing. The theme mutex only
17 // publishes the completed value and is not held across session/tab/pane25 // publishes the completed value and is not held across session/tab/pane

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