detached e1f87b8

Merge pull request #150 from bnema/fix/remote-resume-token-race

fix(daemon): recover remote resume before parking
commit
e1f87b8b4834487b2e1f6e78353bd2bc5d4c9226
author
bnema · · 2026-07-30 05:00 UTC
committer
GitHub · · 2026-07-30 05:00 UTC
merge of
386ed92 + c668837 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +1595 -18
M internal/usecase/daemon/client_transport_errors.go +46 -9
@@ -5,11 +5,25 @@ import "github.com/bnema/vev/internal/ports"
55 // clientGone detaches ac if it is still the session's current client. The
66 // session remains registered and headless after the client is gone.
77 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
8- if failed != nil && !ac.currentTransportIs(failed) {
9- return // stale connection loop; a newer transport owns this client
10- }
11- if !d.detachIfCurrent(sess, ac) {
12- return // already displaced by a newer client; nothing to do
8+ expected := transportSnapshot{}
9+ if failed != nil {
10+ expected = ac.transportSnapshot()
11+ if expected.transport != failed {
12+ return // stale connection loop; a newer transport owns this client
13+ }
14+ }
15+ // Advertise parking before detach so IntentResume never observes both the
16+ // live seat and parking/parked registries empty for a still-valid token.
17+ var parkingToken uint64
18+ if !explicit {
19+ parkingToken = d.markParkingInFlight(sess, ac)
20+ }
21+ if d.beforeClientGoneDetach != nil {
22+ d.beforeClientGoneDetach()
23+ }
24+ if !d.detachIfCurrentTransport(sess, ac, expected) {
25+ d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
26+ return // displaced, or the link was rebound after the precheck
1327 }
1428 d.finishClientGone(sess, ac, failed, explicit)
1529 }
@@ -20,7 +34,12 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo
2034 }
2135 token.effect.bindActionEnd(d, "detach")
2236 token.effect.End()
37+ var parkingToken uint64
38+ if !explicit {
39+ parkingToken = d.markParkingInFlight(token.sess, token.ac)
40+ }
2341 if !d.detachIfRoleCurrent(token) {
42+ d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
2443 return false
2544 }
2645 d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)
@@ -28,6 +47,9 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo
2847 }
2948
3049 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
50+ if d.afterClientGoneDetach != nil {
51+ d.afterClientGoneDetach()
52+ }
3153 if rc := sess.renderCoordinator(); rc != nil {
3254 rc.noteDetach(ac)
3355 }
@@ -48,6 +70,10 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
4870 d.log.Info("client parked", "session", sess.name)
4971 return
5072 }
73+ // Explicit winners (and non-explicit park failures) must drop any same-
74+ // attachment parking marker left by a raced non-explicit teardown so
75+ // IntentResume waiters are not stranded on a never-published park.
76+ d.clearParkingInFlight(ac.resumeToken, ac)
5177
5278 d.resetScreenDefaultColors(sess)
5379 ac.clearPreviousSession()
@@ -65,12 +91,19 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
6591 // detachOnSendError drops a client whose transport failed, leaving the session
6692 // registered and headless.
6793 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
68- if failed != nil && !ac.currentTransportIs(failed) {
69- return
70- }
71- if d.detachIfCurrent(sess, ac) {
94+ expected := transportSnapshot{}
95+ if failed != nil {
96+ expected = ac.transportSnapshot()
97+ if expected.transport != failed {
98+ return
99+ }
100+ }
101+ parkingToken := d.markParkingInFlight(sess, ac)
102+ if d.detachIfCurrentTransport(sess, ac, expected) {
72103 d.finishSendErrorDetach(sess, ac, failed)
104+ return
73105 }
106+ d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
74107 }
75108
76109 func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
@@ -81,9 +114,12 @@ func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed po
81114 if failed != token.transport.transport {
82115 return
83116 }
117+ parkingToken := d.markParkingInFlight(token.sess, token.ac)
84118 if d.detachIfRoleCurrentUntil(token, done) {
85119 d.finishSendErrorDetach(token.sess, token.ac, failed)
120+ return
86121 }
122+ d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
87123 }
88124
89125 // reserveRoleSendErrorCleanup accounts for cleanup before End releases the
@@ -117,6 +153,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
117153 d.log.Warn("parked client after send error", "session", sess.name)
118154 return
119155 }
156+ d.clearParkingInFlight(ac.resumeToken, ac)
120157 d.resetScreenDefaultColors(sess)
121158 ac.clearPreviousSession()
122159 _ = ac.closeCapturedTransport(failed)
@@ -5,11 +5,25 @@ import "github.com/bnema/vev/internal/ports"
5 // clientGone detaches ac if it is still the session's current client. The5 // clientGone detaches ac if it is still the session's current client. The
6 // session remains registered and headless after the client is gone.6 // session remains registered and headless after the client is gone.
7 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {7 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
8- if failed != nil && !ac.currentTransportIs(failed) {8+ expected := transportSnapshot{}
9- return // stale connection loop; a newer transport owns this client9+ if failed != nil {
10- }10+ expected = ac.transportSnapshot()
11- if !d.detachIfCurrent(sess, ac) {11+ if expected.transport != failed {
12- return // already displaced by a newer client; nothing to do12+ return // stale connection loop; a newer transport owns this client
13+ }
14+ }
15+ // Advertise parking before detach so IntentResume never observes both the
16+ // live seat and parking/parked registries empty for a still-valid token.
17+ var parkingToken uint64
18+ if !explicit {
19+ parkingToken = d.markParkingInFlight(sess, ac)
20+ }
21+ if d.beforeClientGoneDetach != nil {
22+ d.beforeClientGoneDetach()
23+ }
24+ if !d.detachIfCurrentTransport(sess, ac, expected) {
25+ d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
26+ return // displaced, or the link was rebound after the precheck
13 }27 }
14 d.finishClientGone(sess, ac, failed, explicit)28 d.finishClientGone(sess, ac, failed, explicit)
15 }29 }
@@ -20,7 +34,12 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo
20 }34 }
21 token.effect.bindActionEnd(d, "detach")35 token.effect.bindActionEnd(d, "detach")
22 token.effect.End()36 token.effect.End()
37+ var parkingToken uint64
38+ if !explicit {
39+ parkingToken = d.markParkingInFlight(token.sess, token.ac)
40+ }
23 if !d.detachIfRoleCurrent(token) {41 if !d.detachIfRoleCurrent(token) {
42+ d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
24 return false43 return false
25 }44 }
26 d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)45 d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)
@@ -28,6 +47,9 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo
28 }47 }
29 48
30 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {49 func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
50+ if d.afterClientGoneDetach != nil {
51+ d.afterClientGoneDetach()
52+ }
31 if rc := sess.renderCoordinator(); rc != nil {53 if rc := sess.renderCoordinator(); rc != nil {
32 rc.noteDetach(ac)54 rc.noteDetach(ac)
33 }55 }
@@ -48,6 +70,10 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
48 d.log.Info("client parked", "session", sess.name)70 d.log.Info("client parked", "session", sess.name)
49 return71 return
50 }72 }
73+ // Explicit winners (and non-explicit park failures) must drop any same-
74+ // attachment parking marker left by a raced non-explicit teardown so
75+ // IntentResume waiters are not stranded on a never-published park.
76+ d.clearParkingInFlight(ac.resumeToken, ac)
51 77
52 d.resetScreenDefaultColors(sess)78 d.resetScreenDefaultColors(sess)
53 ac.clearPreviousSession()79 ac.clearPreviousSession()
@@ -65,12 +91,19 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
65 // detachOnSendError drops a client whose transport failed, leaving the session91 // detachOnSendError drops a client whose transport failed, leaving the session
66 // registered and headless.92 // registered and headless.
67 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {93 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
68- if failed != nil && !ac.currentTransportIs(failed) {94+ expected := transportSnapshot{}
69- return95+ if failed != nil {
70- }96+ expected = ac.transportSnapshot()
71- if d.detachIfCurrent(sess, ac) {97+ if expected.transport != failed {
98+ return
99+ }
100+ }
101+ parkingToken := d.markParkingInFlight(sess, ac)
102+ if d.detachIfCurrentTransport(sess, ac, expected) {
72 d.finishSendErrorDetach(sess, ac, failed)103 d.finishSendErrorDetach(sess, ac, failed)
104+ return
73 }105 }
106+ d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
74 }107 }
75 108
76 func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {109 func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
@@ -81,9 +114,12 @@ func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed po
81 if failed != token.transport.transport {114 if failed != token.transport.transport {
82 return115 return
83 }116 }
117+ parkingToken := d.markParkingInFlight(token.sess, token.ac)
84 if d.detachIfRoleCurrentUntil(token, done) {118 if d.detachIfRoleCurrentUntil(token, done) {
85 d.finishSendErrorDetach(token.sess, token.ac, failed)119 d.finishSendErrorDetach(token.sess, token.ac, failed)
120+ return
86 }121 }
122+ d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
87 }123 }
88 124
89 // reserveRoleSendErrorCleanup accounts for cleanup before End releases the125 // reserveRoleSendErrorCleanup accounts for cleanup before End releases the
@@ -117,6 +153,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
117 d.log.Warn("parked client after send error", "session", sess.name)153 d.log.Warn("parked client after send error", "session", sess.name)
118 return154 return
119 }155 }
156+ d.clearParkingInFlight(ac.resumeToken, ac)
120 d.resetScreenDefaultColors(sess)157 d.resetScreenDefaultColors(sess)
121 ac.clearPreviousSession()158 ac.clearPreviousSession()
122 _ = ac.closeCapturedTransport(failed)159 _ = ac.closeCapturedTransport(failed)
M internal/usecase/daemon/daemon.go +66 -3
@@ -108,6 +108,11 @@ type Daemon struct {
108108 // with Wait.
109109 notifies []chan struct{}
110110 parked map[uint64]*parkedAttachment
111+ // parking tracks resume-capable attachments from before detach clears the
112+ // live seat until parkAttachment publishes the token into parked.
113+ // IntentResume waits on the matching entry instead of treating the live
114+ // credential as unknown across that gap.
115+ parking map[uint64]*parkingAttachment
111116
112117 attnMu sync.Mutex
113118 animFrame int
@@ -172,6 +177,32 @@ type Daemon struct {
172177 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
173178 // attachment gate but before it checks session ownership.
174179 afterDetachRoleEffectsFrozen func()
180+ // beforeClientGoneDetach pauses clientGone after the stale-transport
181+ // precheck and before exact transport/incarnation detach validation.
182+ beforeClientGoneDetach func()
183+ // beforeMarkParkingInFlight pauses resumeLiveAttachment after the live
184+ // credential matches and before markParkingInFlight, so tests can win
185+ // explicit detach or session removal before late marker publication.
186+ beforeMarkParkingInFlight func()
187+ // afterClientGoneDetach pauses finishClientGone after detach won and before
188+ // parkAttachment. The parking-in-flight marker must already be published.
189+ afterClientGoneDetach func()
190+ // afterResumeLiveDetach pauses resumeLiveAttachment after detach won and
191+ // before parkAttachment. The parking-in-flight marker must already be
192+ // published so a concurrent same-token resume can wait the gap.
193+ afterResumeLiveDetach func()
194+ // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a
195+ // matching in-flight entry and before it blocks on done.
196+ afterParkingWaitArmed func()
197+ // afterSnatchedUnrouteBeforePark pauses parkSnatchedAttachment and
198+ // cleanupInterruptedSnatchedAttachment after exact snatched ownership is
199+ // removed and before parkAttachmentAs. The parking-in-flight marker must
200+ // already be published so a concurrent same-token resume can wait the gap.
201+ afterSnatchedUnrouteBeforePark func()
202+ // beforeResumeParkedSendMu pauses resumeParked after the initial parked
203+ // lookup and before the attachment send lock, so tests can consume or
204+ // replace the credential while the handshake waits.
205+ beforeResumeParkedSendMu func()
175206 // afterDisplacedCleanupStarted observes deferred displaced cleanup before it
176207 // synchronizes with the attachment gate.
177208 afterDisplacedCleanupStarted func()
@@ -300,6 +331,23 @@ type parkedAttachment struct {
300331 doneOnce sync.Once
301332 }
302333
334+// parkingAttachment is the observable detach→park lifecycle for one resume
335+// credential. done closes when the token is published into parked or parking
336+// is abandoned.
337+type parkingAttachment struct {
338+ sess *session
339+ ac *attachedClient
340+ done chan struct{}
341+ doneOnce sync.Once
342+}
343+
344+func (p *parkingAttachment) closeDone() {
345+ if p == nil || p.done == nil {
346+ return
347+ }
348+ p.doneOnce.Do(func() { close(p.done) })
349+}
350+
303351 // session is a single multiplexed session. It owns one or more full-screen
304352
305353 type stoppedSession struct {
@@ -532,6 +580,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
532580 stopped: make(map[string]stoppedSession),
533581 creating: make(map[string]struct{}),
534582 parked: make(map[uint64]*parkedAttachment),
583+ parking: make(map[uint64]*parkingAttachment),
535584 paneProcessCtx: paneProcessCtx,
536585 paneProcessCancel: paneProcessCancel,
537586 ptys: ptys,
@@ -724,6 +773,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
724773 d.closeMoveLifecycles()
725774 d.mu.Lock()
726775 d.closing = true
776+ d.purgeAllParkingLocked()
727777 parkedRetirements := d.purgeAllParkedLocked()
728778 snapshot := d.sessionsSnapshotLocked()
729779 empty := len(snapshot) == 0
@@ -1171,11 +1221,24 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
11711221 // A non-zero token is an authoritative resume credential. If it is unknown,
11721222 // expired, or raced with lifecycle teardown, fail closed instead of routing
11731223 // the Hello as an ordinary attach that could create or replace ownership.
1224+ // The one legitimate pre-park race is a still-active same-client credential
1225+ // for the requested session; hand that into the park/resume lifecycle.
11741226 if h.ResumeToken != 0 {
11751227 d.mu.Lock()
11761228 parkedAtStart := d.parked[h.ResumeToken]
11771229 d.mu.Unlock()
11781230 if parkedAtStart == nil {
1231+ if sess, ac, ok, err := d.resumeLiveAttachment(h, tr, sz); err != nil {
1232+ if errors.Is(err, errResumeTokenLifecycleRace) {
1233+ // Live recovery parked then lost a competing resumeParked
1234+ // race; keep the fail-closed wire response instead of
1235+ // leaking the internal sentinel to handleHello as ErrInternal.
1236+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1237+ }
1238+ return nil, nil, err
1239+ } else if ok {
1240+ return sess, ac, nil
1241+ }
11791242 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
11801243 }
11811244 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
@@ -1185,9 +1248,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
11851248 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
11861249 } else if errors.Is(err, errResumeTokenLifecycleRace) {
11871250 // The parked entry was replaced while this handshake waited for
1188- // its send lock. The ordinary attach path can reclaim the still-live
1189- // named session instead of turning this grace-period race into a
1190- // terminal no-such-session error.
1251+ // its send lock. Fail closed on the original credential rather
1252+ // than falling through to ordinary attach/create routing.
1253+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
11911254 } else {
11921255 return nil, nil, err
11931256 }
@@ -108,6 +108,11 @@ type Daemon struct {
108 // with Wait.108 // with Wait.
109 notifies []chan struct{}109 notifies []chan struct{}
110 parked map[uint64]*parkedAttachment110 parked map[uint64]*parkedAttachment
111+ // parking tracks resume-capable attachments from before detach clears the
112+ // live seat until parkAttachment publishes the token into parked.
113+ // IntentResume waits on the matching entry instead of treating the live
114+ // credential as unknown across that gap.
115+ parking map[uint64]*parkingAttachment
111 116
112 attnMu sync.Mutex117 attnMu sync.Mutex
113 animFrame int118 animFrame int
@@ -172,6 +177,32 @@ type Daemon struct {
172 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the177 // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
173 // attachment gate but before it checks session ownership.178 // attachment gate but before it checks session ownership.
174 afterDetachRoleEffectsFrozen func()179 afterDetachRoleEffectsFrozen func()
180+ // beforeClientGoneDetach pauses clientGone after the stale-transport
181+ // precheck and before exact transport/incarnation detach validation.
182+ beforeClientGoneDetach func()
183+ // beforeMarkParkingInFlight pauses resumeLiveAttachment after the live
184+ // credential matches and before markParkingInFlight, so tests can win
185+ // explicit detach or session removal before late marker publication.
186+ beforeMarkParkingInFlight func()
187+ // afterClientGoneDetach pauses finishClientGone after detach won and before
188+ // parkAttachment. The parking-in-flight marker must already be published.
189+ afterClientGoneDetach func()
190+ // afterResumeLiveDetach pauses resumeLiveAttachment after detach won and
191+ // before parkAttachment. The parking-in-flight marker must already be
192+ // published so a concurrent same-token resume can wait the gap.
193+ afterResumeLiveDetach func()
194+ // afterParkingWaitArmed observes waitParkingInFlight after it has resolved a
195+ // matching in-flight entry and before it blocks on done.
196+ afterParkingWaitArmed func()
197+ // afterSnatchedUnrouteBeforePark pauses parkSnatchedAttachment and
198+ // cleanupInterruptedSnatchedAttachment after exact snatched ownership is
199+ // removed and before parkAttachmentAs. The parking-in-flight marker must
200+ // already be published so a concurrent same-token resume can wait the gap.
201+ afterSnatchedUnrouteBeforePark func()
202+ // beforeResumeParkedSendMu pauses resumeParked after the initial parked
203+ // lookup and before the attachment send lock, so tests can consume or
204+ // replace the credential while the handshake waits.
205+ beforeResumeParkedSendMu func()
175 // afterDisplacedCleanupStarted observes deferred displaced cleanup before it206 // afterDisplacedCleanupStarted observes deferred displaced cleanup before it
176 // synchronizes with the attachment gate.207 // synchronizes with the attachment gate.
177 afterDisplacedCleanupStarted func()208 afterDisplacedCleanupStarted func()
@@ -300,6 +331,23 @@ type parkedAttachment struct {
300 doneOnce sync.Once331 doneOnce sync.Once
301 }332 }
302 333
334+// parkingAttachment is the observable detach→park lifecycle for one resume
335+// credential. done closes when the token is published into parked or parking
336+// is abandoned.
337+type parkingAttachment struct {
338+ sess *session
339+ ac *attachedClient
340+ done chan struct{}
341+ doneOnce sync.Once
342+}
343+
344+func (p *parkingAttachment) closeDone() {
345+ if p == nil || p.done == nil {
346+ return
347+ }
348+ p.doneOnce.Do(func() { close(p.done) })
349+}
350+
303 // session is a single multiplexed session. It owns one or more full-screen351 // session is a single multiplexed session. It owns one or more full-screen
304 352
305 type stoppedSession struct {353 type stoppedSession struct {
@@ -532,6 +580,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
532 stopped: make(map[string]stoppedSession),580 stopped: make(map[string]stoppedSession),
533 creating: make(map[string]struct{}),581 creating: make(map[string]struct{}),
534 parked: make(map[uint64]*parkedAttachment),582 parked: make(map[uint64]*parkedAttachment),
583+ parking: make(map[uint64]*parkingAttachment),
535 paneProcessCtx: paneProcessCtx,584 paneProcessCtx: paneProcessCtx,
536 paneProcessCancel: paneProcessCancel,585 paneProcessCancel: paneProcessCancel,
537 ptys: ptys,586 ptys: ptys,
@@ -724,6 +773,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
724 d.closeMoveLifecycles()773 d.closeMoveLifecycles()
725 d.mu.Lock()774 d.mu.Lock()
726 d.closing = true775 d.closing = true
776+ d.purgeAllParkingLocked()
727 parkedRetirements := d.purgeAllParkedLocked()777 parkedRetirements := d.purgeAllParkedLocked()
728 snapshot := d.sessionsSnapshotLocked()778 snapshot := d.sessionsSnapshotLocked()
729 empty := len(snapshot) == 0779 empty := len(snapshot) == 0
@@ -1171,11 +1221,24 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1171 // A non-zero token is an authoritative resume credential. If it is unknown,1221 // A non-zero token is an authoritative resume credential. If it is unknown,
1172 // expired, or raced with lifecycle teardown, fail closed instead of routing1222 // expired, or raced with lifecycle teardown, fail closed instead of routing
1173 // the Hello as an ordinary attach that could create or replace ownership.1223 // the Hello as an ordinary attach that could create or replace ownership.
1224+ // The one legitimate pre-park race is a still-active same-client credential
1225+ // for the requested session; hand that into the park/resume lifecycle.
1174 if h.ResumeToken != 0 {1226 if h.ResumeToken != 0 {
1175 d.mu.Lock()1227 d.mu.Lock()
1176 parkedAtStart := d.parked[h.ResumeToken]1228 parkedAtStart := d.parked[h.ResumeToken]
1177 d.mu.Unlock()1229 d.mu.Unlock()
1178 if parkedAtStart == nil {1230 if parkedAtStart == nil {
1231+ if sess, ac, ok, err := d.resumeLiveAttachment(h, tr, sz); err != nil {
1232+ if errors.Is(err, errResumeTokenLifecycleRace) {
1233+ // Live recovery parked then lost a competing resumeParked
1234+ // race; keep the fail-closed wire response instead of
1235+ // leaking the internal sentinel to handleHello as ErrInternal.
1236+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1237+ }
1238+ return nil, nil, err
1239+ } else if ok {
1240+ return sess, ac, nil
1241+ }
1179 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}1242 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1180 }1243 }
1181 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {1244 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
@@ -1185,9 +1248,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1185 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}1248 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1186 } else if errors.Is(err, errResumeTokenLifecycleRace) {1249 } else if errors.Is(err, errResumeTokenLifecycleRace) {
1187 // The parked entry was replaced while this handshake waited for1250 // The parked entry was replaced while this handshake waited for
1188- // its send lock. The ordinary attach path can reclaim the still-live1251+ // its send lock. Fail closed on the original credential rather
1189- // named session instead of turning this grace-period race into a1252+ // than falling through to ordinary attach/create routing.
1190- // terminal no-such-session error.1253+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
1191 } else {1254 } else {
1192 return nil, nil, err1255 return nil, nil, err
1193 }1256 }
M internal/usecase/daemon/move_pane_commit.go +1 -0
@@ -158,6 +158,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
158158 if candidate.removeSourceTab && len(c.source.tabs) == 0 {
159159 delete(d.sessions, c.source.id)
160160 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
161+ d.purgeParkingForSessionLocked(c.source)
161162 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
162163 }
163164 c.sourceEmpty = len(c.source.tabs) == 0
@@ -158,6 +158,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
158 if candidate.removeSourceTab && len(c.source.tabs) == 0 {158 if candidate.removeSourceTab && len(c.source.tabs) == 0 {
159 delete(d.sessions, c.source.id)159 delete(d.sessions, c.source.id)
160 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)160 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
161+ d.purgeParkingForSessionLocked(c.source)
161 c.retiredParked = d.purgeParkedForSessionLocked(c.source)162 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
162 }163 }
163 c.sourceEmpty = len(c.source.tabs) == 0164 c.sourceEmpty = len(c.source.tabs) == 0
M internal/usecase/daemon/move_tab.go +1 -0
@@ -386,6 +386,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
386386 if len(c.source.tabs) == 0 {
387387 delete(d.sessions, c.source.id)
388388 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
389+ d.purgeParkingForSessionLocked(c.source)
389390 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
390391 }
391392 c.sourceEmpty = len(c.source.tabs) == 0
@@ -386,6 +386,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
386 if len(c.source.tabs) == 0 {386 if len(c.source.tabs) == 0 {
387 delete(d.sessions, c.source.id)387 delete(d.sessions, c.source.id)
388 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)388 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
389+ d.purgeParkingForSessionLocked(c.source)
389 c.retiredParked = d.purgeParkedForSessionLocked(c.source)390 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
390 }391 }
391 c.sourceEmpty = len(c.source.tabs) == 0392 c.sourceEmpty = len(c.source.tabs) == 0
M internal/usecase/daemon/resume.go +259 -1
@@ -26,11 +26,163 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
2626 continue
2727 }
2828 if _, exists := d.parked[tok]; !exists {
29- return tok
29+ if _, parking := d.parking[tok]; !parking {
30+ return tok
31+ }
3032 }
3133 }
3234 }
3335
36+// markParkingInFlight publishes an observable parking lifecycle so IntentResume
37+// can wait out the detach→park gap. Callers must publish this before clearing
38+// the live seat so resume never observes both registries empty for a still-valid
39+// credential. Under d.mu then sess.mu it advertises only when the daemon still
40+// registers the exact session and ac is still the exact active or snatched
41+// owner; otherwise it returns 0 and never creates a marker after terminal
42+// cleanup. Returns the resume token, or 0 when parking cannot be advertised.
43+func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
44+ if sess == nil || ac == nil || !ac.resumeCapable {
45+ return 0
46+ }
47+ d.mu.Lock()
48+ defer d.mu.Unlock()
49+ if d.closing || d.sessions[sess.id] != sess {
50+ return 0
51+ }
52+ sess.mu.Lock()
53+ role := sess.attachmentRoleLocked(ac)
54+ owner := role == attachmentActive || role == attachmentSnatched
55+ sess.mu.Unlock()
56+ if !owner {
57+ return 0
58+ }
59+ if ac.resumeToken == 0 {
60+ ac.resumeToken = d.nextResumeTokenLocked()
61+ }
62+ d.ensureParkingInFlightLocked(sess, ac)
63+ d.log.Info("parking in flight", "session", sess.name)
64+ return ac.resumeToken
65+}
66+
67+// ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
68+// Caller holds d.mu and has verified the daemon is not closing.
69+func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) {
70+ token := ac.resumeToken
71+ if token == 0 {
72+ return
73+ }
74+ if existing := d.parking[token]; existing != nil {
75+ if existing.ac == ac {
76+ existing.sess = sess
77+ return
78+ }
79+ delete(d.parking, token)
80+ existing.closeDone()
81+ }
82+ d.parking[token] = &parkingAttachment{
83+ sess: sess,
84+ ac: ac,
85+ done: make(chan struct{}),
86+ }
87+}
88+
89+func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
90+ if token == 0 {
91+ return
92+ }
93+ d.mu.Lock()
94+ d.clearParkingInFlightLocked(token, ac)
95+ d.mu.Unlock()
96+}
97+
98+// clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this
99+// teardown lost the seat while still the live owner (stale transport fence for
100+// active or snatched). If another path already detached/unrouted, that path
101+// owns park publication.
102+func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {
103+ if token == 0 || ac == nil {
104+ return
105+ }
106+ d.mu.Lock()
107+ defer d.mu.Unlock()
108+ if d.parked[token] != nil {
109+ return
110+ }
111+ stillOwner := false
112+ if sess != nil {
113+ sess.mu.Lock()
114+ role := sess.attachmentRoleLocked(ac)
115+ stillOwner = role == attachmentActive || role == attachmentSnatched
116+ sess.mu.Unlock()
117+ }
118+ if !stillOwner {
119+ return
120+ }
121+ d.clearParkingInFlightLocked(token, ac)
122+}
123+
124+func (d *Daemon) clearParkingInFlightLocked(token uint64, ac *attachedClient) {
125+ pending := d.parking[token]
126+ if pending == nil {
127+ return
128+ }
129+ if ac != nil && pending.ac != ac {
130+ return
131+ }
132+ delete(d.parking, token)
133+ pending.closeDone()
134+}
135+
136+func (d *Daemon) purgeAllParkingLocked() {
137+ for token, pending := range d.parking {
138+ delete(d.parking, token)
139+ pending.closeDone()
140+ }
141+}
142+
143+// purgeParkingForSessionLocked removes and closes every in-flight parking
144+// marker for sess so same-token waiters fail closed instead of hanging when
145+// the session is removed before park publication. Caller holds d.mu.
146+func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
147+ for token, pending := range d.parking {
148+ if pending.sess == sess {
149+ delete(d.parking, token)
150+ pending.closeDone()
151+ }
152+ }
153+}
154+
155+// waitParkingInFlight waits for an identified same-client parking publication
156+// to finish. Returns true when a matching in-flight entry was observed.
157+// Unknown and mismatched credentials return false without waiting.
158+func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
159+ if h.ResumeToken == 0 {
160+ return false
161+ }
162+ d.mu.Lock()
163+ pending := d.parking[h.ResumeToken]
164+ if pending == nil {
165+ d.mu.Unlock()
166+ return false
167+ }
168+ if pending.ac.clientID != h.ClientID {
169+ d.mu.Unlock()
170+ return false
171+ }
172+ if h.Name != "" && pending.sess != nil && pending.sess.name != h.Name {
173+ d.mu.Unlock()
174+ return false
175+ }
176+ done := pending.done
177+ d.mu.Unlock()
178+ d.log.Info("resume waiting for in-flight parking", "session", h.Name)
179+ if d.afterParkingWaitArmed != nil {
180+ d.afterParkingWaitArmed()
181+ }
182+ <-done
183+ return true
184+}
185+
34186 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
35187 if !ac.resumeCapable {
36188 return false
@@ -43,6 +195,10 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
43195 if ac.resumeToken == 0 {
44196 ac.resumeToken = d.nextResumeTokenLocked()
45197 }
198+ // Do not recreate parking markers here: post-detach publication races
199+ // terminal session removal and can strand IntentResume waiters. Callers
200+ // that need the detach→park gap advertised must markParkingInFlight while
201+ // still the exact live owner. Direct parkAttachment still parks below.
46202 d.log.Info("parking client prepared", "session", sess.name)
47203 return true
48204 }
@@ -62,6 +218,7 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
62218 ac.clearCaptureFrames()
63219 d.mu.Lock()
64220 if d.closing || d.sessions[sess.id] != sess {
221+ d.clearParkingInFlightLocked(ac.resumeToken, ac)
65222 d.mu.Unlock()
66223 return false
67224 }
@@ -90,6 +247,7 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
90247 parked := &parkedAttachment{sess: sess, ac: ac, role: role, timer: timer, done: make(chan struct{})}
91248 ac.parked = true
92249 d.parked[token] = parked
250+ d.clearParkingInFlightLocked(token, ac)
93251 d.mu.Unlock()
94252 if oldRetirement != nil {
95253 finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
@@ -205,6 +363,103 @@ func finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement)
205363 }
206364 }
207365
366+// resumeLiveAttachment recovers a nonzero resume credential that still belongs
367+// to the named session's active attachment because transport teardown has not
368+// parked it yet. Only the exact live owner (same session, token, and client ID)
369+// is accepted; arbitrary unknown tokens stay fail-closed.
370+func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) {
371+ d.mu.Lock()
372+ if d.closing {
373+ d.mu.Unlock()
374+ return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
375+ }
376+ // Teardown may have parked the credential already.
377+ if d.parked[h.ResumeToken] != nil {
378+ d.mu.Unlock()
379+ return d.resumeParked(h, tr, sz)
380+ }
381+ sess := d.findByNameLocked(h.Name)
382+ var (
383+ ac *attachedClient
384+ oldSnap transportSnapshot
385+ credentialMatch bool
386+ clientMismatch bool
387+ )
388+ if sess != nil {
389+ sess.mu.Lock()
390+ ac = sess.client
391+ // resumeToken writes run under d.mu; read the live credential here
392+ // before releasing the daemon lock, then revalidate via the exact
393+ // transport incarnation through detachIfCurrentTransport.
394+ if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken {
395+ if ac.clientID != h.ClientID {
396+ clientMismatch = true
397+ } else {
398+ oldSnap = ac.transportSnapshot()
399+ credentialMatch = oldSnap.transport != nil
400+ }
401+ }
402+ sess.mu.Unlock()
403+ }
404+ // Teardown may have parked the credential while we resolved the session.
405+ if d.parked[h.ResumeToken] != nil {
406+ d.mu.Unlock()
407+ return d.resumeParked(h, tr, sz)
408+ }
409+ d.mu.Unlock()
410+
411+ if clientMismatch {
412+ d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch")
413+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
414+ }
415+ if !credentialMatch {
416+ // Detach may have won and not published parked yet; wait out that gap
417+ // for the exact same-client credential, then try the parked path.
418+ _ = d.waitParkingInFlight(h)
419+ return d.resumeParked(h, tr, sz)
420+ }
421+
422+ if d.beforeMarkParkingInFlight != nil {
423+ d.beforeMarkParkingInFlight()
424+ }
425+ // Advertise parking before detach so a concurrent same-token resume never
426+ // observes both the live seat and parking/parked registries empty. Late
427+ // publication after terminal detach/session removal returns 0.
428+ parkingToken := d.markParkingInFlight(sess, ac)
429+ if !d.detachIfCurrentTransport(sess, ac, oldSnap) {
430+ d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
431+ // A newer owner, rebound link, or teardown won; wait if teardown is
432+ // still publishing the park, then try the parked path.
433+ _ = d.waitParkingInFlight(h)
434+ return d.resumeParked(h, tr, sz)
435+ }
436+ if d.afterResumeLiveDetach != nil {
437+ d.afterResumeLiveDetach()
438+ }
439+ if rc := sess.renderCoordinator(); rc != nil {
440+ rc.noteDetach(ac)
441+ }
442+ d.unregisterPreview(ac)
443+ if !d.parkAttachment(sess, ac) {
444+ // Detach already published; retire the captured link exactly once so
445+ // shutdown cannot leave an orphaned open transport with no owner.
446+ d.clearParkingInFlight(ac.resumeToken, ac)
447+ d.resetScreenDefaultColors(sess)
448+ ac.clearPreviousSession()
449+ _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))
450+ d.mu.Lock()
451+ closing := d.closing
452+ d.mu.Unlock()
453+ if closing {
454+ return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
455+ }
456+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
457+ }
458+ _ = ac.closeCapturedTransport(oldSnap.transport)
459+ d.log.Info("live resume credential parked for reconnect", "session", sess.name)
460+ return d.resumeParked(h, tr, sz)
461+}
462+
208463 // resumeParked acquires the parked client's output lock before the daemon
209464 // registry lock, preserving the global sendMu > Daemon.mu ordering. The parked
210465 // entry is revalidated after both locks are held because it may expire between
@@ -218,6 +473,9 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
218473 }
219474
220475 ac := parked.ac
476+ if d.beforeResumeParkedSendMu != nil {
477+ d.beforeResumeParkedSendMu()
478+ }
221479 ac.sendMu.Lock()
222480 defer ac.sendMu.Unlock()
223481 d.mu.Lock()
@@ -26,11 +26,163 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
26 continue26 continue
27 }27 }
28 if _, exists := d.parked[tok]; !exists {28 if _, exists := d.parked[tok]; !exists {
29- return tok29+ if _, parking := d.parking[tok]; !parking {
30+ return tok
31+ }
30 }32 }
31 }33 }
32 }34 }
33 35
36+// markParkingInFlight publishes an observable parking lifecycle so IntentResume
37+// can wait out the detach→park gap. Callers must publish this before clearing
38+// the live seat so resume never observes both registries empty for a still-valid
39+// credential. Under d.mu then sess.mu it advertises only when the daemon still
40+// registers the exact session and ac is still the exact active or snatched
41+// owner; otherwise it returns 0 and never creates a marker after terminal
42+// cleanup. Returns the resume token, or 0 when parking cannot be advertised.
43+func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
44+ if sess == nil || ac == nil || !ac.resumeCapable {
45+ return 0
46+ }
47+ d.mu.Lock()
48+ defer d.mu.Unlock()
49+ if d.closing || d.sessions[sess.id] != sess {
50+ return 0
51+ }
52+ sess.mu.Lock()
53+ role := sess.attachmentRoleLocked(ac)
54+ owner := role == attachmentActive || role == attachmentSnatched
55+ sess.mu.Unlock()
56+ if !owner {
57+ return 0
58+ }
59+ if ac.resumeToken == 0 {
60+ ac.resumeToken = d.nextResumeTokenLocked()
61+ }
62+ d.ensureParkingInFlightLocked(sess, ac)
63+ d.log.Info("parking in flight", "session", sess.name)
64+ return ac.resumeToken
65+}
66+
67+// ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
68+// Caller holds d.mu and has verified the daemon is not closing.
69+func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) {
70+ token := ac.resumeToken
71+ if token == 0 {
72+ return
73+ }
74+ if existing := d.parking[token]; existing != nil {
75+ if existing.ac == ac {
76+ existing.sess = sess
77+ return
78+ }
79+ delete(d.parking, token)
80+ existing.closeDone()
81+ }
82+ d.parking[token] = &parkingAttachment{
83+ sess: sess,
84+ ac: ac,
85+ done: make(chan struct{}),
86+ }
87+}
88+
89+func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
90+ if token == 0 {
91+ return
92+ }
93+ d.mu.Lock()
94+ d.clearParkingInFlightLocked(token, ac)
95+ d.mu.Unlock()
96+}
97+
98+// clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this
99+// teardown lost the seat while still the live owner (stale transport fence for
100+// active or snatched). If another path already detached/unrouted, that path
101+// owns park publication.
102+func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {
103+ if token == 0 || ac == nil {
104+ return
105+ }
106+ d.mu.Lock()
107+ defer d.mu.Unlock()
108+ if d.parked[token] != nil {
109+ return
110+ }
111+ stillOwner := false
112+ if sess != nil {
113+ sess.mu.Lock()
114+ role := sess.attachmentRoleLocked(ac)
115+ stillOwner = role == attachmentActive || role == attachmentSnatched
116+ sess.mu.Unlock()
117+ }
118+ if !stillOwner {
119+ return
120+ }
121+ d.clearParkingInFlightLocked(token, ac)
122+}
123+
124+func (d *Daemon) clearParkingInFlightLocked(token uint64, ac *attachedClient) {
125+ pending := d.parking[token]
126+ if pending == nil {
127+ return
128+ }
129+ if ac != nil && pending.ac != ac {
130+ return
131+ }
132+ delete(d.parking, token)
133+ pending.closeDone()
134+}
135+
136+func (d *Daemon) purgeAllParkingLocked() {
137+ for token, pending := range d.parking {
138+ delete(d.parking, token)
139+ pending.closeDone()
140+ }
141+}
142+
143+// purgeParkingForSessionLocked removes and closes every in-flight parking
144+// marker for sess so same-token waiters fail closed instead of hanging when
145+// the session is removed before park publication. Caller holds d.mu.
146+func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
147+ for token, pending := range d.parking {
148+ if pending.sess == sess {
149+ delete(d.parking, token)
150+ pending.closeDone()
151+ }
152+ }
153+}
154+
155+// waitParkingInFlight waits for an identified same-client parking publication
156+// to finish. Returns true when a matching in-flight entry was observed.
157+// Unknown and mismatched credentials return false without waiting.
158+func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
159+ if h.ResumeToken == 0 {
160+ return false
161+ }
162+ d.mu.Lock()
163+ pending := d.parking[h.ResumeToken]
164+ if pending == nil {
165+ d.mu.Unlock()
166+ return false
167+ }
168+ if pending.ac.clientID != h.ClientID {
169+ d.mu.Unlock()
170+ return false
171+ }
172+ if h.Name != "" && pending.sess != nil && pending.sess.name != h.Name {
173+ d.mu.Unlock()
174+ return false
175+ }
176+ done := pending.done
177+ d.mu.Unlock()
178+ d.log.Info("resume waiting for in-flight parking", "session", h.Name)
179+ if d.afterParkingWaitArmed != nil {
180+ d.afterParkingWaitArmed()
181+ }
182+ <-done
183+ return true
184+}
185+
34 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {186 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
35 if !ac.resumeCapable {187 if !ac.resumeCapable {
36 return false188 return false
@@ -43,6 +195,10 @@ func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
43 if ac.resumeToken == 0 {195 if ac.resumeToken == 0 {
44 ac.resumeToken = d.nextResumeTokenLocked()196 ac.resumeToken = d.nextResumeTokenLocked()
45 }197 }
198+ // Do not recreate parking markers here: post-detach publication races
199+ // terminal session removal and can strand IntentResume waiters. Callers
200+ // that need the detach→park gap advertised must markParkingInFlight while
201+ // still the exact live owner. Direct parkAttachment still parks below.
46 d.log.Info("parking client prepared", "session", sess.name)202 d.log.Info("parking client prepared", "session", sess.name)
47 return true203 return true
48 }204 }
@@ -62,6 +218,7 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
62 ac.clearCaptureFrames()218 ac.clearCaptureFrames()
63 d.mu.Lock()219 d.mu.Lock()
64 if d.closing || d.sessions[sess.id] != sess {220 if d.closing || d.sessions[sess.id] != sess {
221+ d.clearParkingInFlightLocked(ac.resumeToken, ac)
65 d.mu.Unlock()222 d.mu.Unlock()
66 return false223 return false
67 }224 }
@@ -90,6 +247,7 @@ func (d *Daemon) parkAttachmentAs(sess *session, ac *attachedClient, role attach
90 parked := &parkedAttachment{sess: sess, ac: ac, role: role, timer: timer, done: make(chan struct{})}247 parked := &parkedAttachment{sess: sess, ac: ac, role: role, timer: timer, done: make(chan struct{})}
91 ac.parked = true248 ac.parked = true
92 d.parked[token] = parked249 d.parked[token] = parked
250+ d.clearParkingInFlightLocked(token, ac)
93 d.mu.Unlock()251 d.mu.Unlock()
94 if oldRetirement != nil {252 if oldRetirement != nil {
95 finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})253 finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
@@ -205,6 +363,103 @@ func finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement)
205 }363 }
206 }364 }
207 365
366+// resumeLiveAttachment recovers a nonzero resume credential that still belongs
367+// to the named session's active attachment because transport teardown has not
368+// parked it yet. Only the exact live owner (same session, token, and client ID)
369+// is accepted; arbitrary unknown tokens stay fail-closed.
370+func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) {
371+ d.mu.Lock()
372+ if d.closing {
373+ d.mu.Unlock()
374+ return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
375+ }
376+ // Teardown may have parked the credential already.
377+ if d.parked[h.ResumeToken] != nil {
378+ d.mu.Unlock()
379+ return d.resumeParked(h, tr, sz)
380+ }
381+ sess := d.findByNameLocked(h.Name)
382+ var (
383+ ac *attachedClient
384+ oldSnap transportSnapshot
385+ credentialMatch bool
386+ clientMismatch bool
387+ )
388+ if sess != nil {
389+ sess.mu.Lock()
390+ ac = sess.client
391+ // resumeToken writes run under d.mu; read the live credential here
392+ // before releasing the daemon lock, then revalidate via the exact
393+ // transport incarnation through detachIfCurrentTransport.
394+ if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken {
395+ if ac.clientID != h.ClientID {
396+ clientMismatch = true
397+ } else {
398+ oldSnap = ac.transportSnapshot()
399+ credentialMatch = oldSnap.transport != nil
400+ }
401+ }
402+ sess.mu.Unlock()
403+ }
404+ // Teardown may have parked the credential while we resolved the session.
405+ if d.parked[h.ResumeToken] != nil {
406+ d.mu.Unlock()
407+ return d.resumeParked(h, tr, sz)
408+ }
409+ d.mu.Unlock()
410+
411+ if clientMismatch {
412+ d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch")
413+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
414+ }
415+ if !credentialMatch {
416+ // Detach may have won and not published parked yet; wait out that gap
417+ // for the exact same-client credential, then try the parked path.
418+ _ = d.waitParkingInFlight(h)
419+ return d.resumeParked(h, tr, sz)
420+ }
421+
422+ if d.beforeMarkParkingInFlight != nil {
423+ d.beforeMarkParkingInFlight()
424+ }
425+ // Advertise parking before detach so a concurrent same-token resume never
426+ // observes both the live seat and parking/parked registries empty. Late
427+ // publication after terminal detach/session removal returns 0.
428+ parkingToken := d.markParkingInFlight(sess, ac)
429+ if !d.detachIfCurrentTransport(sess, ac, oldSnap) {
430+ d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
431+ // A newer owner, rebound link, or teardown won; wait if teardown is
432+ // still publishing the park, then try the parked path.
433+ _ = d.waitParkingInFlight(h)
434+ return d.resumeParked(h, tr, sz)
435+ }
436+ if d.afterResumeLiveDetach != nil {
437+ d.afterResumeLiveDetach()
438+ }
439+ if rc := sess.renderCoordinator(); rc != nil {
440+ rc.noteDetach(ac)
441+ }
442+ d.unregisterPreview(ac)
443+ if !d.parkAttachment(sess, ac) {
444+ // Detach already published; retire the captured link exactly once so
445+ // shutdown cannot leave an orphaned open transport with no owner.
446+ d.clearParkingInFlight(ac.resumeToken, ac)
447+ d.resetScreenDefaultColors(sess)
448+ ac.clearPreviousSession()
449+ _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))
450+ d.mu.Lock()
451+ closing := d.closing
452+ d.mu.Unlock()
453+ if closing {
454+ return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
455+ }
456+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
457+ }
458+ _ = ac.closeCapturedTransport(oldSnap.transport)
459+ d.log.Info("live resume credential parked for reconnect", "session", sess.name)
460+ return d.resumeParked(h, tr, sz)
461+}
462+
208 // resumeParked acquires the parked client's output lock before the daemon463 // resumeParked acquires the parked client's output lock before the daemon
209 // registry lock, preserving the global sendMu > Daemon.mu ordering. The parked464 // registry lock, preserving the global sendMu > Daemon.mu ordering. The parked
210 // entry is revalidated after both locks are held because it may expire between465 // entry is revalidated after both locks are held because it may expire between
@@ -218,6 +473,9 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
218 }473 }
219 474
220 ac := parked.ac475 ac := parked.ac
476+ if d.beforeResumeParkedSendMu != nil {
477+ d.beforeResumeParkedSendMu()
478+ }
221 ac.sendMu.Lock()479 ac.sendMu.Lock()
222 defer ac.sendMu.Unlock()480 defer ac.sendMu.Unlock()
223 d.mu.Lock()481 d.mu.Lock()
M internal/usecase/daemon/resume_test.go +1168 -0
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "strings"
77 "sync"
8+ "sync/atomic"
89 "testing"
910 "time"
1011
@@ -268,6 +269,1103 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
268269 require.Same(t, resumedAC, sess.client)
269270 }
270271
272+// TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race
273+// where a parked credential is consumed or replaced while IntentResume waits on
274+// the attachment send lock. The nonzero token must fail closed instead of
275+// falling through to ordinary attach/create routing.
276+func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
277+ pty, release := newBlockingPTY(t)
278+ defer release()
279+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
280+
281+ oldTr := &closeTrackingTransport{}
282+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
283+ require.NoError(t, err)
284+ token := ac.resumeToken
285+ require.NotZero(t, token)
286+ d.clientGone(sess, ac, oldTr, false)
287+ require.Nil(t, sess.client)
288+ d.mu.Lock()
289+ parked := d.parked[token]
290+ sessionsBefore := len(d.sessions)
291+ d.mu.Unlock()
292+ require.NotNil(t, parked)
293+
294+ reachedLookup := make(chan struct{})
295+ releaseLookup := make(chan struct{})
296+ var releaseOnce sync.Once
297+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseLookup) }) })
298+ d.beforeResumeParkedSendMu = func() {
299+ close(reachedLookup)
300+ <-releaseLookup
301+ }
302+
303+ resumeTr := &closeTrackingTransport{}
304+ type routeResult struct {
305+ sess *session
306+ ac *attachedClient
307+ err error
308+ }
309+ result := make(chan routeResult, 1)
310+ go func() {
311+ routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), resumeTr)
312+ result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
313+ }()
314+ awaitTestCompletion(t, reachedLookup, "resumeParked did not pause after the parked lookup")
315+
316+ d.mu.Lock()
317+ require.Same(t, parked, d.parked[token], "fixture: token must still be parked at the seam")
318+ d.removeParkedLocked(token, parked)
319+ require.Nil(t, d.parked[token])
320+ require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")
321+ d.mu.Unlock()
322+
323+ releaseOnce.Do(func() { close(releaseLookup) })
324+ got := awaitTestValue(t, result, "IntentResume did not finish after parked token replacement")
325+
326+ require.Error(t, got.err)
327+ require.Nil(t, got.sess)
328+ require.Nil(t, got.ac)
329+ var pe *protoErr
330+ require.ErrorAs(t, got.err, &pe)
331+ require.Equal(t, ports.ErrNoSuchSession, pe.code)
332+ require.Contains(t, pe.Error(), "resume token is no longer valid")
333+ require.Nil(t, sess.client, "lifecycle-race resume must not take over the named session")
334+ require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake")
335+ d.mu.Lock()
336+ _, stillParked := d.parked[token]
337+ sessionsAfter := len(d.sessions)
338+ d.mu.Unlock()
339+ require.False(t, stillParked, "consumed token must stay invalid")
340+ require.Equal(t, sessionsBefore, sessionsAfter, "lifecycle-race resume must not create a session")
341+}
342+
343+// TestResumeLiveAttachmentParkedResumeRaceFailsClosed covers the live-recovery
344+// path that parks a still-active credential and then loses a competing
345+// resumeParked race before sendMu revalidation. The lifecycle sentinel must
346+// surface as fail-closed ErrNoSuchSession, never as a raw ErrInternal leak to
347+// handleHello.
348+func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
349+ pty, release := newBlockingPTY(t)
350+ defer release()
351+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
352+
353+ oldTr := &closeTrackingTransport{}
354+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
355+ require.NoError(t, err)
356+ token := ac.resumeToken
357+ require.NotZero(t, token)
358+ require.Same(t, ac, sess.client)
359+ d.mu.Lock()
360+ _, parkedAtStart := d.parked[token]
361+ sessionsBefore := len(d.sessions)
362+ d.mu.Unlock()
363+ require.False(t, parkedAtStart, "fixture: live attachment must not be parked yet")
364+
365+ reachedLookup := make(chan struct{})
366+ releaseLookup := make(chan struct{})
367+ var releaseOnce sync.Once
368+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseLookup) }) })
369+ d.beforeResumeParkedSendMu = func() {
370+ close(reachedLookup)
371+ <-releaseLookup
372+ }
373+
374+ resumeTr := &closeTrackingTransport{}
375+ type routeResult struct {
376+ sess *session
377+ ac *attachedClient
378+ err error
379+ }
380+ result := make(chan routeResult, 1)
381+ go func() {
382+ routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), resumeTr)
383+ result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
384+ }()
385+ awaitTestCompletion(t, reachedLookup, "live resume did not pause inside resumeParked after parking")
386+
387+ d.mu.Lock()
388+ parked := d.parked[token]
389+ require.NotNil(t, parked, "fixture: live recovery must have parked before the sendMu seam")
390+ require.Same(t, ac, parked.ac)
391+ d.removeParkedLocked(token, parked)
392+ require.Nil(t, d.parked[token])
393+ require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")
394+ d.mu.Unlock()
395+
396+ releaseOnce.Do(func() { close(releaseLookup) })
397+ got := awaitTestValue(t, result, "live IntentResume did not finish after competing resume consumed the token")
398+
399+ require.Error(t, got.err)
400+ require.Nil(t, got.sess)
401+ require.Nil(t, got.ac)
402+ require.False(t, errors.Is(got.err, errResumeTokenLifecycleRace), "raw lifecycle sentinel must not reach route callers")
403+ var pe *protoErr
404+ require.ErrorAs(t, got.err, &pe)
405+ require.Equal(t, ports.ErrNoSuchSession, pe.code)
406+ require.Contains(t, pe.Error(), "resume token is no longer valid")
407+ require.Nil(t, sess.client, "losing live resume must not take over the named session")
408+ require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake")
409+ d.mu.Lock()
410+ _, stillParked := d.parked[token]
411+ sessionsAfter := len(d.sessions)
412+ d.mu.Unlock()
413+ require.False(t, stillParked, "consumed token must stay invalid")
414+ require.Equal(t, sessionsBefore, sessionsAfter, "losing live resume must not create a session")
415+}
416+
417+// TestResumeActiveTokenBeforeParkRecoversSameAttachment covers the laptop-sleep
418+// reconnect race: IntentResume arrives with the live attachment's token before
419+// transport teardown has parked it. Same client must recover; unknown and
420+// 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.
485+func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
486+ pty, release := newBlockingPTY(t)
487+ defer release()
488+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
489+
490+ oldTr := &closeTrackingTransport{}
491+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
492+ require.NoError(t, err)
493+ token := ac.resumeToken
494+ require.NotZero(t, token)
495+ require.Same(t, ac, sess.client)
496+
497+ reachedGap := make(chan struct{})
498+ releaseGap := make(chan struct{})
499+ var releaseOnce sync.Once
500+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) })
501+ d.afterClientGoneDetach = func() {
502+ close(reachedGap)
503+ <-releaseGap
504+ }
505+
506+ goneDone := make(chan struct{})
507+ go func() {
508+ d.clientGone(sess, ac, oldTr, false)
509+ close(goneDone)
510+ }()
511+ awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park")
512+
513+ require.Nil(t, sess.client, "fixture: detach must have cleared the live owner")
514+ d.mu.Lock()
515+ parkingInGap := d.parking[token]
516+ _, parkedInGap := d.parked[token]
517+ d.mu.Unlock()
518+ require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
519+ require.Same(t, ac, parkingInGap.ac)
520+ require.Same(t, sess, parkingInGap.sess)
521+ require.False(t, parkedInGap, "fixture: park must not have published yet")
522+
523+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
524+ require.Error(t, err)
525+ var unknownErr *protoErr
526+ require.ErrorAs(t, err, &unknownErr)
527+ require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
528+ require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
529+
530+ wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
531+ wrongClient.ClientID = [16]byte{9, 9, 9, 9}
532+ _, _, err = d.route(wrongClient, &closeTrackingTransport{})
533+ require.Error(t, err)
534+ var mismatchErr *protoErr
535+ require.ErrorAs(t, err, &mismatchErr)
536+ require.Equal(t, ports.ErrNoSuchSession, mismatchErr.code)
537+ require.Contains(t, mismatchErr.Error(), "resume token is no longer valid")
538+
539+ newTr := &closeTrackingTransport{}
540+ type routeResult struct {
541+ sess *session
542+ ac *attachedClient
543+ err error
544+ }
545+ waiterArmed := make(chan struct{})
546+ d.afterParkingWaitArmed = func() { close(waiterArmed) }
547+ result := make(chan routeResult, 1)
548+ go func() {
549+ routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
550+ result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
551+ }()
552+ awaitTestCompletion(t, waiterArmed, "resume waiter did not arm on parking marker before park")
553+
554+ releaseOnce.Do(func() { close(releaseGap) })
555+ awaitTestCompletion(t, goneDone, "teardown did not finish after park release")
556+ got := awaitTestValue(t, result, "IntentResume did not finish after teardown-before-park gap")
557+
558+ require.NoError(t, got.err, "same-client token must recover across detach-before-park")
559+ require.Same(t, sess, got.sess)
560+ require.Same(t, ac, got.ac)
561+ require.Same(t, ac, sess.client)
562+ require.Same(t, newTr, ac.transport())
563+ require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
564+ require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")
565+ require.False(t, newTr.Closed(), "rebound transport must survive")
566+ d.mu.Lock()
567+ _, oldTokenParked := d.parked[token]
568+ _, stillParking := d.parking[token]
569+ d.mu.Unlock()
570+ require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
571+ require.False(t, stillParking, "parking marker must be consumed after park/resume")
572+
573+ d.clientGone(sess, ac, oldTr, false)
574+ require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment")
575+ require.Same(t, newTr, ac.transport())
576+ require.False(t, newTr.Closed())
577+}
578+
579+// TestConcurrentLiveResumesWaitParkingMarkerBeforePark covers two same-token
580+// IntentResume handshakes where the winner of detachIfCurrentTransport pauses
581+// before parkAttachment. The parking marker must already be published so the
582+// loser waits instead of treating the credential as unknown across that gap.
583+func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
584+ pty, release := newBlockingPTY(t)
585+ defer release()
586+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
587+
588+ oldTr := &closeTrackingTransport{}
589+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
590+ require.NoError(t, err)
591+ token := ac.resumeToken
592+ require.NotZero(t, token)
593+ require.Same(t, ac, sess.client)
594+
595+ reachedGap := make(chan struct{})
596+ releaseGap := make(chan struct{})
597+ var releaseOnce sync.Once
598+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) })
599+ d.afterResumeLiveDetach = func() {
600+ close(reachedGap)
601+ <-releaseGap
602+ }
603+
604+ type routeResult struct {
605+ sess *session
606+ ac *attachedClient
607+ err error
608+ }
609+ firstTr := &closeTrackingTransport{}
610+ firstResult := make(chan routeResult, 1)
611+ go func() {
612+ routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), firstTr)
613+ firstResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
614+ }()
615+ awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park")
616+
617+ require.Nil(t, sess.client, "fixture: winning resume must have cleared the live owner")
618+ d.mu.Lock()
619+ parkingInGap := d.parking[token]
620+ _, parkedInGap := d.parked[token]
621+ d.mu.Unlock()
622+ require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach for concurrent resumes")
623+ require.Same(t, ac, parkingInGap.ac)
624+ require.False(t, parkedInGap, "fixture: park must not have published yet")
625+
626+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
627+ require.Error(t, err)
628+ var unknownErr *protoErr
629+ require.ErrorAs(t, err, &unknownErr)
630+ require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
631+
632+ secondTr := &closeTrackingTransport{}
633+ secondWaiting := make(chan struct{})
634+ d.afterParkingWaitArmed = func() {
635+ close(secondWaiting)
636+ }
637+ secondResult := make(chan routeResult, 1)
638+ go func() {
639+ routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), secondTr)
640+ secondResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
641+ }()
642+ awaitTestCompletion(t, secondWaiting, "second live resume did not arm parking wait")
643+
644+ releaseOnce.Do(func() { close(releaseGap) })
645+ first := awaitTestValue(t, firstResult, "first concurrent IntentResume did not finish")
646+ second := awaitTestValue(t, secondResult, "second concurrent IntentResume did not finish")
647+
648+ var winner, loser routeResult
649+ var winnerTr, loserTr *closeTrackingTransport
650+ switch {
651+ case first.err == nil && second.err != nil:
652+ winner, loser = first, second
653+ winnerTr, loserTr = firstTr, secondTr
654+ case second.err == nil && first.err != nil:
655+ winner, loser = second, first
656+ winnerTr, loserTr = secondTr, firstTr
657+ default:
658+ t.Fatalf("expected exactly one concurrent resume to succeed; first=%v second=%v", first.err, second.err)
659+ }
660+
661+ require.Same(t, sess, winner.sess)
662+ require.Same(t, ac, winner.ac)
663+ require.Same(t, ac, sess.client)
664+ require.Same(t, winnerTr, ac.transport())
665+ require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
666+ require.True(t, oldTr.Closed(), "winning resume must retire the old transport")
667+ require.False(t, winnerTr.Closed(), "winning rebound transport must survive")
668+
669+ var loserErr *protoErr
670+ require.ErrorAs(t, loser.err, &loserErr)
671+ require.Equal(t, ports.ErrNoSuchSession, loserErr.code)
672+ require.Contains(t, loserErr.Error(), "resume token is no longer valid")
673+ require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")
674+
675+ d.mu.Lock()
676+ _, oldTokenParked := d.parked[token]
677+ _, stillParking := d.parking[token]
678+ d.mu.Unlock()
679+ require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
680+ require.False(t, stillParking, "parking marker must be consumed after park/resume")
681+
682+ d.clientGone(sess, ac, oldTr, false)
683+ require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment")
684+ require.Same(t, winnerTr, ac.transport())
685+ require.False(t, winnerTr.Closed())
686+}
687+
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.
1047+func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
1048+ workPTY, releaseWork := newBlockingPTY(t)
1049+ defer releaseWork()
1050+ otherPTY, releaseOther := newBlockingPTY(t)
1051+ defer releaseOther()
1052+ // Separate PTYs keep the other session alive while work detaches.
1053+ d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY), stubClock{})
1054+
1055+ oldTr := &closeTrackingTransport{}
1056+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1057+ require.NoError(t, err)
1058+ token := ac.resumeToken
1059+ require.NotZero(t, token)
1060+
1061+ otherTr := &closeTrackingTransport{}
1062+ otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr)
1063+ require.NoError(t, err)
1064+ otherToken := d.markParkingInFlight(otherSess, otherAC)
1065+ require.NotZero(t, otherToken)
1066+ require.NotEqual(t, token, otherToken)
1067+
1068+ // Pre-mark as a same-attachment non-explicit teardown would before detach.
1069+ require.Equal(t, token, d.markParkingInFlight(sess, ac))
1070+ d.mu.Lock()
1071+ orphaned := d.parking[token]
1072+ otherMarker := d.parking[otherToken]
1073+ d.mu.Unlock()
1074+ require.NotNil(t, orphaned, "fixture: non-explicit teardown must publish the parking marker")
1075+ require.Same(t, ac, orphaned.ac)
1076+ require.NotNil(t, otherMarker, "fixture: other attachment marker must remain published")
1077+ require.Same(t, otherAC, otherMarker.ac)
1078+
1079+ waiterArmed := make(chan struct{})
1080+ d.afterParkingWaitArmed = func() { close(waiterArmed) }
1081+ waiterDone := make(chan bool, 1)
1082+ go func() {
1083+ waiterDone <- d.waitParkingInFlight(helloResumeCapable(ports.IntentResume, "work", token))
1084+ }()
1085+ awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker")
1086+
1087+ d.clientGone(sess, ac, oldTr, true)
1088+ require.Nil(t, sess.client, "explicit detach must clear the live owner")
1089+
1090+ waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach")
1091+ require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")
1092+
1093+ d.mu.Lock()
1094+ _, stillParking := d.parking[token]
1095+ otherAfter := d.parking[otherToken]
1096+ d.mu.Unlock()
1097+ require.False(t, stillParking, "explicit winner must clear the same attachment's orphaned marker")
1098+ require.NotNil(t, otherAfter, "explicit winner must not clear another attachment's marker")
1099+ require.Same(t, otherAC, otherAfter.ac)
1100+
1101+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
1102+ require.Error(t, err)
1103+ var unknownErr *protoErr
1104+ require.ErrorAs(t, err, &unknownErr)
1105+ require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
1106+ require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
1107+
1108+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
1109+ require.Error(t, err)
1110+ var retiredErr *protoErr
1111+ require.ErrorAs(t, err, &retiredErr)
1112+ require.Equal(t, ports.ErrNoSuchSession, retiredErr.code)
1113+ require.Contains(t, retiredErr.Error(), "resume token is no longer valid")
1114+
1115+ // A later parked lifecycle for another attachment must survive a clear
1116+ // scoped to the retired attachment identity.
1117+ require.True(t, d.parkAttachment(otherSess, otherAC))
1118+ d.mu.Lock()
1119+ parkedOther := d.parked[otherToken]
1120+ _, otherStillParking := d.parking[otherToken]
1121+ d.mu.Unlock()
1122+ require.NotNil(t, parkedOther, "subsequent park must publish the other attachment")
1123+ require.False(t, otherStillParking, "park consumes the other attachment's in-flight marker")
1124+
1125+ d.clearParkingInFlight(otherToken, ac)
1126+ d.mu.Lock()
1127+ parkedAfterMismatchedClear := d.parked[otherToken]
1128+ d.mu.Unlock()
1129+ require.Same(t, parkedOther, parkedAfterMismatchedClear, "mismatched clear must not drop a subsequent parked lifecycle")
1130+}
1131+
1132+// TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins covers the race where
1133+// live resume has validated the same-client credential, then explicit detach or
1134+// session removal wins before markParkingInFlight. Late marker publication must
1135+// be rejected so IntentResume returns promptly ErrNoSuchSession with no parking
1136+// marker left to strand waiters. Direct parkAttachment after detach still works.
1137+func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1138+ cases := []struct {
1139+ name string
1140+ win func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64)
1141+ }{
1142+ {
1143+ name: "explicit_detach",
1144+ win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) {
1145+ t.Helper()
1146+ d.clientGone(sess, ac, oldTr, true)
1147+ require.Nil(t, sess.client, "explicit detach must clear the live owner before late mark")
1148+ },
1149+ },
1150+ {
1151+ name: "session_removal",
1152+ win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) {
1153+ t.Helper()
1154+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
1155+ d.mu.Lock()
1156+ registered := d.sessions[sess.id] == sess
1157+ d.mu.Unlock()
1158+ require.False(t, registered, "killSession must unregister before late mark")
1159+ },
1160+ },
1161+ }
1162+ for _, tc := range cases {
1163+ t.Run(tc.name, func(t *testing.T) {
1164+ pty, release := newBlockingPTY(t)
1165+ defer release()
1166+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1167+
1168+ oldTr := &closeTrackingTransport{}
1169+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1170+ require.NoError(t, err)
1171+ token := ac.resumeToken
1172+ require.NotZero(t, token)
1173+ require.Same(t, ac, sess.client)
1174+
1175+ validated := make(chan struct{})
1176+ releaseMark := make(chan struct{})
1177+ var releaseOnce sync.Once
1178+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseMark) }) })
1179+ d.beforeMarkParkingInFlight = func() {
1180+ close(validated)
1181+ <-releaseMark
1182+ }
1183+
1184+ type routeResult struct {
1185+ err error
1186+ }
1187+ result := make(chan routeResult, 1)
1188+ go func() {
1189+ _, _, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
1190+ result <- routeResult{err: routeErr}
1191+ }()
1192+ awaitTestCompletion(t, validated, "live resume did not pause after credential validation before mark")
1193+
1194+ tc.win(t, d, sess, ac, oldTr, token)
1195+
1196+ d.mu.Lock()
1197+ _, parkingBeforeRelease := d.parking[token]
1198+ d.mu.Unlock()
1199+ require.False(t, parkingBeforeRelease, "terminal cleanup must not leave a parking marker before late mark resumes")
1200+
1201+ releaseOnce.Do(func() { close(releaseMark) })
1202+ got := awaitTestValue(t, result, "live IntentResume stayed blocked after terminal cleanup won before mark")
1203+ require.Error(t, got.err)
1204+ var pe *protoErr
1205+ require.ErrorAs(t, got.err, &pe)
1206+ require.Equal(t, ports.ErrNoSuchSession, pe.code)
1207+ require.Contains(t, pe.Error(), "resume token is no longer valid")
1208+
1209+ d.mu.Lock()
1210+ _, stillParking := d.parking[token]
1211+ _, stillParked := d.parked[token]
1212+ d.mu.Unlock()
1213+ require.False(t, stillParking, "late markParkingInFlight must not recreate a parking marker after terminal cleanup")
1214+ require.False(t, stillParked, "terminal cleanup must not leave a parked credential")
1215+ })
1216+ }
1217+
1218+ t.Run("direct_park_after_detach_without_marker", func(t *testing.T) {
1219+ pty, release := newBlockingPTY(t)
1220+ defer release()
1221+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1222+
1223+ oldTr := &closeTrackingTransport{}
1224+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1225+ require.NoError(t, err)
1226+ token := ac.resumeToken
1227+ require.NotZero(t, token)
1228+
1229+ require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot()))
1230+ require.Nil(t, sess.client)
1231+ require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker")
1232+
1233+ d.mu.Lock()
1234+ _, parkingBeforePark := d.parking[token]
1235+ d.mu.Unlock()
1236+ require.False(t, parkingBeforePark)
1237+
1238+ require.True(t, d.parkAttachment(sess, ac), "direct parkAttachment after detach must still park without recreating an in-flight marker")
1239+ d.mu.Lock()
1240+ parked := d.parked[token]
1241+ _, stillParking := d.parking[token]
1242+ d.mu.Unlock()
1243+ require.NotNil(t, parked)
1244+ require.Same(t, ac, parked.ac)
1245+ require.False(t, stillParking, "park must not leave an in-flight marker")
1246+ })
1247+}
1248+
1249+// TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection
1250+// when the attachment is no longer the exact active or snatched owner, while
1251+// 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.
1292+func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {
1293+ pty, release := newBlockingPTY(t)
1294+ defer release()
1295+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1296+
1297+ oldTr := &closeTrackingTransport{}
1298+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1299+ require.NoError(t, err)
1300+ token := ac.resumeToken
1301+ require.NotZero(t, token)
1302+
1303+ passedPrecheck := make(chan struct{})
1304+ releaseDetach := make(chan struct{})
1305+ var releaseOnce sync.Once
1306+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseDetach) }) })
1307+ d.beforeClientGoneDetach = func() {
1308+ close(passedPrecheck)
1309+ <-releaseDetach
1310+ }
1311+
1312+ goneDone := make(chan struct{})
1313+ go func() {
1314+ d.clientGone(sess, ac, oldTr, false)
1315+ close(goneDone)
1316+ }()
1317+ awaitTestCompletion(t, passedPrecheck, "stale clientGone did not reach the post-precheck seam")
1318+
1319+ newTr := &closeTrackingTransport{}
1320+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
1321+ require.NoError(t, err)
1322+ require.Same(t, sess, resumedSess)
1323+ require.Same(t, ac, resumedAC)
1324+ require.Same(t, newTr, ac.transport())
1325+
1326+ releaseOnce.Do(func() { close(releaseDetach) })
1327+ awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound")
1328+
1329+ require.Same(t, ac, sess.client, "stale cleanup must not detach the rebound attachment")
1330+ require.Same(t, newTr, ac.transport())
1331+ require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup")
1332+ require.True(t, oldTr.Closed(), "live resume retires the captured old transport")
1333+}
1334+
1335+// TestResumeLiveAttachmentParkFailureRetiresOldTransport covers shutdown racing
1336+// park after live detach: the old link must still be revoked/closed exactly once
1337+// and the session must not keep a half-detached owner.
1338+func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
1339+ pty, release := newBlockingPTY(t)
1340+ defer release()
1341+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1342+
1343+ oldTr := &closeTrackingTransport{}
1344+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1345+ require.NoError(t, err)
1346+ token := ac.resumeToken
1347+ require.NotZero(t, token)
1348+
1349+ d.afterDetachRoleEffectsFrozen = func() {
1350+ d.mu.Lock()
1351+ d.closing = true
1352+ d.mu.Unlock()
1353+ }
1354+
1355+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
1356+ require.Error(t, err)
1357+ var pe *protoErr
1358+ require.ErrorAs(t, err, &pe)
1359+ require.Equal(t, ports.ErrServerShutdown, pe.code)
1360+ require.Nil(t, sess.client, "failed live park must leave the session without an owner")
1361+ require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport")
1362+ require.True(t, oldTr.Closed(), "failed live park must close the captured old transport")
1363+ d.mu.Lock()
1364+ _, parked := d.parked[token]
1365+ d.mu.Unlock()
1366+ require.False(t, parked, "failed live park must not publish a resume credential")
1367+}
1368+
2711369 func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
2721370 pty, release := newBlockingPTY(t)
2731371 defer release()
@@ -660,6 +1758,76 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
6601758 require.False(t, ok, "killed session cannot be resumed")
6611759 }
6621760
1761+// TestKilledSessionUnblocksParkingWaiterFailsClosed covers session removal while
1762+// a same-token IntentResume is waiting on an in-flight parking marker. killSession
1763+// must purge only that session's markers so the waiter unblocks and fails closed,
1764+// without touching another session's parking marker.
1765+func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) {
1766+ workPTY, releaseWork := newBlockingPTY(t)
1767+ defer releaseWork()
1768+ otherPTY, releaseOther := newBlockingPTY(t)
1769+ defer releaseOther()
1770+ // Separate PTYs are required: a shared mock PTY makes killSession(work)
1771+ // close other's pane and cascade-remove the other session under -race,
1772+ // falsely failing the other-marker / fail-closed assertions.
1773+ d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY), stubClock{})
1774+
1775+ oldTr := &closeTrackingTransport{}
1776+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1777+ require.NoError(t, err)
1778+ token := ac.resumeToken
1779+ require.NotZero(t, token)
1780+
1781+ otherTr := &closeTrackingTransport{}
1782+ otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr)
1783+ require.NoError(t, err)
1784+ otherToken := d.markParkingInFlight(otherSess, otherAC)
1785+ require.NotZero(t, otherToken)
1786+ require.NotEqual(t, token, otherToken)
1787+
1788+ require.Equal(t, token, d.markParkingInFlight(sess, ac))
1789+ d.mu.Lock()
1790+ parkingMarker := d.parking[token]
1791+ otherMarker := d.parking[otherToken]
1792+ d.mu.Unlock()
1793+ require.NotNil(t, parkingMarker, "fixture: parking marker must be published before kill")
1794+ require.Same(t, ac, parkingMarker.ac)
1795+ require.NotNil(t, otherMarker, "fixture: other session marker must remain published")
1796+ require.Same(t, otherAC, otherMarker.ac)
1797+
1798+ waiterArmed := make(chan struct{})
1799+ d.afterParkingWaitArmed = func() { close(waiterArmed) }
1800+ waiterDone := make(chan bool, 1)
1801+ go func() {
1802+ waiterDone <- d.waitParkingInFlight(helloResumeCapable(ports.IntentResume, "work", token))
1803+ }()
1804+ awaitTestCompletion(t, waiterArmed, "parking waiter did not arm before session kill")
1805+
1806+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
1807+
1808+ waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after session kill")
1809+ require.True(t, waited, "waiter must observe the session marker before it is purged")
1810+
1811+ d.mu.Lock()
1812+ _, stillParking := d.parking[token]
1813+ otherAfter := d.parking[otherToken]
1814+ otherRegistered := d.sessions[otherSess.id] == otherSess
1815+ closing := d.closing
1816+ d.mu.Unlock()
1817+ require.False(t, stillParking, "killSession must purge the killed session's parking marker")
1818+ require.NotNil(t, otherAfter, "killSession must not purge another session's parking marker")
1819+ require.Same(t, otherAC, otherAfter.ac)
1820+ require.True(t, otherRegistered, "other session must survive work kill when PTYs are isolated")
1821+ require.False(t, closing, "daemon must not shut down while the other session remains")
1822+
1823+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
1824+ require.Error(t, err)
1825+ var killedErr *protoErr
1826+ require.ErrorAs(t, err, &killedErr)
1827+ require.Equal(t, ports.ErrNoSuchSession, killedErr.code)
1828+ require.Contains(t, killedErr.Error(), "resume token is no longer valid")
1829+}
1830+
6631831 func TestParkedPredecessorResumesSnatchedWithoutStealingActiveAttachment(t *testing.T) {
6641832 pty, release := newBlockingPTY(t)
6651833 defer release()
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "strings"6 "strings"
7 "sync"7 "sync"
8+ "sync/atomic"
8 "testing"9 "testing"
9 "time"10 "time"
10 11
@@ -268,6 +269,1103 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
268 require.Same(t, resumedAC, sess.client)269 require.Same(t, resumedAC, sess.client)
269 }270 }
270 271
272+// TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race
273+// where a parked credential is consumed or replaced while IntentResume waits on
274+// the attachment send lock. The nonzero token must fail closed instead of
275+// falling through to ordinary attach/create routing.
276+func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
277+ pty, release := newBlockingPTY(t)
278+ defer release()
279+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
280+
281+ oldTr := &closeTrackingTransport{}
282+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
283+ require.NoError(t, err)
284+ token := ac.resumeToken
285+ require.NotZero(t, token)
286+ d.clientGone(sess, ac, oldTr, false)
287+ require.Nil(t, sess.client)
288+ d.mu.Lock()
289+ parked := d.parked[token]
290+ sessionsBefore := len(d.sessions)
291+ d.mu.Unlock()
292+ require.NotNil(t, parked)
293+
294+ reachedLookup := make(chan struct{})
295+ releaseLookup := make(chan struct{})
296+ var releaseOnce sync.Once
297+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseLookup) }) })
298+ d.beforeResumeParkedSendMu = func() {
299+ close(reachedLookup)
300+ <-releaseLookup
301+ }
302+
303+ resumeTr := &closeTrackingTransport{}
304+ type routeResult struct {
305+ sess *session
306+ ac *attachedClient
307+ err error
308+ }
309+ result := make(chan routeResult, 1)
310+ go func() {
311+ routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), resumeTr)
312+ result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
313+ }()
314+ awaitTestCompletion(t, reachedLookup, "resumeParked did not pause after the parked lookup")
315+
316+ d.mu.Lock()
317+ require.Same(t, parked, d.parked[token], "fixture: token must still be parked at the seam")
318+ d.removeParkedLocked(token, parked)
319+ require.Nil(t, d.parked[token])
320+ require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")
321+ d.mu.Unlock()
322+
323+ releaseOnce.Do(func() { close(releaseLookup) })
324+ got := awaitTestValue(t, result, "IntentResume did not finish after parked token replacement")
325+
326+ require.Error(t, got.err)
327+ require.Nil(t, got.sess)
328+ require.Nil(t, got.ac)
329+ var pe *protoErr
330+ require.ErrorAs(t, got.err, &pe)
331+ require.Equal(t, ports.ErrNoSuchSession, pe.code)
332+ require.Contains(t, pe.Error(), "resume token is no longer valid")
333+ require.Nil(t, sess.client, "lifecycle-race resume must not take over the named session")
334+ require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake")
335+ d.mu.Lock()
336+ _, stillParked := d.parked[token]
337+ sessionsAfter := len(d.sessions)
338+ d.mu.Unlock()
339+ require.False(t, stillParked, "consumed token must stay invalid")
340+ require.Equal(t, sessionsBefore, sessionsAfter, "lifecycle-race resume must not create a session")
341+}
342+
343+// TestResumeLiveAttachmentParkedResumeRaceFailsClosed covers the live-recovery
344+// path that parks a still-active credential and then loses a competing
345+// resumeParked race before sendMu revalidation. The lifecycle sentinel must
346+// surface as fail-closed ErrNoSuchSession, never as a raw ErrInternal leak to
347+// handleHello.
348+func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
349+ pty, release := newBlockingPTY(t)
350+ defer release()
351+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
352+
353+ oldTr := &closeTrackingTransport{}
354+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
355+ require.NoError(t, err)
356+ token := ac.resumeToken
357+ require.NotZero(t, token)
358+ require.Same(t, ac, sess.client)
359+ d.mu.Lock()
360+ _, parkedAtStart := d.parked[token]
361+ sessionsBefore := len(d.sessions)
362+ d.mu.Unlock()
363+ require.False(t, parkedAtStart, "fixture: live attachment must not be parked yet")
364+
365+ reachedLookup := make(chan struct{})
366+ releaseLookup := make(chan struct{})
367+ var releaseOnce sync.Once
368+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseLookup) }) })
369+ d.beforeResumeParkedSendMu = func() {
370+ close(reachedLookup)
371+ <-releaseLookup
372+ }
373+
374+ resumeTr := &closeTrackingTransport{}
375+ type routeResult struct {
376+ sess *session
377+ ac *attachedClient
378+ err error
379+ }
380+ result := make(chan routeResult, 1)
381+ go func() {
382+ routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), resumeTr)
383+ result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
384+ }()
385+ awaitTestCompletion(t, reachedLookup, "live resume did not pause inside resumeParked after parking")
386+
387+ d.mu.Lock()
388+ parked := d.parked[token]
389+ require.NotNil(t, parked, "fixture: live recovery must have parked before the sendMu seam")
390+ require.Same(t, ac, parked.ac)
391+ d.removeParkedLocked(token, parked)
392+ require.Nil(t, d.parked[token])
393+ require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")
394+ d.mu.Unlock()
395+
396+ releaseOnce.Do(func() { close(releaseLookup) })
397+ got := awaitTestValue(t, result, "live IntentResume did not finish after competing resume consumed the token")
398+
399+ require.Error(t, got.err)
400+ require.Nil(t, got.sess)
401+ require.Nil(t, got.ac)
402+ require.False(t, errors.Is(got.err, errResumeTokenLifecycleRace), "raw lifecycle sentinel must not reach route callers")
403+ var pe *protoErr
404+ require.ErrorAs(t, got.err, &pe)
405+ require.Equal(t, ports.ErrNoSuchSession, pe.code)
406+ require.Contains(t, pe.Error(), "resume token is no longer valid")
407+ require.Nil(t, sess.client, "losing live resume must not take over the named session")
408+ require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake")
409+ d.mu.Lock()
410+ _, stillParked := d.parked[token]
411+ sessionsAfter := len(d.sessions)
412+ d.mu.Unlock()
413+ require.False(t, stillParked, "consumed token must stay invalid")
414+ require.Equal(t, sessionsBefore, sessionsAfter, "losing live resume must not create a session")
415+}
416+
417+// TestResumeActiveTokenBeforeParkRecoversSameAttachment covers the laptop-sleep
418+// reconnect race: IntentResume arrives with the live attachment's token before
419+// transport teardown has parked it. Same client must recover; unknown and
420+// 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.
485+func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
486+ pty, release := newBlockingPTY(t)
487+ defer release()
488+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
489+
490+ oldTr := &closeTrackingTransport{}
491+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
492+ require.NoError(t, err)
493+ token := ac.resumeToken
494+ require.NotZero(t, token)
495+ require.Same(t, ac, sess.client)
496+
497+ reachedGap := make(chan struct{})
498+ releaseGap := make(chan struct{})
499+ var releaseOnce sync.Once
500+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) })
501+ d.afterClientGoneDetach = func() {
502+ close(reachedGap)
503+ <-releaseGap
504+ }
505+
506+ goneDone := make(chan struct{})
507+ go func() {
508+ d.clientGone(sess, ac, oldTr, false)
509+ close(goneDone)
510+ }()
511+ awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park")
512+
513+ require.Nil(t, sess.client, "fixture: detach must have cleared the live owner")
514+ d.mu.Lock()
515+ parkingInGap := d.parking[token]
516+ _, parkedInGap := d.parked[token]
517+ d.mu.Unlock()
518+ require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
519+ require.Same(t, ac, parkingInGap.ac)
520+ require.Same(t, sess, parkingInGap.sess)
521+ require.False(t, parkedInGap, "fixture: park must not have published yet")
522+
523+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
524+ require.Error(t, err)
525+ var unknownErr *protoErr
526+ require.ErrorAs(t, err, &unknownErr)
527+ require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
528+ require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
529+
530+ wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
531+ wrongClient.ClientID = [16]byte{9, 9, 9, 9}
532+ _, _, err = d.route(wrongClient, &closeTrackingTransport{})
533+ require.Error(t, err)
534+ var mismatchErr *protoErr
535+ require.ErrorAs(t, err, &mismatchErr)
536+ require.Equal(t, ports.ErrNoSuchSession, mismatchErr.code)
537+ require.Contains(t, mismatchErr.Error(), "resume token is no longer valid")
538+
539+ newTr := &closeTrackingTransport{}
540+ type routeResult struct {
541+ sess *session
542+ ac *attachedClient
543+ err error
544+ }
545+ waiterArmed := make(chan struct{})
546+ d.afterParkingWaitArmed = func() { close(waiterArmed) }
547+ result := make(chan routeResult, 1)
548+ go func() {
549+ routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
550+ result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
551+ }()
552+ awaitTestCompletion(t, waiterArmed, "resume waiter did not arm on parking marker before park")
553+
554+ releaseOnce.Do(func() { close(releaseGap) })
555+ awaitTestCompletion(t, goneDone, "teardown did not finish after park release")
556+ got := awaitTestValue(t, result, "IntentResume did not finish after teardown-before-park gap")
557+
558+ require.NoError(t, got.err, "same-client token must recover across detach-before-park")
559+ require.Same(t, sess, got.sess)
560+ require.Same(t, ac, got.ac)
561+ require.Same(t, ac, sess.client)
562+ require.Same(t, newTr, ac.transport())
563+ require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
564+ require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")
565+ require.False(t, newTr.Closed(), "rebound transport must survive")
566+ d.mu.Lock()
567+ _, oldTokenParked := d.parked[token]
568+ _, stillParking := d.parking[token]
569+ d.mu.Unlock()
570+ require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
571+ require.False(t, stillParking, "parking marker must be consumed after park/resume")
572+
573+ d.clientGone(sess, ac, oldTr, false)
574+ require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment")
575+ require.Same(t, newTr, ac.transport())
576+ require.False(t, newTr.Closed())
577+}
578+
579+// TestConcurrentLiveResumesWaitParkingMarkerBeforePark covers two same-token
580+// IntentResume handshakes where the winner of detachIfCurrentTransport pauses
581+// before parkAttachment. The parking marker must already be published so the
582+// loser waits instead of treating the credential as unknown across that gap.
583+func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
584+ pty, release := newBlockingPTY(t)
585+ defer release()
586+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
587+
588+ oldTr := &closeTrackingTransport{}
589+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
590+ require.NoError(t, err)
591+ token := ac.resumeToken
592+ require.NotZero(t, token)
593+ require.Same(t, ac, sess.client)
594+
595+ reachedGap := make(chan struct{})
596+ releaseGap := make(chan struct{})
597+ var releaseOnce sync.Once
598+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) })
599+ d.afterResumeLiveDetach = func() {
600+ close(reachedGap)
601+ <-releaseGap
602+ }
603+
604+ type routeResult struct {
605+ sess *session
606+ ac *attachedClient
607+ err error
608+ }
609+ firstTr := &closeTrackingTransport{}
610+ firstResult := make(chan routeResult, 1)
611+ go func() {
612+ routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), firstTr)
613+ firstResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
614+ }()
615+ awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park")
616+
617+ require.Nil(t, sess.client, "fixture: winning resume must have cleared the live owner")
618+ d.mu.Lock()
619+ parkingInGap := d.parking[token]
620+ _, parkedInGap := d.parked[token]
621+ d.mu.Unlock()
622+ require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach for concurrent resumes")
623+ require.Same(t, ac, parkingInGap.ac)
624+ require.False(t, parkedInGap, "fixture: park must not have published yet")
625+
626+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
627+ require.Error(t, err)
628+ var unknownErr *protoErr
629+ require.ErrorAs(t, err, &unknownErr)
630+ require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
631+
632+ secondTr := &closeTrackingTransport{}
633+ secondWaiting := make(chan struct{})
634+ d.afterParkingWaitArmed = func() {
635+ close(secondWaiting)
636+ }
637+ secondResult := make(chan routeResult, 1)
638+ go func() {
639+ routedSess, routedAC, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), secondTr)
640+ secondResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
641+ }()
642+ awaitTestCompletion(t, secondWaiting, "second live resume did not arm parking wait")
643+
644+ releaseOnce.Do(func() { close(releaseGap) })
645+ first := awaitTestValue(t, firstResult, "first concurrent IntentResume did not finish")
646+ second := awaitTestValue(t, secondResult, "second concurrent IntentResume did not finish")
647+
648+ var winner, loser routeResult
649+ var winnerTr, loserTr *closeTrackingTransport
650+ switch {
651+ case first.err == nil && second.err != nil:
652+ winner, loser = first, second
653+ winnerTr, loserTr = firstTr, secondTr
654+ case second.err == nil && first.err != nil:
655+ winner, loser = second, first
656+ winnerTr, loserTr = secondTr, firstTr
657+ default:
658+ t.Fatalf("expected exactly one concurrent resume to succeed; first=%v second=%v", first.err, second.err)
659+ }
660+
661+ require.Same(t, sess, winner.sess)
662+ require.Same(t, ac, winner.ac)
663+ require.Same(t, ac, sess.client)
664+ require.Same(t, winnerTr, ac.transport())
665+ require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
666+ require.True(t, oldTr.Closed(), "winning resume must retire the old transport")
667+ require.False(t, winnerTr.Closed(), "winning rebound transport must survive")
668+
669+ var loserErr *protoErr
670+ require.ErrorAs(t, loser.err, &loserErr)
671+ require.Equal(t, ports.ErrNoSuchSession, loserErr.code)
672+ require.Contains(t, loserErr.Error(), "resume token is no longer valid")
673+ require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")
674+
675+ d.mu.Lock()
676+ _, oldTokenParked := d.parked[token]
677+ _, stillParking := d.parking[token]
678+ d.mu.Unlock()
679+ require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
680+ require.False(t, stillParking, "parking marker must be consumed after park/resume")
681+
682+ d.clientGone(sess, ac, oldTr, false)
683+ require.Same(t, ac, sess.client, "stale old-link cleanup must not detach the rebound attachment")
684+ require.Same(t, winnerTr, ac.transport())
685+ require.False(t, winnerTr.Closed())
686+}
687+
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.
1047+func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
1048+ workPTY, releaseWork := newBlockingPTY(t)
1049+ defer releaseWork()
1050+ otherPTY, releaseOther := newBlockingPTY(t)
1051+ defer releaseOther()
1052+ // Separate PTYs keep the other session alive while work detaches.
1053+ d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY), stubClock{})
1054+
1055+ oldTr := &closeTrackingTransport{}
1056+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1057+ require.NoError(t, err)
1058+ token := ac.resumeToken
1059+ require.NotZero(t, token)
1060+
1061+ otherTr := &closeTrackingTransport{}
1062+ otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr)
1063+ require.NoError(t, err)
1064+ otherToken := d.markParkingInFlight(otherSess, otherAC)
1065+ require.NotZero(t, otherToken)
1066+ require.NotEqual(t, token, otherToken)
1067+
1068+ // Pre-mark as a same-attachment non-explicit teardown would before detach.
1069+ require.Equal(t, token, d.markParkingInFlight(sess, ac))
1070+ d.mu.Lock()
1071+ orphaned := d.parking[token]
1072+ otherMarker := d.parking[otherToken]
1073+ d.mu.Unlock()
1074+ require.NotNil(t, orphaned, "fixture: non-explicit teardown must publish the parking marker")
1075+ require.Same(t, ac, orphaned.ac)
1076+ require.NotNil(t, otherMarker, "fixture: other attachment marker must remain published")
1077+ require.Same(t, otherAC, otherMarker.ac)
1078+
1079+ waiterArmed := make(chan struct{})
1080+ d.afterParkingWaitArmed = func() { close(waiterArmed) }
1081+ waiterDone := make(chan bool, 1)
1082+ go func() {
1083+ waiterDone <- d.waitParkingInFlight(helloResumeCapable(ports.IntentResume, "work", token))
1084+ }()
1085+ awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker")
1086+
1087+ d.clientGone(sess, ac, oldTr, true)
1088+ require.Nil(t, sess.client, "explicit detach must clear the live owner")
1089+
1090+ waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach")
1091+ require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")
1092+
1093+ d.mu.Lock()
1094+ _, stillParking := d.parking[token]
1095+ otherAfter := d.parking[otherToken]
1096+ d.mu.Unlock()
1097+ require.False(t, stillParking, "explicit winner must clear the same attachment's orphaned marker")
1098+ require.NotNil(t, otherAfter, "explicit winner must not clear another attachment's marker")
1099+ require.Same(t, otherAC, otherAfter.ac)
1100+
1101+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
1102+ require.Error(t, err)
1103+ var unknownErr *protoErr
1104+ require.ErrorAs(t, err, &unknownErr)
1105+ require.Equal(t, ports.ErrNoSuchSession, unknownErr.code)
1106+ require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
1107+
1108+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
1109+ require.Error(t, err)
1110+ var retiredErr *protoErr
1111+ require.ErrorAs(t, err, &retiredErr)
1112+ require.Equal(t, ports.ErrNoSuchSession, retiredErr.code)
1113+ require.Contains(t, retiredErr.Error(), "resume token is no longer valid")
1114+
1115+ // A later parked lifecycle for another attachment must survive a clear
1116+ // scoped to the retired attachment identity.
1117+ require.True(t, d.parkAttachment(otherSess, otherAC))
1118+ d.mu.Lock()
1119+ parkedOther := d.parked[otherToken]
1120+ _, otherStillParking := d.parking[otherToken]
1121+ d.mu.Unlock()
1122+ require.NotNil(t, parkedOther, "subsequent park must publish the other attachment")
1123+ require.False(t, otherStillParking, "park consumes the other attachment's in-flight marker")
1124+
1125+ d.clearParkingInFlight(otherToken, ac)
1126+ d.mu.Lock()
1127+ parkedAfterMismatchedClear := d.parked[otherToken]
1128+ d.mu.Unlock()
1129+ require.Same(t, parkedOther, parkedAfterMismatchedClear, "mismatched clear must not drop a subsequent parked lifecycle")
1130+}
1131+
1132+// TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins covers the race where
1133+// live resume has validated the same-client credential, then explicit detach or
1134+// session removal wins before markParkingInFlight. Late marker publication must
1135+// be rejected so IntentResume returns promptly ErrNoSuchSession with no parking
1136+// marker left to strand waiters. Direct parkAttachment after detach still works.
1137+func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
1138+ cases := []struct {
1139+ name string
1140+ win func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64)
1141+ }{
1142+ {
1143+ name: "explicit_detach",
1144+ win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) {
1145+ t.Helper()
1146+ d.clientGone(sess, ac, oldTr, true)
1147+ require.Nil(t, sess.client, "explicit detach must clear the live owner before late mark")
1148+ },
1149+ },
1150+ {
1151+ name: "session_removal",
1152+ win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) {
1153+ t.Helper()
1154+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
1155+ d.mu.Lock()
1156+ registered := d.sessions[sess.id] == sess
1157+ d.mu.Unlock()
1158+ require.False(t, registered, "killSession must unregister before late mark")
1159+ },
1160+ },
1161+ }
1162+ for _, tc := range cases {
1163+ t.Run(tc.name, func(t *testing.T) {
1164+ pty, release := newBlockingPTY(t)
1165+ defer release()
1166+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1167+
1168+ oldTr := &closeTrackingTransport{}
1169+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1170+ require.NoError(t, err)
1171+ token := ac.resumeToken
1172+ require.NotZero(t, token)
1173+ require.Same(t, ac, sess.client)
1174+
1175+ validated := make(chan struct{})
1176+ releaseMark := make(chan struct{})
1177+ var releaseOnce sync.Once
1178+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseMark) }) })
1179+ d.beforeMarkParkingInFlight = func() {
1180+ close(validated)
1181+ <-releaseMark
1182+ }
1183+
1184+ type routeResult struct {
1185+ err error
1186+ }
1187+ result := make(chan routeResult, 1)
1188+ go func() {
1189+ _, _, routeErr := d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
1190+ result <- routeResult{err: routeErr}
1191+ }()
1192+ awaitTestCompletion(t, validated, "live resume did not pause after credential validation before mark")
1193+
1194+ tc.win(t, d, sess, ac, oldTr, token)
1195+
1196+ d.mu.Lock()
1197+ _, parkingBeforeRelease := d.parking[token]
1198+ d.mu.Unlock()
1199+ require.False(t, parkingBeforeRelease, "terminal cleanup must not leave a parking marker before late mark resumes")
1200+
1201+ releaseOnce.Do(func() { close(releaseMark) })
1202+ got := awaitTestValue(t, result, "live IntentResume stayed blocked after terminal cleanup won before mark")
1203+ require.Error(t, got.err)
1204+ var pe *protoErr
1205+ require.ErrorAs(t, got.err, &pe)
1206+ require.Equal(t, ports.ErrNoSuchSession, pe.code)
1207+ require.Contains(t, pe.Error(), "resume token is no longer valid")
1208+
1209+ d.mu.Lock()
1210+ _, stillParking := d.parking[token]
1211+ _, stillParked := d.parked[token]
1212+ d.mu.Unlock()
1213+ require.False(t, stillParking, "late markParkingInFlight must not recreate a parking marker after terminal cleanup")
1214+ require.False(t, stillParked, "terminal cleanup must not leave a parked credential")
1215+ })
1216+ }
1217+
1218+ t.Run("direct_park_after_detach_without_marker", func(t *testing.T) {
1219+ pty, release := newBlockingPTY(t)
1220+ defer release()
1221+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1222+
1223+ oldTr := &closeTrackingTransport{}
1224+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1225+ require.NoError(t, err)
1226+ token := ac.resumeToken
1227+ require.NotZero(t, token)
1228+
1229+ require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot()))
1230+ require.Nil(t, sess.client)
1231+ require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker")
1232+
1233+ d.mu.Lock()
1234+ _, parkingBeforePark := d.parking[token]
1235+ d.mu.Unlock()
1236+ require.False(t, parkingBeforePark)
1237+
1238+ require.True(t, d.parkAttachment(sess, ac), "direct parkAttachment after detach must still park without recreating an in-flight marker")
1239+ d.mu.Lock()
1240+ parked := d.parked[token]
1241+ _, stillParking := d.parking[token]
1242+ d.mu.Unlock()
1243+ require.NotNil(t, parked)
1244+ require.Same(t, ac, parked.ac)
1245+ require.False(t, stillParking, "park must not leave an in-flight marker")
1246+ })
1247+}
1248+
1249+// TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection
1250+// when the attachment is no longer the exact active or snatched owner, while
1251+// 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.
1292+func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {
1293+ pty, release := newBlockingPTY(t)
1294+ defer release()
1295+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1296+
1297+ oldTr := &closeTrackingTransport{}
1298+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1299+ require.NoError(t, err)
1300+ token := ac.resumeToken
1301+ require.NotZero(t, token)
1302+
1303+ passedPrecheck := make(chan struct{})
1304+ releaseDetach := make(chan struct{})
1305+ var releaseOnce sync.Once
1306+ t.Cleanup(func() { releaseOnce.Do(func() { close(releaseDetach) }) })
1307+ d.beforeClientGoneDetach = func() {
1308+ close(passedPrecheck)
1309+ <-releaseDetach
1310+ }
1311+
1312+ goneDone := make(chan struct{})
1313+ go func() {
1314+ d.clientGone(sess, ac, oldTr, false)
1315+ close(goneDone)
1316+ }()
1317+ awaitTestCompletion(t, passedPrecheck, "stale clientGone did not reach the post-precheck seam")
1318+
1319+ newTr := &closeTrackingTransport{}
1320+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
1321+ require.NoError(t, err)
1322+ require.Same(t, sess, resumedSess)
1323+ require.Same(t, ac, resumedAC)
1324+ require.Same(t, newTr, ac.transport())
1325+
1326+ releaseOnce.Do(func() { close(releaseDetach) })
1327+ awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound")
1328+
1329+ require.Same(t, ac, sess.client, "stale cleanup must not detach the rebound attachment")
1330+ require.Same(t, newTr, ac.transport())
1331+ require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup")
1332+ require.True(t, oldTr.Closed(), "live resume retires the captured old transport")
1333+}
1334+
1335+// TestResumeLiveAttachmentParkFailureRetiresOldTransport covers shutdown racing
1336+// park after live detach: the old link must still be revoked/closed exactly once
1337+// and the session must not keep a half-detached owner.
1338+func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
1339+ pty, release := newBlockingPTY(t)
1340+ defer release()
1341+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1342+
1343+ oldTr := &closeTrackingTransport{}
1344+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1345+ require.NoError(t, err)
1346+ token := ac.resumeToken
1347+ require.NotZero(t, token)
1348+
1349+ d.afterDetachRoleEffectsFrozen = func() {
1350+ d.mu.Lock()
1351+ d.closing = true
1352+ d.mu.Unlock()
1353+ }
1354+
1355+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
1356+ require.Error(t, err)
1357+ var pe *protoErr
1358+ require.ErrorAs(t, err, &pe)
1359+ require.Equal(t, ports.ErrServerShutdown, pe.code)
1360+ require.Nil(t, sess.client, "failed live park must leave the session without an owner")
1361+ require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport")
1362+ require.True(t, oldTr.Closed(), "failed live park must close the captured old transport")
1363+ d.mu.Lock()
1364+ _, parked := d.parked[token]
1365+ d.mu.Unlock()
1366+ require.False(t, parked, "failed live park must not publish a resume credential")
1367+}
1368+
271 func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {1369 func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
272 pty, release := newBlockingPTY(t)1370 pty, release := newBlockingPTY(t)
273 defer release()1371 defer release()
@@ -660,6 +1758,76 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
660 require.False(t, ok, "killed session cannot be resumed")1758 require.False(t, ok, "killed session cannot be resumed")
661 }1759 }
662 1760
1761+// TestKilledSessionUnblocksParkingWaiterFailsClosed covers session removal while
1762+// a same-token IntentResume is waiting on an in-flight parking marker. killSession
1763+// must purge only that session's markers so the waiter unblocks and fails closed,
1764+// without touching another session's parking marker.
1765+func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) {
1766+ workPTY, releaseWork := newBlockingPTY(t)
1767+ defer releaseWork()
1768+ otherPTY, releaseOther := newBlockingPTY(t)
1769+ defer releaseOther()
1770+ // Separate PTYs are required: a shared mock PTY makes killSession(work)
1771+ // close other's pane and cascade-remove the other session under -race,
1772+ // falsely failing the other-marker / fail-closed assertions.
1773+ d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY), stubClock{})
1774+
1775+ oldTr := &closeTrackingTransport{}
1776+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
1777+ require.NoError(t, err)
1778+ token := ac.resumeToken
1779+ require.NotZero(t, token)
1780+
1781+ otherTr := &closeTrackingTransport{}
1782+ otherSess, otherAC, err := d.route(helloResumeCapable(ports.IntentNew, "other", 0), otherTr)
1783+ require.NoError(t, err)
1784+ otherToken := d.markParkingInFlight(otherSess, otherAC)
1785+ require.NotZero(t, otherToken)
1786+ require.NotEqual(t, token, otherToken)
1787+
1788+ require.Equal(t, token, d.markParkingInFlight(sess, ac))
1789+ d.mu.Lock()
1790+ parkingMarker := d.parking[token]
1791+ otherMarker := d.parking[otherToken]
1792+ d.mu.Unlock()
1793+ require.NotNil(t, parkingMarker, "fixture: parking marker must be published before kill")
1794+ require.Same(t, ac, parkingMarker.ac)
1795+ require.NotNil(t, otherMarker, "fixture: other session marker must remain published")
1796+ require.Same(t, otherAC, otherMarker.ac)
1797+
1798+ waiterArmed := make(chan struct{})
1799+ d.afterParkingWaitArmed = func() { close(waiterArmed) }
1800+ waiterDone := make(chan bool, 1)
1801+ go func() {
1802+ waiterDone <- d.waitParkingInFlight(helloResumeCapable(ports.IntentResume, "work", token))
1803+ }()
1804+ awaitTestCompletion(t, waiterArmed, "parking waiter did not arm before session kill")
1805+
1806+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
1807+
1808+ waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after session kill")
1809+ require.True(t, waited, "waiter must observe the session marker before it is purged")
1810+
1811+ d.mu.Lock()
1812+ _, stillParking := d.parking[token]
1813+ otherAfter := d.parking[otherToken]
1814+ otherRegistered := d.sessions[otherSess.id] == otherSess
1815+ closing := d.closing
1816+ d.mu.Unlock()
1817+ require.False(t, stillParking, "killSession must purge the killed session's parking marker")
1818+ require.NotNil(t, otherAfter, "killSession must not purge another session's parking marker")
1819+ require.Same(t, otherAC, otherAfter.ac)
1820+ require.True(t, otherRegistered, "other session must survive work kill when PTYs are isolated")
1821+ require.False(t, closing, "daemon must not shut down while the other session remains")
1822+
1823+ _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{})
1824+ require.Error(t, err)
1825+ var killedErr *protoErr
1826+ require.ErrorAs(t, err, &killedErr)
1827+ require.Equal(t, ports.ErrNoSuchSession, killedErr.code)
1828+ require.Contains(t, killedErr.Error(), "resume token is no longer valid")
1829+}
1830+
663 func TestParkedPredecessorResumesSnatchedWithoutStealingActiveAttachment(t *testing.T) {1831 func TestParkedPredecessorResumesSnatchedWithoutStealingActiveAttachment(t *testing.T) {
664 pty, release := newBlockingPTY(t)1832 pty, release := newBlockingPTY(t)
665 defer release()1833 defer release()
M internal/usecase/daemon/session.go +11 -0
@@ -677,6 +677,13 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
677677 // detachIfCurrent publishes terminal role invalidation through the attachment
678678 // gate. Freeze/drain happens before routing or session locks are acquired.
679679 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
680+ return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})
681+}
682+
683+// detachIfCurrentTransport is detachIfCurrent with an optional exact-link
684+// fence. When expected.transport is non-nil, a rebound incarnation of the same
685+// attachment object cannot be detached by a stale connection loop.
686+func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, expected transportSnapshot) bool {
680687 if sess == nil || ac == nil {
681688 return false
682689 }
@@ -688,6 +695,9 @@ func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
688695 d.notices.routingMu.Lock()
689696 sess.mu.Lock()
690697 current := sess.client == ac && ac.currentSession() == sess
698+ if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
699+ current = false
700+ }
691701 if current {
692702 sess.client = nil
693703 ac.roleGeneration.Add(1)
@@ -1473,6 +1483,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
14731483 // while the complete immutable gate set remains frozen.
14741484 delete(d.sessions, sess.id)
14751485 d.clearBarScriptsForSession(sess.id)
1486+ d.purgeParkingForSessionLocked(sess)
14761487 parkedRetirements := d.purgeParkedForSessionLocked(sess)
14771488 detachedActive := sess.client
14781489 attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
@@ -677,6 +677,13 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
677 // detachIfCurrent publishes terminal role invalidation through the attachment677 // detachIfCurrent publishes terminal role invalidation through the attachment
678 // gate. Freeze/drain happens before routing or session locks are acquired.678 // gate. Freeze/drain happens before routing or session locks are acquired.
679 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {679 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
680+ return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})
681+}
682+
683+// detachIfCurrentTransport is detachIfCurrent with an optional exact-link
684+// fence. When expected.transport is non-nil, a rebound incarnation of the same
685+// attachment object cannot be detached by a stale connection loop.
686+func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, expected transportSnapshot) bool {
680 if sess == nil || ac == nil {687 if sess == nil || ac == nil {
681 return false688 return false
682 }689 }
@@ -688,6 +695,9 @@ func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
688 d.notices.routingMu.Lock()695 d.notices.routingMu.Lock()
689 sess.mu.Lock()696 sess.mu.Lock()
690 current := sess.client == ac && ac.currentSession() == sess697 current := sess.client == ac && ac.currentSession() == sess
698+ if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
699+ current = false
700+ }
691 if current {701 if current {
692 sess.client = nil702 sess.client = nil
693 ac.roleGeneration.Add(1)703 ac.roleGeneration.Add(1)
@@ -1473,6 +1483,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1473 // while the complete immutable gate set remains frozen.1483 // while the complete immutable gate set remains frozen.
1474 delete(d.sessions, sess.id)1484 delete(d.sessions, sess.id)
1475 d.clearBarScriptsForSession(sess.id)1485 d.clearBarScriptsForSession(sess.id)
1486+ d.purgeParkingForSessionLocked(sess)
1476 parkedRetirements := d.purgeParkedForSessionLocked(sess)1487 parkedRetirements := d.purgeParkedForSessionLocked(sess)
1477 detachedActive := sess.client1488 detachedActive := sess.client
1478 attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))1489 attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
M internal/usecase/daemon/snatched_cleanup.go +43 -5
@@ -37,21 +37,43 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b
3737 }
3838 token.ac.setSession(nil)
3939 token.ac.clearCaptureFrames()
40+ if terminal {
41+ // A losing park path may have published a same-attachment marker before
42+ // this terminal unroute won. Retire only that attachment's marker so
43+ // waiters fail closed instead of hanging; park paths (terminal=false)
44+ // keep the marker until parkAttachmentAs or abandonment cleanup.
45+ d.clearParkingInFlight(token.ac.resumeToken, token.ac)
46+ }
4047 return true
4148 }
4249
4350 // parkSnatchedAttachment removes only the exact lost snatched incarnation and
4451 // retains its resume identity for a role-preserving reconnect.
4552 func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
46- if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) {
53+ if token.ac == nil || !token.ac.resumeCapable {
4754 return false
4855 }
56+ // Advertise parking before unroute so IntentResume never observes both the
57+ // snatched seat and parking/parked registries empty for a still-valid token.
58+ parkingToken := d.markParkingInFlight(token.sess, token.ac)
59+ if !d.unrouteSnatchedAttachment(token, false) {
60+ d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
61+ return false
62+ }
63+ if d.afterSnatchedUnrouteBeforePark != nil {
64+ d.afterSnatchedUnrouteBeforePark()
65+ }
4966 if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
50- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
67+ captured := token.transport.transport
68+ _ = token.ac.revokeTransport(captured)
69+ _ = token.ac.closeCapturedTransport(captured)
5170 return true
5271 }
72+ d.clearParkingInFlight(parkingToken, token.ac)
5373 token.ac.clearPreviousSession()
54- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
74+ captured := token.transport.transport
75+ _ = token.ac.revokeTransport(captured)
76+ _ = token.ac.closeCapturedTransport(captured)
5577 return false
5678 }
5779
@@ -85,6 +107,13 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
85107 return false
86108 }
87109
110+ // Advertise parking before ownership removal so IntentResume can wait out
111+ // the unroute→park gap for a still-valid snatched credential.
112+ var parkingToken uint64
113+ if token.ac.resumeCapable {
114+ parkingToken = d.markParkingInFlight(token.sess, token.ac)
115+ }
116+
88117 // The frozen gate prevents a promotion from publishing between overlay
89118 // cleanup and terminal registry publication.
90119 d.clearForSnatch(token)
@@ -102,17 +131,26 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
102131 token.sess.mu.Unlock()
103132 d.notices.routingMu.Unlock()
104133 if !current {
134+ d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
105135 return false
106136 }
107137
108138 token.ac.setSession(nil)
139+ if d.afterSnatchedUnrouteBeforePark != nil {
140+ d.afterSnatchedUnrouteBeforePark()
141+ }
109142 if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
110- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
143+ captured := token.transport.transport
144+ _ = token.ac.revokeTransport(captured)
145+ _ = token.ac.closeCapturedTransport(captured)
111146 token.ac.clearSnatchedInput()
112147 d.unregisterPreview(token.ac)
113148 return true
114149 }
115- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))
150+ d.clearParkingInFlight(parkingToken, token.ac)
151+ captured := token.transport.transport
152+ _ = token.ac.revokeTransport(captured)
153+ _ = token.ac.closeCapturedTransport(captured)
116154 token.ac.clearSnatchedInput()
117155 d.unregisterPreview(token.ac)
118156 token.ac.clearPreviousSession()
@@ -37,21 +37,43 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b
37 }37 }
38 token.ac.setSession(nil)38 token.ac.setSession(nil)
39 token.ac.clearCaptureFrames()39 token.ac.clearCaptureFrames()
40+ if terminal {
41+ // A losing park path may have published a same-attachment marker before
42+ // this terminal unroute won. Retire only that attachment's marker so
43+ // waiters fail closed instead of hanging; park paths (terminal=false)
44+ // keep the marker until parkAttachmentAs or abandonment cleanup.
45+ d.clearParkingInFlight(token.ac.resumeToken, token.ac)
46+ }
40 return true47 return true
41 }48 }
42 49
43 // parkSnatchedAttachment removes only the exact lost snatched incarnation and50 // parkSnatchedAttachment removes only the exact lost snatched incarnation and
44 // retains its resume identity for a role-preserving reconnect.51 // retains its resume identity for a role-preserving reconnect.
45 func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {52 func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
46- if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) {53+ if token.ac == nil || !token.ac.resumeCapable {
47 return false54 return false
48 }55 }
56+ // Advertise parking before unroute so IntentResume never observes both the
57+ // snatched seat and parking/parked registries empty for a still-valid token.
58+ parkingToken := d.markParkingInFlight(token.sess, token.ac)
59+ if !d.unrouteSnatchedAttachment(token, false) {
60+ d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
61+ return false
62+ }
63+ if d.afterSnatchedUnrouteBeforePark != nil {
64+ d.afterSnatchedUnrouteBeforePark()
65+ }
49 if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {66 if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
50- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))67+ captured := token.transport.transport
68+ _ = token.ac.revokeTransport(captured)
69+ _ = token.ac.closeCapturedTransport(captured)
51 return true70 return true
52 }71 }
72+ d.clearParkingInFlight(parkingToken, token.ac)
53 token.ac.clearPreviousSession()73 token.ac.clearPreviousSession()
54- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))74+ captured := token.transport.transport
75+ _ = token.ac.revokeTransport(captured)
76+ _ = token.ac.closeCapturedTransport(captured)
55 return false77 return false
56 }78 }
57 79
@@ -85,6 +107,13 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
85 return false107 return false
86 }108 }
87 109
110+ // Advertise parking before ownership removal so IntentResume can wait out
111+ // the unroute→park gap for a still-valid snatched credential.
112+ var parkingToken uint64
113+ if token.ac.resumeCapable {
114+ parkingToken = d.markParkingInFlight(token.sess, token.ac)
115+ }
116+
88 // The frozen gate prevents a promotion from publishing between overlay117 // The frozen gate prevents a promotion from publishing between overlay
89 // cleanup and terminal registry publication.118 // cleanup and terminal registry publication.
90 d.clearForSnatch(token)119 d.clearForSnatch(token)
@@ -102,17 +131,26 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
102 token.sess.mu.Unlock()131 token.sess.mu.Unlock()
103 d.notices.routingMu.Unlock()132 d.notices.routingMu.Unlock()
104 if !current {133 if !current {
134+ d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
105 return false135 return false
106 }136 }
107 137
108 token.ac.setSession(nil)138 token.ac.setSession(nil)
139+ if d.afterSnatchedUnrouteBeforePark != nil {
140+ d.afterSnatchedUnrouteBeforePark()
141+ }
109 if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {142 if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
110- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))143+ captured := token.transport.transport
144+ _ = token.ac.revokeTransport(captured)
145+ _ = token.ac.closeCapturedTransport(captured)
111 token.ac.clearSnatchedInput()146 token.ac.clearSnatchedInput()
112 d.unregisterPreview(token.ac)147 d.unregisterPreview(token.ac)
113 return true148 return true
114 }149 }
115- _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport))150+ d.clearParkingInFlight(parkingToken, token.ac)
151+ captured := token.transport.transport
152+ _ = token.ac.revokeTransport(captured)
153+ _ = token.ac.closeCapturedTransport(captured)
116 token.ac.clearSnatchedInput()154 token.ac.clearSnatchedInput()
117 d.unregisterPreview(token.ac)155 d.unregisterPreview(token.ac)
118 token.ac.clearPreviousSession()156 token.ac.clearPreviousSession()

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