detached 8f5598a

refactor(daemon): park independent attachments

commit
8f5598a0c09e51ac35eb7b4f8a201a39ba86a8e5
author
brice · · 2026-08-02 23:16 UTC
committer
brice · · 2026-08-03 20:53 UTC
parent
d318448
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
28 files · +488 -5439
A internal/usecase/daemon/attachment_parking_test.go +268 -0
@@ -0,0 +1,268 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+type resumeWelcomeFailureTransport struct {
13+ closeTrackingTransport
14+}
15+
16+func (*resumeWelcomeFailureTransport) Send(ports.Frame) error { return errWelcomeSendFailed }
17+
18+var errWelcomeSendFailed = errors.New("welcome send failed")
19+
20+func TestAttachmentLossParksOnlyThatAttachment(t *testing.T) {
21+ pty, release := newBlockingPTY(t)
22+ defer release()
23+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
24+
25+ firstTransport := &closeTrackingTransport{}
26+ sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport)
27+ require.NoError(t, err)
28+ secondTransport := &closeTrackingTransport{}
29+ _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
30+ require.NoError(t, err)
31+
32+ d.clientGone(sess, first, firstTransport, false)
33+
34+ d.mu.Lock()
35+ parked := d.parked[first.resumeToken]
36+ d.mu.Unlock()
37+ require.NotNil(t, parked)
38+ sess.mu.Lock()
39+ _, firstAttached := sess.attachments[first]
40+ _, secondAttached := sess.attachments[second]
41+ sess.mu.Unlock()
42+ require.False(t, firstAttached)
43+ require.True(t, secondAttached)
44+ require.Same(t, second, sess.client)
45+ require.False(t, secondTransport.Closed())
46+}
47+
48+func TestTwoAttachmentsParkIndependently(t *testing.T) {
49+ pty, release := newBlockingPTY(t)
50+ defer release()
51+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
52+ firstTransport := &closeTrackingTransport{}
53+ sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport)
54+ require.NoError(t, err)
55+ secondTransport := &closeTrackingTransport{}
56+ _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
57+ require.NoError(t, err)
58+ firstToken, secondToken := first.resumeToken, second.resumeToken
59+
60+ d.clientGone(sess, first, firstTransport, false)
61+ d.clientGone(sess, second, secondTransport, false)
62+ d.mu.Lock()
63+ firstParked, secondParked := d.parked[firstToken], d.parked[secondToken]
64+ d.mu.Unlock()
65+ require.NotNil(t, firstParked)
66+ require.NotNil(t, secondParked)
67+ require.NotSame(t, firstParked, secondParked)
68+ sess.mu.Lock()
69+ require.Empty(t, sess.attachments)
70+ sess.mu.Unlock()
71+}
72+
73+func TestParkExpiryRemovesOnlyOneAttachment(t *testing.T) {
74+ pty, release := newBlockingPTY(t)
75+ defer release()
76+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
77+ firstTransport := &closeTrackingTransport{}
78+ sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport)
79+ require.NoError(t, err)
80+ secondTransport := &closeTrackingTransport{}
81+ _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
82+ require.NoError(t, err)
83+ d.clientGone(sess, first, firstTransport, false)
84+ d.clientGone(sess, second, secondTransport, false)
85+ firstToken, secondToken := first.resumeToken, second.resumeToken
86+ d.mu.Lock()
87+ firstParked := d.parked[firstToken]
88+ d.mu.Unlock()
89+ d.expireParked(firstToken, firstParked)
90+ d.mu.Lock()
91+ _, firstRetained := d.parked[firstToken]
92+ _, secondRetained := d.parked[secondToken]
93+ d.mu.Unlock()
94+ require.False(t, firstRetained)
95+ require.True(t, secondRetained)
96+}
97+
98+func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
99+ pty, release := newBlockingPTY(t)
100+ defer release()
101+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
102+ oldTransport := &closeTrackingTransport{}
103+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
104+ require.NoError(t, err)
105+ oldToken := ac.resumeToken
106+ oldGeneration := ac.roleGeneration.Load()
107+ d.clientGone(sess, ac, oldTransport, false)
108+
109+ newTransport := &closeTrackingTransport{}
110+ resumed, same, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", oldToken), newTransport, defaultSize)
111+ require.NoError(t, err)
112+ require.True(t, ok)
113+ require.Same(t, sess, resumed)
114+ require.Same(t, ac, same)
115+ require.NotEqual(t, oldToken, ac.resumeToken)
116+ require.Greater(t, ac.roleGeneration.Load(), oldGeneration)
117+ require.True(t, d.commitResumeClaim(ac))
118+ d.mu.Lock()
119+ _, oldRetained := d.parked[oldToken]
120+ d.mu.Unlock()
121+ require.False(t, oldRetained)
122+
123+ before := ac.resumeToken
124+ d.clientGone(sess, ac, oldTransport, false)
125+ require.Equal(t, before, ac.resumeToken)
126+ d.mu.Lock()
127+ _, staleParked := d.parked[oldToken]
128+ d.mu.Unlock()
129+ require.False(t, staleParked)
130+}
131+
132+func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) {
133+ pty, release := newBlockingPTY(t)
134+ defer release()
135+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
136+ oldTransport := &closeTrackingTransport{}
137+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
138+ require.NoError(t, err)
139+ oldToken := ac.resumeToken
140+ d.clientGone(sess, ac, oldTransport, false)
141+
142+ resumedTransport := &closeTrackingTransport{}
143+ d.handleHello(resumedTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", oldToken))})
144+ newToken := ac.resumeToken
145+ require.NotEqual(t, oldToken, newToken)
146+ d.mu.Lock()
147+ _, oldRetained := d.parked[oldToken]
148+ newParked := d.parked[newToken]
149+ d.mu.Unlock()
150+ require.False(t, oldRetained)
151+ require.NotNil(t, newParked)
152+ require.NotEmpty(t, resumedTransport.Sends())
153+}
154+
155+func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) {
156+ pty, release := newBlockingPTY(t)
157+ defer release()
158+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
159+ oldTransport := &closeTrackingTransport{}
160+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
161+ require.NoError(t, err)
162+ token := ac.resumeToken
163+ d.clientGone(sess, ac, oldTransport, false)
164+
165+ failed := &resumeWelcomeFailureTransport{}
166+ d.handleHello(failed, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", token))})
167+ require.Nil(t, ac.transport())
168+ d.mu.Lock()
169+ parked := d.parked[token]
170+ d.mu.Unlock()
171+ require.NotNil(t, parked)
172+ require.False(t, parked.claimed)
173+ require.Equal(t, token, ac.resumeToken)
174+
175+ _, resumed, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize)
176+ require.NoError(t, err)
177+ require.True(t, ok)
178+ require.Same(t, ac, resumed)
179+}
180+
181+func TestConcurrentResumeHasExactlyOneWinner(t *testing.T) {
182+ pty, release := newBlockingPTY(t)
183+ defer release()
184+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
185+ oldTransport := &closeTrackingTransport{}
186+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
187+ require.NoError(t, err)
188+ token := ac.resumeToken
189+ d.clientGone(sess, ac, oldTransport, false)
190+
191+ type result struct {
192+ ok bool
193+ err error
194+ }
195+ results := make(chan result, 2)
196+ for range 2 {
197+ go func() {
198+ _, _, ok, resumeErr := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize)
199+ results <- result{ok: ok, err: resumeErr}
200+ }()
201+ }
202+ var winners int
203+ for range 2 {
204+ got := <-results
205+ if got.ok {
206+ winners++
207+ }
208+ }
209+ require.Equal(t, 1, winners)
210+ require.NotEqual(t, token, ac.resumeToken)
211+}
212+
213+func TestKillSessionClosesEveryAttachmentAndParkedCredential(t *testing.T) {
214+ pty, release := newBlockingPTY(t)
215+ defer release()
216+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
217+ firstTransport := &closeTrackingTransport{}
218+ sess, _, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport)
219+ require.NoError(t, err)
220+ secondTransport := &closeTrackingTransport{}
221+ _, _, err = d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
222+ require.NoError(t, err)
223+ parkedTransport := &closeTrackingTransport{}
224+ _, parkedAttachment, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), parkedTransport)
225+ require.NoError(t, err)
226+ d.clientGone(sess, parkedAttachment, parkedTransport, false)
227+ parkedToken := parkedAttachment.resumeToken
228+
229+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
230+ d.waitNotifies()
231+ require.True(t, firstTransport.Closed())
232+ require.True(t, secondTransport.Closed())
233+ require.True(t, parkedTransport.Closed())
234+ d.mu.Lock()
235+ _, retained := d.parked[parkedToken]
236+ d.mu.Unlock()
237+ require.False(t, retained)
238+}
239+
240+func TestResumeCredentialIsInvalidAfterDaemonRestart(t *testing.T) {
241+ pty, release := newBlockingPTY(t)
242+ defer release()
243+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
244+ transport := &closeTrackingTransport{}
245+ _, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport)
246+ require.NoError(t, err)
247+ token := ac.resumeToken
248+ restarted := newTestDaemon(t, newFactory(t, pty), stubClock{})
249+ _, _, err = restarted.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
250+ var protocolErr *protoErr
251+ require.ErrorAs(t, err, &protocolErr)
252+ require.Equal(t, ports.ErrNoSuchSession, protocolErr.code)
253+}
254+
255+func TestHandleHelloAllowsMultipleAttachments(t *testing.T) {
256+ pty, release := newBlockingPTY(t)
257+ defer release()
258+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
259+ firstTransport := &closeTrackingTransport{}
260+ sess, first, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), firstTransport)
261+ require.NoError(t, err)
262+ secondTransport := &closeTrackingTransport{}
263+ _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
264+ require.NoError(t, err)
265+ require.Equal(t, attachmentActive, sess.attachmentRole(first))
266+ require.Equal(t, attachmentActive, sess.attachmentRole(second))
267+ require.NotSame(t, first, second)
268+}
M internal/usecase/daemon/attachment_role.go +15 -3
@@ -39,7 +39,10 @@ func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) at
3939 return attachmentDetached
4040 }
4141 core := entry.core()
42- if core.client == ac {
42+ // Membership is the lifecycle authority. Keep the primary pointer as a
43+ // narrow construction-time fallback for older headless fixtures; attached
44+ // routes always publish into the collection.
45+ if _, ok := core.attachments[ac]; ok || core.client == ac {
4346 return attachmentActive
4447 }
4548 if _, ok := core.snatched[ac]; ok {
@@ -156,9 +159,15 @@ func (t attachmentRoleToken) current() bool {
156159 // activeCurrent admits post-transition effects only for the exact published
157160 // role, transport incarnation, current-session link, and coordinator lease.
158161 func (t attachmentRoleToken) activeCurrent() bool {
159- if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentAttachmentSession() != t.sess {
162+ if t.role != attachmentActive || !t.current() || t.ac.currentAttachmentSession() != t.sess {
160163 return false
161164 }
165+ // Membership grants each attachment its own protocol capability. The
166+ // session coordinator may still have a primary render lease while a second
167+ // attachment is joining; that attachment must not be treated as displaced.
168+ if t.lease == nil {
169+ return true
170+ }
162171 rc := t.sess.core().coordinator.Load()
163172 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
164173 }
@@ -196,11 +205,14 @@ func (t attachmentRoleToken) activeEffectSessionLocked() bool {
196205 if t.effect != nil && !t.effect.ended.Load() {
197206 return true
198207 }
199- if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||
208+ if t.role != attachmentActive || t.sess == nil || t.ac == nil ||
200209 t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
201210 t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive {
202211 return false
203212 }
213+ if t.lease == nil {
214+ return true
215+ }
204216 rc := t.sess.core().coordinator.Load()
205217 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
206218 }
M internal/usecase/daemon/attachment_transition.go +18 -63
@@ -48,10 +48,6 @@ type attachmentTransitionRequest struct {
4848 preflighted bool
4949 roleEffectsFrozen bool
5050 expectedTargetTransportInterrupted bool
51- // activationBarrier is used when promoting an already connected snatched
52- // attachment. Its role gate is deadline-drained before sendMu is acquired,
53- // preserving the gate -> sendMu -> daemon/routing/session publication order.
54- activationBarrier bool
5551 }
5652
5753 func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool {
@@ -110,9 +106,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
110106 targetCore = req.target.core()
111107 }
112108 if sourceCore == nil || req.target != nil && targetCore == nil || req.next == nil || d.closing ||
113- d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target ||
114- req.activationBarrier && (req.sourceToken == nil || source != req.target ||
115- req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
109+ d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {
116110 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
117111 }
118112 if req.target != nil {
@@ -127,12 +121,17 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
127121 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
128122 }
129123 req.preflighted = true
130- return req, attachmentTransitionParticipants{
131- clients: []*attachedClient{req.next, req.expectedTargetCurrent},
132- interrupts: []roleTransportInterrupt{{
124+ participants := attachmentTransitionParticipants{clients: []*attachedClient{req.next}}
125+ // A new attachment joins a session; it does not displace or interrupt an
126+ // existing attachment. Handoff callers provide sourceToken and retain the
127+ // legacy target participant set for their cross-session publication.
128+ if req.source != nil || req.action != "" {
129+ participants.clients = append(participants.clients, req.expectedTargetCurrent)
130+ participants.interrupts = append(participants.interrupts, roleTransportInterrupt{
133131 ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
134- }},
135- }, nil
132+ })
133+ }
134+ return req, participants, nil
136135 }
137136
138137 // freezeAttachmentTransition drains the snapshotted participants without any
@@ -142,25 +141,12 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par
142141 if d.afterRoleEffectParticipantsSnapshotted != nil {
143142 d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients)
144143 }
145- var drainDeadline *roleEffectDrainDeadline
146- var drainDone func() <-chan struct{}
147- if req.activationBarrier {
148- drainDeadline = newRoleEffectDrainDeadline(d.clock)
149- drainDone = drainDeadline.Done
150- }
151- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
144+ frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, afterFrozen: func(ac *attachedClient) {
152145 if d.afterRoleEffectGateFrozen != nil {
153146 d.afterRoleEffectGateFrozen(req.action, ac)
154147 }
155148 }}, participants.clients...)
156- if drainDeadline != nil {
157- drainDeadline.stop()
158- }
159149 if !frozen.acquired || !frozen.drained {
160- if req.activationBarrier {
161- _ = req.next.closeCapturedTransport(req.expectedTransport.transport)
162- return frozen, false, errSendTimedOut
163- }
164150 return frozen, false, errAttachmentTransition
165151 }
166152 interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
@@ -191,20 +177,6 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a
191177 return d.transitionAttachmentLocked(req)
192178 }
193179
194-// prepareActivatedAttachment rebases the first active paint while the caller
195-// still owns the activation barrier (and therefore sendMu).
196-func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) {
197- ac := result.published.ac
198- ac.rebaseOutput()
199- ac.captureFrames = nil
200- if sess, ok := localSession(result.published.sess); ok {
201- d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false)
202- }
203- // The first paint still requests a reset, but the old panel dependency chain
204- // has already been rebased atomically under the activation barrier.
205- result.published.rebase = false
206-}
207-
208180 func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
209181 req, participants, err := d.snapshotAttachmentTransition(req)
210182 if err != nil {
@@ -221,36 +193,19 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
221193 }
222194 req.expectedTargetTransportInterrupted = interrupted
223195
224- var releaseActivation func()
225- if req.activationBarrier {
226- releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken)
227- if err != nil {
228- return attachmentTransitionResult{}, err
229- }
230- defer func() {
231- if releaseActivation != nil {
232- releaseActivation()
233- }
234- }()
235- }
236-
237196 result, err := d.publishAttachmentTransition(req)
238197 if err != nil {
239198 return result, err
240199 }
241- if req.activationBarrier && result.published.role == attachmentActive {
242- d.prepareActivatedAttachment(&result)
243- }
244- if releaseActivation != nil {
245- releaseActivation()
246- releaseActivation = nil
247- }
248- if result.published.role == attachmentActive {
249- result.published.ac.clearSnatchedInput()
250- }
251200 // Warm-proxy timers are external clock operations. Apply them only after
252201 // frozen ownership publication and every architecture lock have completed;
253202 // the lifecycle helper revalidates exact registry pointers and clients.
254203 d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveRole)
255204 return result, nil
256205 }
206+
207+func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
208+ for _, cleanup := range result.cleanups {
209+ d.attachmentCleanupWg.Go(cleanup.finish)
210+ }
211+}
M internal/usecase/daemon/attachment_transition_publish.go +18 -35
@@ -141,7 +141,14 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
141141
142142 var targetCoordinator *renderCoordinator
143143 if req.targetRole == attachmentActive {
144- targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
144+ // The coordinator remains session-owned while attachments join the
145+ // session. Only the first attachment claims its primary render lease;
146+ // later attachments must not replace or invalidate it.
147+ if targetCore.client == nil {
148+ targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
149+ } else {
150+ targetCoordinator = targetCore.coordinator.Load()
151+ }
145152 }
146153 sourceCoordinator := sourceCore.coordinator.Load()
147154 if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
@@ -160,7 +167,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
160167 }
161168 invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
162169 !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab))
163- invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
170+ invalid = invalid || targetCoordinator != nil && old == nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
164171 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
165172 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
166173 invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle)
@@ -176,11 +183,6 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
176183 // part of the same locked publication. It reports role-preserving transitions.
177184 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
178185 req := publication.req
179- if req.targetRole == attachmentActive {
180- if target, ok := localSession(req.target); ok {
181- d.demoteParkedActiveForSessionLocked(target)
182- }
183- }
184186 if req.copySourceEnvironment {
185187 source, sourceOK := localSession(publication.source)
186188 target, targetOK := localSession(req.target)
@@ -202,43 +204,24 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
202204 registerAttachmentSessionLocked(publication.source, req.next)
203205 registerAttachmentSessionLocked(req.target, req.next)
204206 if publication.old != nil && publication.old != req.next {
207+ // Existing attachments remain registered and retain their transport and
208+ // generation. A new attach is a collection insertion, never a
209+ // replacement handoff.
205210 registerAttachmentSessionLocked(req.target, publication.old)
206211 }
207- if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next {
208- publication.displacedTransport = publication.old.transportSnapshot()
209- // A proxied daemon attachment has no local snatched UI and must never
210- // linger in that role. Replacing it is terminal so its owning local proxy
211- // receives ReasonReplaced and cannot kick the direct user back later.
212- publication.terminalDisplaced = publication.old.proxied
213- }
214- if publication.source != req.target {
215- if publication.source.core().client == req.next {
216- publication.source.core().client = nil
217- }
218- delete(publication.source.core().snatched, req.next)
212+ if publication.source != req.target && publication.source.core().client == req.next {
213+ publication.source.core().client = nil
219214 }
215+ delete(publication.source.core().snatched, req.next)
220216 if req.targetRole == attachmentActive {
221217 delete(req.target.core().snatched, req.next)
222- if publication.old != nil && publication.old != req.next {
223- if publication.terminalDisplaced {
224- delete(req.target.core().snatched, publication.old)
225- req.target.core().unregisterAttachmentLocked(publication.old)
226- } else {
227- addSnatchedLocked(req.target, publication.old)
228- }
218+ if req.target.core().client == nil {
219+ req.target.core().client = req.next
229220 }
230- req.target.core().client = req.next
231221 } else {
232222 addSnatchedLocked(req.target, req.next)
233223 }
234224 publication.nextGeneration = req.next.roleGeneration.Add(1)
235- if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {
236- publication.displacedGeneration = publication.old.roleGeneration.Add(1)
237- if publication.terminalDisplaced {
238- publication.old.setSession(nil)
239- publication.old.invalidateFrozenRoleCapability()
240- }
241- }
242225 req.next.setSession(req.target)
243226 }
244227
@@ -251,7 +234,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
251234 result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))
252235 }
253236 var lease *attachmentLease
254- if publication.targetCoordinator != nil {
237+ if publication.targetCoordinator != nil && publication.old == nil {
255238 var cleanup renderLifecycleCleanup
256239 cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready)
257240 result.cleanups = append(result.cleanups, cleanup)
M internal/usecase/daemon/client.go +9 -19
@@ -50,7 +50,11 @@ type attachedClient struct {
5050 roleEffects roleEffectGate
5151 resumeCapable bool
5252 resumeToken uint64
53- parked bool
53+ // resumeClaimToken is non-zero only between a parked resume claim and its
54+ // successful Welcome. It lets a failed pre-claim handshake restore the old
55+ // credential instead of consuming it.
56+ resumeClaimToken uint64
57+ parked bool
5458 // proxied is negotiated once by Hello and remains immutable for this
5559 // attachment, including across transport resume.
5660 proxied bool
@@ -75,14 +79,8 @@ type attachedClient struct {
7579 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
7680 // snapshots cannot leak when an attachment switches tabs or sessions.
7781 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
78- // initialSnatchedMu elects exactly one reset-panel sender per role generation.
79- // A Welcome handshake and displaced-client cleanup may both discover the
80- // same post-transition snatched role; the loser waits for the elected send.
81- initialSnatchedMu sync.Mutex
82- initialSnatchedGeneration uint64
83- initialSnatchedAttempt *initialSnatchedPanelAttempt
84- size domain.Size
85- keys *keys.Router
82+ size domain.Size
83+ keys *keys.Router
8684 // view is attachment-local navigation state. It is never inferred from a
8785 // session-wide active tab, so multiple attachments can observe different
8886 // tabs and panes without changing shared session ownership.
@@ -98,15 +96,8 @@ type attachedClient struct {
9896 // previousSession is guarded independently. It is retained through temporary
9997 // setSession(nil) hand-offs and cleared only on terminal teardown.
10098 previousSession Guarded[attachmentSession]
101- // snatchedInputMu serializes the restricted input parser with its delayed
102- // standalone-ESC callback. It is independent of routing and transport locks;
103- // role activation and terminal close clear it only after releasing those locks.
104- snatchedInputMu sync.Mutex
105- snatchedInputPending []byte
106- snatchedInputDrain bool
107- snatchedInputESC pendingByteTimer
108- linkMu sync.Mutex
109- sendMu sync.Mutex
99+ linkMu sync.Mutex
100+ sendMu sync.Mutex
110101 }
111102
112103 type cursorOut struct {
@@ -261,7 +252,6 @@ func (ac *attachedClient) revokeTransport(tr ports.Transport) ports.Transport {
261252 ac.tr = nil
262253 ac.transportIncarnation++
263254 ac.linkMu.Unlock()
264- ac.clearSnatchedInput()
265255 return tr
266256 }
267257
M internal/usecase/daemon/client_frame_routing.go +7 -91
@@ -36,9 +36,8 @@ func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (
3636 }
3737
3838 // runConnLoop is the per-connection input router: it pumps client messages
39-// until detach, EOF, or a transport error. Role is resolved after every Recv so
40-// a transport displaced while blocked in Recv immediately adopts its restricted
41-// snatched routing rather than executing one stale active frame.
39+// until detach, EOF, or a transport error. The attachment generation is
40+// resolved after every Recv so a stale transport cannot execute a new frame.
4241 func (d *Daemon) runConnLoop(ac *attachedClient) {
4342 tr := ac.transport()
4443 if tr == nil {
@@ -51,13 +50,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
5150 f, err := tr.Recv()
5251 if err != nil {
5352 for range connectionRoleAttempts {
54- sess, token, ok := d.currentConnectionRole(ac, tr)
53+ sess, _, ok := d.currentConnectionRole(ac, tr)
5554 if !ok {
5655 return
5756 }
58- if token.role == attachmentSnatched {
59- d.parkOrDropSnatchedAttachment(token)
60- } else if proxy, ok := sess.(*proxySession); ok {
57+ if proxy, ok := sess.(*proxySession); ok {
6158 d.detachProxyOnSendError(proxy, ac, tr)
6259 } else if local, ok := localSession(sess); ok {
6360 d.clientGone(local, ac, tr, false)
@@ -75,21 +72,13 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
7572 if !ok {
7673 return
7774 }
78- switch token.role {
79- case attachmentActive:
80- if d.handleActiveClientFrame(token, f) {
81- return
82- }
83- case attachmentSnatched:
84- if d.handleSnatchedClientFrame(token, f) {
85- return
86- }
75+ if token.role == attachmentActive && d.handleActiveClientFrame(token, f) {
76+ return
8777 }
8878 }
8979 }
9080
91-// handleActiveClientFrame owns the ordinary attached-client protocol. Keeping
92-// it separate makes the restricted snatched protocol an explicit allow-list.
81+// handleActiveClientFrame owns the attached-client protocol.
9382 func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {
9483 if !token.activeEffect() {
9584 return false
@@ -287,76 +276,3 @@ func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
287276 }
288277 return true
289278 }
290-
291-// handleSnatchedClientFrame is intentionally an allow-list. Strict resume and
292-// quit actions are introduced separately; until then ordinary input is ignored.
293-func (d *Daemon) handleSnatchedClientFrame(token attachmentRoleToken, f ports.Frame) bool {
294- if !token.current() {
295- return false
296- }
297- ticket, admitted := token.ac.beginRoleEffect(token)
298- if !admitted {
299- return false
300- }
301- token.effect = ticket
302- defer token.endRoleEffect()
303- if d.afterRoleEffectAdmitted != nil {
304- d.afterRoleEffectAdmitted(token)
305- }
306- switch f.Type {
307- case ports.MsgInput:
308- if in, err := ports.UnmarshalInput(f.Payload); err == nil && d.handleSnatchedInput(token, in) {
309- return true
310- }
311- case ports.MsgResize:
312- if resize, err := ports.UnmarshalResize(f.Payload); err == nil && resize.Size.Valid() {
313- ac := token.ac
314- ac.sendMu.Lock()
315- if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
316- ac.sendMu.Unlock()
317- return false
318- }
319- ac.size = resize.Size
320- ac.sendMu.Unlock()
321- if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
322- token.endRoleEffect()
323- d.parkOrDropSnatchedAttachment(token)
324- return true
325- }
326- }
327- case ports.MsgTheme:
328- if theme, err := ports.UnmarshalTheme(f.Payload); err == nil {
329- ac := token.ac
330- ac.sendMu.Lock()
331- if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
332- ac.sendMu.Unlock()
333- return false
334- }
335- clientTheme := themeFromMessage(theme)
336- ac.setClientTheme(clientTheme)
337- ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
338- ac.sendMu.Unlock()
339- if err := d.sendSnatchedPanel(ac, token.transport, token.generation, "", token.effect); err != nil {
340- token.endRoleEffect()
341- d.parkOrDropSnatchedAttachment(token)
342- return true
343- }
344- }
345- case ports.MsgAck:
346- if ack, err := ports.UnmarshalAck(f.Payload); err == nil {
347- token.ac.ackOutputState(ack.AckedStateNum)
348- }
349- case ports.MsgPing:
350- if err := d.sendSnatchedControl(token, framePong()); err != nil {
351- token.endRoleEffect()
352- d.parkOrDropSnatchedAttachment(token)
353- return true
354- }
355- case ports.MsgDetach:
356- _ = d.sendSnatchedControl(token, frameDetached(ports.ReasonDetach))
357- token.endRoleEffect()
358- d.dropSnatchedAttachment(token)
359- return true
360- }
361- return false
362-}
M internal/usecase/daemon/client_transport_errors.go +6 -0
@@ -64,6 +64,12 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
6464 }
6565 if rc := sess.renderCoordinator(); rc != nil {
6666 rc.noteDetach(ac)
67+ sess.mu.Lock()
68+ primary := sess.client
69+ sess.mu.Unlock()
70+ if primary != nil {
71+ d.attachCoordinator(sess, ac, primary, true)
72+ }
6773 }
6874 sess.mu.Lock()
6975 ephemeral := sess.ephemeral
M internal/usecase/daemon/daemon.go +15 -43
@@ -144,15 +144,6 @@ type Daemon struct {
144144 // after publishResizeCommit acquires attachment sendMu and before it acquires
145145 // owner fences. Tests must not perform external work from this callback.
146146 afterResizeCommitSendLocked func()
147- // afterSnatchOverlayFamily is a deterministic structural lock seam. It runs
148- // after one overlay-family mutex is released and before the next is taken.
149- afterSnatchOverlayFamily func(string)
150- // afterSnatchedEscapeAccepted observes a standalone ESC after parser state
151- // is consumed and before its captured role and transport are revalidated.
152- afterSnatchedEscapeAccepted func()
153- // afterSnatchedEscapeAttempt reports whether that callback acquired its exact
154- // role capability after revalidation.
155- afterSnatchedEscapeAttempt func(bool)
156147 // afterConnectionSessionSnapshot is a deterministic test seam after the
157148 // connection loop reads its current session and before it captures that role.
158149 afterConnectionSessionSnapshot func(attachmentSession)
@@ -200,21 +191,10 @@ type Daemon struct {
200191 // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a
201192 // matching in-flight entry and before it blocks on done.
202193 afterParkingWaitArmed func()
203- // afterSnatchedUnrouteBeforePark pauses parkSnatchedAttachment and
204- // cleanupInterruptedSnatchedAttachment after exact snatched ownership is
205- // removed and before parkAttachmentAs. The parking-in-flight marker must
206- // already be published so a concurrent same-token resume can wait the gap.
207- afterSnatchedUnrouteBeforePark func()
208194 // beforeResumeParkedSendMu pauses resumeParked after the initial parked
209195 // lookup and before the attachment send lock, so tests can consume or
210196 // replace the credential while the handshake waits.
211197 beforeResumeParkedSendMu func()
212- // afterDisplacedCleanupStarted observes deferred displaced cleanup before it
213- // synchronizes with the attachment gate.
214- afterDisplacedCleanupStarted func()
215- // beforeReclaimFirstPaint observes a committed reclaim after displaced cleanup
216- // is queued and before its generation-bound reset first paint is admitted.
217- beforeReclaimFirstPaint func(attachmentRoleToken)
218198 // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint
219199 // reserves its exact capability and before its first observable mutation.
220200 afterRoleEffectAdmitted func(attachmentRoleToken)
@@ -339,9 +319,9 @@ type Daemon struct {
339319 type parkedAttachment struct {
340320 sess *session
341321 ac *attachedClient
342- role attachmentRole
343322 pickerGeneration uint64
344323 timer ports.Timer
324+ claimed bool
345325 done chan struct{}
346326 doneOnce sync.Once
347327 }
@@ -1120,23 +1100,26 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11201100 welcomeToken := sess.attachmentToken(ac, tr)
11211101 welcomeToken.lease = lease
11221102 welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken)
1123- validRole := welcomeToken.role == attachmentSnatched || welcomeToken.role == attachmentActive && lease != nil
1103+ validRole := welcomeToken.role == attachmentActive
11241104 if expected.transport != tr || !admitted || !validRole {
11251105 if admitted {
11261106 welcomeTicket.End()
11271107 }
1128- if welcomeToken.role == attachmentSnatched {
1129- d.parkOrDropSnatchedAttachment(welcomeToken)
1130- } else {
1108+ if !d.abortResumeClaim(ac) {
11311109 d.clientGone(sess, ac, tr, false)
11321110 }
11331111 return
11341112 }
11351113 if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {
11361114 welcomeTicket.End()
1137- if welcomeToken.role == attachmentSnatched {
1138- d.parkOrDropSnatchedAttachment(welcomeToken)
1139- } else {
1115+ if !d.abortResumeClaim(ac) {
1116+ d.clientGone(sess, ac, tr, false)
1117+ }
1118+ return
1119+ }
1120+ if !d.commitResumeClaim(ac) {
1121+ welcomeTicket.End()
1122+ if !d.abortResumeClaim(ac) {
11401123 d.clientGone(sess, ac, tr, false)
11411124 }
11421125 return
@@ -1150,19 +1133,8 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11501133 d.clientGone(sess, ac, tr, false)
11511134 return
11521135 }
1153- if postWelcomeToken.role == attachmentSnatched {
1154- if err := d.sendInitialSnatchedPanel(postWelcomeToken, postWelcomeTicket); err != nil {
1155- postWelcomeTicket.End()
1156- d.parkOrDropSnatchedAttachment(postWelcomeToken)
1157- return
1158- }
1159- postWelcomeTicket.End()
1160- d.runConnLoop(ac)
1161- _ = tr.Close()
1162- return
1163- }
11641136 postWelcomeLease := postWelcomeToken.lease
1165- if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil {
1137+ if postWelcomeToken.role != attachmentActive {
11661138 postWelcomeTicket.End()
11671139 d.clientGone(sess, ac, tr, false)
11681140 return
@@ -1177,10 +1149,10 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11771149 }
11781150 ac.markSessionMetaSent(meta)
11791151 }
1180- if !rc.markAttachmentReady(postWelcomeLease) {
1152+ if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
11811153 postWelcomeTicket.End()
1182- // The attachment was displaced or detached while Welcome was in flight;
1183- // never let this stale handshake emit an Output frame.
1154+ // The attachment was detached while Welcome was in flight; never let
1155+ // this stale handshake emit an Output frame.
11841156 d.clientGone(sess, ac, tr, false)
11851157 return
11861158 }
M internal/usecase/daemon/input.go +1 -1
@@ -350,7 +350,7 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool
350350 return sess, effect, true
351351 }
352352 sess := h.ac.currentSession()
353- if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched {
353+ if sess == nil {
354354 return nil, nil, false
355355 }
356356 return sess, nil, false
D internal/usecase/daemon/move_handoff_failure_test.go +0 -336
@@ -1,336 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "maps"
6- "testing"
7-
8- "github.com/stretchr/testify/require"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/usecase/layout"
12-)
13-
14-type moveTabState struct {
15- ptr *tab
16- stableID string
17- tree *layout.Tree
18- panes map[layout.PaneID]*pane
19- nextPaneID int
20- layoutGeneration uint64
21- floatingState floatingState
22- floatingPane *pane
23- floatingVisible bool
24- floatingGeneration uint64
25- ctxDone bool
26-}
27-
28-type moveSessionState struct {
29- ptr *session
30- id domain.SessionID
31- name string
32- incarnation domain.IncarnationID
33- tabs []*tab
34- active int
35- client *attachedClient
36- snatched map[*attachedClient]struct{}
37- moveReservations uint
38- snapshotGeneration uint64
39- snapshotPublished uint64
40- snapshotDirty bool
41- snapshotEligible bool
42- ctxDone bool
43-}
44-
45-type moveLeaseState struct {
46- lease *attachmentLease
47- attachment *attachedClient
48- ready bool
49- active bool
50-}
51-
52-type moveClientState struct {
53- transport transportSnapshot
54- roleGeneration uint64
55- currentSession *session
56- sourceRole attachmentRole
57- destinationRole attachmentRole
58- sourceLease moveLeaseState
59- destinationLease moveLeaseState
60- roleEffectPhase roleEffectPhase
61-}
62-
63-type movePaneState struct {
64- id layout.PaneID
65- stableID string
66- owner *paneOwner
67- ownerGeneration uint64
68- ctxDone bool
69-}
70-
71-type moveState struct {
72- closing bool
73- registry map[domain.SessionID]attachmentSession
74- source moveSessionState
75- destination moveSessionState
76- sourceTab moveTabState
77- destinationTab moveTabState
78- movedPane movePaneState
79- client moveClientState
80-}
81-
82-func captureMoveState(d *Daemon, source, destination *session, sourceTab, destinationTab *tab, movedPane *pane, client *attachedClient) moveState {
83- clientState := captureMoveClientState(source, destination, client)
84- d.mu.Lock()
85- d.notices.routingMu.Lock()
86- unlockSessions := lockAttachmentSessions(source, destination)
87- unlockTabs := lockMoveTabs(sourceTab, destinationTab)
88- movedPane.mu.Lock()
89-
90- registry := make(map[domain.SessionID]attachmentSession, len(d.sessions))
91- maps.Copy(registry, d.sessions)
92- state := moveState{
93- closing: d.closing,
94- registry: registry,
95- source: captureMoveSessionStateLocked(source),
96- destination: captureMoveSessionStateLocked(destination),
97- sourceTab: captureMoveTabStateLocked(sourceTab),
98- destinationTab: captureMoveTabStateLocked(destinationTab),
99- movedPane: movePaneState{
100- id: movedPane.id,
101- stableID: movedPane.stableID,
102- owner: movedPane.ownerSnapshot(),
103- ownerGeneration: movedPane.ownerGeneration,
104- ctxDone: moveContextDone(movedPane.ctx),
105- },
106- client: clientState,
107- }
108-
109- movedPane.mu.Unlock()
110- unlockTabs()
111- unlockSessions()
112- d.notices.routingMu.Unlock()
113- d.mu.Unlock()
114- return state
115-}
116-
117-func captureMoveSessionStateLocked(sess *session) moveSessionState {
118- snatched := make(map[*attachedClient]struct{}, len(sess.snatched))
119- maps.Copy(snatched, sess.snatched)
120- return moveSessionState{
121- ptr: sess,
122- id: sess.id,
123- name: sess.name,
124- incarnation: sess.incarnation,
125- tabs: append([]*tab(nil), sess.tabs...),
126- active: sess.active,
127- client: sess.client,
128- snatched: snatched,
129- moveReservations: sess.moveReservations,
130- snapshotGeneration: sess.snapshotGeneration,
131- snapshotPublished: sess.snapshotPublishedGeneration,
132- snapshotDirty: sess.snapDirty.Load(),
133- snapshotEligible: sess.snapEligible.Load(),
134- ctxDone: moveContextDone(sess.ctx),
135- }
136-}
137-
138-func captureMoveTabStateLocked(tb *tab) moveTabState {
139- panes := make(map[layout.PaneID]*pane, len(tb.panes))
140- maps.Copy(panes, tb.panes)
141- return moveTabState{
142- ptr: tb,
143- stableID: tb.stableID,
144- tree: tb.tree.Clone(),
145- panes: panes,
146- nextPaneID: tb.nextPaneID,
147- layoutGeneration: tb.layoutGeneration,
148- floatingState: tb.floating.state,
149- floatingPane: tb.floating.pane,
150- floatingVisible: tb.floating.desiredVisible,
151- floatingGeneration: tb.floating.generation,
152- ctxDone: moveContextDone(tb.ctx),
153- }
154-}
155-
156-// captureMoveClientState snapshots coordinator and role-effect state before
157-// callers acquire daemon/session/tab move-state locks. This keeps coordinator
158-// and role-gate locks above, never beneath, the outer move fixture locks.
159-func captureMoveClientState(source, destination *session, client *attachedClient) moveClientState {
160- sourceLease := captureMoveLeaseState(source, client)
161- destinationLease := captureMoveLeaseState(destination, client)
162- roleEffectPhase := captureMoveRoleEffectPhase(client)
163- return moveClientState{
164- transport: client.transportSnapshot(),
165- roleGeneration: client.roleGeneration.Load(),
166- currentSession: client.currentSession(),
167- sourceRole: source.attachmentRole(client),
168- destinationRole: destination.attachmentRole(client),
169- sourceLease: sourceLease,
170- destinationLease: destinationLease,
171- roleEffectPhase: roleEffectPhase,
172- }
173-}
174-
175-func captureMoveLeaseState(sess *session, client *attachedClient) moveLeaseState {
176- coordinator := sess.renderCoordinator()
177- if coordinator == nil {
178- return moveLeaseState{}
179- }
180- coordinator.mu.Lock()
181- defer coordinator.mu.Unlock()
182- lease := coordinator.lease
183- if lease == nil || lease.attachment != client {
184- return moveLeaseState{lease: lease}
185- }
186- return moveLeaseState{lease: lease, attachment: lease.attachment, ready: lease.ready, active: lease.active}
187-}
188-
189-func captureMoveRoleEffectPhase(client *attachedClient) roleEffectPhase {
190- client.roleEffects.mu.Lock()
191- defer client.roleEffects.mu.Unlock()
192- return client.roleEffects.phase
193-}
194-
195-func moveContextDone(ctx context.Context) bool {
196- return ctx != nil && ctx.Err() != nil
197-}
198-
199-func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
200- p1, release1 := newBlockingPTY(t)
201- p2, release2 := newBlockingPTY(t)
202- defer release1()
203- defer release2()
204-
205- d, source, sourceClient, _ := newManualSessionWithPTYs(t, p1)
206- sourceTab := source.tabs[0]
207- sourceTab.stableID = "source-tab"
208- movedPane := sourceTab.focusedPane()
209-
210- destination := &session{sessionCore: sessionCore{id: "destination",
211- name: "destination",
212- ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
213- active: 0,
214- }
215- destinationTab := destination.tabs[0]
216- publishTiledPaneOwners(destination, destinationTab)
217- d.mu.Lock()
218- d.sessions[destination.id] = destination
219- d.mu.Unlock()
220- require.NotNil(t, d.attachCoordinator(source, nil, sourceClient, true))
221-
222- before := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
223- originalTransport := before.client.transport
224- staleTransport := &closeTrackingTransport{}
225- staleInjected := make(chan struct{})
226- d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) {
227- if action != "move-pane" || client != sourceClient {
228- return
229- }
230- client.replaceTransport(staleTransport)
231- close(staleInjected)
232- }
233-
234- moveDone := make(chan error, 1)
235- go func() {
236- moveDone <- d.movePane(movePaneRequest{
237- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
238- SourceTabID: domain.TabStableID(sourceTab.stableID),
239- SourcePaneID: domain.PaneStableID(movedPane.stableID),
240- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
241- DestinationTabID: domain.TabStableID(destinationTab.stableID),
242- })
243- }()
244- <-staleInjected
245- err := <-moveDone
246-
247- // Restore the exact test-injected transport incarnation before comparing the
248- // complete state. The move itself must not be able to alter this state.
249- sourceClient.linkMu.Lock()
250- sourceClient.tr = originalTransport.transport
251- sourceClient.transportIncarnation = originalTransport.incarnation
252- sourceClient.linkMu.Unlock()
253- d.afterRoleEffectGateFrozen = nil
254-
255- require.ErrorIs(t, err, errAttachmentTransition)
256- after := captureMoveState(d, source, destination, sourceTab, destinationTab, movedPane, sourceClient)
257- require.Equal(t, before, after, "stale handoff validation must reject before any move publication")
258-}
259-
260-type moveTabHandoffState struct {
261- closing bool
262- registry map[domain.SessionID]attachmentSession
263- source moveSessionState
264- destination moveSessionState
265- movedTab moveTabState
266- follower moveClientState
267-}
268-
269-func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *tab, follower *attachedClient) moveTabHandoffState {
270- followerState := captureMoveClientState(source, destination, follower)
271- d.mu.Lock()
272- d.notices.routingMu.Lock()
273- unlockSessions := lockAttachmentSessions(source, destination)
274- moved.mu.Lock()
275-
276- registry := make(map[domain.SessionID]attachmentSession, len(d.sessions))
277- maps.Copy(registry, d.sessions)
278- state := moveTabHandoffState{
279- closing: d.closing,
280- registry: registry,
281- source: captureMoveSessionStateLocked(source),
282- destination: captureMoveSessionStateLocked(destination),
283- movedTab: captureMoveTabStateLocked(moved),
284- follower: followerState,
285- }
286-
287- moved.mu.Unlock()
288- unlockSessions()
289- d.notices.routingMu.Unlock()
290- d.mu.Unlock()
291- return state
292-}
293-
294-func TestMoveTabFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
295- d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
296- floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
297- floating.ctx, floating.cancel = context.WithCancel(d.paneProcessCtx)
298- t.Cleanup(floating.cancel)
299- moved.mu.Lock()
300- moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 9}
301- moved.mu.Unlock()
302- publishPaneOwner(floating, source, moved, 9)
303-
304- before := captureMoveTabHandoffState(d, source, destination, moved, follower)
305- originalTransport := before.follower.transport
306- staleTransport := &closeTrackingTransport{}
307- staleInjected := make(chan struct{})
308- d.afterRoleEffectGateFrozen = func(action string, client *attachedClient) {
309- if action != "move-tab" || client != follower {
310- return
311- }
312- client.replaceTransport(staleTransport)
313- close(staleInjected)
314- }
315-
316- moveDone := make(chan error, 1)
317- go func() {
318- moveDone <- d.moveTab(moveTabRequest{
319- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
320- SourceTabID: domain.TabStableID(moved.stableID),
321- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
322- })
323- }()
324- <-staleInjected
325- err := <-moveDone
326-
327- follower.linkMu.Lock()
328- follower.tr = originalTransport.transport
329- follower.transportIncarnation = originalTransport.incarnation
330- follower.linkMu.Unlock()
331- d.afterRoleEffectGateFrozen = nil
332-
333- require.ErrorIs(t, err, errAttachmentTransition)
334- after := captureMoveTabHandoffState(d, source, destination, moved, follower)
335- require.Equal(t, before, after, "stale move-tab handoff validation must reject before any move publication")
336-}
M internal/usecase/daemon/move_picker_test.go +0 -37
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "sync/atomic"
54 "testing"
65 "time"
76
@@ -279,42 +278,6 @@ func TestMovePickerSourceClientReplacementInvalidatesAction(t *testing.T) {
279278 require.Equal(t, "Source client is no longer active.", history[0].Message)
280279 }
281280
282-func TestMovePickerFinalSourceClosesBeforeSnatchFinalization(t *testing.T) {
283- d, source, follower, destination, _, releases := setupMovePickerSessions(t, 0)
284- defer releaseAll(releases)
285- follower.replaceTransport(&closeTrackingTransport{})
286- require.NotNil(t, d.attachCoordinator(source, nil, follower, true))
287-
288- displacedTransport := &closeTrackingTransport{}
289- displaced := &attachedClient{tr: displacedTransport, output: newOutputStateStream(), size: follower.size}
290- displaced.initOverlays()
291- displaced.setSession(destination)
292- destination.mu.Lock()
293- destination.client = displaced
294- destination.mu.Unlock()
295- require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))
296-
297- var pickerClosed atomic.Bool
298- d.afterDisplacedCleanupStarted = func() {
299- require.False(t, follower.overlays.pickerActive(), "picker must close before snatch finalization")
300- pickerClosed.Store(true)
301- }
302- defer func() { d.afterDisplacedCleanupStarted = nil }()
303-
304- require.NoError(t, d.enterPickerForIntent(source, follower, pickerMovePane, moveSourceLocator{
305- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
306- TabID: "source-tab", PaneID: "source-pane",
307- }))
308- d.handlePickerInput(follower, []byte("\r"))
309- d.attachmentCleanupWg.Wait()
310-
311- require.True(t, pickerClosed.Load())
312- require.False(t, follower.overlays.pickerActive())
313- frames := displacedTransport.Sends()
314- require.NotEmpty(t, frames)
315- require.Equal(t, ports.MsgOutput, frames[0].Type)
316-}
317-
318281 func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
319282 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
320283 defer releaseAll(releases)
D internal/usecase/daemon/move_tab_handoff_test.go +0 -339
@@ -1,339 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/ports"
10- scopy "github.com/bnema/vev/internal/usecase/copy"
11-)
12-
13-func setupMoveTabFinalSourceHandoff(t *testing.T) (*Daemon, *session, *attachedClient, *session, *attachedClient, *closeTrackingTransport, *tab) {
14- t.Helper()
15- d, source, follower, _ := newManualSessionWithPTYs(t, newQuietPTY())
16- follower.replaceTransport(&closeTrackingTransport{})
17- moved := source.tabs[0]
18- moved.stableID = "moved-tab"
19- require.NotNil(t, d.attachCoordinator(source, nil, follower, true))
20-
21- destination := addMoveTabTestSession(d, "destination", "destination-tab")
22- displacedTransport := &closeTrackingTransport{}
23- displaced := &attachedClient{
24- tr: displacedTransport, output: newOutputStateStream(), size: follower.size,
25- }
26- displaced.initOverlays()
27- displaced.setSession(destination)
28- destination.mu.Lock()
29- destination.client = displaced
30- destination.mu.Unlock()
31- require.NotNil(t, d.attachCoordinator(destination, nil, displaced, true))
32-
33- return d, source, follower, destination, displaced, displacedTransport, moved
34-}
35-
36-func moveTabFinalSource(t *testing.T, d *Daemon, source, destination *session, moved *tab) {
37- t.Helper()
38- require.NoError(t, d.moveTab(moveTabRequest{
39- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
40- SourceTabID: domain.TabStableID(moved.stableID),
41- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
42- }))
43-}
44-
45-func detachedWithReason(frames []ports.Frame, reason uint8) bool {
46- for _, frame := range frames {
47- if frame.Type != ports.MsgDetached {
48- continue
49- }
50- detached, err := ports.UnmarshalDetached(frame.Payload)
51- if err == nil && detached.Reason == reason {
52- return true
53- }
54- }
55- return false
56-}
57-
58-func TestMoveTabFinalSourceSnatchesDestinationActiveClient(t *testing.T) {
59- d, source, follower, destination, displaced, displacedTransport, moved := setupMoveTabFinalSourceHandoff(t)
60-
61- moveTabFinalSource(t, d, source, destination, moved)
62- d.attachmentCleanupWg.Wait()
63-
64- destination.mu.Lock()
65- require.Same(t, follower, destination.client)
66- require.Contains(t, destination.snatched, displaced)
67- require.Same(t, moved, destination.tabs[destination.active])
68- destination.mu.Unlock()
69- require.Same(t, destination, follower.currentSession())
70- require.Equal(t, attachmentActive, destination.attachmentRole(follower))
71- require.Equal(t, attachmentSnatched, destination.attachmentRole(displaced))
72-
73- frames := displacedTransport.Sends()
74- require.Len(t, frames, 1)
75- require.Equal(t, ports.MsgOutput, frames[0].Type)
76- output, err := ports.UnmarshalOutput(frames[0].Payload)
77- require.NoError(t, err)
78- require.Contains(t, string(output.Data), "Session snatched")
79- require.False(t, detachedWithReason(frames, ports.ReasonReplaced))
80- require.False(t, displacedTransport.Closed())
81-}
82-
83-func TestMoveTabFinalSourcePreservesDestinationSnatchedWaiters(t *testing.T) {
84- d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
85-
86- waitingTransport := &closeTrackingTransport{}
87- waiting := &attachedClient{tr: waitingTransport, output: newOutputStateStream(), size: follower.size}
88- waiting.initOverlays()
89- waiting.setSession(destination)
90- destination.mu.Lock()
91- destination.addSnatchedLocked(waiting)
92- destination.mu.Unlock()
93-
94- moveTabFinalSource(t, d, source, destination, moved)
95- d.attachmentCleanupWg.Wait()
96-
97- destination.mu.Lock()
98- require.Contains(t, destination.snatched, displaced)
99- require.Contains(t, destination.snatched, waiting)
100- require.Len(t, destination.snatched, 2)
101- destination.mu.Unlock()
102- requireSingleOwnerAndLease(t, destination, follower, displaced, waiting)
103-}
104-
105-func TestMoveTabFinalSourceRetiresSourceParkedAndSnatchedClients(t *testing.T) {
106- d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
107-
108- parkedTransport := &closeTrackingTransport{}
109- parked := &attachedClient{tr: parkedTransport, output: newOutputStateStream(), size: follower.size, resumeCapable: true}
110- parked.initOverlays()
111- require.True(t, d.parkAttachment(source, parked))
112-
113- sourceSnatchedTransport := &closeTrackingTransport{}
114- sourceSnatched := &attachedClient{tr: sourceSnatchedTransport, output: newOutputStateStream(), size: follower.size}
115- sourceSnatched.initOverlays()
116- sourceSnatched.setSession(source)
117- source.mu.Lock()
118- source.addSnatchedLocked(sourceSnatched)
119- source.mu.Unlock()
120-
121- moveTabFinalSource(t, d, source, destination, moved)
122- d.attachmentCleanupWg.Wait()
123- d.waitNotifies()
124-
125- d.mu.Lock()
126- require.Empty(t, d.parked)
127- d.mu.Unlock()
128- require.True(t, parkedTransport.Closed())
129- require.True(t, sourceSnatchedTransport.Closed())
130- require.Nil(t, sourceSnatched.currentSession())
131-}
132-
133-func TestMoveTabFinalSourceClearsFollowerCopyCapturePreservesDestinationWaiterOverlays(t *testing.T) {
134- d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
135- movedPane := moved.focusedPane()
136-
137- follower.overlays.copyMu.Lock()
138- follower.overlays.copyPane = movedPane
139- follower.overlays.copyMode = &scopy.Mode{}
140- follower.overlays.copyMu.Unlock()
141- follower.sendMu.Lock()
142- follower.captureFrames = map[*pane]capturedPaneRenderState{movedPane: {title: "stale-source-capture"}}
143- follower.sendMu.Unlock()
144-
145- otherPane := destination.tabs[0].focusedPane()
146- waiting := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: follower.size}
147- waiting.initOverlays()
148- waiting.setSession(destination)
149- waiting.overlays.copyMu.Lock()
150- waiting.overlays.copyPane = otherPane
151- waiting.overlays.copyMode = &scopy.Mode{}
152- waiting.overlays.copyMu.Unlock()
153- waiting.sendMu.Lock()
154- waiting.captureFrames = map[*pane]capturedPaneRenderState{otherPane: {}}
155- waiting.sendMu.Unlock()
156- destination.mu.Lock()
157- destination.addSnatchedLocked(waiting)
158- destination.mu.Unlock()
159-
160- moveTabFinalSource(t, d, source, destination, moved)
161-
162- follower.overlays.copyMu.Lock()
163- require.Nil(t, follower.overlays.copyMode)
164- require.Nil(t, follower.overlays.copyPane)
165- follower.overlays.copyMu.Unlock()
166- follower.sendMu.Lock()
167- followerCapture, followerCaptured := follower.captureFrames[movedPane]
168- follower.sendMu.Unlock()
169- if followerCaptured {
170- require.NotEqual(t, "stale-source-capture", followerCapture.title, "source capture survived destination first paint")
171- }
172-
173- waiting.overlays.copyMu.Lock()
174- require.NotNil(t, waiting.overlays.copyMode)
175- require.Same(t, otherPane, waiting.overlays.copyPane)
176- waiting.overlays.copyMu.Unlock()
177- waiting.sendMu.Lock()
178- _, waitingCaptured := waiting.captureFrames[otherPane]
179- waiting.sendMu.Unlock()
180- require.True(t, waitingCaptured)
181-}
182-
183-func TestMoveTabFinalSourceFirstPaintNotBlockedByDisplacedPanelCleanup(t *testing.T) {
184- d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
185- followerRebased := make(chan struct{})
186- follower.renderStages.handoffRebase = func() { close(followerRebased) }
187-
188- displaced.sendMu.Lock()
189- panelCleanupStarted := make(chan struct{})
190- d.afterDisplacedCleanupStarted = func() { close(panelCleanupStarted) }
191- defer func() { d.afterDisplacedCleanupStarted = nil }()
192-
193- moveDone := make(chan struct{})
194- go func() {
195- moveTabFinalSource(t, d, source, destination, moved)
196- close(moveDone)
197- }()
198-
199- awaitTestCompletion(t, panelCleanupStarted, "displaced panel cleanup did not start")
200- awaitTestCompletion(t, followerRebased, "follower reset first paint waited for displaced panel cleanup")
201- awaitTestCompletion(t, moveDone, "move-tab handoff did not finish after follower first paint")
202-
203- displaced.sendMu.Unlock()
204- d.attachmentCleanupWg.Wait()
205-}
206-
207-func TestMoveTabFinalSourceConcurrentReclaimKeepsNewestOwnerAndLease(t *testing.T) {
208- d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
209-
210- paintAdmitted := make(chan struct{})
211- releasePaint := make(chan struct{})
212- d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
213- if token.ac == follower && token.role == attachmentActive {
214- close(paintAdmitted)
215- <-releasePaint
216- }
217- }
218- defer func() { d.afterRoleEffectAdmitted = nil }()
219-
220- moveDone := make(chan error, 1)
221- go func() {
222- moveDone <- d.moveTab(moveTabRequest{
223- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
224- SourceTabID: domain.TabStableID(moved.stableID),
225- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
226- })
227- }()
228- awaitTestCompletion(t, paintAdmitted, "move-tab follower first paint was not admitted")
229-
230- displacedToken := destination.attachmentToken(displaced, displaced.transportSnapshot().transport)
231- reclaimDone := make(chan struct{})
232- go func() {
233- d.handleSnatchedClientFrame(displacedToken, ports.Frame{
234- Type: ports.MsgInput,
235- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
236- })
237- close(reclaimDone)
238- }()
239-
240- close(releasePaint)
241- require.NoError(t, <-moveDone)
242- awaitTestCompletion(t, reclaimDone, "concurrent destination reclaim did not finish")
243- d.attachmentCleanupWg.Wait()
244-
245- requireSingleOwnerAndLease(t, destination, follower, displaced)
246- destination.mu.Lock()
247- require.Same(t, displaced, destination.client)
248- destination.mu.Unlock()
249- require.Equal(t, attachmentSnatched, destination.attachmentRole(follower))
250- require.Equal(t, attachmentActive, destination.attachmentRole(displaced))
251-}
252-
253-func TestMoveTabFinalSourcePostCommitPaintFailureDoesNotRollback(t *testing.T) {
254- d, source, follower, destination, _, _, moved := setupMoveTabFinalSourceHandoff(t)
255-
256- d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
257- if token.ac == follower {
258- token.ac.roleGeneration.Add(1)
259- }
260- }
261- defer func() { d.afterRoleEffectAdmitted = nil }()
262-
263- beforeTabs := append([]*tab(nil), destination.tabs...)
264- moveTabFinalSource(t, d, source, destination, moved)
265-
266- require.Nil(t, source.tabs)
267- require.Same(t, destination, follower.currentSession())
268- require.Same(t, follower, destination.client)
269- require.Equal(t, append(beforeTabs, moved), destination.tabs)
270- require.Same(t, moved, destination.tabs[destination.active])
271-}
272-
273-func TestMoveTabFinalSourceHandoffCommitHidesPartialPublication(t *testing.T) {
274- d, source, follower, destination, displaced, _, moved := setupMoveTabFinalSourceHandoff(t)
275-
276- commitEntered := make(chan struct{})
277- releaseCommit := make(chan struct{})
278- d.beforeMoveTabCommit = func() {
279- close(commitEntered)
280- <-releaseCommit
281- }
282- defer func() { d.beforeMoveTabCommit = nil }()
283-
284- moveDone := make(chan error, 1)
285- go func() {
286- moveDone <- d.moveTab(moveTabRequest{
287- Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
288- SourceTabID: domain.TabStableID(moved.stableID),
289- Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
290- })
291- }()
292- <-commitEntered
293-
294- type observation struct {
295- sourceRegistered bool
296- movedInSource bool
297- movedInDestination bool
298- followerOnDestination bool
299- displacedSnatched bool
300- movedActive bool
301- }
302- read := func() observation {
303- d.mu.Lock()
304- _, sourceLive := d.sessions[source.id]
305- d.mu.Unlock()
306- source.mu.Lock()
307- movedInSource := indexMoveTabLocked(source, moved) >= 0
308- source.mu.Unlock()
309- destination.mu.Lock()
310- obs := observation{
311- sourceRegistered: sourceLive,
312- movedInSource: movedInSource,
313- movedInDestination: indexMoveTabLocked(destination, moved) >= 0,
314- followerOnDestination: destination.client == follower,
315- displacedSnatched: false,
316- movedActive: destination.active >= 0 && destination.tabs[destination.active] == moved,
317- }
318- if displaced != nil {
319- _, obs.displacedSnatched = destination.snatched[displaced]
320- }
321- destination.mu.Unlock()
322- return obs
323- }
324-
325- require.False(t, d.mu.TryLock(), "daemon observers must be excluded from partial move-tab publication")
326- require.False(t, source.mu.TryLock(), "source observers must be excluded from partial move-tab publication")
327- require.False(t, destination.mu.TryLock(), "destination observers must be excluded from partial move-tab publication")
328-
329- close(releaseCommit)
330- require.NoError(t, <-moveDone)
331-
332- final := read()
333- require.False(t, final.sourceRegistered)
334- require.False(t, final.movedInSource)
335- require.True(t, final.movedInDestination)
336- require.True(t, final.followerOnDestination)
337- require.True(t, final.displacedSnatched)
338- require.True(t, final.movedActive)
339-}
M internal/usecase/daemon/overlay_runtime.go +0 -106
@@ -242,112 +242,6 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
242242 return active || prePublication
243243 }
244244
245-// clearForSnatch clears each overlay family independently. Every family
246-// revalidates the displaced role generation while holding only its own mutex,
247-// so cleanup can neither participate in cross-family lock inversion nor erase
248-// state published by a later promotion.
249-func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
250- if token.ac == nil || token.ac.overlays == nil {
251- return false
252- }
253- rt := token.ac.overlays
254- current := func() bool {
255- return token.ac.roleGeneration.Load() == token.generation && token.ac.transportSnapshotCurrent(token.transport)
256- }
257- afterFamily := func(family string) {
258- if d.afterSnatchOverlayFamily != nil {
259- d.afterSnatchOverlayFamily(family)
260- }
261- }
262-
263- rt.pickerMu.Lock()
264- if !current() {
265- rt.pickerMu.Unlock()
266- return false
267- }
268- previewSession := rt.pickerPreviewSession
269- previewGeneration := rt.pickerPreviewGeneration
270- pickerInstance := remotePickerInstance{ac: token.ac, generation: rt.pickerGeneration, model: rt.picker}
271- rt.picker = nil
272- rt.pickerIntent = pickerNavigate
273- rt.pickerSource = moveSourceLocator{}
274- rt.pickerPending = nil
275- rt.pickerESC.stop()
276- rt.pickerPreviewGeneration++
277- rt.pickerPreview = nil
278- rt.pickerPreviewSession = nil
279- rt.pickerMu.Unlock()
280- afterFamily("picker")
281- // Subscription teardown may enter a coordinator and therefore runs outside
282- // every overlay-family lock.
283- d.teardownPreviewSubscription(token.ac, previewSession, previewGeneration)
284- if pickerInstance.model != nil {
285- d.remotePickerClosed(pickerInstance)
286- }
287-
288- rt.paletteMu.Lock()
289- if !current() {
290- rt.paletteMu.Unlock()
291- return false
292- }
293- token.ac.clearPaletteLocked()
294- rt.paletteMu.Unlock()
295- afterFamily("palette")
296-
297- rt.promptMu.Lock()
298- if !current() {
299- rt.promptMu.Unlock()
300- return false
301- }
302- rt.prompt = nil
303- rt.promptSubmit = nil
304- rt.promptTransitionSubmit = nil
305- rt.promptPending = nil
306- rt.promptMu.Unlock()
307- afterFamily("prompt")
308-
309- rt.resizeMu.Lock()
310- if !current() {
311- rt.resizeMu.Unlock()
312- return false
313- }
314- rt.resizeActive = false
315- rt.resizePending = nil
316- rt.resizeESC.stop()
317- rt.resizeMu.Unlock()
318- afterFamily("resize")
319-
320- rt.copyMu.Lock()
321- if !current() {
322- rt.copyMu.Unlock()
323- return false
324- }
325- d.stopCopyPendingTimerLocked(token.ac)
326- rt.copyPending = nil
327- rt.clearCopyModeLocked()
328- rt.statusFeedback = ""
329- rt.copyMu.Unlock()
330- afterFamily("copy")
331-
332- rt.noticeMu.Lock()
333- if !current() {
334- rt.noticeMu.Unlock()
335- return false
336- }
337- for i := range rt.noticeToasts {
338- rt.noticeToasts[i].timer.stop()
339- }
340- rt.noticeToasts = nil
341- rt.noticeOverflow = 0
342- rt.noticeSeq++
343- rt.noticesOverlay = nil
344- rt.noticesPending = nil
345- rt.noticesESC.stop()
346- rt.noticeMu.Unlock()
347- afterFamily("notice")
348- return true
349-}
350-
351245 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool {
352246 if rt == nil || rt.ac == nil {
353247 return false
M internal/usecase/daemon/remote_picker_test.go +0 -34
@@ -861,40 +861,6 @@ func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
861861 }
862862 }
863863
864-func TestRemotePickerPublishCannotRegisterAfterSnatchClear(t *testing.T) {
865- d := newRemotePickerDaemon(nil)
866- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
867- ac.roleGeneration.Store(1)
868- model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
869- registrationReached := make(chan struct{})
870- allowRegistration := make(chan struct{})
871- registrationWait := make(chan error, 1)
872- ac.overlays.beforeRemotePickerRegistration = func() {
873- close(registrationReached)
874- registrationWait <- waitRemotePickerClose(allowRegistration, "picker registration release")
875- }
876- published := make(chan struct{})
877- go func() {
878- d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
879- close(published)
880- }()
881- receiveRemotePickerClose(t, registrationReached, "picker registration")
882-
883- token := attachmentRoleToken{
884- sess: sess, ac: ac, role: attachmentSnatched,
885- generation: 1, transport: ac.transportSnapshot(),
886- }
887- require.True(t, d.clearForSnatch(token))
888- close(allowRegistration)
889- require.NoError(t, receiveRemotePicker(t, registrationWait, "picker registration release"))
890- receiveRemotePickerClose(t, published, "picker publication")
891-
892- d.remoteCatalog.mu.Lock()
893- _, registered := d.remoteCatalog.pickers[ac]
894- d.remoteCatalog.mu.Unlock()
895- require.False(t, registered, "a picker cleared before delayed registration must not become a refresh owner")
896-}
897-
898864 func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
899865 d := newRemotePickerDaemon(nil)
900866 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
M internal/usecase/daemon/resume.go +86 -33
@@ -204,10 +204,6 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
204204 }
205205
206206 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
207- return d.parkAttachmentAs(sess, ac, attachmentActive)
208-}
209-
210-func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attachmentRole) bool {
211207 if !d.prepareParkAttachment(sess, ac) {
212208 return false
213209 }
@@ -228,13 +224,6 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
228224 d.mu.Unlock()
229225 return false
230226 }
231- if role == attachmentActive {
232- sess.mu.Lock()
233- if sess.client != nil {
234- role = attachmentSnatched
235- }
236- sess.mu.Unlock()
237- }
238227 token := ac.resumeToken
239228 var (
240229 oldRetirement *parkedAttachmentRetirement
@@ -250,7 +239,7 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
250239 }
251240 grace := d.resumeParkGrace
252241 timer := d.clock.NewTimer(grace)
253- parked := &parkedAttachment{sess: sess, ac: ac, role: role, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
242+ parked := &parkedAttachment{sess: sess, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
254243 ac.parked = true
255244 d.parked[token] = parked
256245 d.clearParkingInFlightLocked(token, ac)
@@ -279,6 +268,10 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
279268 func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
280269 d.mu.Lock()
281270 if d.parked[token] == parked {
271+ if parked.claimed {
272+ d.mu.Unlock()
273+ return
274+ }
282275 d.removeParkedLocked(token, parked)
283276 d.mu.Unlock()
284277 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
@@ -308,14 +301,65 @@ func (p *parkedAttachment) closeDone() {
308301 p.doneOnce.Do(func() { close(p.done) })
309302 }
310303
311-// demoteParkedActiveForSessionLocked preserves resumable predecessors when a
312-// new active owner is published. Caller holds d.mu.
313-func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) {
314- for _, parked := range d.parked {
315- if parked.sess == sess && parked.role == attachmentActive {
316- parked.role = attachmentSnatched
304+// commitResumeClaim consumes the old credential only after Welcome has been
305+// written successfully. The claim itself is serialized by d.mu, so a racing
306+// resume cannot acquire the same parked attachment.
307+func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
308+ if d == nil || ac == nil {
309+ return false
310+ }
311+ d.mu.Lock()
312+ defer d.mu.Unlock()
313+ token := ac.resumeClaimToken
314+ parked := d.parked[token]
315+ if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
316+ return token == 0
317+ }
318+ delete(d.parked, token)
319+ parked.claimed = false
320+ if parked.timer != nil {
321+ parked.timer.Stop()
322+ }
323+ parked.closeDone()
324+ ac.resumeClaimToken = 0
325+ return true
326+}
327+
328+// abortResumeClaim restores a parked credential when the transport could not
329+// complete the pre-claim Welcome handshake. It invalidates the claimed link
330+// generation before making the credential available to another winner.
331+func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
332+ if d == nil || ac == nil {
333+ return false
334+ }
335+ d.mu.Lock()
336+ token := ac.resumeClaimToken
337+ parked := d.parked[token]
338+ if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
339+ d.mu.Unlock()
340+ return false
341+ }
342+ parked.claimed = false
343+ ac.resumeClaimToken = 0
344+ ac.resumeToken = token
345+ ac.parked = true
346+ ac.roleGeneration.Add(1)
347+ sess := parked.sess
348+ if sess != nil {
349+ sess.mu.Lock()
350+ if sess.client == ac {
351+ sess.client = nil
317352 }
353+ sess.unregisterAttachmentLocked(ac)
354+ sess.mu.Unlock()
318355 }
356+ captured := ac.transportSnapshot().transport
357+ ac.setSession(nil)
358+ d.mu.Unlock()
359+ if captured != nil {
360+ _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
361+ }
362+ return true
319363 }
320364
321365 type parkedAttachmentRetirement struct {
@@ -494,19 +538,29 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
494538 d.beforeResumeParkedSendMu()
495539 }
496540 ac.sendMu.Lock()
497- defer ac.sendMu.Unlock()
498541 d.mu.Lock()
499- defer d.mu.Unlock()
500542 if d.closing {
543+ d.mu.Unlock()
544+ ac.sendMu.Unlock()
501545 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
502546 }
503- if d.parked[h.ResumeToken] != parked {
547+ if d.parked[h.ResumeToken] != parked || parked.claimed {
504548 if d.sessions[parked.sess.id] == parked.sess {
549+ d.mu.Unlock()
550+ ac.sendMu.Unlock()
505551 return nil, nil, false, errResumeTokenLifecycleRace
506552 }
553+ d.mu.Unlock()
554+ ac.sendMu.Unlock()
507555 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
508556 }
509- return d.resumeParkedLocked(h, tr, sz)
557+ sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)
558+ d.mu.Unlock()
559+ ac.sendMu.Unlock()
560+ if err != nil && resumed != nil {
561+ d.abortResumeClaim(resumed)
562+ }
563+ return sess, resumed, ok, err
510564 }
511565
512566 // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu
@@ -523,13 +577,10 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
523577 d.log.Warn("resume rejected", "session", sess.name, "registered", false)
524578 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
525579 }
526- sess.mu.Lock()
527- active := sess.client != nil
528- sess.mu.Unlock()
529- if active && parked.role == attachmentActive {
530- parked.role = attachmentSnatched
531- }
532580 ac := parked.ac
581+ if parked.claimed {
582+ return nil, nil, false, errResumeTokenLifecycleRace
583+ }
533584 if ac.clientID != h.ClientID || ac.proxied != h.Proxied {
534585 reason := "client id mismatch"
535586 if ac.proxied != h.Proxied {
@@ -538,9 +589,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
538589 d.log.Warn("resume rejected", "session", sess.name, "err", reason)
539590 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
540591 }
541- delete(d.parked, h.ResumeToken)
542- parked.timer.Stop()
543- parked.closeDone()
592+ // Claim the attachment while retaining the old credential until Welcome is
593+ // accepted. A failed handshake can therefore restore this exact parked
594+ // entry; another resume sees claimed and loses atomically.
595+ parked.claimed = true
596+ ac.resumeClaimToken = h.ResumeToken
544597 // The parked attachment has no session owner and cannot paint. Retire the
545598 // abandoned transport's output chain before binding the replacement so the
546599 // mandatory first paint cannot be blocked by ACKs that died with the link.
@@ -565,14 +618,14 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
565618 target: sess,
566619 next: ac,
567620 expectedRole: attachmentDetached,
568- targetRole: parked.role,
621+ targetRole: attachmentActive,
569622 expectedTransport: ac.transportSnapshot(),
570623 ready: false,
571624 })
572625 ac.sendMu.Lock()
573626 d.mu.Lock()
574627 if err != nil {
575- return nil, nil, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"}
628+ return sess, ac, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"}
576629 }
577630 d.deferAttachmentTransitionCleanups(transition)
578631 d.touchMRU(sess)
M internal/usecase/daemon/resume_test.go +0 -492
@@ -5,7 +5,6 @@ import (
55 "errors"
66 "strings"
77 "sync"
8- "sync/atomic"
98 "testing"
109 "time"
1110
@@ -418,70 +417,6 @@ func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
418417 // reconnect race: IntentResume arrives with the live attachment's token before
419418 // transport teardown has parked it. Same client must recover; unknown and
420419 // mismatched credentials stay fail-closed.
421-func TestResumeActiveTokenBeforeParkRecoversSameAttachment(t *testing.T) {
422- pty, release := newBlockingPTY(t)
423- defer release()
424- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
425-
426- oldTr := &closeTrackingTransport{}
427- sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
428- require.NoError(t, err)
429- token := ac.resumeToken
430- require.NotZero(t, token)
431- require.True(t, ac.resumeCapable)
432- require.Same(t, ac, sess.client)
433- d.mu.Lock()
434- _, parked := d.parked[token]
435- d.mu.Unlock()
436- require.False(t, parked, "fixture: live attachment must not be parked yet")
437-
438- _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
439- require.Error(t, err)
440- var unknownErr *protoErr
441- require.ErrorAs(t, err, &unknownErr)
442- require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
443- require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
444- require.Same(t, ac, sess.client)
445- require.Equal(t, token, ac.resumeToken)
446- require.Same(t, oldTr, ac.transport())
447-
448- wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
449- wrongClient.ClientID = [16]byte{9, 9, 9, 9}
450- _, _, err = d.route(wrongClient, &closeTrackingTransport{})
451- require.Error(t, err)
452- var mismatchErr *protoErr
453- require.ErrorAs(t, err, &mismatchErr)
454- require.Equal(t, ports.ErrNoSuchSession, mismatchErr.code)
455- require.Contains(t, mismatchErr.Error(), "resume token is no longer valid")
456- require.Same(t, ac, sess.client)
457- require.Equal(t, token, ac.resumeToken)
458- require.Same(t, oldTr, ac.transport())
459-
460- newTr := &closeTrackingTransport{}
461- resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
462- require.NoError(t, err, "same-token same-client IntentResume must recover before parking")
463- require.Same(t, sess, resumedSess)
464- require.Same(t, ac, resumedAC)
465- require.Same(t, ac, sess.client)
466- require.Same(t, newTr, ac.transport())
467- require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
468- d.mu.Lock()
469- _, oldTokenParked := d.parked[token]
470- d.mu.Unlock()
471- require.False(t, oldTokenParked, "consumed live credential must not remain resumeable")
472-
473- d.clientGone(sess, ac, oldTr, false)
474- require.Same(t, ac, sess.client, "retired pre-park transport must not detach the rebound attachment")
475- require.Same(t, newTr, ac.transport())
476- require.False(t, newTr.Closed())
477-}
478-
479-// TestResumeDuringTeardownBeforeParkRecoversSameAttachment covers the opposite
480-// interleaving from live-before-park recovery: old-link teardown wins
481-// detachIfCurrentTransport, then pauses before parkAttachment publishes the
482-// resume token. The parking-in-flight marker must already be visible in that
483-// gap (published before detach). Same-client IntentResume must wait it out and
484-// recover; unknown tokens stay fail-closed.
485420 func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
486421 pty, release := newBlockingPTY(t)
487422 defer release()
@@ -685,365 +620,6 @@ func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
685620 require.False(t, winnerTr.Closed())
686621 }
687622
688-func setupResumableSnatchedAttachment(t *testing.T, d *Daemon) (*session, *attachedClient, *attachedClient, *closeTrackingTransport, uint64) {
689- t.Helper()
690- oldTr := &closeTrackingTransport{}
691- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
692- require.NoError(t, err)
693- token := waiting.resumeToken
694- require.NotZero(t, token)
695-
696- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
697- require.NoError(t, err)
698- d.attachmentCleanupWg.Wait()
699- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
700- require.Same(t, active, sess.client)
701- return sess, waiting, active, oldTr, token
702-}
703-
704-// TestSnatchedResumeDuringTeardownBeforeParkRecoversSameAttachment covers the
705-// snatched unroute→park gap: parkSnatchedAttachment publishes the parking
706-// marker before ownership removal, then pauses before parkAttachmentAs.
707-// Same-client IntentResume must wait it out and recover as snatched without
708-// replacing the active owner; unknown/mismatched tokens stay fail-closed.
709-func TestSnatchedResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
710- pty, release := newBlockingPTY(t)
711- defer release()
712- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
713-
714- sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d)
715- roleToken := sess.attachmentToken(waiting, oldTr)
716-
717- reachedGap := make(chan struct{})
718- releaseGap := make(chan struct{})
719- var releaseOnce sync.Once
720- t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) })
721- d.afterSnatchedUnrouteBeforePark = func() {
722- close(reachedGap)
723- <-releaseGap
724- }
725-
726- parkDone := make(chan bool, 1)
727- go func() {
728- parkDone <- d.parkSnatchedAttachment(roleToken)
729- }()
730- awaitTestCompletion(t, reachedGap, "snatched park did not pause after unroute before park")
731-
732- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting), "fixture: unroute must have cleared snatched ownership")
733- d.mu.Lock()
734- parkingInGap := d.parking[token]
735- _, parkedInGap := d.parked[token]
736- d.mu.Unlock()
737- require.NotNil(t, parkingInGap, "fixture: parking marker must precede snatched ownership removal")
738- require.Same(t, waiting, parkingInGap.ac)
739- require.Same(t, sess, parkingInGap.sess)
740- require.False(t, parkedInGap, "fixture: park must not have published yet")
741- require.Same(t, active, sess.client, "fixture: active owner must remain undisturbed")
742-
743- _, _, err := d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
744- require.Error(t, err)
745- var unknownErr *protoErr
746- require.ErrorAs(t, err, &unknownErr)
747- require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
748- require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
749-
750- wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
751- wrongClient.ClientID = [16]byte{9, 9, 9, 9}
752- _, _, err = d.route(wrongClient, &closeTrackingTransport{})
753- require.Error(t, err)
754- var mismatchErr *protoErr
755- require.ErrorAs(t, err, &mismatchErr)
756- require.Equal(t, ports.ErrNoSuchSession, mismatchErr.code)
757- require.Contains(t, mismatchErr.Error(), "resume token is no longer valid")
758-
759- newTr := &closeTrackingTransport{}
760- type routeResult struct {
761- sess *session
762- ac *attachedClient
763- err error
764- }
765- waiterArmed := make(chan struct{})
766- d.afterParkingWaitArmed = func() { close(waiterArmed) }
767- result := make(chan routeResult, 1)
768- go func() {
769- routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
770- result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
771- }()
772- awaitTestCompletion(t, waiterArmed, "resume waiter did not arm on snatched parking marker before park")
773-
774- releaseOnce.Do(func() { close(releaseGap) })
775- require.True(t, awaitTestValue(t, parkDone, "snatched park did not finish after release"), "parkSnatchedAttachment must succeed")
776- got := awaitTestValue(t, result, "IntentResume did not finish after snatched teardown-before-park gap")
777-
778- require.NoError(t, got.err, "same-client snatched token must recover across unroute-before-park")
779- require.Same(t, sess, got.sess)
780- require.Same(t, waiting, got.ac)
781- require.Same(t, active, sess.client, "snatched resume must not replace the active owner")
782- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
783- require.Same(t, newTr, waiting.transport())
784- require.NotEqual(t, token, waiting.resumeToken, "successful resume rotates the credential")
785- require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")
786- require.False(t, newTr.Closed(), "rebound transport must survive")
787- d.mu.Lock()
788- _, oldTokenParked := d.parked[token]
789- _, stillParking := d.parking[token]
790- d.mu.Unlock()
791- require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
792- require.False(t, stillParking, "parking marker must be consumed after park/resume")
793-}
794-
795-// TestConcurrentSnatchedResumesWaitParkingMarkerBeforePark covers two same-token
796-// IntentResume handshakes while parkSnatchedAttachment pauses after unroute and
797-// before park. The parking marker must already be published so losers wait
798-// instead of treating the credential as unknown across that gap.
799-func TestConcurrentSnatchedResumesWaitParkingMarkerBeforePark(t *testing.T) {
800- pty, release := newBlockingPTY(t)
801- defer release()
802- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
803-
804- sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d)
805- roleToken := sess.attachmentToken(waiting, oldTr)
806-
807- reachedGap := make(chan struct{})
808- releaseGap := make(chan struct{})
809- var releaseOnce sync.Once
810- t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) })
811- d.afterSnatchedUnrouteBeforePark = func() {
812- close(reachedGap)
813- <-releaseGap
814- }
815-
816- parkDone := make(chan bool, 1)
817- go func() {
818- parkDone <- d.parkSnatchedAttachment(roleToken)
819- }()
820- awaitTestCompletion(t, reachedGap, "snatched park did not pause after unroute before park")
821-
822- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting), "fixture: unroute must have cleared snatched ownership")
823- d.mu.Lock()
824- parkingInGap := d.parking[token]
825- _, parkedInGap := d.parked[token]
826- d.mu.Unlock()
827- require.NotNil(t, parkingInGap, "fixture: parking marker must precede unroute for concurrent resumes")
828- require.Same(t, waiting, parkingInGap.ac)
829- require.False(t, parkedInGap, "fixture: park must not have published yet")
830-
831- _, _, err := d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
832- require.Error(t, err)
833- var unknownErr *protoErr
834- require.ErrorAs(t, err, &unknownErr)
835- require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
836-
837- type routeResult struct {
838- sess *session
839- ac *attachedClient
840- err error
841- }
842- firstTr := &closeTrackingTransport{}
843- secondTr := &closeTrackingTransport{}
844- bothWaitersArmed := make(chan struct{})
845- var armedCount atomic.Int32
846- d.afterParkingWaitArmed = func() {
847- if armedCount.Add(1) == 2 {
848- close(bothWaitersArmed)
849- }
850- }
851- firstResult := make(chan routeResult, 1)
852- secondResult := make(chan routeResult, 1)
853- go func() {
854- routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), firstTr)
855- firstResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
856- }()
857- go func() {
858- routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), secondTr)
859- secondResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
860- }()
861- awaitTestCompletion(t, bothWaitersArmed, "both snatched resume waiters did not arm on parking marker before park")
862-
863- releaseOnce.Do(func() { close(releaseGap) })
864- require.True(t, awaitTestValue(t, parkDone, "snatched park did not finish after release"))
865- first := awaitTestValue(t, firstResult, "first concurrent snatched IntentResume did not finish")
866- second := awaitTestValue(t, secondResult, "second concurrent snatched IntentResume did not finish")
867-
868- var winner, loser routeResult
869- var winnerTr, loserTr *closeTrackingTransport
870- switch {
871- case first.err == nil && second.err != nil:
872- winner, loser = first, second
873- winnerTr, loserTr = firstTr, secondTr
874- case second.err == nil && first.err != nil:
875- winner, loser = second, first
876- winnerTr, loserTr = secondTr, firstTr
877- default:
878- t.Fatalf("expected exactly one concurrent snatched resume to succeed; first=%v second=%v", first.err, second.err)
879- }
880-
881- require.Same(t, sess, winner.sess)
882- require.Same(t, waiting, winner.ac)
883- require.Same(t, active, sess.client, "snatched resume must not replace the active owner")
884- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
885- require.Same(t, winnerTr, waiting.transport())
886- require.NotEqual(t, token, waiting.resumeToken, "successful resume rotates the credential")
887- require.True(t, oldTr.Closed(), "winning resume must retire the old transport")
888- require.False(t, winnerTr.Closed(), "winning rebound transport must survive")
889-
890- var loserErr *protoErr
891- require.ErrorAs(t, loser.err, &loserErr)
892- require.Equal(t, ports.ErrNoSuchSession, loserErr.code)
893- require.Contains(t, loserErr.Error(), "resume token is no longer valid")
894- require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")
895-
896- d.mu.Lock()
897- _, oldTokenParked := d.parked[token]
898- _, stillParking := d.parking[token]
899- d.mu.Unlock()
900- require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
901- require.False(t, stillParking, "parking marker must be consumed after park/resume")
902-}
903-
904-// TestStaleSnatchedParkClearsOnlyAbandonedParkingMarker covers a stale
905-// incarnation that publishes a parking marker then loses exact unroute. Only
906-// that attachment's abandoned marker is cleared; another attachment's marker
907-// and the fresh snatched incarnation remain intact.
908-func TestStaleSnatchedParkClearsOnlyAbandonedParkingMarker(t *testing.T) {
909- workPTY, releaseWork := newBlockingPTY(t)
910- defer releaseWork()
911- otherPTY, releaseOther := newBlockingPTY(t)
912- defer releaseOther()
913- // Separate PTYs so work teardown cannot cascade-close the other session.
914- d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY), stubClock{})
915-
916- sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d)
917- stale := sess.attachmentToken(waiting, oldTr)
918-
919- otherTr := &closeTrackingTransport{}
920- otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr)
921- require.NoError(t, err)
922- otherToken := d.markParkingInFlight(otherSess, otherAC)
923- require.NotZero(t, otherToken)
924- require.NotEqual(t, token, otherToken)
925-
926- fresh := &closeTrackingTransport{}
927- waiting.replaceTransport(fresh)
928-
929- require.False(t, d.parkSnatchedAttachment(stale))
930- d.mu.Lock()
931- _, parked := d.parked[token]
932- _, stillParking := d.parking[token]
933- otherMarker := d.parking[otherToken]
934- d.mu.Unlock()
935- require.False(t, parked)
936- require.False(t, stillParking, "stale unroute must clear only the abandoned same-attachment marker")
937- require.NotNil(t, otherMarker, "stale unroute must not clear another attachment's marker")
938- require.Same(t, otherAC, otherMarker.ac)
939- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
940- require.Same(t, fresh, waiting.transport())
941- require.False(t, fresh.Closed())
942- require.Same(t, active, sess.client)
943- require.Equal(t, token, waiting.resumeToken)
944-}
945-
946-// TestTerminalSnatchedRemovalClearsOrphanedParkingMarker covers the race where a
947-// park path publishes a same-attachment parking-in-flight marker, then terminal
948-// snatched removal wins before park completes. Only that attachment's marker is
949-// retired and waiters unblock; other markers stay intact and the stale token
950-// fails closed. A subsequent legitimate snatched park remains resumable.
951-func TestTerminalSnatchedRemovalClearsOrphanedParkingMarker(t *testing.T) {
952- workPTY, releaseWork := newBlockingPTY(t)
953- defer releaseWork()
954- otherPTY, releaseOther := newBlockingPTY(t)
955- defer releaseOther()
956- legitPTY, releaseLegit := newBlockingPTY(t)
957- defer releaseLegit()
958- // Separate PTYs so session teardown cannot cascade across marker fixtures.
959- d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY, legitPTY), stubClock{})
960-
961- sess, waiting, active, oldTr, token := setupResumableSnatchedAttachment(t, d)
962- roleToken := sess.attachmentToken(waiting, oldTr)
963-
964- otherTr := &closeTrackingTransport{}
965- otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr)
966- require.NoError(t, err)
967- otherToken := d.markParkingInFlight(otherSess, otherAC)
968- require.NotZero(t, otherToken)
969- require.NotEqual(t, token, otherToken)
970-
971- // Simulate the losing park path publishing before terminal unroute wins.
972- require.Equal(t, token, d.markParkingInFlight(sess, waiting))
973- d.mu.Lock()
974- orphaned := d.parking[token]
975- otherMarker := d.parking[otherToken]
976- d.mu.Unlock()
977- require.NotNil(t, orphaned, "fixture: park path must publish the parking marker")
978- require.Same(t, waiting, orphaned.ac)
979- require.NotNil(t, otherMarker, "fixture: other attachment marker must remain published")
980- require.Same(t, otherAC, otherMarker.ac)
981-
982- waiterArmed := make(chan struct{})
983- d.afterParkingWaitArmed = func() { close(waiterArmed) }
984- waiterDone := make(chan bool, 1)
985- go func() {
986- waiterDone <- d.waitParkingInFlight(helloResumeCapable(ports.IntentResume, "work", token))
987- }()
988- awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned snatched marker")
989-
990- require.True(t, d.removeSnatchedAttachment(roleToken), "terminal snatched removal must win exact unroute")
991- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
992- require.Same(t, active, sess.client, "terminal snatched removal must not disturb the active owner")
993-
994- waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after terminal snatched removal")
995- require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")
996-
997- d.mu.Lock()
998- _, stillParking := d.parking[token]
999- otherAfter := d.parking[otherToken]
1000- _, parked := d.parked[token]
1001- d.mu.Unlock()
1002- require.False(t, stillParking, "terminal winner must clear the same attachment's orphaned marker")
1003- require.False(t, parked, "terminal removal must not publish parked ownership")
1004- require.NotNil(t, otherAfter, "terminal winner must not clear another attachment's marker")
1005- require.Same(t, otherAC, otherAfter.ac)
1006-
1007- _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
1008- require.Error(t, err)
1009- var unknownErr *protoErr
1010- require.ErrorAs(t, err, &unknownErr)
1011- require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
1012- require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
1013-
1014- _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
1015- require.Error(t, err)
1016- var retiredErr *protoErr
1017- require.ErrorAs(t, err, &retiredErr)
1018- require.Equal(t, ports.ErrNoSuchSession, retiredErr.code)
1019- require.Contains(t, retiredErr.Error(), "resume token is no longer valid")
1020-
1021- // A later legitimate snatched park on a fresh attachment must still resume.
1022- legitTr := &closeTrackingTransport{}
1023- legitSess, legitWaiting, err := d.route(helloResumeCapable(ports.IntentNew, "legit", 0), legitTr)
1024- require.NoError(t, err)
1025- legitToken := legitWaiting.resumeToken
1026- require.NotZero(t, legitToken)
1027- _, _, err = d.route(helloResumeCapable(ports.IntentAttach, "legit", 0), &closeTrackingTransport{})
1028- require.NoError(t, err)
1029- d.attachmentCleanupWg.Wait()
1030- require.Equal(t, attachmentSnatched, legitSess.attachmentRole(legitWaiting))
1031- require.True(t, d.parkSnatchedAttachment(legitSess.attachmentToken(legitWaiting, legitTr)))
1032-
1033- resumeTr := &closeTrackingTransport{}
1034- routedSess, routedAC, err := d.route(helloResumeCapable(ports.IntentResume, "legit", legitToken), resumeTr)
1035- require.NoError(t, err)
1036- require.Same(t, legitSess, routedSess)
1037- require.Same(t, legitWaiting, routedAC)
1038- require.Equal(t, attachmentSnatched, legitSess.attachmentRole(legitWaiting))
1039- require.Same(t, resumeTr, legitWaiting.transport())
1040-}
1041-
1042-// TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker covers the race
1043-// where non-explicit teardown publishes a parking-in-flight marker, then
1044-// explicit detach wins the seat. The winner must clear only that attachment's
1045-// orphaned marker and unblock waiters; other attachments' markers and a later
1046-// parked lifecycle must stay intact. Unknown tokens stay fail-closed.
1047623 func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
1048624 workPTY, releaseWork := newBlockingPTY(t)
1049625 defer releaseWork()
@@ -1249,46 +825,6 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1249825 // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection
1250826 // when the attachment is no longer the exact active or snatched owner, while
1251827 // still advertising for both live roles.
1252-func TestMarkParkingInFlightRequiresExactLiveOwnership(t *testing.T) {
1253- pty, release := newBlockingPTY(t)
1254- defer release()
1255- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1256-
1257- oldTr := &closeTrackingTransport{}
1258- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1259- require.NoError(t, err)
1260- token := waiting.resumeToken
1261- require.NotZero(t, token)
1262-
1263- require.Equal(t, token, d.markParkingInFlight(sess, waiting), "active owner must publish")
1264- d.clearParkingInFlight(token, waiting)
1265-
1266- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
1267- require.NoError(t, err)
1268- d.attachmentCleanupWg.Wait()
1269- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
1270- require.Same(t, active, sess.client)
1271-
1272- require.Equal(t, token, d.markParkingInFlight(sess, waiting), "snatched owner must publish")
1273- d.clearParkingInFlight(token, waiting)
1274-
1275- roleToken := sess.attachmentToken(waiting, oldTr)
1276- require.True(t, d.unrouteSnatchedAttachment(roleToken, false))
1277- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
1278- require.Zero(t, d.markParkingInFlight(sess, waiting), "detached snatched attachment must not publish")
1279-
1280- d.mu.Lock()
1281- _, stillParking := d.parking[token]
1282- d.mu.Unlock()
1283- require.False(t, stillParking)
1284-
1285- require.Equal(t, active.resumeToken, d.markParkingInFlight(sess, active), "active owner must still publish after snatched unroute")
1286- d.clearParkingInFlight(active.resumeToken, active)
1287-}
1288-
1289-// TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment covers
1290-// the TOCTOU where clientGone passes the old-transport precheck, then a live
1291-// resume rebinds the same attachment before detach runs.
1292828 func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {
1293829 pty, release := newBlockingPTY(t)
1294830 defer release()
@@ -1828,34 +1364,6 @@ func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) {
18281364 require.Contains(t, killedErr.Error(), "resume token is no longer valid")
18291365 }
18301366
1831-func TestParkedPredecessorResumesSnatchedWithoutStealingActiveAttachment(t *testing.T) {
1832- pty, release := newBlockingPTY(t)
1833- defer release()
1834- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1835- tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
1836- sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1837- require.NoError(t, err)
1838- token := oldAC.resumeToken
1839- d.clientGone(sess, oldAC, oldAC.transport(), false)
1840-
1841- activeTr := &closeTrackingTransport{}
1842- _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr)
1843- require.NoError(t, err)
1844- require.NotSame(t, oldAC, activeAC)
1845-
1846- d.mu.Lock()
1847- parked := d.parked[token]
1848- d.mu.Unlock()
1849- require.NotNil(t, parked, "normal attach preserves the parked predecessor")
1850- require.Equal(t, attachmentSnatched, parked.role)
1851- _, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
1852- require.NoError(t, err)
1853- require.True(t, ok)
1854- require.Same(t, oldAC, resumedAC)
1855- require.Equal(t, attachmentSnatched, sess.attachmentRole(resumedAC))
1856- require.Same(t, activeAC, sess.client)
1857-}
1858-
18591367 func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
18601368 p, release := newBlockingPTY(t)
18611369 defer release()
D internal/usecase/daemon/role_aware_parking_test.go +0 -482
@@ -1,482 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "sync"
6- "testing"
7-
8- "github.com/stretchr/testify/require"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/ports"
12- themeui "github.com/bnema/vev/internal/usecase/theme"
13- "github.com/bnema/vev/pkg/renderer"
14-)
15-
16-func TestActiveLinkLossParksActiveAndDetachesCoordinator(t *testing.T) {
17- pty, release := newBlockingPTY(t)
18- defer release()
19- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
20-
21- transport := &closeTrackingTransport{}
22- sess, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport)
23- require.NoError(t, err)
24- token := active.resumeToken
25- rc := sess.renderCoordinator()
26- require.NotNil(t, rc.attachmentLease(active))
27-
28- d.clientGone(sess, active, transport, false)
29-
30- d.mu.Lock()
31- parked := d.parked[token]
32- d.mu.Unlock()
33- require.NotNil(t, parked)
34- require.Equal(t, attachmentActive, parked.role)
35- require.Nil(t, sess.client)
36- require.Nil(t, rc.attachmentLease(active), "active loss should detach coordinator ownership")
37-}
38-
39-func TestSnatchedLinkLossParksWithoutDisturbingActiveOwner(t *testing.T) {
40- pty, release := newBlockingPTY(t)
41- defer release()
42- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
43-
44- oldTransport := &closeTrackingTransport{}
45- sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
46- require.NoError(t, err)
47- oldToken := old.resumeToken
48-
49- activeTransport := &closeTrackingTransport{}
50- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
51- require.NoError(t, err)
52- d.attachmentCleanupWg.Wait()
53- lease := sess.renderCoordinator().attachmentLease(active)
54- require.NotNil(t, lease)
55-
56- d.runConnLoop(old)
57-
58- d.mu.Lock()
59- parked := d.parked[oldToken]
60- d.mu.Unlock()
61- require.NotNil(t, parked, "resumable snatched attachment should survive link loss")
62- require.Equal(t, attachmentSnatched, parked.role)
63- require.Same(t, active, sess.client)
64- require.Same(t, lease, sess.renderCoordinator().attachmentLease(active))
65- require.False(t, activeTransport.Closed())
66-}
67-
68-type sendErrorTransport struct {
69- closeTrackingTransport
70-}
71-
72-func (*sendErrorTransport) Send(ports.Frame) error { return errors.New("send failed") }
73-
74-func TestSnatchedPanelSendFailureParksResumableAttachment(t *testing.T) {
75- pty, release := newBlockingPTY(t)
76- defer release()
77- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
78-
79- failed := &sendErrorTransport{}
80- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), failed)
81- require.NoError(t, err)
82- token := waiting.resumeToken
83-
84- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
85- require.NoError(t, err)
86- d.attachmentCleanupWg.Wait()
87-
88- d.mu.Lock()
89- parked := d.parked[token]
90- d.mu.Unlock()
91- require.NotNil(t, parked, "failed snatched panel should park a resumable attachment")
92- require.Equal(t, attachmentSnatched, parked.role)
93- require.Nil(t, waiting.transport(), "parking should revoke the exact failed transport")
94- require.True(t, failed.Closed())
95- require.Same(t, active, sess.client)
96-}
97-
98-func TestStaleSnatchedTransportCannotParkFreshIncarnation(t *testing.T) {
99- pty, release := newBlockingPTY(t)
100- defer release()
101- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
102-
103- oldTransport := &closeTrackingTransport{}
104- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
105- require.NoError(t, err)
106- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
107- require.NoError(t, err)
108- d.attachmentCleanupWg.Wait()
109- stale := sess.attachmentToken(waiting, oldTransport)
110- fresh := &closeTrackingTransport{}
111- waiting.replaceTransport(fresh)
112-
113- require.False(t, d.parkOrDropSnatchedAttachment(stale))
114- d.mu.Lock()
115- _, parked := d.parked[waiting.resumeToken]
116- d.mu.Unlock()
117- require.False(t, parked)
118- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
119- require.Same(t, fresh, waiting.transport())
120- require.False(t, fresh.Closed())
121- require.Same(t, active, sess.client)
122-}
123-
124-func TestSnatchedParkExpiryDoesNotAffectActiveOwner(t *testing.T) {
125- pty, release := newBlockingPTY(t)
126- defer release()
127- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
128-
129- waitingTransport := &closeTrackingTransport{}
130- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), waitingTransport)
131- require.NoError(t, err)
132- token := waiting.resumeToken
133- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
134- require.NoError(t, err)
135- d.attachmentCleanupWg.Wait()
136- lease := sess.renderCoordinator().attachmentLease(active)
137-
138- d.runConnLoop(waiting)
139- d.mu.Lock()
140- parked := d.parked[token]
141- d.mu.Unlock()
142- require.NotNil(t, parked)
143-
144- d.expireParked(token, parked)
145-
146- d.mu.Lock()
147- _, retained := d.parked[token]
148- d.mu.Unlock()
149- require.False(t, retained)
150- require.Same(t, active, sess.client)
151- require.Same(t, lease, sess.renderCoordinator().attachmentLease(active))
152-}
153-
154-func TestNonzeroResumeTokenOverridesOrdinaryRoutingFallbacks(t *testing.T) {
155- pty, release := newBlockingPTY(t)
156- defer release()
157- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
158-
159- work, active, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), &closeTrackingTransport{})
160- require.NoError(t, err)
161- const unknownToken = uint64(0xdecafbad)
162- for _, tc := range []struct {
163- name string
164- intent uint8
165- target string
166- }{
167- {name: "attach existing", intent: ports.IntentAttach, target: "work"},
168- {name: "create unique", intent: ports.IntentNew, target: "fallback"},
169- {name: "create ephemeral", intent: ports.IntentEphemeral},
170- } {
171- t.Run(tc.name, func(t *testing.T) {
172- h := helloResumeCapable(tc.intent, tc.target, unknownToken)
173- routedSession, routedClient, routeErr := d.route(h, &closeTrackingTransport{})
174- require.Error(t, routeErr)
175- require.Nil(t, routedSession)
176- require.Nil(t, routedClient)
177- var protocolErr *protoErr
178- require.ErrorAs(t, routeErr, &protocolErr)
179- require.Equal(t, ports.ErrNoSuchSession, protocolErr.code)
180- require.Same(t, active, work.client)
181- d.mu.Lock()
182- require.Len(t, d.sessions, 1)
183- require.Nil(t, d.findByNameLocked("fallback"))
184- d.mu.Unlock()
185- })
186- }
187-
188- _, replacement, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
189- require.NoError(t, err, "a zero token keeps ordinary attach semantics")
190- require.NotSame(t, active, replacement)
191- require.Same(t, replacement, work.client)
192- require.Equal(t, attachmentSnatched, work.attachmentRole(active))
193-}
194-
195-func TestHandleHelloExpiredSnatchedResumeFailsClosedAfterSessionEnds(t *testing.T) {
196- workPTY, releaseWork := newBlockingPTY(t)
197- keeperPTY, releaseKeeper := newBlockingPTY(t)
198- fallbackPTY, releaseFallback := newBlockingPTY(t)
199- defer releaseWork()
200- defer releaseKeeper()
201- defer releaseFallback()
202- d := newTestDaemon(t, newFactorySeq(t, workPTY, keeperPTY, fallbackPTY), stubClock{})
203-
204- oldTransport := &closeTrackingTransport{}
205- work, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
206- require.NoError(t, err)
207- oldToken := old.resumeToken
208-
209- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
210- require.NoError(t, err)
211- d.attachmentCleanupWg.Wait()
212- require.Equal(t, attachmentSnatched, work.attachmentRole(old))
213- require.Same(t, active, work.client)
214-
215- d.runConnLoop(old)
216- d.mu.Lock()
217- parked := d.parked[oldToken]
218- d.mu.Unlock()
219- require.NotNil(t, parked)
220- require.Equal(t, attachmentSnatched, parked.role)
221-
222- keeper, _, err := d.route(helloResumeCapable(ports.IntentNew, "keeper", 0), &closeTrackingTransport{})
223- require.NoError(t, err)
224- require.NoError(t, d.killSession(work, ports.ReasonSessionKilled, false))
225-
226- d.mu.Lock()
227- _, tokenRetained := d.parked[oldToken]
228- stoppedWork, lifecycleRetained := d.stopped["work"]
229- d.mu.Unlock()
230- require.False(t, tokenRetained, "terminal lifecycle teardown must purge the parked token")
231- require.True(t, lifecycleRetained, "the stopped lifecycle makes attach fallback observable")
232-
233- reconnect := &closeTrackingTransport{}
234- d.handleHello(reconnect, ports.Frame{
235- Type: ports.MsgHello,
236- Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", oldToken)),
237- })
238-
239- frames := reconnect.Sends()
240- require.Len(t, frames, 1)
241- require.Equal(t, ports.MsgError, frames[0].Type)
242- wireErr, err := ports.UnmarshalErrorMsg(frames[0].Payload)
243- require.NoError(t, err)
244- require.Equal(t, ports.ErrNoSuchSession, wireErr.Code)
245- require.True(t, reconnect.Closed())
246- d.mu.Lock()
247- require.Same(t, keeper, d.findByNameLocked("keeper"))
248- require.Nil(t, d.findByNameLocked("work"), "expired resume must not recreate the stopped session")
249- require.Equal(t, stoppedWork, d.stopped["work"])
250- require.Len(t, d.sessions, 1)
251- d.mu.Unlock()
252- require.Nil(t, work.client, "expired resume must not reclaim the ended lifecycle")
253- require.Nil(t, active.currentSession())
254-}
255-
256-func TestSnatchedResumeHandshakeSendsWelcomeAndPanelWithoutReplacingOwner(t *testing.T) {
257- pty, release := newBlockingPTY(t)
258- defer release()
259- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
260-
261- oldTransport := &closeTrackingTransport{}
262- sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
263- require.NoError(t, err)
264- token := old.resumeToken
265- d.clientGone(sess, old, oldTransport, false)
266-
267- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), &closeTrackingTransport{})
268- require.NoError(t, err)
269-
270- resumedTransport := &closeTrackingTransport{}
271- hello := helloResumeCapable(ports.IntentResume, "work", token)
272- d.handleHello(resumedTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
273-
274- frames := resumedTransport.Sends()
275- require.GreaterOrEqual(t, len(frames), 2)
276- require.Equal(t, ports.MsgWelcome, frames[0].Type)
277- welcome, err := ports.UnmarshalWelcome(frames[0].Payload)
278- require.NoError(t, err)
279- newToken := welcome.ResumeToken
280- require.NotZero(t, newToken)
281- require.NotEqual(t, token, newToken, "successful snatched resume rotates the credential")
282- require.Equal(t, newToken, old.resumeToken, "the Welcome publishes the one rotated credential")
283- require.Equal(t, ports.MsgOutput, frames[1].Type)
284- output, err := ports.UnmarshalOutput(frames[1].Payload)
285- require.NoError(t, err)
286- require.Contains(t, string(output.Data), "Session snatched")
287- d.mu.Lock()
288- _, oldTokenParked := d.parked[token]
289- parked := d.parked[newToken]
290- d.mu.Unlock()
291- require.False(t, oldTokenParked, "the consumed credential cannot be reused")
292- require.NotNil(t, parked)
293- require.Equal(t, attachmentSnatched, parked.role, "the rotated credential remains bound to the snatched role")
294- require.Same(t, active, sess.client)
295-
296- replay := &closeTrackingTransport{}
297- d.handleHello(replay, ports.Frame{
298- Type: ports.MsgHello,
299- Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, "work", token)),
300- })
301- replayFrames := replay.Sends()
302- require.Len(t, replayFrames, 1)
303- require.Equal(t, ports.MsgError, replayFrames[0].Type)
304- replayErr, err := ports.UnmarshalErrorMsg(replayFrames[0].Payload)
305- require.NoError(t, err)
306- require.Equal(t, ports.ErrNoSuchSession, replayErr.Code)
307- require.True(t, replay.Closed())
308- require.Same(t, active, sess.client, "replaying the old credential must not replace active ownership")
309- d.mu.Lock()
310- require.Same(t, parked, d.parked[newToken])
311- d.mu.Unlock()
312-}
313-
314-func TestResumeWelcomeRechecksRoleAfterReplacement(t *testing.T) {
315- pty, release := newBlockingPTY(t)
316- defer release()
317- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
318-
319- oldTransport := &closeTrackingTransport{}
320- sess, resumed, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
321- require.NoError(t, err)
322- token := resumed.resumeToken
323- d.clientGone(sess, resumed, oldTransport, false)
324-
325- blocked := newWelcomeBlockingTransport(t)
326- handshakeDone := make(chan struct{})
327- go func() {
328- hello := helloResumeCapable(ports.IntentResume, "work", token)
329- d.handleHello(blocked.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
330- close(handshakeDone)
331- }()
332- awaitTestCompletion(t, blocked.welcomeEntered, "resume did not start Welcome")
333- welcome := awaitFrame(t, blocked.sends, ports.MsgWelcome)
334- require.Equal(t, ports.MsgWelcome, welcome.Type)
335- requireNoCoordinatorOutputFrame(t, blocked.sends)
336- resumedLease := sess.renderCoordinator().attachmentLease(resumed)
337- require.NotNil(t, resumedLease)
338- require.False(t, resumedLease.ready, "blocked Welcome must keep the resumed lease unready")
339-
340- resumeGateFrozen := make(chan struct{})
341- var frozenOnce sync.Once
342- d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) {
343- if ac == resumed {
344- frozenOnce.Do(func() { close(resumeGateFrozen) })
345- }
346- }
347- type attachResult struct {
348- ac *attachedClient
349- err error
350- }
351- replacementResult := make(chan attachResult, 1)
352- go func() {
353- _, replacement, routeErr := d.route(
354- helloResumeCapable(ports.IntentAttach, "work", 0),
355- &closeTrackingTransport{},
356- )
357- replacementResult <- attachResult{ac: replacement, err: routeErr}
358- }()
359- awaitTestCompletion(t, resumeGateFrozen, "replacement did not freeze the resumed Welcome role")
360-
361- blocked.release()
362- replacement := awaitTestValue(t, replacementResult, "replacement did not finish after Welcome")
363- require.NoError(t, replacement.err)
364- d.attachmentCleanupWg.Wait()
365- panelFrame := awaitFrame(t, blocked.sends, ports.MsgOutput)
366- panel, err := ports.UnmarshalOutput(panelFrame.Payload)
367- require.NoError(t, err)
368- require.Zero(t, panel.BaseStateNum, "post-Welcome snatched panel must reset output state")
369- require.Contains(t, string(panel.Data), "Session snatched")
370- requireNoCoordinatorOutputFrame(t, blocked.sends)
371-
372- require.Same(t, replacement.ac, sess.client)
373- require.Equal(t, attachmentSnatched, sess.attachmentRole(resumed))
374- rc := sess.renderCoordinator()
375- rc.mu.Lock()
376- require.False(t, resumedLease.ready, "replacement during Welcome must not ready the stale active lease")
377- rc.mu.Unlock()
378- require.Nil(t, rc.attachmentLease(resumed))
379- require.NotNil(t, rc.attachmentLease(replacement.ac))
380-
381- blocked.finish()
382- awaitTestCompletion(t, handshakeDone, "resumed snatched connection did not continue after replacement")
383-}
384-
385-func TestSnatchedResumePreservesOwnerCapabilityAndCanReclaim(t *testing.T) {
386- pty, release := newBlockingPTY(t)
387- defer release()
388- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
389-
390- oldTransport := &closeTrackingTransport{}
391- sess, waiting, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
392- require.NoError(t, err)
393- token := waiting.resumeToken
394- waiting.setClientTheme(themeui.Theme{
395- Foreground: renderer.RGB{R: 90, G: 10, B: 10},
396- Background: renderer.RGB{R: 20, G: 1, B: 1},
397- HasFG: true, HasBG: true, Known: true,
398- })
399- d.clientGone(sess, waiting, oldTransport, false)
400-
401- activeTransport := &closeTrackingTransport{}
402- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
403- require.NoError(t, err)
404- rc := sess.renderCoordinator()
405- activeGeneration := active.roleGeneration.Load()
406- activeLease := rc.attachmentLease(active)
407- require.NotNil(t, activeLease)
408- activeTheme := themeui.Theme{
409- Foreground: renderer.RGB{R: 10, G: 80, B: 20},
410- Background: renderer.RGB{R: 1, G: 15, B: 3},
411- HasFG: true, HasBG: true, Known: true,
412- }
413- active.setClientTheme(activeTheme)
414- require.True(t, d.applyHostTheme(sess, active, activeTheme, false))
415- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
416-
417- resumedTransport := &closeTrackingTransport{}
418- resumedSess, resumed, ok, err := d.resumeParked(
419- helloResumeCapable(ports.IntentResume, "work", token),
420- resumedTransport,
421- domain.Size{Cols: 80, Rows: 24},
422- )
423- require.NoError(t, err)
424- require.True(t, ok)
425- require.Same(t, sess, resumedSess)
426- require.Same(t, waiting, resumed)
427- require.Same(t, active, sess.client)
428- require.Equal(t, activeGeneration, active.roleGeneration.Load())
429- require.Same(t, activeLease, rc.attachmentLease(active))
430- require.Nil(t, rc.attachmentLease(waiting), "snatched resume must not acquire a render lease")
431- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
432- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
433-
434- resumedGeneration := waiting.roleGeneration.Load()
435- require.False(t, d.handleSnatchedClientFrame(sess.attachmentToken(waiting, resumedTransport), ports.Frame{
436- Type: ports.MsgInput,
437- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
438- }))
439- d.attachmentCleanupWg.Wait()
440-
441- require.Same(t, waiting, sess.client)
442- require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
443- require.Greater(t, waiting.roleGeneration.Load(), resumedGeneration)
444- require.NotNil(t, rc.attachmentLease(waiting))
445- require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
446-}
447-
448-func TestOrdinaryAttachDemotesParkedActiveAndAllowsSnatchedResume(t *testing.T) {
449- pty, release := newBlockingPTY(t)
450- defer release()
451- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
452-
453- oldTransport := &closeTrackingTransport{}
454- sess, old, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
455- require.NoError(t, err)
456- token := old.resumeToken
457- d.clientGone(sess, old, oldTransport, false)
458-
459- activeTransport := &closeTrackingTransport{}
460- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
461- require.NoError(t, err)
462-
463- d.mu.Lock()
464- parked := d.parked[token]
465- d.mu.Unlock()
466- require.NotNil(t, parked, "ordinary attach must preserve the predecessor's resume token")
467- require.Equal(t, attachmentSnatched, parked.role)
468- require.Equal(t, token, old.resumeToken)
469-
470- resumedTransport := &closeTrackingTransport{}
471- resumedSess, resumed, ok, err := d.resumeParked(
472- helloResumeCapable(ports.IntentResume, "work", token),
473- resumedTransport,
474- domain.Size{Cols: 80, Rows: 24},
475- )
476- require.NoError(t, err)
477- require.True(t, ok)
478- require.Same(t, sess, resumedSess)
479- require.Same(t, old, resumed)
480- require.Equal(t, attachmentSnatched, sess.attachmentRole(old))
481- require.Same(t, active, sess.client)
482-}
M internal/usecase/daemon/role_effect_gate_test.go +0 -177
@@ -577,124 +577,6 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t
577577 require.False(t, newTransport.Closed())
578578 }
579579
580-func TestDisplacedBlockedRenderCleanupWaitsForStaleDetachFreeze(t *testing.T) {
581- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
582- oldTransport := newBlockedRenderReplacementTransport(d, sess)
583- old.replaceTransport(oldTransport)
584- rc := d.attachCoordinator(sess, nil, old, true)
585- lease := rc.attachmentLease(old)
586- token := sess.attachmentToken(old, oldTransport)
587- token.lease = lease
588- old.publishRoleCapability(token)
589- old.sendMu.Lock()
590- old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}}
591- old.sendMu.Unlock()
592-
593- staleDetachFrozen := make(chan struct{})
594- releaseStaleDetach := make(chan struct{})
595- var releaseOnce sync.Once
596- t.Cleanup(func() { releaseOnce.Do(func() { close(releaseStaleDetach) }) })
597- d.afterDetachRoleEffectsFrozen = func() {
598- close(staleDetachFrozen)
599- <-releaseStaleDetach
600- }
601- cleanupStarted := make(chan struct{})
602- d.afterDisplacedCleanupStarted = func() { close(cleanupStarted) }
603-
604- paintDone := make(chan struct{})
605- go func() {
606- d.paint(sess, old, true, lease)
607- close(paintDone)
608- }()
609- awaitTestCompletion(t, oldTransport.sendEntered, "old render did not block in transport send")
610-
611- newTransport := &closeTrackingTransport{}
612- next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
613- next.initOverlays()
614- transitionDone := make(chan attachmentTransitionResult, 1)
615- transitionErr := make(chan error, 1)
616- go func() {
617- result, err := d.transitionAttachment(attachmentTransitionRequest{
618- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
619- expectedTransport: next.transportSnapshot(), ready: true,
620- })
621- transitionDone <- result
622- transitionErr <- err
623- }()
624-
625- var result attachmentTransitionResult
626- select {
627- case result = <-transitionDone:
628- require.NoError(t, <-transitionErr)
629- case <-time.After(time.Second):
630- t.Fatal("replacement did not interrupt the blocked render")
631- }
632- awaitTestCompletion(t, paintDone, "interrupted render did not retire")
633- awaitTestCompletion(t, staleDetachFrozen, "stale send-error detach did not freeze the role gate")
634-
635- d.deferAttachmentTransitionCleanups(result)
636- awaitTestCompletion(t, cleanupStarted, "displaced cleanup did not start during stale detach freeze")
637- releaseOnce.Do(func() { close(releaseStaleDetach) })
638- select {
639- case <-waitGroupDone(&d.attachmentCleanupWg):
640- case <-time.After(time.Second):
641- t.Fatal("displaced cleanup deadlocked behind stale detach")
642- }
643-
644- sess.mu.Lock()
645- require.Same(t, next, sess.client)
646- require.NotContains(t, sess.snatched, old, "closed displaced incarnation remained registered")
647- sess.mu.Unlock()
648- require.Equal(t, attachmentActive, sess.attachmentRole(next))
649- require.Nil(t, old.transport(), "closed displaced transport remained attached")
650- require.Nil(t, old.currentSession(), "terminal displaced cleanup retained session ownership")
651- old.sendMu.Lock()
652- require.Empty(t, old.captureFrames, "terminal displaced cleanup retained captures")
653- old.sendMu.Unlock()
654- require.False(t, newTransport.Closed(), "stale cleanup affected the new active owner")
655-}
656-
657-func TestInterruptedDisplacedCleanupRejectsNewerReclaimedIncarnation(t *testing.T) {
658- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
659- staleTransport := &closeTrackingTransport{}
660- old.replaceTransport(staleTransport)
661- d.attachCoordinator(sess, nil, old, true)
662-
663- nextTransport := &closeTrackingTransport{}
664- next := &attachedClient{tr: nextTransport, output: newOutputStateStream(), size: old.size}
665- next.initOverlays()
666- displaced, err := d.transitionAttachment(attachmentTransitionRequest{
667- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
668- expectedTransport: next.transportSnapshot(), ready: true,
669- })
670- require.NoError(t, err)
671- stale := displaced.displaced
672-
673- _, err = d.transitionAttachment(attachmentTransitionRequest{
674- target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive,
675- expectedTransport: old.transportSnapshot(), ready: true,
676- })
677- require.NoError(t, err)
678- freshTransport := &closeTrackingTransport{}
679- old.replaceTransport(freshTransport)
680- _, err = d.transitionAttachment(attachmentTransitionRequest{
681- target: sess, next: old, expectedRole: attachmentActive, targetRole: attachmentActive,
682- expectedTransport: old.transportSnapshot(), ready: true,
683- })
684- require.NoError(t, err)
685- require.NoError(t, staleTransport.Close())
686-
687- require.False(t, d.cleanupInterruptedSnatchedAttachment(stale))
688- sess.mu.Lock()
689- require.Same(t, old, sess.client)
690- require.Contains(t, sess.snatched, next)
691- sess.mu.Unlock()
692- require.Equal(t, attachmentActive, sess.attachmentRole(old))
693- require.Same(t, freshTransport, old.transport())
694- require.False(t, freshTransport.Closed(), "stale terminal cleanup closed the newer transport")
695- require.False(t, nextTransport.Closed(), "stale terminal cleanup affected the newer displaced owner")
696-}
697-
698580 func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) {
699581 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
700582 oldTransport := newDatagramTestTransport()
@@ -1193,65 +1075,6 @@ func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) {
11931075 }
11941076 }
11951077
1196-func TestRoleEffectGateAdmittedSnatchedPanelFinishesBeforePromotion(t *testing.T) {
1197- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1198- oldTransport := newDatagramTestTransport()
1199- old.replaceTransport(oldTransport)
1200- d.attachCoordinator(sess, nil, old, true)
1201-
1202- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1203- next.initOverlays()
1204- replaced, err := d.transitionAttachment(attachmentTransitionRequest{
1205- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1206- expectedTransport: next.transportSnapshot(), ready: true,
1207- })
1208- require.NoError(t, err)
1209- token := replaced.displaced
1210-
1211- admitted := make(chan struct{})
1212- releaseGate := make(chan struct{})
1213- release := releaseTestGate(t, releaseGate)
1214- afterFrozen := make(chan struct{})
1215- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1216- close(admitted)
1217- <-releaseGate
1218- }
1219- d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
1220- if action == "" && ac == old {
1221- close(afterFrozen)
1222- }
1223- }
1224- effectDone := make(chan struct{})
1225- wantSize := domain.Size{Cols: 100, Rows: 30}
1226- go func() {
1227- d.handleSnatchedClientFrame(token, ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: wantSize})})
1228- close(effectDone)
1229- }()
1230- <-admitted
1231-
1232- transitionDone := make(chan error, 1)
1233- go func() {
1234- _, err := d.transitionAttachment(attachmentTransitionRequest{
1235- target: sess, next: old, expectedRole: attachmentSnatched, targetRole: attachmentActive,
1236- expectedTransport: old.transportSnapshot(), ready: true,
1237- })
1238- transitionDone <- err
1239- }()
1240- <-afterFrozen
1241- select {
1242- case err := <-transitionDone:
1243- t.Fatalf("promotion overtook admitted snatched panel: %v", err)
1244- default:
1245- }
1246- release()
1247- <-effectDone
1248- require.NoError(t, <-transitionDone)
1249- old.sendMu.Lock()
1250- gotSize := old.size
1251- old.sendMu.Unlock()
1252- require.Equal(t, wantSize, gotSize)
1253-}
1254-
12551078 func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {
12561079 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
12571080 oldTransport := newDatagramTestTransport()
M internal/usecase/daemon/session.go +45 -6
@@ -744,13 +744,22 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
744744 }
745745 d.notices.routingMu.Lock()
746746 sess.mu.Lock()
747- current := sess.client == ac && ac.currentSession() == sess
747+ _, registered := sess.attachments[ac]
748+ current := (registered || sess.client == ac) && ac.currentSession() == sess
748749 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
749750 current = false
750751 }
751752 if current {
752- sess.client = nil
753+ if sess.client == ac {
754+ sess.client = nil
755+ }
753756 sess.unregisterAttachmentLocked(ac)
757+ if sess.client == nil {
758+ for candidate := range sess.attachments {
759+ sess.client = candidate
760+ break
761+ }
762+ }
754763 ac.roleGeneration.Add(1)
755764 ac.setSession(nil)
756765 ac.invalidateFrozenRoleCapability()
@@ -805,6 +814,18 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
805814 if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
806815 return false
807816 }
817+ // Attachments without the session's optional primary render lease still
818+ // own an independent connection lifecycle and detach through the same exact
819+ // transport/generation fence.
820+ if token.lease == nil {
821+ if sess, ok := localSession(token.sess); ok {
822+ return d.detachIfCurrentTransport(sess, token.ac, token.transport)
823+ }
824+ if proxy, ok := token.sess.(*proxySession); ok {
825+ return d.detachProxyIfCurrentTransport(proxy, token.ac, token.transport)
826+ }
827+ return false
828+ }
808829 frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac)
809830 defer frozen.unfreeze()
810831 if !frozen.acquired || !frozen.drained {
@@ -1170,6 +1191,7 @@ type sessionKillAdmission struct {
11701191
11711192 type sessionKillParticipants struct {
11721193 active *attachedClient
1194+ attachments map[*attachedClient]struct{}
11731195 snatched map[*attachedClient]struct{}
11741196 transports map[*attachedClient]transportSnapshot
11751197 roleGates []*attachedClient
@@ -1214,8 +1236,13 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
12141236 }
12151237 unlockSessions := lockAttachmentSessions(source, target)
12161238 snapshot.active = target.client
1239+ snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments))
12171240 snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched))
1218- snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.snatched))
1241+ snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.attachments)+len(target.snatched))
1242+ for ac := range target.attachments {
1243+ snapshot.attachments[ac] = struct{}{}
1244+ snapshot.transports[ac] = ac.transportSnapshot()
1245+ }
12191246 if snapshot.active != nil {
12201247 snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot()
12211248 }
@@ -1227,7 +1254,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
12271254 d.notices.routingMu.Unlock()
12281255 d.mu.Unlock()
12291256
1230- seen := make(map[*attachedClient]struct{}, 2+len(snapshot.snatched))
1257+ seen := make(map[*attachedClient]struct{}, 2+len(snapshot.attachments)+len(snapshot.snatched))
12311258 add := func(ac *attachedClient) {
12321259 if ac == nil {
12331260 return
@@ -1242,6 +1269,9 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
12421269 add(admission.token.ac)
12431270 }
12441271 add(snapshot.active)
1272+ for ac := range snapshot.attachments {
1273+ add(ac)
1274+ }
12451275 for ac := range snapshot.snatched {
12461276 add(ac)
12471277 }
@@ -1281,9 +1311,14 @@ func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants
12811311 // session and coordinator locks.
12821312 func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {
12831313 target := snapshot.target
1284- if target.client != snapshot.active || len(target.snatched) != len(snapshot.snatched) {
1314+ if target.client != snapshot.active || len(target.attachments) != len(snapshot.attachments) || len(target.snatched) != len(snapshot.snatched) {
12851315 return false
12861316 }
1317+ for ac := range snapshot.attachments {
1318+ if _, current := target.attachments[ac]; !current {
1319+ return false
1320+ }
1321+ }
12871322 for ac := range snapshot.snatched {
12881323 if _, current := target.snatched[ac]; !current {
12891324 return false
@@ -1593,11 +1628,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15931628 attached.setSession(nil)
15941629 attached.invalidateFrozenRoleCapability()
15951630 }
1596- capture(sess.client)
1631+ for attached := range sess.attachments {
1632+ capture(attached)
1633+ }
15971634 for snatched := range sess.snatched {
15981635 capture(snatched)
15991636 }
1637+ capture(sess.client)
16001638 sess.client = nil
1639+ clear(sess.attachments)
16011640 clear(sess.snatched)
16021641 stoppedName := sess.name
16031642 stoppedCwd := sess.cwd
D internal/usecase/daemon/snatched_cleanup.go +0 -224
@@ -1,224 +0,0 @@
1-package daemon
2-
3-import "github.com/bnema/vev/internal/ports"
4-
5-// removeSnatchedAttachment removes exactly one role generation and transport
6-// from session routing. It never touches the active attachment or closes a
7-// transport, allowing callers that already interrupted a blocked send to avoid
8-// closing the same link twice.
9-func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool {
10- return d.unrouteSnatchedAttachment(token, true)
11-}
12-
13-func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool {
14- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
15- return false
16- }
17- frozen := freezeRoleEffectGates(token.ac)
18- defer frozen.unfreeze()
19- d.notices.routingMu.Lock()
20- core := token.sess.core()
21- core.mu.Lock()
22- current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
23- token.ac.roleGeneration.Load() == token.generation &&
24- token.ac.transportSnapshotCurrent(token.transport)
25- if current {
26- delete(core.snatched, token.ac)
27- if terminal {
28- core.unregisterAttachmentLocked(token.ac)
29- }
30- token.ac.roleGeneration.Add(1)
31- token.ac.invalidateFrozenRoleCapability()
32- }
33- core.mu.Unlock()
34- d.notices.routingMu.Unlock()
35- if !current {
36- return false
37- }
38-
39- token.ac.clearSnatchedInput()
40- d.unregisterPreview(token.ac)
41- if terminal {
42- token.ac.clearPreviousSession()
43- }
44- token.ac.setSession(nil)
45- token.ac.clearCaptureFrames()
46- if terminal {
47- // A losing park path may have published a same-attachment marker before
48- // this terminal unroute won. Retire only that attachment's marker so
49- // waiters fail closed instead of hanging; park paths (terminal=false)
50- // keep the marker until parkAttachmentAs or abandonment cleanup.
51- d.clearParkingInFlight(token.ac.resumeToken, token.ac)
52- }
53- return true
54-}
55-
56-// parkSnatchedAttachment removes only the exact lost snatched incarnation and
57-// retains its resume identity for a role-preserving reconnect.
58-func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
59- sess, ok := localSession(token.sess)
60- if !ok || token.ac == nil || !token.ac.resumeCapable {
61- return false
62- }
63- // Advertise parking before unroute so IntentResume never observes both the
64- // snatched seat and parking/parked registries empty for a still-valid token.
65- parkingToken := d.markParkingInFlight(sess, token.ac)
66- if !d.unrouteSnatchedAttachment(token, false) {
67- d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
68- return false
69- }
70- if d.afterSnatchedUnrouteBeforePark != nil {
71- d.afterSnatchedUnrouteBeforePark()
72- }
73- if d.parkAttachmentAs(sess, token.ac, attachmentSnatched) {
74- captured := token.transport.transport
75- _ = token.ac.revokeTransport(captured)
76- _ = token.ac.closeCapturedTransport(captured)
77- return true
78- }
79- d.clearParkingInFlight(parkingToken, token.ac)
80- token.ac.clearPreviousSession()
81- captured := token.transport.transport
82- _ = token.ac.revokeTransport(captured)
83- _ = token.ac.closeCapturedTransport(captured)
84- return false
85-}
86-
87-func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool {
88- if d.parkSnatchedAttachment(token) {
89- return true
90- }
91- return d.dropSnatchedAttachment(token)
92-}
93-
94-// cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced
95-// transport that was closed to drain an admitted send. It waits on the gate's
96-// condition rather than ordinary effect admission, then removes only the exact
97-// published snatched incarnation. Attachment-local cleanup runs without routing
98-// or session locks while the terminal capability remains frozen.
99-func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {
100- sess, ok := localSession(token.sess)
101- if !ok || token.ac == nil || token.transport.transport == nil {
102- return false
103- }
104- frozen := freezeRoleEffectGates(token.ac)
105- defer frozen.unfreeze()
106-
107- d.notices.routingMu.Lock()
108- sess.mu.Lock()
109- current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
110- token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
111- token.ac.transportSnapshotCurrent(token.transport)
112- sess.mu.Unlock()
113- d.notices.routingMu.Unlock()
114- if !current {
115- return false
116- }
117-
118- // Advertise parking before ownership removal so IntentResume can wait out
119- // the unroute→park gap for a still-valid snatched credential.
120- var parkingToken uint64
121- if token.ac.resumeCapable {
122- parkingToken = d.markParkingInFlight(sess, token.ac)
123- }
124-
125- // The frozen gate prevents a promotion from publishing between overlay
126- // cleanup and terminal registry publication.
127- d.clearForSnatch(token)
128-
129- d.notices.routingMu.Lock()
130- sess.mu.Lock()
131- current = attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
132- token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
133- token.ac.transportSnapshotCurrent(token.transport)
134- if current {
135- delete(sess.snatched, token.ac)
136- token.ac.roleGeneration.Add(1)
137- token.ac.invalidateFrozenRoleCapability()
138- }
139- sess.mu.Unlock()
140- d.notices.routingMu.Unlock()
141- if !current {
142- d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
143- return false
144- }
145-
146- token.ac.setSession(nil)
147- if d.afterSnatchedUnrouteBeforePark != nil {
148- d.afterSnatchedUnrouteBeforePark()
149- }
150- if token.ac.resumeCapable && d.parkAttachmentAs(sess, token.ac, attachmentSnatched) {
151- captured := token.transport.transport
152- _ = token.ac.revokeTransport(captured)
153- _ = token.ac.closeCapturedTransport(captured)
154- token.ac.clearSnatchedInput()
155- d.unregisterPreview(token.ac)
156- return true
157- }
158- d.clearParkingInFlight(parkingToken, token.ac)
159- captured := token.transport.transport
160- _ = token.ac.revokeTransport(captured)
161- _ = token.ac.closeCapturedTransport(captured)
162- token.ac.clearSnatchedInput()
163- d.unregisterPreview(token.ac)
164- token.ac.clearPreviousSession()
165- token.ac.clearCaptureFrames()
166- return true
167-}
168-
169-// dropSnatchedAttachment removes and closes only the captured snatched link.
170-func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool {
171- if !d.removeSnatchedAttachment(token) {
172- return false
173- }
174- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
175- return true
176-}
177-
178-// deferAttachmentTransitionCleanups retires coordinator timers and accounting
179-// after ownership publication. Cleanup never gates the attaching handshake. A
180-// displaced render that already owns sendMu is interrupted by closing only its
181-// captured transport; an idle healthy snatched transport remains connected and
182-// receives a dependency-free reset panel.
183-func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) {
184- if terminal := result.terminalDisplaced; terminal.ac != nil && terminal.transport.transport != nil {
185- terminal.ac.clearSnatchedInput()
186- d.unregisterPreview(terminal.ac)
187- terminal.ac.clearPreviousSession()
188- terminal.ac.clearCaptureFrames()
189- d.notifyDetachedSnapshotAsync(terminal, ports.ReasonReplaced)
190- }
191- if token := result.displaced; token.ac != nil && token.transport.transport != nil {
192- blockedRender := result.displacedInterrupted
193- if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
194- blockedRender = !token.ac.sendMu.TryLock()
195- if !blockedRender {
196- token.ac.sendMu.Unlock()
197- }
198- }
199- d.attachmentCleanupWg.Go(func() {
200- if d.afterDisplacedCleanupStarted != nil {
201- d.afterDisplacedCleanupStarted()
202- }
203- if blockedRender {
204- // This path owns terminal cleanup independently of ordinary role
205- // admission: a stale send-error detach may transiently freeze the gate.
206- _ = token.ac.closeCapturedTransport(token.transport.transport)
207- d.cleanupInterruptedSnatchedAttachment(token)
208- return
209- }
210- ticket, admitted := token.ac.beginRoleEffect(token)
211- if !admitted {
212- return
213- }
214- defer ticket.End()
215- if err := d.sendInitialSnatchedPanel(token, ticket); err != nil {
216- ticket.End()
217- d.parkOrDropSnatchedAttachment(token)
218- }
219- })
220- }
221- for _, cleanup := range result.cleanups {
222- d.attachmentCleanupWg.Go(cleanup.finish)
223- }
224-}
D internal/usecase/daemon/snatched_client.go +0 -107
@@ -1,107 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5-
6- "github.com/bnema/vev/internal/ports"
7-)
8-
9-type initialSnatchedPanelAttempt struct {
10- done chan struct{}
11- err error
12-}
13-
14-func (ac *attachedClient) claimInitialSnatchedPanel(generation uint64) (*initialSnatchedPanelAttempt, bool) {
15- ac.initialSnatchedMu.Lock()
16- defer ac.initialSnatchedMu.Unlock()
17- if generation < ac.initialSnatchedGeneration {
18- return nil, false
19- }
20- if generation == ac.initialSnatchedGeneration && ac.initialSnatchedAttempt != nil {
21- return ac.initialSnatchedAttempt, false
22- }
23- attempt := &initialSnatchedPanelAttempt{done: make(chan struct{})}
24- ac.initialSnatchedGeneration = generation
25- ac.initialSnatchedAttempt = attempt
26- return attempt, true
27-}
28-
29-func (ac *attachedClient) initialSnatchedPanelClaimed(generation uint64) bool {
30- ac.initialSnatchedMu.Lock()
31- defer ac.initialSnatchedMu.Unlock()
32- return ac.initialSnatchedGeneration == generation && ac.initialSnatchedAttempt != nil
33-}
34-
35-func (ac *attachedClient) completeInitialSnatchedPanel(attempt *initialSnatchedPanelAttempt, err error) {
36- ac.initialSnatchedMu.Lock()
37- attempt.err = err
38- close(attempt.done)
39- ac.initialSnatchedMu.Unlock()
40-}
41-
42-func (d *Daemon) sendInitialSnatchedPanel(token attachmentRoleToken, ticket *roleEffectTicket) error {
43- attempt, owner := token.ac.claimInitialSnatchedPanel(token.generation)
44- if attempt == nil {
45- return errSnatchedOutputStale
46- }
47- if !owner {
48- <-attempt.done
49- return attempt.err
50- }
51-
52- if !token.current() || !d.clearForSnatch(token) || !d.clearCaptureFramesForSnatch(token) {
53- token.ac.completeInitialSnatchedPanel(attempt, errSnatchedOutputStale)
54- return errSnatchedOutputStale
55- }
56- err := d.sendSnatchedPanel(token.ac, token.transport, token.generation, "", ticket)
57- token.ac.completeInitialSnatchedPanel(attempt, err)
58- return err
59-}
60-
61-func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool {
62- ac := token.ac
63- ac.sendMu.Lock()
64- defer ac.sendMu.Unlock()
65- if ac.roleGeneration.Load() != token.generation || !ac.transportSnapshotCurrent(token.transport) {
66- return false
67- }
68- ac.captureFrames = nil
69- return true
70-}
71-
72-func (d *Daemon) sendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error {
73- ac := token.ac
74- ac.sendMu.Lock()
75- defer ac.sendMu.Unlock()
76- if ac.roleGeneration.Load() != token.generation {
77- return errSnatchedOutputStale
78- }
79- if !ac.transportSnapshotCurrent(token.transport) {
80- return errTransportReplaced
81- }
82- if token.effect == nil || !token.effect.beginTransportSend(token.transport) {
83- return errAttachmentTransition
84- }
85- err := token.transport.transport.Send(frame)
86- if err != nil {
87- token.effect.reportTransportFailure(token.transport)
88- }
89- token.effect.endTransportSend()
90- return err
91-}
92-
93-// boundedSendSnatchedControl keeps a best-effort control acknowledgement bound
94-// to the exact admitted role and link incarnation. On deadline it closes only
95-// that captured link, releasing either the transport Send or a wait on sendMu.
96-func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame ports.Frame) error {
97- if token.ac == nil || token.effect == nil || token.transport.transport == nil {
98- return errAttachmentTransition
99- }
100- _, err := d.boundedSendWith(token.transport.transport, func() error {
101- return d.sendSnatchedControl(token, frame)
102- })
103- if errors.Is(err, errSendTimedOut) {
104- _ = token.ac.closeCapturedTransport(token.transport.transport)
105- }
106- return err
107-}
D internal/usecase/daemon/snatched_client_test.go +0 -1145
@@ -1,1145 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "io"
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- themeui "github.com/bnema/vev/internal/usecase/theme"
17- "github.com/bnema/vev/pkg/renderer"
18-)
19-
20-func TestSessionAttachmentRole(t *testing.T) {
21- active := &attachedClient{}
22- waiting := &attachedClient{}
23- unknown := &attachedClient{}
24- sess := &session{sessionCore: sessionCore{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}}
25-
26- require.Equal(t, attachmentActive, sess.attachmentRole(active))
27- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
28- require.Equal(t, attachmentDetached, sess.attachmentRole(unknown))
29-}
30-
31-func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) {
32- d := newTestDaemon(t, nil, stubClock{})
33- sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
34- oldTransport := &closeTrackingTransport{}
35- old := &attachedClient{tr: oldTransport}
36- old.setSession(sess)
37- sess.client = old
38- d.sessions[sess.id] = sess
39-
40- newTransport := &closeTrackingTransport{}
41- next := &attachedClient{tr: newTransport}
42- expected := next.transportSnapshot()
43- result, err := d.transitionAttachment(attachmentTransitionRequest{
44- target: sess,
45- next: next,
46- expectedRole: attachmentDetached,
47- targetRole: attachmentActive,
48- expectedTransport: expected,
49- })
50- require.NoError(t, err)
51-
52- sess.mu.Lock()
53- require.Same(t, next, sess.client)
54- require.Contains(t, sess.snatched, old)
55- _, nextSnatched := sess.snatched[next]
56- require.False(t, nextSnatched)
57- sess.mu.Unlock()
58- require.Equal(t, attachmentActive, sess.attachmentRole(next))
59- require.Equal(t, attachmentSnatched, sess.attachmentRole(old))
60- require.True(t, result.published.current())
61- require.True(t, result.displaced.current())
62- require.Equal(t, uint64(1), result.published.generation)
63- require.Equal(t, uint64(1), result.displaced.generation)
64- require.Same(t, sess, next.currentSession())
65- require.Same(t, sess, old.currentSession())
66-}
67-
68-func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) {
69- d := newTestDaemon(t, nil, stubClock{})
70- sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
71- oldTransport := &closeTrackingTransport{}
72- old := &attachedClient{
73- tr: oldTransport,
74- output: newOutputStateStream(),
75- size: domain.Size{Cols: 80, Rows: 24},
76- }
77- old.initOverlays()
78- old.setThemeForTest(themeui.Theme{})
79- old.setSession(sess)
80- sess.client = old
81- d.sessions[sess.id] = sess
82-
83- next := &attachedClient{tr: &closeTrackingTransport{}}
84- result, err := d.transitionAttachment(attachmentTransitionRequest{
85- target: sess,
86- next: next,
87- expectedRole: attachmentDetached,
88- targetRole: attachmentActive,
89- expectedTransport: next.transportSnapshot(),
90- })
91- require.NoError(t, err)
92-
93- d.deferAttachmentTransitionCleanups(result)
94- d.attachmentCleanupWg.Wait()
95-
96- require.False(t, oldTransport.Closed(), "snatched transport must stay open")
97- frames := oldTransport.Sends()
98- require.Len(t, frames, 1)
99- require.Equal(t, ports.MsgOutput, frames[0].Type)
100- output, err := ports.UnmarshalOutput(frames[0].Payload)
101- require.NoError(t, err)
102- require.Contains(t, string(output.Data), "Session snatched")
103-}
104-
105-func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) {
106- tests := []struct {
107- name string
108- setup func(*attachedClient, *session, *transactionalResizePTY)
109- frame func() ports.Frame
110- assert func(*testing.T, *Daemon, *session, *attachedClient, *attachedClient, *transactionalResizePTY, *datagramTestTransport)
111- }{
112- {
113- name: "forwarded input",
114- frame: func() ports.Frame { return frameInput([]byte("stale")) },
115- assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
116- require.Empty(t, pty.writes(), "stale input reached the PTY")
117- },
118- },
119- {
120- name: "key action",
121- frame: func() ports.Frame { return frameInput([]byte("\x1b ")) },
122- assert: func(t *testing.T, _ *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
123- require.False(t, old.overlays.paletteActive(), "stale key action mutated the displaced overlay")
124- require.False(t, next.overlays.paletteActive(), "stale key action mutated the active overlay")
125- },
126- },
127- {
128- name: "mouse input",
129- setup: func(_ *attachedClient, sess *session, _ *transactionalResizePTY) {
130- pane := sess.activeTab().focusedPane()
131- pane.mu.Lock()
132- pane.screen.Write([]byte("\x1b[?1000h"))
133- pane.mu.Unlock()
134- },
135- frame: func() ports.Frame { return frameInput([]byte("\x1b[<0;1;2M")) },
136- assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
137- require.Empty(t, pty.writes(), "stale mouse input reached the PTY")
138- },
139- },
140- {
141- name: "image push",
142- frame: func() ports.Frame {
143- return ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: "image/png", Data: []byte("stale-image")})}
144- },
145- assert: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
146- sess.mu.Lock()
147- files := append([]string(nil), sess.clipFiles...)
148- sess.mu.Unlock()
149- require.Empty(t, files, "stale image push created a session clipboard file")
150- require.Empty(t, pty.writes(), "stale image path reached the PTY")
151- },
152- },
153- {
154- name: "resize",
155- frame: func() ports.Frame {
156- return ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 120, Rows: 40}})}
157- },
158- assert: func(t *testing.T, _ *Daemon, sess *session, old, _ *attachedClient, pty *transactionalResizePTY, _ *datagramTestTransport) {
159- require.Empty(t, pty.requested(), "stale resize reached a session PTY")
160- require.Nil(t, sess.renderCoordinator().attachmentLease(old), "stale resize rebound the coordinator to the displaced client")
161- },
162- },
163- {
164- name: "theme",
165- frame: func() ports.Frame {
166- return ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{
167- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
168- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
169- })}
170- },
171- assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
172- require.False(t, old.getClientTheme().HasFG, "stale theme mutated the displaced client")
173- },
174- },
175- {
176- name: "client notice",
177- frame: func() ports.Frame {
178- return ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})}
179- },
180- assert: func(t *testing.T, d *Daemon, _ *session, old, next *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
181- require.Empty(t, d.notices.history(), "stale notice mutated daemon history")
182- oldToasts, _ := visibleToasts(old)
183- nextToasts, _ := visibleToasts(next)
184- require.Empty(t, oldToasts)
185- require.Empty(t, nextToasts)
186- },
187- },
188- {
189- name: "ack",
190- setup: func(old *attachedClient, _ *session, _ *transactionalResizePTY) {
191- old.output.next = 2
192- },
193- frame: func() ports.Frame {
194- return ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})}
195- },
196- assert: func(t *testing.T, _ *Daemon, _ *session, old, _ *attachedClient, _ *transactionalResizePTY, _ *datagramTestTransport) {
197- require.Equal(t, uint64(2), old.output.outstanding(), "stale ack retired displaced output state")
198- },
199- },
200- {
201- name: "ping",
202- frame: func() ports.Frame { return ports.Frame{Type: ports.MsgPing} },
203- assert: func(t *testing.T, _ *Daemon, _ *session, _ *attachedClient, _ *attachedClient, _ *transactionalResizePTY, tr *datagramTestTransport) {
204- select {
205- case frame := <-tr.sends:
206- t.Fatalf("stale ping wrote frame type %d", frame.Type)
207- default:
208- }
209- },
210- },
211- }
212-
213- for _, tt := range tests {
214- t.Run(tt.name, func(t *testing.T) {
215- pty := &transactionalResizePTY{}
216- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
217- d.tempDir = t.TempDir()
218- oldTransport := newDatagramTestTransport()
219- old.replaceTransport(oldTransport)
220- old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
221- rc := d.attachCoordinator(sess, nil, old, true)
222- token := sess.attachmentToken(old, oldTransport)
223- token.lease = rc.attachmentLease(old)
224- require.True(t, token.activeCurrent())
225- if tt.setup != nil {
226- tt.setup(old, sess, pty)
227- }
228-
229- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
230- next.initOverlays()
231- var transition attachmentTransitionResult
232- d.afterActiveFrameDispatch = func(attachmentRoleToken) {
233- d.afterActiveFrameDispatch = nil
234- var err error
235- transition, err = d.transitionAttachment(attachmentTransitionRequest{
236- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
237- expectedTransport: next.transportSnapshot(), ready: true,
238- })
239- require.NoError(t, err)
240- }
241-
242- d.handleActiveClientFrame(token, tt.frame())
243- for _, cleanup := range transition.cleanups {
244- cleanup.finish()
245- }
246- tt.assert(t, d, sess, old, next, pty, oldTransport)
247- })
248- }
249-}
250-
251-func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) {
252- pty := &transactionalResizePTY{}
253- d, sess, first, _ := newManualSessionWithPTYs(t, pty)
254- firstTransport := newDatagramTestTransport()
255- first.replaceTransport(firstTransport)
256-
257- secondTransport := newDatagramTestTransport()
258- second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
259- second.initOverlays()
260- firstReplacement, err := d.transitionAttachment(attachmentTransitionRequest{
261- target: sess, next: second, expectedRole: attachmentDetached, targetRole: attachmentActive,
262- expectedTransport: second.transportSnapshot(), ready: true,
263- })
264- require.NoError(t, err)
265- for _, cleanup := range firstReplacement.cleanups {
266- cleanup.finish()
267- }
268-
269- thirdTransport := newDatagramTestTransport()
270- third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size}
271- third.initOverlays()
272- secondReplacement, err := d.transitionAttachment(attachmentTransitionRequest{
273- target: sess, next: third, expectedRole: attachmentDetached, targetRole: attachmentActive,
274- expectedTransport: third.transportSnapshot(), ready: true,
275- })
276- require.NoError(t, err)
277- for _, cleanup := range secondReplacement.cleanups {
278- cleanup.finish()
279- }
280-
281- otherCtx, otherCancel := context.WithCancel(d.serveCtx)
282- defer otherCancel()
283- otherTransport := newDatagramTestTransport()
284- otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size}
285- otherClient.initOverlays()
286- other := &session{sessionCore: sessionCore{id: "other", name: "other", client: otherClient}, ctx: otherCtx, cancel: otherCancel}
287- otherClient.setSession(other)
288- d.mu.Lock()
289- d.sessions[other.id] = other
290- d.mu.Unlock()
291- d.attachCoordinator(other, nil, otherClient, true)
292-
293- roles := []*attachedClient{first, second, third}
294- generations := make([]uint64, len(roles))
295- for i, ac := range roles {
296- generations[i] = ac.roleGeneration.Load()
297- }
298- var killedLoops sync.WaitGroup
299- for _, ac := range roles {
300- killedLoops.Go(func() { d.runConnLoop(ac) })
301- }
302- otherDone := make(chan struct{})
303- go func() {
304- d.runConnLoop(otherClient)
305- close(otherDone)
306- }()
307- defer func() {
308- _ = firstTransport.Close()
309- _ = secondTransport.Close()
310- _ = thirdTransport.Close()
311- _ = otherTransport.Close()
312- killedLoops.Wait()
313- <-otherDone
314- }()
315-
316- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
317- d.waitNotifies()
318- select {
319- case <-waitGroupDone(&killedLoops):
320- case <-time.After(2 * time.Second):
321- t.Fatal("killed active and snatched connection loops did not retire")
322- }
323-
324- sess.mu.Lock()
325- require.Nil(t, sess.client)
326- require.Empty(t, sess.snatched)
327- sess.mu.Unlock()
328- for i, ac := range roles {
329- require.Nil(t, ac.currentSession())
330- require.Greater(t, ac.roleGeneration.Load(), generations[i], "session kill did not invalidate role generation")
331- }
332- for _, transport := range []*datagramTestTransport{firstTransport, secondTransport, thirdTransport} {
333- detached := awaitFrame(t, transport.sends, ports.MsgDetached)
334- message, err := ports.UnmarshalDetached(detached.Payload)
335- require.NoError(t, err)
336- require.Equal(t, ports.ReasonSessionKilled, message.Reason)
337- _, recvErr := transport.Recv()
338- require.ErrorIs(t, recvErr, io.EOF)
339- }
340- require.Same(t, otherClient, other.client)
341- require.Same(t, other, otherClient.currentSession())
342- require.Equal(t, attachmentActive, other.attachmentRole(otherClient))
343-
344- otherTransport.recv <- ports.Frame{Type: ports.MsgPing}
345- require.Equal(t, ports.MsgPong, awaitFrame(t, otherTransport.sends, ports.MsgPong).Type)
346- select {
347- case <-otherDone:
348- t.Fatal("killing another session retired the active client's connection")
349- default:
350- }
351-}
352-
353-func TestKillSessionRetiresParkedSnatchedAttachment(t *testing.T) {
354- pty, release := newBlockingPTY(t)
355- defer release()
356- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
357-
358- parkedTransport := &closeTrackingTransport{}
359- sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport)
360- require.NoError(t, err)
361- parkedToken := parkedClient.resumeToken
362- d.clientGone(sess, parkedClient, parkedTransport, false)
363-
364- activeTransport := &closeTrackingTransport{}
365- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
366- require.NoError(t, err)
367- d.mu.Lock()
368- parked := d.parked[parkedToken]
369- d.mu.Unlock()
370- require.NotNil(t, parked)
371- require.Equal(t, attachmentSnatched, parked.role)
372- parkedGeneration := parkedClient.roleGeneration.Load()
373-
374- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
375- d.waitNotifies()
376-
377- require.Nil(t, parkedClient.transport(), "terminal teardown must revoke the parked link ownership")
378- require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration, "terminal teardown must invalidate the parked role generation")
379- require.Zero(t, parkedClient.resumeToken)
380- require.False(t, parkedClient.parked)
381- select {
382- case <-parked.done:
383- default:
384- t.Fatal("terminal teardown left the parked expiry goroutine live")
385- }
386- require.True(t, parkedTransport.Closed())
387- require.Nil(t, parkedClient.currentSession())
388- require.Nil(t, active.currentSession())
389- require.True(t, activeTransport.Closed())
390-}
391-
392-func TestDaemonShutdownClosesSnatchedClients(t *testing.T) {
393- pty, release := newBlockingPTY(t)
394- defer release()
395- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
396-
397- parkedTransport := &closeTrackingTransport{}
398- sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport)
399- require.NoError(t, err)
400- parkedToken := parkedClient.resumeToken
401- d.clientGone(sess, parkedClient, parkedTransport, false)
402-
403- waitingTransport := &closeTrackingTransport{}
404- _, waiting, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), waitingTransport)
405- require.NoError(t, err)
406- activeTransport := &closeTrackingTransport{}
407- _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport)
408- require.NoError(t, err)
409- d.attachmentCleanupWg.Wait()
410-
411- d.mu.Lock()
412- parked := d.parked[parkedToken]
413- d.mu.Unlock()
414- require.NotNil(t, parked)
415- require.Equal(t, attachmentSnatched, parked.role)
416- parkedGeneration := parkedClient.roleGeneration.Load()
417- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
418- require.Equal(t, attachmentActive, sess.attachmentRole(active))
419-
420- require.False(t, d.shutdownAll(ports.ReasonServerShutdown))
421- d.waitNotifies()
422- d.attachmentCleanupWg.Wait()
423-
424- require.Nil(t, parkedClient.transport(), "shutdown must revoke parked attachment link ownership")
425- require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration)
426- select {
427- case <-parked.done:
428- default:
429- t.Fatal("shutdown left the parked expiry goroutine live")
430- }
431- for _, client := range []*attachedClient{parkedClient, waiting, active} {
432- require.Nil(t, client.currentSession())
433- }
434- for _, transport := range []*closeTrackingTransport{parkedTransport, waitingTransport, activeTransport} {
435- require.True(t, transport.Closed())
436- }
437- for _, transport := range []*closeTrackingTransport{waitingTransport, activeTransport} {
438- frames := transport.Sends()
439- require.NotEmpty(t, frames)
440- detached, err := ports.UnmarshalDetached(frames[len(frames)-1].Payload)
441- require.NoError(t, err)
442- require.Equal(t, ports.ReasonServerShutdown, detached.Reason)
443- }
444-}
445-
446-func TestSnatchedQuitDoesNotResetActivePaneThemeOrSize(t *testing.T) {
447- pty := &transactionalResizePTY{}
448- d, sess, waiting, _ := newManualSessionWithPTYs(t, pty)
449- waitingTransport := &closeTrackingTransport{}
450- waiting.replaceTransport(waitingTransport)
451-
452- activeTransport := &closeTrackingTransport{}
453- active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
454- active.initOverlays()
455- transition, err := d.transitionAttachment(attachmentTransitionRequest{
456- target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
457- expectedTransport: active.transportSnapshot(), ready: true,
458- })
459- require.NoError(t, err)
460- for _, cleanup := range transition.cleanups {
461- cleanup.finish()
462- }
463-
464- activeTheme := ports.Theme{
465- HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56},
466- HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21},
467- TrueColor: true,
468- }
469- d.applyTheme(sess, active, activeTheme)
470- activeSize := domain.Size{Cols: 101, Rows: 37}
471- d.resize(sess, active, activeSize)
472- require.Equal(t, activeSize, active.size)
473- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
474- requestedBeforeQuit := pty.requested()
475-
476- token := sess.attachmentToken(waiting, waitingTransport)
477- require.True(t, d.handleSnatchedClientFrame(token, ports.Frame{
478- Type: ports.MsgInput,
479- Payload: ports.MarshalInput(ports.Input{
480- Data: []byte{'q'},
481- }),
482- }))
483-
484- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
485- require.Same(t, active, sess.client)
486- require.False(t, activeTransport.Closed())
487- require.True(t, waitingTransport.Closed())
488- require.Equal(t, activeSize, active.size)
489- require.Equal(t, requestedBeforeQuit, pty.requested(), "snatched quit must not resize active panes")
490- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
491-}
492-
493-func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) {
494- writes := make(chan []byte, 1)
495- pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
496- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
497- oldTransport := newDatagramTestTransport()
498- old.replaceTransport(oldTransport)
499-
500- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
501- next.initOverlays()
502- result, err := d.transitionAttachment(attachmentTransitionRequest{
503- target: sess,
504- next: next,
505- expectedRole: attachmentDetached,
506- targetRole: attachmentActive,
507- expectedTransport: next.transportSnapshot(),
508- })
509- require.NoError(t, err)
510- d.deferAttachmentTransitionCleanups(result)
511- d.attachmentCleanupWg.Wait()
512- awaitFrame(t, oldTransport.sends, ports.MsgOutput)
513-
514- oldTransport.recv <- frameInput([]byte("restricted"))
515- require.NoError(t, oldTransport.Close())
516- d.runConnLoop(old)
517-
518- select {
519- case got := <-writes:
520- t.Fatalf("snatched input reached PTY: %q", got)
521- default:
522- }
523-}
524-
525-func TestStaleActiveKeyForwardCannotReachPTYAfterSnatch(t *testing.T) {
526- writes := make(chan []byte, 1)
527- pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }}
528- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
529- old.replaceTransport(&closeTrackingTransport{})
530-
531- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
532- next.initOverlays()
533- _, err := d.transitionAttachment(attachmentTransitionRequest{
534- target: sess,
535- next: next,
536- expectedRole: attachmentDetached,
537- targetRole: attachmentActive,
538- expectedTransport: next.transportSnapshot(),
539- })
540- require.NoError(t, err)
541-
542- (daemonKeyHandler{d: d, ac: old}).Forward([]byte("stale"))
543-
544- select {
545- case got := <-writes:
546- t.Fatalf("stale active key forward reached PTY: %q", got)
547- default:
548- }
549-}
550-
551-func TestSnatchedConnectionResizeUpdatesPanelWithoutResizingSession(t *testing.T) {
552- pty := &transactionalResizePTY{}
553- d, sess, old, _ := newManualSessionWithPTYs(t, pty)
554- oldTransport := newDatagramTestTransport()
555- old.replaceTransport(oldTransport)
556-
557- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
558- next.initOverlays()
559- result, err := d.transitionAttachment(attachmentTransitionRequest{
560- target: sess,
561- next: next,
562- expectedRole: attachmentDetached,
563- targetRole: attachmentActive,
564- expectedTransport: next.transportSnapshot(),
565- })
566- require.NoError(t, err)
567- d.deferAttachmentTransitionCleanups(result)
568- d.attachmentCleanupWg.Wait()
569- awaitFrame(t, oldTransport.sends, ports.MsgOutput)
570-
571- want := domain.Size{Cols: 100, Rows: 30}
572- oldTransport.recv <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: want})}
573- require.NoError(t, oldTransport.Close())
574- d.runConnLoop(old)
575-
576- require.Equal(t, want, old.size)
577- require.Empty(t, pty.requested(), "snatched resize must not resize session PTYs")
578- panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
579- output, err := ports.UnmarshalOutput(panel.Payload)
580- require.NoError(t, err)
581- require.Contains(t, string(output.Data), "Session snatched")
582-}
583-
584-func TestSnatchedConnectionAcknowledgesPanelAndAnswersPing(t *testing.T) {
585- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
586- oldTransport := newDatagramTestTransport()
587- old.replaceTransport(oldTransport)
588-
589- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
590- next.initOverlays()
591- result, err := d.transitionAttachment(attachmentTransitionRequest{
592- target: sess,
593- next: next,
594- expectedRole: attachmentDetached,
595- targetRole: attachmentActive,
596- expectedTransport: next.transportSnapshot(),
597- })
598- require.NoError(t, err)
599- d.deferAttachmentTransitionCleanups(result)
600- d.attachmentCleanupWg.Wait()
601- panelFrame := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
602- panel, err := ports.UnmarshalOutput(panelFrame.Payload)
603- require.NoError(t, err)
604- require.Equal(t, uint64(1), old.output.outstanding())
605-
606- oldTransport.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: panel.NewStateNum})}
607- oldTransport.recv <- ports.Frame{Type: ports.MsgPing}
608- require.NoError(t, oldTransport.Close())
609- d.runConnLoop(old)
610-
611- require.Zero(t, old.output.outstanding())
612- require.Equal(t, ports.MsgPong, awaitFrame(t, oldTransport.sends, ports.MsgPong).Type)
613-}
614-
615-func TestSnatchedConnectionThemeRepaintsPanelWithoutRecoloringSession(t *testing.T) {
616- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
617- oldTransport := newDatagramTestTransport()
618- old.replaceTransport(oldTransport)
619-
620- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
621- next.initOverlays()
622- result, err := d.transitionAttachment(attachmentTransitionRequest{
623- target: sess,
624- next: next,
625- expectedRole: attachmentDetached,
626- targetRole: attachmentActive,
627- expectedTransport: next.transportSnapshot(),
628- })
629- require.NoError(t, err)
630- d.deferAttachmentTransitionCleanups(result)
631- d.attachmentCleanupWg.Wait()
632- awaitFrame(t, oldTransport.sends, ports.MsgOutput)
633-
634- activeTheme := ports.Theme{
635- HasForeground: true, Foreground: renderer.RGB{R: 10, G: 20, B: 30},
636- HasBackground: true, Background: renderer.RGB{R: 40, G: 50, B: 60},
637- TrueColor: true,
638- }
639- d.applyTheme(sess, next, activeTheme)
640- snatchedTheme := ports.Theme{
641- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
642- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
643- TrueColor: true,
644- }
645- oldTransport.recv <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snatchedTheme)}
646- require.NoError(t, oldTransport.Close())
647- d.runConnLoop(old)
648-
649- require.Equal(t, snatchedTheme.Foreground, old.getAppliedTheme().Raw.Foreground)
650- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
651- panel := awaitFrame(t, oldTransport.sends, ports.MsgOutput)
652- output, err := ports.UnmarshalOutput(panel.Payload)
653- require.NoError(t, err)
654- require.Contains(t, string(output.Data), "Session snatched")
655-}
656-
657-func TestSnatchedConnectionDetachRemovesOnlySnatchedAttachment(t *testing.T) {
658- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
659- oldTransport := newDatagramTestTransport()
660- old.replaceTransport(oldTransport)
661-
662- activeTransport := &closeTrackingTransport{}
663- next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
664- next.initOverlays()
665- result, err := d.transitionAttachment(attachmentTransitionRequest{
666- target: sess,
667- next: next,
668- expectedRole: attachmentDetached,
669- targetRole: attachmentActive,
670- expectedTransport: next.transportSnapshot(),
671- })
672- require.NoError(t, err)
673- d.deferAttachmentTransitionCleanups(result)
674- d.attachmentCleanupWg.Wait()
675- awaitFrame(t, oldTransport.sends, ports.MsgOutput)
676-
677- oldTransport.recv <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}
678- d.runConnLoop(old)
679-
680- detached := awaitFrame(t, oldTransport.sends, ports.MsgDetached)
681- message, err := ports.UnmarshalDetached(detached.Payload)
682- require.NoError(t, err)
683- require.Equal(t, ports.ReasonDetach, message.Reason)
684- require.Equal(t, attachmentDetached, sess.attachmentRole(old))
685- require.Equal(t, attachmentActive, sess.attachmentRole(next))
686- require.Same(t, next, sess.client)
687- require.False(t, activeTransport.Closed())
688-}
689-
690-func TestReplacementClearsSnatchedOverlaysPreviewAndCaptures(t *testing.T) {
691- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
692- oldTransport := &closeTrackingTransport{}
693- old.replaceTransport(oldTransport)
694-
695- d.enterPicker(sess, old)
696- d.enterPalette(sess, old)
697- d.enterPrompt(sess, old, " Rename ", "work", func(string) error { return nil })
698- d.enterCopyMode(sess, old)
699- d.enterNotices(sess, old)
700- old.overlays.resizeMu.Lock()
701- old.overlays.resizeActive = true
702- old.overlays.resizePending = []byte("pending")
703- old.overlays.resizeMu.Unlock()
704- old.sendMu.Lock()
705- old.captureFrames = map[*pane]capturedPaneRenderState{sess.activeTab().focusedPane(): {}}
706- old.sendMu.Unlock()
707- require.True(t, old.overlays.Active())
708-
709- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
710- next.initOverlays()
711- result, err := d.transitionAttachment(attachmentTransitionRequest{
712- target: sess,
713- next: next,
714- expectedRole: attachmentDetached,
715- targetRole: attachmentActive,
716- expectedTransport: next.transportSnapshot(),
717- })
718- require.NoError(t, err)
719- d.deferAttachmentTransitionCleanups(result)
720- d.attachmentCleanupWg.Wait()
721-
722- require.False(t, old.overlays.Active())
723- old.overlays.pickerMu.Lock()
724- require.Nil(t, old.overlays.pickerPreview)
725- require.Nil(t, old.overlays.pickerPreviewSession)
726- old.overlays.pickerMu.Unlock()
727- old.sendMu.Lock()
728- require.Empty(t, old.captureFrames)
729- old.sendMu.Unlock()
730- require.False(t, oldTransport.Closed())
731-}
732-
733-type failingSnatchedTransport struct {
734- mu sync.Mutex
735- closed bool
736-}
737-
738-func (*failingSnatchedTransport) Send(ports.Frame) error { return errors.New("send failed") }
739-func (*failingSnatchedTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
740-func (t *failingSnatchedTransport) Close() error {
741- t.mu.Lock()
742- t.closed = true
743- t.mu.Unlock()
744- return nil
745-}
746-func (t *failingSnatchedTransport) Closed() bool {
747- t.mu.Lock()
748- defer t.mu.Unlock()
749- return t.closed
750-}
751-
752-func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) {
753- d := newTestDaemon(t, nil, stubClock{})
754- oldTransport := &failingSnatchedTransport{}
755- old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
756- old.initOverlays()
757- sess := &session{sessionCore: sessionCore{id: "work", client: old}}
758- old.setSession(sess)
759- d.sessions[sess.id] = sess
760-
761- activeTransport := &closeTrackingTransport{}
762- next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
763- next.initOverlays()
764- result, err := d.transitionAttachment(attachmentTransitionRequest{
765- target: sess,
766- next: next,
767- expectedRole: attachmentDetached,
768- targetRole: attachmentActive,
769- expectedTransport: next.transportSnapshot(),
770- })
771- require.NoError(t, err)
772- d.deferAttachmentTransitionCleanups(result)
773- d.attachmentCleanupWg.Wait()
774-
775- require.True(t, oldTransport.Closed())
776- require.Equal(t, attachmentDetached, sess.attachmentRole(old))
777- require.Equal(t, attachmentActive, sess.attachmentRole(next))
778- require.Same(t, next, sess.client)
779- require.False(t, activeTransport.Closed())
780-}
781-
782-func TestSnatchedPanelFailurePreservesActivePaneThemeAndSize(t *testing.T) {
783- pty := &transactionalResizePTY{}
784- d, sess, waiting, _ := newManualSessionWithPTYs(t, pty)
785- failed := &failingSnatchedTransport{}
786- waiting.replaceTransport(failed)
787-
788- activeTransport := &closeTrackingTransport{}
789- active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
790- active.initOverlays()
791- transition, err := d.transitionAttachment(attachmentTransitionRequest{
792- target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
793- expectedTransport: active.transportSnapshot(), ready: true,
794- })
795- require.NoError(t, err)
796-
797- activeTheme := ports.Theme{
798- HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56},
799- HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21},
800- TrueColor: true,
801- }
802- d.applyTheme(sess, active, activeTheme)
803- activeSize := domain.Size{Cols: 103, Rows: 39}
804- d.resize(sess, active, activeSize)
805- require.Equal(t, activeSize, active.size)
806- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
807- requestedBeforeFailure := pty.requested()
808-
809- d.deferAttachmentTransitionCleanups(transition)
810- d.attachmentCleanupWg.Wait()
811-
812- require.True(t, failed.Closed())
813- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
814- require.Same(t, active, sess.client)
815- require.False(t, activeTransport.Closed())
816- require.Equal(t, activeSize, active.size)
817- require.Equal(t, requestedBeforeFailure, pty.requested(), "snatched panel failure must not resize active panes")
818- assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background)
819-}
820-
821-func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
822- d := newTestDaemon(t, nil, stubClock{})
823- oldTransport := &failingSnatchedTransport{}
824- old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
825- old.initOverlays()
826- sess := &session{sessionCore: sessionCore{id: "work", client: old}}
827- old.setSession(sess)
828- d.sessions[sess.id] = sess
829-
830- activeTransport := &closeTrackingTransport{}
831- next := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: old.size}
832- next.initOverlays()
833- result, err := d.transitionAttachment(attachmentTransitionRequest{
834- target: sess,
835- next: next,
836- expectedRole: attachmentDetached,
837- targetRole: attachmentActive,
838- expectedTransport: next.transportSnapshot(),
839- })
840- require.NoError(t, err)
841- for _, cleanup := range result.cleanups {
842- cleanup.finish()
843- }
844-
845- theme := ports.Theme{
846- HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
847- HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
848- TrueColor: true,
849- }
850- require.True(t, d.handleSnatchedClientFrame(result.displaced, ports.Frame{
851- Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme),
852- }))
853-
854- require.True(t, oldTransport.Closed())
855- require.Equal(t, attachmentDetached, sess.attachmentRole(old))
856- require.Equal(t, attachmentActive, sess.attachmentRole(next))
857- require.Same(t, next, sess.client)
858- require.False(t, activeTransport.Closed())
859-}
860-
861-func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) {
862- d := newTestDaemon(t, nil, stubClock{})
863- tr := &closeTrackingTransport{}
864- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
865- ac.initOverlays()
866- ac.roleGeneration.Store(1)
867- sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}}
868- ac.setSession(sess)
869- token := attachmentRoleToken{
870- sess: sess, ac: ac, role: attachmentSnatched,
871- generation: 1, transport: ac.transportSnapshot(),
872- }
873-
874- pickerCleared := make(chan struct{})
875- d.afterSnatchOverlayFamily = func(family string) {
876- if family == "picker" {
877- close(pickerCleared)
878- }
879- }
880- ac.overlays.resizeMu.Lock()
881- resizeLocked := true
882- defer func() {
883- if resizeLocked {
884- ac.overlays.resizeMu.Unlock()
885- }
886- }()
887- cleared := make(chan bool, 1)
888- go func() { cleared <- d.clearForSnatch(token) }()
889- select {
890- case <-pickerCleared:
891- case <-time.After(2 * time.Second):
892- t.Fatal("picker family did not clear before blocked resize family")
893- }
894- require.True(t, ac.overlays.pickerMu.TryLock(), "clear retained pickerMu while waiting for resizeMu")
895- ac.overlays.pickerMu.Unlock()
896-
897- ac.overlays.resizeMu.Unlock()
898- resizeLocked = false
899- require.True(t, <-cleared)
900-}
901-
902-func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testing.T) {
903- d := newTestDaemon(t, nil, stubClock{})
904- tr := &closeTrackingTransport{}
905- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
906- ac.initOverlays()
907- ac.roleGeneration.Store(1)
908- sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}}
909- ac.setSession(sess)
910- token := attachmentRoleToken{
911- sess: sess, ac: ac, role: attachmentSnatched,
912- generation: 1, transport: ac.transportSnapshot(),
913- }
914-
915- ac.overlays.pickerMu.Lock()
916- started := make(chan struct{})
917- cleared := make(chan bool, 1)
918- go func() {
919- close(started)
920- cleared <- d.clearForSnatch(token)
921- }()
922- <-started
923- ac.roleGeneration.Add(1)
924- ac.overlays.resizeMu.Lock()
925- ac.overlays.resizeActive = true
926- ac.overlays.resizeMu.Unlock()
927- ac.overlays.pickerMu.Unlock()
928-
929- require.False(t, <-cleared)
930- require.True(t, ac.overlays.resizeModeActive(), "stale cleanup cleared a newer generation")
931-}
932-
933-func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) {
934- tests := []struct {
935- name string
936- mutate func(*Daemon, *session, *attachedClient)
937- }{
938- {
939- name: "stale transport",
940- mutate: func(_ *Daemon, _ *session, next *attachedClient) {
941- next.replaceTransport(&closeTrackingTransport{})
942- },
943- },
944- {
945- name: "session lifecycle ended",
946- mutate: func(d *Daemon, sess *session, _ *attachedClient) {
947- delete(d.sessions, sess.id)
948- },
949- },
950- {
951- name: "unexpected role",
952- mutate: func(_ *Daemon, sess *session, next *attachedClient) {
953- sess.mu.Lock()
954- sess.addSnatchedLocked(next)
955- sess.mu.Unlock()
956- },
957- },
958- }
959-
960- for _, tt := range tests {
961- t.Run(tt.name, func(t *testing.T) {
962- d := newTestDaemon(t, nil, stubClock{})
963- sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
964- old := &attachedClient{tr: &closeTrackingTransport{}}
965- old.setSession(sess)
966- sess.client = old
967- d.sessions[sess.id] = sess
968- next := &attachedClient{tr: &closeTrackingTransport{}}
969- expected := next.transportSnapshot()
970- tt.mutate(d, sess, next)
971-
972- _, err := d.transitionAttachment(attachmentTransitionRequest{
973- target: sess,
974- next: next,
975- expectedRole: attachmentDetached,
976- targetRole: attachmentActive,
977- expectedTransport: expected,
978- })
979- require.ErrorIs(t, err, errAttachmentTransition)
980- require.Same(t, old, sess.client)
981- require.Zero(t, old.roleGeneration.Load())
982- require.Zero(t, next.roleGeneration.Load())
983- })
984- }
985-}
986-
987-func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) {
988- d := newTestDaemon(t, nil, stubClock{})
989- from := &session{sessionCore: sessionCore{id: "z-source"}}
990- target := &session{sessionCore: sessionCore{id: "a-target"}}
991- moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
992- displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size}
993- moving.initOverlays()
994- displaced.initOverlays()
995- moving.setSession(from)
996- displaced.setSession(target)
997- from.client = moving
998- target.client = displaced
999- d.sessions[from.id] = from
1000- d.sessions[target.id] = target
1001- sourceCoordinator := d.attachCoordinator(from, nil, moving, true)
1002- targetCoordinator := d.attachCoordinator(target, nil, displaced, true)
1003-
1004- moving.sendMu.Lock()
1005- locked := true
1006- defer func() {
1007- if locked {
1008- moving.sendMu.Unlock()
1009- }
1010- }()
1011- transitionDone := make(chan attachmentTransitionResult, 1)
1012- transitionErr := make(chan error, 1)
1013- go func() {
1014- result, err := d.transitionAttachment(attachmentTransitionRequest{
1015- source: from, target: target, next: moving,
1016- expectedRole: attachmentActive, targetRole: attachmentActive,
1017- expectedTransport: moving.transportSnapshot(), ready: true,
1018- })
1019- transitionDone <- result
1020- transitionErr <- err
1021- }()
1022-
1023- var result attachmentTransitionResult
1024- select {
1025- case result = <-transitionDone:
1026- require.NoError(t, <-transitionErr)
1027- case <-time.After(2 * time.Second):
1028- t.Fatal("ownership publication waited for blocked sendMu")
1029- }
1030-
1031- from.mu.Lock()
1032- require.Nil(t, from.client)
1033- from.mu.Unlock()
1034- target.mu.Lock()
1035- require.Same(t, moving, target.client)
1036- require.Contains(t, target.snatched, displaced)
1037- target.mu.Unlock()
1038- require.Same(t, target, moving.currentSession())
1039- sourceCoordinator.mu.Lock()
1040- require.NotNil(t, sourceCoordinator.lease)
1041- require.False(t, sourceCoordinator.lease.active)
1042- sourceCoordinator.mu.Unlock()
1043- targetCoordinator.mu.Lock()
1044- require.Same(t, result.published.lease, targetCoordinator.lease)
1045- require.True(t, targetCoordinator.lease.active)
1046- require.Same(t, moving, targetCoordinator.lease.attachment)
1047- targetCoordinator.mu.Unlock()
1048- require.True(t, result.published.activeCurrent())
1049-
1050- beforeSendWait := make(chan struct{})
1051- d.beforeFirstPaintSendWait = func(token attachmentRoleToken) {
1052- if token.ac == moving {
1053- close(beforeSendWait)
1054- }
1055- }
1056- paintDone := make(chan bool, 1)
1057- go func() {
1058- paintDone <- d.firstPaintForTransition(result.published)
1059- }()
1060- <-beforeSendWait
1061- require.True(t, d.mu.TryLock(), "first paint retained daemon lock while waiting for sendMu")
1062- d.mu.Unlock()
1063- require.True(t, d.notices.routingMu.TryLock(), "first paint retained routing lock while waiting for sendMu")
1064- d.notices.routingMu.Unlock()
1065- require.True(t, from.mu.TryLock(), "first paint retained source session lock while waiting for sendMu")
1066- from.mu.Unlock()
1067- require.True(t, target.mu.TryLock(), "first paint retained target session lock while waiting for sendMu")
1068- target.mu.Unlock()
1069- require.True(t, sourceCoordinator.mu.TryLock(), "first paint retained source coordinator lock while waiting for sendMu")
1070- sourceCoordinator.mu.Unlock()
1071- require.True(t, targetCoordinator.mu.TryLock(), "first paint retained target coordinator lock while waiting for sendMu")
1072- targetCoordinator.mu.Unlock()
1073-
1074- moving.sendMu.Unlock()
1075- locked = false
1076- require.True(t, <-paintDone)
1077-}
1078-
1079-func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *testing.T) {
1080- d := newTestDaemon(t, nil, stubClock{})
1081- tr := &closeTrackingTransport{}
1082- ac := &attachedClient{
1083- tr: tr,
1084- output: newOutputStateStream(),
1085- size: domain.Size{Cols: 80, Rows: 24},
1086- }
1087- ac.initOverlays()
1088- ac.roleGeneration.Store(2)
1089- ac.output.next = 4
1090- ac.output.acked = 1
1091- sess := &session{sessionCore: sessionCore{id: "work", client: ac}}
1092- ac.setSession(sess)
1093- d.sessions[sess.id] = sess
1094- rc := d.attachCoordinator(sess, nil, ac, true)
1095- rebased := false
1096- ac.renderStages.handoffRebase = func() { rebased = true }
1097-
1098- stale := attachmentRoleToken{
1099- sess: sess, ac: ac, role: attachmentActive,
1100- generation: 1, transport: ac.transportSnapshot(), lease: rc.attachmentLease(ac), rebase: true,
1101- }
1102- require.False(t, d.firstPaintForTransition(stale))
1103-
1104- require.False(t, rebased)
1105- require.Equal(t, uint64(4), ac.output.next)
1106- require.Equal(t, uint64(1), ac.output.acked)
1107- require.Empty(t, tr.Sends(), "stale first paint emitted a reset")
1108-}
1109-
1110-func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) {
1111- d := newTestDaemon(t, nil, stubClock{})
1112- from := &session{sessionCore: sessionCore{id: domain.SessionID("z-source")}}
1113- target := &session{sessionCore: sessionCore{id: domain.SessionID("a-target")}}
1114- moving := &attachedClient{tr: &closeTrackingTransport{}}
1115- displaced := &attachedClient{tr: &closeTrackingTransport{}}
1116- moving.setSession(from)
1117- displaced.setSession(target)
1118- from.client = moving
1119- target.client = displaced
1120- d.sessions[from.id] = from
1121- d.sessions[target.id] = target
1122-
1123- result, err := d.transitionAttachment(attachmentTransitionRequest{
1124- source: from,
1125- target: target,
1126- next: moving,
1127- expectedRole: attachmentActive,
1128- targetRole: attachmentActive,
1129- expectedTransport: moving.transportSnapshot(),
1130- ready: true,
1131- })
1132- require.NoError(t, err)
1133-
1134- from.mu.Lock()
1135- require.Nil(t, from.client)
1136- require.NotContains(t, from.snatched, moving)
1137- from.mu.Unlock()
1138- target.mu.Lock()
1139- require.Same(t, moving, target.client)
1140- require.Contains(t, target.snatched, displaced)
1141- target.mu.Unlock()
1142- require.True(t, result.published.current())
1143- require.True(t, result.displaced.current())
1144- require.Same(t, target, moving.currentSession())
1145-}
D internal/usecase/daemon/snatched_input.go +0 -261
@@ -1,261 +0,0 @@
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.parkOrDropSnatchedAttachment(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.parkOrDropSnatchedAttachment(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-}
D internal/usecase/daemon/snatched_input_test.go +0 -363
@@ -1,363 +0,0 @@
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{sessionCore: sessionCore{id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}}
143- waiting.setSession(sess)
144- active.setSession(sess)
145- d.sessions[sess.id] = sess
146- d.attachCoordinator(sess, nil, active, true)
147- return d, sess, waiting, active, waitingTransport
148-}
149-
150-func sendSnatchedInput(d *Daemon, sess *session, ac *attachedClient, data string) bool {
151- tr := ac.transport()
152- token := sess.attachmentToken(ac, tr)
153- return d.handleSnatchedClientFrame(token, ports.Frame{
154- Type: ports.MsgInput,
155- Payload: ports.MarshalInput(ports.Input{Data: []byte(data)}),
156- })
157-}
158-
159-func TestSnatchedInputStrictActions(t *testing.T) {
160- tests := []struct {
161- name string
162- chunks []string
163- fireEscape bool
164- wantRole attachmentRole
165- wantClosed bool
166- }{
167- {name: "lowercase reclaim", chunks: []string{"r"}, wantRole: attachmentActive},
168- {name: "uppercase reclaim", chunks: []string{"R"}, wantRole: attachmentActive},
169- {name: "lowercase quit", chunks: []string{"q"}, wantRole: attachmentDetached, wantClosed: true},
170- {name: "uppercase quit", chunks: []string{"Q"}, wantRole: attachmentDetached, wantClosed: true},
171- {name: "standalone escape quits after delay", chunks: []string{"\x1b"}, fireEscape: true, wantRole: attachmentDetached, wantClosed: true},
172- {name: "coalesced reclaim ignored", chunks: []string{"rr"}, wantRole: attachmentSnatched},
173- {name: "paste containing quit ignored", chunks: []string{"paste q"}, wantRole: attachmentSnatched},
174- {name: "complete CSI ignored", chunks: []string{"\x1b[r"}, wantRole: attachmentSnatched},
175- {name: "complete SS3 ignored", chunks: []string{"\x1bOR"}, wantRole: attachmentSnatched},
176- {name: "complete sequence tail ignored", chunks: []string{"\x1b[Aq"}, wantRole: attachmentSnatched},
177- {name: "split CSI final reclaim ignored", chunks: []string{"\x1b[", "r"}, wantRole: attachmentSnatched},
178- {name: "split SS3 final reclaim ignored", chunks: []string{"\x1bO", "R"}, wantRole: attachmentSnatched},
179- {name: "split sequence frame tail ignored", chunks: []string{"\x1b[", "Aq"}, wantRole: attachmentSnatched},
180- {name: "escape followed by action ignored as alt chord", chunks: []string{"\x1b", "q"}, wantRole: attachmentSnatched},
181- {name: "CSI prefix remains inert", chunks: []string{"\x1b[", "12;"}, wantRole: attachmentSnatched},
182- {name: "overflow drain consumes final and frame tail", chunks: []string{"\x1b[", strings.Repeat(";", 80), "rQ"}, wantRole: attachmentSnatched},
183- }
184-
185- for _, tt := range tests {
186- t.Run(tt.name, func(t *testing.T) {
187- clock := newSnatchedInputClock()
188- d, sess, waiting, active, waitingTransport := newSnatchedInputFixture(t, clock)
189- for _, chunk := range tt.chunks {
190- sendSnatchedInput(d, sess, waiting, chunk)
191- }
192- if tt.fireEscape {
193- require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting), "escape acted before its delay")
194- require.False(t, waitingTransport.Closed(), "escape closed the transport before its delay")
195- timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer")
196- timer.Fire()
197- awaitTestCompletion(t, waitingTransport.closedCh, "standalone escape did not quit")
198- }
199- d.attachmentCleanupWg.Wait()
200-
201- require.Equal(t, tt.wantRole, sess.attachmentRole(waiting))
202- require.Equal(t, tt.wantClosed, waitingTransport.Closed())
203- if tt.wantRole == attachmentActive {
204- require.Same(t, waiting, sess.client)
205- require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
206- } else {
207- require.Same(t, active, sess.client)
208- }
209- })
210- }
211-}
212-
213-func TestBlockedSnatchedQuitDeadlineRetiresOnlyExactWaiter(t *testing.T) {
214- clock := &signalClock{timers: make(chan *signalTimer, 2)}
215- waitingTransport := newBlockedSnatchedQuitTransport()
216- defer func() { _ = waitingTransport.Close() }()
217- activeTransport := &closeTrackingTransport{}
218- d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
219-
220- otherTransport := &closeTrackingTransport{}
221- other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: waiting.size}
222- other.initOverlays()
223- other.setSession(sess)
224- sess.mu.Lock()
225- sess.addSnatchedLocked(other)
226- sess.mu.Unlock()
227- otherToken := sess.attachmentToken(other, otherTransport)
228- require.Equal(t, attachmentSnatched, otherToken.role)
229-
230- ownerLease := sess.renderCoordinator().attachmentLease(active)
231- require.NotNil(t, ownerLease)
232- activeGeneration := active.roleGeneration.Load()
233- otherGeneration := other.roleGeneration.Load()
234-
235- loopDone := make(chan struct{})
236- go func() {
237- d.runConnLoop(waiting)
238- close(loopDone)
239- }()
240- waitingTransport.recv <- ports.Frame{
241- Type: ports.MsgInput,
242- Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}),
243- }
244- awaitTestCompletion(t, waitingTransport.sendStarted, "snatched quit did not attempt its detach acknowledgement")
245- select {
246- case <-loopDone:
247- t.Fatal("snatched quit returned before its acknowledgement deadline")
248- default:
249- }
250-
251- timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline")
252- require.Equal(t, detachNotifyTimeout, timer.duration)
253- timer.ch <- time.Time{}
254- awaitTestCompletion(t, waitingTransport.sendDone, "closing the captured link did not release its blocked send")
255- awaitTestCompletion(t, loopDone, "snatched connection parser did not retire after the acknowledgement deadline")
256-
257- require.True(t, waitingTransport.Closed())
258- requireRoleGateRetired(t, waiting)
259- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
260- require.Nil(t, waiting.currentSession())
261- require.Nil(t, waiting.transport())
262- require.Same(t, active, sess.client)
263- require.Equal(t, attachmentActive, sess.attachmentRole(active))
264- require.Same(t, ownerLease, sess.renderCoordinator().attachmentLease(active))
265- require.Equal(t, activeGeneration, active.roleGeneration.Load())
266- require.False(t, activeTransport.Closed())
267- require.Equal(t, attachmentSnatched, sess.attachmentRole(other))
268- require.Equal(t, otherGeneration, other.roleGeneration.Load())
269- require.Same(t, otherTransport, other.transport())
270- require.False(t, otherTransport.Closed())
271-}
272-
273-func TestBlockedSnatchedQuitCleanupRejectsReclaimedTransportGeneration(t *testing.T) {
274- clock := &signalClock{timers: make(chan *signalTimer, 2)}
275- staleTransport := newBlockedSnatchedQuitTransport()
276- defer func() { _ = staleTransport.Close() }()
277- ownerTransport := &closeTrackingTransport{}
278- d, sess, waiting, owner := newAtomicReclaimFixture(t, clock, staleTransport, ownerTransport)
279-
280- ticketEnded := make(chan struct{})
281- releaseQuit := make(chan struct{})
282- d.afterActionRoleEffectEnded = func(action string) {
283- if action == "quit-snatched" {
284- close(ticketEnded)
285- <-releaseQuit
286- }
287- }
288- loopDone := make(chan struct{})
289- go func() {
290- d.runConnLoop(waiting)
291- close(loopDone)
292- }()
293- staleTransport.recv <- ports.Frame{
294- Type: ports.MsgInput,
295- Payload: ports.MarshalInput(ports.Input{Data: []byte{'Q'}}),
296- }
297- awaitTestCompletion(t, staleTransport.sendStarted, "snatched quit did not block in its captured transport")
298- timer := awaitTestValue(t, clock.timers, "snatched quit did not install its acknowledgement deadline")
299- timer.ch <- time.Time{}
300- awaitTestCompletion(t, staleTransport.sendDone, "deadline close did not retire the captured send")
301- awaitTestCompletion(t, ticketEnded, "snatched quit did not end its role ticket before cleanup")
302-
303- freshTransport := &closeTrackingTransport{}
304- waiting.replaceTransport(freshTransport)
305- reclaimed, err := d.transitionAttachment(attachmentTransitionRequest{
306- target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive,
307- expectedTransport: waiting.transportSnapshot(), ready: true,
308- })
309- require.NoError(t, err)
310- d.deferAttachmentTransitionCleanups(reclaimed)
311- freshGeneration := reclaimed.published.generation
312- freshLease := reclaimed.published.lease
313-
314- close(releaseQuit)
315- awaitTestCompletion(t, loopDone, "stale quit parser did not retire after reclaim")
316- d.attachmentCleanupWg.Wait()
317-
318- require.True(t, staleTransport.Closed())
319- requireRoleGateRetired(t, waiting)
320- require.Same(t, waiting, sess.client)
321- require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
322- require.Equal(t, freshGeneration, waiting.roleGeneration.Load())
323- require.Same(t, freshTransport, waiting.transport())
324- require.False(t, freshTransport.Closed(), "stale quit cleanup closed the reclaimed transport")
325- require.Same(t, freshLease, sess.renderCoordinator().attachmentLease(waiting))
326- require.Equal(t, attachmentSnatched, sess.attachmentRole(owner))
327- require.False(t, ownerTransport.Closed(), "stale quit cleanup affected the displaced owner")
328-}
329-
330-func TestSnatchedInputTimerCannotAffectNewRoleOrTransport(t *testing.T) {
331- clock := newSnatchedInputClock()
332- d, sess, waiting, _, oldTransport := newSnatchedInputFixture(t, clock)
333-
334- sendSnatchedInput(d, sess, waiting, "\x1b")
335- timer := awaitTestValue(t, clock.timers, "standalone escape did not create a timer")
336- accepted := make(chan struct{})
337- release := make(chan struct{})
338- attempted := make(chan bool, 1)
339- d.afterSnatchedEscapeAccepted = func() {
340- close(accepted)
341- <-release
342- }
343- d.afterSnatchedEscapeAttempt = func(admitted bool) { attempted <- admitted }
344- timer.Fire()
345- awaitTestCompletion(t, accepted, "escape timer callback did not consume parser state")
346-
347- result, err := d.transitionAttachment(attachmentTransitionRequest{
348- target: sess, next: waiting, expectedRole: attachmentSnatched, targetRole: attachmentActive,
349- expectedTransport: waiting.transportSnapshot(), ready: true,
350- })
351- require.NoError(t, err)
352- d.deferAttachmentTransitionCleanups(result)
353- newTransport := newSnatchedCloseSignalTransport()
354- waiting.replaceTransport(newTransport)
355- close(release)
356- require.False(t, awaitTestValue(t, attempted, "escape callback did not revalidate its stale role"))
357- d.attachmentCleanupWg.Wait()
358-
359- require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
360- require.Same(t, newTransport, waiting.transport())
361- require.False(t, newTransport.Closed(), "stale timer closed the replacement transport")
362- require.False(t, oldTransport.Closed(), "stale timer closed the old transport after role activation")
363-}
D internal/usecase/daemon/snatched_reclaim_test.go +0 -444
@@ -1,444 +0,0 @@
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{sessionCore: sessionCore{id: "atomic-reclaim", client: active,
46- snatched: map[*attachedClient]struct{}{waiting: {}}},
47- }
48- waiting.setSession(sess)
49- active.setSession(sess)
50- d.sessions[sess.id] = sess
51- d.attachCoordinator(sess, nil, active, true)
52- return d, sess, waiting, active
53-}
54-
55-func requireSingleOwnerAndLease(t *testing.T, sess *session, clients ...*attachedClient) {
56- t.Helper()
57- sess.mu.Lock()
58- owner := sess.client
59- snatched := make(map[*attachedClient]struct{}, len(sess.snatched))
60- for ac := range sess.snatched {
61- snatched[ac] = struct{}{}
62- }
63- sess.mu.Unlock()
64- require.NotNil(t, owner)
65-
66- activeCount := 0
67- for _, ac := range clients {
68- if ac == owner {
69- activeCount++
70- continue
71- }
72- _, waiting := snatched[ac]
73- require.True(t, waiting, "every non-owner must remain snatched")
74- }
75- require.Equal(t, 1, activeCount)
76-
77- rc := sess.renderCoordinator()
78- rc.mu.Lock()
79- lease := rc.lease
80- rc.mu.Unlock()
81- require.NotNil(t, lease)
82- require.True(t, lease.active)
83- require.Same(t, owner, lease.attachment)
84-}
85-
86-func TestReclaimSnatchedSwapsOwnerLeaseRebasesAndAppliesTheme(t *testing.T) {
87- d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
88- requesterTransport := &closeTrackingTransport{}
89- requester.replaceTransport(requesterTransport)
90- d.attachCoordinator(sess, nil, requester, true)
91-
92- active := &attachedClient{
93- tr: &closeTrackingTransport{},
94- output: newOutputStateStream(),
95- size: requester.size,
96- }
97- active.initOverlays()
98- displaced, err := d.transitionAttachment(attachmentTransitionRequest{
99- target: sess, next: active,
100- expectedRole: attachmentDetached, targetRole: attachmentActive,
101- expectedTransport: active.transportSnapshot(), ready: true,
102- })
103- require.NoError(t, err)
104- d.deferAttachmentTransitionCleanups(displaced)
105- d.attachmentCleanupWg.Wait()
106-
107- requester.sendMu.Lock()
108- beforeReclaimState := requester.output.next
109- requester.sendMu.Unlock()
110- requesterTheme := themeui.Theme{
111- Foreground: renderer.RGB{R: 11, G: 22, B: 33},
112- Background: renderer.RGB{R: 44, G: 55, B: 66},
113- HasFG: true, HasBG: true, Known: true, TrueColor: true,
114- }
115- requester.setClientTheme(requesterTheme)
116-
117- token := sess.attachmentToken(requester, requesterTransport)
118- require.Equal(t, attachmentSnatched, token.role)
119- require.False(t, d.handleSnatchedClientFrame(token, ports.Frame{
120- Type: ports.MsgInput,
121- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
122- }))
123-
124- sess.mu.Lock()
125- require.Same(t, requester, sess.client)
126- _, activeIsSnatched := sess.snatched[active]
127- _, requesterIsSnatched := sess.snatched[requester]
128- sess.mu.Unlock()
129- require.True(t, activeIsSnatched)
130- require.False(t, requesterIsSnatched)
131- rc := sess.renderCoordinator()
132- lease := rc.attachmentLease(requester)
133- require.NotNil(t, lease)
134- require.Nil(t, rc.attachmentLease(active))
135- require.True(t, lease.active)
136-
137- requester.sendMu.Lock()
138- require.Greater(t, requester.output.next, beforeReclaimState, "reclaim must emit a reset first paint")
139- require.GreaterOrEqual(t, requester.output.acked, beforeReclaimState, "reclaim must rebase the old panel output chain")
140- requester.sendMu.Unlock()
141- assertSessionDefaultColors(t, sess, requesterTheme.Foreground, requesterTheme.Background)
142-
143- d.attachmentCleanupWg.Wait()
144- require.Equal(t, attachmentSnatched, sess.attachmentRole(active))
145- activeTransport, ok := active.transport().(*closeTrackingTransport)
146- require.True(t, ok, "active transport has unexpected type")
147- require.False(t, activeTransport.Closed())
148- require.Equal(t, domain.Size{Cols: 80, Rows: 24}, requester.size)
149-}
150-
151-func TestFailedReclaimBeforeCommitPreservesOwnerAndShowsUnavailable(t *testing.T) {
152- waitingTransport := &closeTrackingTransport{}
153- activeTransport := &closeTrackingTransport{}
154- d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
155- token := sess.attachmentToken(waiting, waitingTransport)
156-
157- d.mu.Lock()
158- delete(d.sessions, sess.id)
159- d.mu.Unlock()
160- d.handleSnatchedClientFrame(token, ports.Frame{
161- Type: ports.MsgInput,
162- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
163- })
164-
165- requireSingleOwnerAndLease(t, sess, waiting, active)
166- require.Same(t, active, sess.client)
167- require.False(t, waitingTransport.Closed())
168- frames := waitingTransport.Sends()
169- require.Len(t, frames, 1)
170- output, err := ports.UnmarshalOutput(frames[0].Payload)
171- require.NoError(t, err)
172- require.Contains(t, string(output.Data), "Session is no longer available.")
173-}
174-
175-func TestPostcommitPanelFailureRetiresOnlyDisplacedClient(t *testing.T) {
176- waitingTransport := &closeTrackingTransport{}
177- activeTransport := &failingReclaimTransport{}
178- d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
179- token := sess.attachmentToken(waiting, waitingTransport)
180-
181- d.handleSnatchedClientFrame(token, ports.Frame{
182- Type: ports.MsgInput,
183- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
184- })
185- d.attachmentCleanupWg.Wait()
186-
187- require.Same(t, waiting, sess.client)
188- require.Equal(t, attachmentActive, sess.attachmentRole(waiting))
189- require.Equal(t, attachmentDetached, sess.attachmentRole(active))
190- require.NotNil(t, sess.renderCoordinator().attachmentLease(waiting))
191- require.Nil(t, sess.renderCoordinator().attachmentLease(active))
192- require.False(t, waitingTransport.Closed())
193- require.True(t, activeTransport.Closed())
194-}
195-
196-func TestBlockedRequesterActivationDeadlinePreservesOwnerLease(t *testing.T) {
197- clock := &signalClock{timers: make(chan *signalTimer, 2)}
198- waitingTransport := newSnatchedCloseSignalTransport()
199- activeTransport := &closeTrackingTransport{}
200- d, sess, waiting, active := newAtomicReclaimFixture(t, clock, waitingTransport, activeTransport)
201-
202- waiting.sendMu.Lock()
203- locked := true
204- defer func() {
205- if locked {
206- waiting.sendMu.Unlock()
207- }
208- }()
209- done := make(chan struct{})
210- go func() {
211- token := sess.attachmentToken(waiting, waitingTransport)
212- d.handleSnatchedClientFrame(token, ports.Frame{
213- Type: ports.MsgInput,
214- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
215- })
216- close(done)
217- }()
218-
219- timer := awaitTestValue(t, clock.timers, "reclaim did not install its activation deadline")
220- require.Equal(t, detachNotifyTimeout, timer.duration)
221- timer.ch <- time.Time{}
222- awaitTestCompletion(t, done, "deadline did not release blocked reclaim")
223- require.Same(t, active, sess.client)
224- require.Same(t, active, sess.renderCoordinator().attachmentLease(active).attachment)
225- require.True(t, waitingTransport.Closed())
226-
227- waiting.sendMu.Unlock()
228- locked = false
229- d.attachmentCleanupWg.Wait()
230- require.Equal(t, attachmentDetached, sess.attachmentRole(waiting))
231- require.Equal(t, attachmentActive, sess.attachmentRole(active))
232-}
233-
234-func TestQuitInvalidatesPausedSnatchedFinalizerWithoutLatePanel(t *testing.T) {
235- waitingTransport := &closeTrackingTransport{}
236- activeTransport := &closeTrackingTransport{}
237- d, sess, waiting, active := newAtomicReclaimFixture(t, stubClock{}, waitingTransport, activeTransport)
238-
239- finalizerStarted := make(chan struct{})
240- releaseFinalizer := make(chan struct{})
241- d.afterDisplacedCleanupStarted = func() {
242- close(finalizerStarted)
243- <-releaseFinalizer
244- }
245- waitingToken := sess.attachmentToken(waiting, waitingTransport)
246- d.handleSnatchedClientFrame(waitingToken, ports.Frame{
247- Type: ports.MsgInput,
248- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
249- })
250- awaitTestCompletion(t, finalizerStarted, "displaced finalizer did not pause")
251-
252- activeToken := sess.attachmentToken(active, activeTransport)
253- require.Equal(t, attachmentSnatched, activeToken.role)
254- require.True(t, d.handleSnatchedClientFrame(activeToken, ports.Frame{
255- Type: ports.MsgInput,
256- Payload: ports.MarshalInput(ports.Input{Data: []byte{'q'}}),
257- }))
258- close(releaseFinalizer)
259- d.attachmentCleanupWg.Wait()
260-
261- require.Equal(t, attachmentDetached, sess.attachmentRole(active))
262- frames := activeTransport.Sends()
263- require.Len(t, frames, 1, "stale finalizer emitted output after quit")
264- require.Equal(t, ports.MsgDetached, frames[0].Type)
265-}
266-
267-func TestReclaimOrdersDisplacedPanelAfterAdmittedOldPaint(t *testing.T) {
268- d, sess, requester, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
269- requesterTransport := &closeTrackingTransport{}
270- requester.replaceTransport(requesterTransport)
271- d.attachCoordinator(sess, nil, requester, true)
272-
273- activeTransport := &closeTrackingTransport{}
274- active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: requester.size}
275- active.initOverlays()
276- initial, err := d.transitionAttachment(attachmentTransitionRequest{
277- target: sess, next: active,
278- expectedRole: attachmentDetached, targetRole: attachmentActive,
279- expectedTransport: active.transportSnapshot(), ready: true,
280- })
281- require.NoError(t, err)
282- d.deferAttachmentTransitionCleanups(initial)
283- d.attachmentCleanupWg.Wait()
284- baselineActiveFrames := len(activeTransport.Sends())
285-
286- paintAdmitted := make(chan struct{})
287- releasePaint := make(chan struct{})
288- d.afterRoleEffectAdmitted = func(token attachmentRoleToken) {
289- if token.ac == active && token.role == attachmentActive {
290- close(paintAdmitted)
291- <-releasePaint
292- }
293- }
294- activeToken := sess.attachmentToken(active, activeTransport)
295- paintDone := make(chan bool, 1)
296- go func() { paintDone <- d.firstPaintForTransition(activeToken) }()
297- awaitTestCompletion(t, paintAdmitted, "old active paint was not admitted")
298-
299- reclaimFrozen := make(chan struct{})
300- var frozenOnce sync.Once
301- d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
302- if action == "reclaim-snatched" {
303- frozenOnce.Do(func() { close(reclaimFrozen) })
304- }
305- }
306- reclaimDone := make(chan struct{})
307- go func() {
308- token := sess.attachmentToken(requester, requesterTransport)
309- d.handleSnatchedClientFrame(token, ports.Frame{
310- Type: ports.MsgInput,
311- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
312- })
313- close(reclaimDone)
314- }()
315- awaitTestCompletion(t, reclaimFrozen, "reclaim did not freeze role effects behind old paint")
316- close(releasePaint)
317- require.True(t, awaitTestValue(t, paintDone, "old paint did not finish"))
318- awaitTestCompletion(t, reclaimDone, "reclaim did not finish after old paint")
319- d.attachmentCleanupWg.Wait()
320-
321- frames := activeTransport.Sends()[baselineActiveFrames:]
322- require.NotEmpty(t, frames)
323- last := frames[len(frames)-1]
324- require.Equal(t, ports.MsgOutput, last.Type)
325- output, err := ports.UnmarshalOutput(last.Payload)
326- require.NoError(t, err)
327- require.Contains(t, string(output.Data), "Session snatched", "old paint overtook the displaced reset panel")
328- requireSingleOwnerAndLease(t, sess, requester, active)
329-}
330-
331-func TestLaterReclaimMakesPausedFirstPaintStale(t *testing.T) {
332- d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
333- firstTransport := &closeTrackingTransport{}
334- first.replaceTransport(firstTransport)
335- d.attachCoordinator(sess, nil, first, true)
336-
337- secondTransport := &closeTrackingTransport{}
338- second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
339- second.initOverlays()
340- initial, err := d.transitionAttachment(attachmentTransitionRequest{
341- target: sess, next: second,
342- expectedRole: attachmentDetached, targetRole: attachmentActive,
343- expectedTransport: second.transportSnapshot(), ready: true,
344- })
345- require.NoError(t, err)
346- d.deferAttachmentTransitionCleanups(initial)
347- d.attachmentCleanupWg.Wait()
348- baselineFirstFrames := len(firstTransport.Sends())
349-
350- firstPaintPaused := make(chan struct{})
351- releaseFirstPaint := make(chan struct{})
352- d.beforeReclaimFirstPaint = func(token attachmentRoleToken) {
353- if token.ac != first {
354- return
355- }
356- close(firstPaintPaused)
357- <-releaseFirstPaint
358- }
359- firstDone := make(chan struct{})
360- go func() {
361- token := sess.attachmentToken(first, firstTransport)
362- d.handleSnatchedClientFrame(token, ports.Frame{
363- Type: ports.MsgInput,
364- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
365- })
366- close(firstDone)
367- }()
368- awaitTestCompletion(t, firstPaintPaused, "first reclaim did not pause before first paint")
369- require.Same(t, first, sess.client, "first reclaim was not committed before its paint")
370-
371- secondToken := sess.attachmentToken(second, secondTransport)
372- require.Equal(t, attachmentSnatched, secondToken.role)
373- d.handleSnatchedClientFrame(secondToken, ports.Frame{
374- Type: ports.MsgInput,
375- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
376- })
377- require.Same(t, second, sess.client)
378- close(releaseFirstPaint)
379- awaitTestCompletion(t, firstDone, "stale first paint did not return")
380- d.attachmentCleanupWg.Wait()
381-
382- newFirstFrames := firstTransport.Sends()[baselineFirstFrames:]
383- require.Len(t, newFirstFrames, 1, "stale first paint emitted after a later reclaim")
384- require.Equal(t, ports.MsgOutput, newFirstFrames[0].Type)
385- output, err := ports.UnmarshalOutput(newFirstFrames[0].Payload)
386- require.NoError(t, err)
387- require.Contains(t, string(output.Data), "Session snatched")
388- requireSingleOwnerAndLease(t, sess, first, second)
389-}
390-
391-func TestAnyWaiterCanReclaimAndConcurrentRequestsKeepSingleOwnerLease(t *testing.T) {
392- d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
393- firstTransport := &closeTrackingTransport{}
394- first.replaceTransport(firstTransport)
395- d.attachCoordinator(sess, nil, first, true)
396-
397- secondTransport := &closeTrackingTransport{}
398- second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: first.size}
399- second.initOverlays()
400- toSecond, err := d.transitionAttachment(attachmentTransitionRequest{
401- target: sess, next: second,
402- expectedRole: attachmentDetached, targetRole: attachmentActive,
403- expectedTransport: second.transportSnapshot(), ready: true,
404- })
405- require.NoError(t, err)
406- d.deferAttachmentTransitionCleanups(toSecond)
407- d.attachmentCleanupWg.Wait()
408-
409- thirdTransport := &closeTrackingTransport{}
410- third := &attachedClient{tr: thirdTransport, output: newOutputStateStream(), size: first.size}
411- third.initOverlays()
412- toThird, err := d.transitionAttachment(attachmentTransitionRequest{
413- target: sess, next: third,
414- expectedRole: attachmentDetached, targetRole: attachmentActive,
415- expectedTransport: third.transportSnapshot(), ready: true,
416- })
417- require.NoError(t, err)
418- d.deferAttachmentTransitionCleanups(toThird)
419- d.attachmentCleanupWg.Wait()
420- requireSingleOwnerAndLease(t, sess, first, second, third)
421-
422- firstToken := sess.attachmentToken(first, firstTransport)
423- secondToken := sess.attachmentToken(second, secondTransport)
424- require.Equal(t, attachmentSnatched, firstToken.role)
425- require.Equal(t, attachmentSnatched, secondToken.role)
426- start := make(chan struct{})
427- done := make(chan struct{}, 2)
428- for _, token := range []attachmentRoleToken{firstToken, secondToken} {
429- go func() {
430- <-start
431- d.handleSnatchedClientFrame(token, ports.Frame{
432- Type: ports.MsgInput,
433- Payload: ports.MarshalInput(ports.Input{Data: []byte{'r'}}),
434- })
435- done <- struct{}{}
436- }()
437- }
438- close(start)
439- awaitTestCompletion(t, done, "first concurrent reclaim did not return")
440- awaitTestCompletion(t, done, "second concurrent reclaim did not return")
441- d.attachmentCleanupWg.Wait()
442-
443- requireSingleOwnerAndLease(t, sess, first, second, third)
444-}
D internal/usecase/daemon/snatched_render.go +0 -169
@@ -1,169 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5-
6- "github.com/bnema/vev/internal/domain"
7- themeui "github.com/bnema/vev/internal/usecase/theme"
8- "github.com/bnema/vev/internal/usecase/ui"
9- "github.com/bnema/vev/pkg/renderer"
10-)
11-
12-var snatchedModal = ui.Modal{
13- FixedWidth: 42, FixedHeight: 7,
14- Title: " Session snatched ", Anchor: domain.AnchorCenter,
15-}
16-
17-func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string) renderer.Frame {
18- width, height := max(size.Cols, 1), max(size.Rows, 1)
19- frame := renderer.NewFrame(width, height)
20- ui.FillRect(frame, domain.Rect{Width: width, Height: height}, renderer.Cell{Rune: ' ', Style: styles.PickerBase})
21- if width < snatchedModal.FixedWidth || height < snatchedModal.FixedHeight {
22- drawSnatchedCompact(frame, styles, feedback)
23- return frame
24- }
25-
26- presentation := snatchedModal.Resolve(domain.Size{Cols: width, Rows: height})
27- inner := snatchedModal.CompositePresentation(frame, presentation, styles.BorderMuted, styles.PickerBase)
28- message := "This session is now active elsewhere."
29- if feedback != "" {
30- message = feedback
31- }
32- drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase)
33- drawCenteredSegments(frame, inner, inner.Y+3, []textSegment{
34- {" r ", styles.HintKey},
35- {" Resume here", styles.PickerBase},
36- {" ", styles.PickerBase},
37- {" q ", styles.HintKey},
38- {" / ", styles.PickerDescription},
39- {" Esc ", styles.HintKey},
40- {" Quit", styles.PickerBase},
41- })
42- return frame
43-}
44-
45-func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback string) {
46- status := "Session snatched"
47- if feedback != "" {
48- status = "Session unavailable"
49- }
50- bounds := domain.Rect{Width: frame.Width, Height: frame.Height}
51- startY := max((frame.Height-3)/2, 0)
52- drawCenteredLine(frame, bounds, startY, status, styles.PickerBase)
53- drawCenteredSegments(frame, bounds, startY+1, []textSegment{
54- {" r ", styles.HintKey},
55- {" Resume", styles.PickerBase},
56- })
57- drawCenteredSegments(frame, bounds, startY+2, []textSegment{
58- {" q ", styles.HintKey},
59- {" Quit", styles.PickerBase},
60- })
61-}
62-
63-// textSegment is one differently-styled run within a centered hint line.
64-type textSegment struct {
65- text string
66- style renderer.Style
67-}
68-
69-func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) {
70- drawCenteredSegments(frame, bounds, y, []textSegment{{text, style}})
71-}
72-
73-// drawCenteredSegments draws consecutive differently-styled segments as one
74-// logical line, centered as a whole within bounds.
75-func drawCenteredSegments(frame renderer.Frame, bounds domain.Rect, y int, segments []textSegment) {
76- total := 0
77- for _, seg := range segments {
78- for _, r := range seg.text {
79- total += renderer.RuneWidth(r)
80- }
81- }
82- x := bounds.X + max((bounds.Width-total)/2, 0)
83- limit := bounds.X + bounds.Width
84- for _, seg := range segments {
85- ui.DrawText(frame, x, y, limit, seg.text, seg.style)
86- for _, r := range seg.text {
87- x += renderer.RuneWidth(r)
88- }
89- }
90-}
91-
92-var errSnatchedOutputStale = errors.New("snatched output role changed")
93-
94-// sendSnatchedPanel serializes a dependency-free panel reset onto one exact
95-// attachment role and transport lifetime. It does not inspect session state or
96-// capture pane content.
97-func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string, effects ...*roleEffectTicket) error {
98- if expected.transport == nil {
99- return errors.New("snatched client transport is nil")
100- }
101- var effect *roleEffectTicket
102- if len(effects) != 0 {
103- effect = effects[0]
104- }
105- tr, err := d.boundedSendWith(expected.transport, func() error {
106- ac.sendMu.Lock()
107- defer ac.sendMu.Unlock()
108- if !ac.transportSnapshotCurrent(expected) {
109- return errTransportReplaced
110- }
111- if ac.roleGeneration.Load() != generation {
112- return errSnatchedOutputStale
113- }
114-
115- applied := ac.getAppliedTheme()
116- frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback)
117- ac.rebaseOutput()
118- var data []byte
119- var cursor cursorCandidate
120- var preparedANSI *preparedOutput
121- var preparedScreen *preparedStructuredOutput
122- var sendErr error
123- if ac.proxied {
124- screenOutput := ac.ensureScreenOutput()
125- if screenOutput == nil {
126- return errors.New("proxied attachment has no structured output stream")
127- }
128- preparedScreen, sendErr = screenOutput.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, cursorOut{hidden: true}, true, ac.echoAck.Load())
129- if sendErr == nil {
130- data = preparedScreen.data
131- }
132- } else {
133- preparedANSI, sendErr = ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true)
134- if sendErr == nil {
135- cursor = ac.prepareCursorTail(cursorOut{hidden: true}, true)
136- data = append(append([]byte(nil), preparedANSI.data...), cursor.data...)
137- }
138- }
139- if sendErr != nil {
140- return sendErr
141- }
142- if effect != nil && !effect.beginTransportSend(expected) {
143- return errAttachmentTransition
144- }
145- if ac.proxied {
146- sendErr = preparedScreen.send(expected.transport.Send)
147- } else {
148- sendErr = preparedANSI.send(data, ac.echoAck.Load(), expected.transport.Send)
149- }
150- if effect != nil {
151- if sendErr != nil {
152- effect.reportTransportFailure(expected)
153- }
154- effect.endTransportSend()
155- }
156- if sendErr == nil {
157- if ac.proxied {
158- ac.lastCursor = cursorOut{hidden: true}
159- } else {
160- ac.lastCursor = cursor.next
161- }
162- }
163- return sendErr
164- })
165- if errors.Is(err, errSendTimedOut) {
166- _ = ac.closeCapturedTransport(tr)
167- }
168- return err
169-}
D internal/usecase/daemon/snatched_render_test.go +0 -340
@@ -1,340 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "strings"
6- "sync"
7- "testing"
8- "time"
9-
10- "github.com/stretchr/testify/require"
11-
12- "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14- themeui "github.com/bnema/vev/internal/usecase/theme"
15- "github.com/bnema/vev/pkg/renderer"
16-)
17-
18-func TestSnatchedPanelFrame(t *testing.T) {
19- styles := themeui.Styles{
20- PickerBase: renderer.Style{Foreground: 1},
21- PickerSelection: renderer.Style{Foreground: 2},
22- BorderMuted: renderer.Style{Foreground: 3},
23- HintKey: renderer.Style{Foreground: 4},
24- PickerDescription: renderer.Style{Foreground: 5},
25- }
26- tests := []struct {
27- name string
28- size domain.Size
29- feedback string
30- wantSize domain.Size
31- want []string
32- wantKeys string
33- wantSep string
34- wantBounds domain.Rect
35- compact bool
36- }{
37- {
38- name: "centered bordered panel",
39- size: domain.Size{Cols: 80, Rows: 24},
40- wantSize: domain.Size{Cols: 80, Rows: 24},
41- want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
42- wantKeys: " r q Esc ",
43- wantSep: " / ",
44- wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
45- },
46- {
47- name: "compact fallback",
48- size: domain.Size{Cols: 24, Rows: 4},
49- wantSize: domain.Size{Cols: 24, Rows: 4},
50- want: []string{"Session snatched", "r Resume", "q Quit"},
51- wantKeys: " r q ",
52- compact: true,
53- },
54- {
55- name: "compact unavailable feedback",
56- size: domain.Size{Cols: 24, Rows: 4},
57- feedback: "Session is no longer available.",
58- wantSize: domain.Size{Cols: 24, Rows: 4},
59- want: []string{"Session unavailable", "r Resume", "q Quit"},
60- wantKeys: " r q ",
61- compact: true,
62- },
63- {
64- name: "bordered unavailable feedback",
65- size: domain.Size{Cols: 80, Rows: 24},
66- feedback: "Session is no longer available.",
67- wantSize: domain.Size{Cols: 80, Rows: 24},
68- want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
69- wantKeys: " r q Esc ",
70- wantSep: " / ",
71- wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
72- },
73- {
74- name: "zero dimensions clamp",
75- size: domain.Size{},
76- wantSize: domain.Size{Cols: 1, Rows: 1},
77- compact: true,
78- },
79- {
80- name: "negative dimensions clamp",
81- size: domain.Size{Cols: -10, Rows: -20},
82- wantSize: domain.Size{Cols: 1, Rows: 1},
83- compact: true,
84- },
85- }
86-
87- for _, tt := range tests {
88- t.Run(tt.name, func(t *testing.T) {
89- frame := snatchedPanelFrame(tt.size, styles, tt.feedback)
90- require.Equal(t, tt.wantSize.Cols, frame.Width)
91- require.Equal(t, tt.wantSize.Rows, frame.Height)
92- require.NoError(t, frame.Validate())
93-
94- text := snatchedFrameText(frame)
95- for _, want := range tt.want {
96- require.Contains(t, text, want)
97- }
98- require.Equal(t, tt.wantKeys, snatchedStyledText(frame, styles.HintKey), "keycap badges must use the HintKey style")
99- require.Equal(t, tt.wantSep, snatchedStyledText(frame, styles.PickerDescription), "hint separator must use the muted description style")
100-
101- if tt.compact {
102- require.NotContains(t, text, "┌")
103- require.NotContains(t, text, "┘")
104- return
105- }
106-
107- require.Equal(t, tt.wantBounds, snatchedModal.Resolve(tt.wantSize).Bounds)
108- topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y)
109- bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1)
110- require.Equal(t, '┌', topLeft.Rune)
111- require.Equal(t, '┘', bottomRight.Rune)
112- require.True(t, topLeft.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style")
113- require.True(t, bottomRight.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style")
114- requireFreshSnatchedBackground(t, frame, tt.wantBounds, styles.PickerBase)
115- })
116- }
117-}
118-
119-func TestSendSnatchedPanelRebasesOutput(t *testing.T) {
120- d := newTestDaemon(t, nil, stubClock{})
121- tr := &closeTrackingTransport{}
122- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
123- ac.output.next = 7
124- ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
125- ac.roleGeneration.Store(1)
126-
127- expected := ac.transportSnapshot()
128- require.NoError(t, d.sendSnatchedPanel(ac, expected, 1, ""))
129-
130- frames := tr.Sends()
131- require.Len(t, frames, 1)
132- require.Equal(t, ports.MsgOutput, frames[0].Type)
133- out, err := ports.UnmarshalOutput(frames[0].Payload)
134- require.NoError(t, err)
135- require.Zero(t, out.BaseStateNum)
136- require.Equal(t, uint64(8), out.NewStateNum)
137- require.Contains(t, string(out.Data), "Session snatched")
138- require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden")
139-}
140-
141-func TestSendSnatchedPanelUsesStructuredSnapshotForProxied(t *testing.T) {
142- d := newTestDaemon(t, nil, stubClock{})
143- tr := &closeTrackingTransport{}
144- ac := &attachedClient{tr: tr, output: newOutputStateStream(), proxied: true, size: domain.Size{Cols: 80, Rows: 24}}
145- ac.output.next = 7
146- ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
147- ac.roleGeneration.Store(1)
148-
149- require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""))
150- frames := tr.Sends()
151- require.Len(t, frames, 1)
152- require.Equal(t, ports.MsgScreenUpdate, frames[0].Type)
153- update, err := ports.UnmarshalScreenUpdate(frames[0].Payload)
154- require.NoError(t, err)
155- require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind)
156- require.Zero(t, update.BaseStateNum)
157- require.Equal(t, uint64(8), update.NewStateNum, "snatched panel must advance the shared output state")
158- require.False(t, update.Cursor.Visible, "structured snatched panel must hide the cursor semantically")
159- require.Len(t, update.Spans, ac.size.Rows, "structured snapshots must carry every full row")
160-}
161-
162-func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) {
163- d := newTestDaemon(t, nil, stubClock{})
164- failed := cacheFailTransport{}
165- ac := &attachedClient{
166- tr: failed,
167- output: newOutputStateStream(),
168- size: domain.Size{Cols: 80, Rows: 24},
169- lastCursor: cursorOut{valid: true, row: 2, col: 3, style: 1, hasStyle: true},
170- }
171- ac.output.next = 7
172- ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
173- ac.roleGeneration.Store(1)
174- beforeCursor := ac.lastCursor
175-
176- require.Error(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""))
177- require.Equal(t, beforeCursor, ac.lastCursor)
178- require.Equal(t, uint64(7), ac.output.next)
179-
180- healthy := &closeTrackingTransport{}
181- ac.replaceTransport(healthy)
182- require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""))
183- require.Equal(t, cursorOut{valid: true, hidden: true}, ac.lastCursor)
184- require.Equal(t, uint64(8), ac.output.next)
185- frames := healthy.Sends()
186- require.Len(t, frames, 1)
187- out, err := ports.UnmarshalOutput(frames[0].Payload)
188- require.NoError(t, err)
189- require.Zero(t, out.BaseStateNum)
190- require.Equal(t, uint64(8), out.NewStateNum)
191-}
192-
193-func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) {
194- clock := &signalClock{timers: make(chan *signalTimer, 1)}
195- d := newTestDaemon(t, nil, clock)
196- old := newBlockingSnatchedTransport()
197- fresh := &closeTrackingTransport{}
198- ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
199- ac.roleGeneration.Store(1)
200- expected := ac.transportSnapshot()
201-
202- result := make(chan error, 1)
203- go func() {
204- result <- d.sendSnatchedPanel(ac, expected, 1, "")
205- }()
206-
207- timer := awaitTestValue(t, clock.timers, "snatched send did not install its timeout")
208- awaitTestCompletion(t, old.sendEntered, "snatched send did not block in the captured transport")
209- ac.replaceTransport(fresh)
210- timer.ch <- time.Time{}
211-
212- require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut)
213- defer func() { _ = old.Close() }() // Release the blocked worker if the assertion below fails.
214- require.True(t, old.Closed(), "timed-out captured transport was not closed")
215- require.False(t, fresh.Closed(), "replacement transport must remain open")
216-
217- sendMuReleased := make(chan uint64, 1)
218- go func() {
219- ac.sendMu.Lock()
220- generation := ac.roleGeneration.Load()
221- ac.sendMu.Unlock()
222- sendMuReleased <- generation
223- }()
224- require.Equal(t, uint64(1), awaitTestValue(t, sendMuReleased, "closing the timed-out transport did not release sendMu"))
225-}
226-
227-type blockingSnatchedTransport struct {
228- sendEntered chan struct{}
229- closed chan struct{}
230- sendOnce sync.Once
231- closeOnce sync.Once
232-}
233-
234-func newBlockingSnatchedTransport() *blockingSnatchedTransport {
235- return &blockingSnatchedTransport{
236- sendEntered: make(chan struct{}),
237- closed: make(chan struct{}),
238- }
239-}
240-
241-func (t *blockingSnatchedTransport) Send(ports.Frame) error {
242- t.sendOnce.Do(func() { close(t.sendEntered) })
243- <-t.closed
244- return errors.New("transport closed")
245-}
246-
247-func (*blockingSnatchedTransport) Recv() (ports.Frame, error) {
248- return ports.Frame{}, errors.New("not implemented")
249-}
250-
251-func (t *blockingSnatchedTransport) Close() error {
252- t.closeOnce.Do(func() { close(t.closed) })
253- return nil
254-}
255-
256-func (t *blockingSnatchedTransport) Closed() bool {
257- select {
258- case <-t.closed:
259- return true
260- default:
261- return false
262- }
263-}
264-
265-func TestSendSnatchedPanelRejectsStaleTransport(t *testing.T) {
266- d := newTestDaemon(t, nil, stubClock{})
267- old := &closeTrackingTransport{}
268- fresh := &closeTrackingTransport{}
269- ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
270- ac.roleGeneration.Store(1)
271- stale := ac.transportSnapshot()
272- ac.replaceTransport(fresh)
273-
274- require.ErrorIs(t, d.sendSnatchedPanel(ac, stale, 1, ""), errTransportReplaced)
275- require.Empty(t, old.Sends())
276- require.Empty(t, fresh.Sends())
277-}
278-
279-func TestSendSnatchedPanelRejectsStaleRole(t *testing.T) {
280- d := newTestDaemon(t, nil, stubClock{})
281- tr := &closeTrackingTransport{}
282- ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
283- ac.roleGeneration.Store(2)
284- expected := ac.transportSnapshot()
285-
286- require.ErrorIs(t, d.sendSnatchedPanel(ac, expected, 1, ""), errSnatchedOutputStale)
287- require.Empty(t, tr.Sends())
288-}
289-
290-func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel domain.Rect, style renderer.Style) {
291- t.Helper()
292- for y := range frame.Height {
293- for x := range frame.Width {
294- if x >= panel.X && x < panel.X+panel.Width && y >= panel.Y && y < panel.Y+panel.Height {
295- continue
296- }
297- cell := frame.At(x, y)
298- require.Equal(t, ' ', cell.Rune, "cell (%d,%d) outside panel retained content", x, y)
299- require.True(t, cell.Style.Equal(style), "cell (%d,%d) outside panel did not use captured base style", x, y)
300- }
301- }
302-}
303-
304-// snatchedStyledText concatenates, in reading order, the runes of every cell
305-// drawn with exactly the given style, so tests can assert which text runs a
306-// style covers without depending on layout coordinates.
307-func snatchedStyledText(frame renderer.Frame, style renderer.Style) string {
308- var out strings.Builder
309- for y := 0; y < frame.Height; y++ {
310- for _, cell := range frame.Row(y) {
311- if cell.Continuation || !cell.Style.Equal(style) {
312- continue
313- }
314- r := cell.Rune
315- if r == 0 {
316- r = ' '
317- }
318- out.WriteRune(r)
319- }
320- }
321- return out.String()
322-}
323-
324-func snatchedFrameText(frame renderer.Frame) string {
325- var out strings.Builder
326- for y := 0; y < frame.Height; y++ {
327- for _, cell := range frame.Row(y) {
328- if cell.Continuation {
329- continue
330- }
331- r := cell.Rune
332- if r == 0 {
333- r = ' '
334- }
335- out.WriteRune(r)
336- }
337- out.WriteByte('\n')
338- }
339- return out.String()
340-}
D internal/usecase/daemon/stalled_attachment_test.go +0 -89
@@ -1,89 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5- "time"
6-
7- "github.com/stretchr/testify/mock"
8- "github.com/stretchr/testify/require"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/internal/ports"
12- portsmocks "github.com/bnema/vev/internal/ports/mocks"
13-)
14-
15-// TestFinishAttachWaitsForAdmittedOldRender verifies role-effect
16-// linearization: an old render already admitted and blocked in Send completes
17-// before replacement publication can change its capability.
18-func TestFinishAttachWaitsForAdmittedOldRender(t *testing.T) {
19- pty, releasePTY := newBlockingPTY(t)
20- defer releasePTY()
21- clock := newCoordinatorMockClock(t, 4)
22- d := newTestDaemon(t, newFactory(t, pty), clock.clock)
23-
24- oldTransport := portsmocks.NewMockTransport(t)
25- oldSendEntered := make(chan struct{})
26- releaseOldSend := make(chan struct{})
27- oldTransport.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {
28- close(oldSendEntered)
29- <-releaseOldSend
30- return nil
31- }).Once()
32- oldTransport.EXPECT().Send(mock.Anything).Return(nil).Maybe()
33- oldTransport.EXPECT().Close().Return(nil).Maybe()
34-
35- hello := ports.Hello{
36- Version: ports.ProtocolVersion,
37- Intent: ports.IntentNew,
38- Name: "stalled-attachment",
39- Size: domain.Size{Cols: 80, Rows: 24},
40- TermEnv: "xterm-256color",
41- }
42- sess, old, err := d.route(hello, oldTransport)
43- require.NoError(t, err)
44- rc := sess.renderCoordinator()
45- lease := rc.attachmentLease(old)
46- require.True(t, rc.markAttachmentReady(lease))
47-
48- rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
49- awaitCoordinatorScheduledTimer(t, clock).ch <- time.Time{}
50- <-oldSendEntered
51-
52- replacement := portsmocks.NewMockTransport(t)
53- replacement.EXPECT().Send(mock.Anything).Return(nil).Maybe()
54- replacement.EXPECT().Close().Return(nil).Maybe()
55- type attachResult struct {
56- client *attachedClient
57- err error
58- }
59- attached := make(chan attachResult, 1)
60- go func() {
61- d.mu.Lock()
62- ac, err := d.finishAttach(sess, replacement, hello.Size, terminalEnv{}, hello)
63- attached <- attachResult{client: ac, err: err}
64- }()
65-
66- select {
67- case <-attached:
68- t.Fatal("replacement published before the admitted old render completed")
69- case <-time.After(20 * time.Millisecond):
70- }
71- close(releaseOldSend)
72- select {
73- case result := <-attached:
74- require.NoError(t, result.err)
75- require.Same(t, replacement, result.client.transport())
76- case <-time.After(time.Second):
77- t.Fatal("replacement did not publish after the admitted render completed")
78- }
79-
80- d.attachmentCleanupWg.Wait()
81- require.Same(t, oldTransport, old.transport(), "the displaced attachment remains snatched")
82- replacement.AssertNotCalled(t, "Close")
83-
84- // Retire the session before mock cleanup so asynchronous terminal Detached
85- // notification cannot race the clock's C expectation after the test returns.
86- releasePTY()
87- awaitTestCompletion(t, d.done, "session did not retire after its final PTY closed")
88- d.waitNotifies()
89-}

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