1package daemon
2
3import (
4 "context"
5 "errors"
6 "slices"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 vt "github.com/bnema/vev-vt"
16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/domain/layout"
18 "github.com/bnema/vev/internal/domain/terminalcap"
19 "github.com/bnema/vev/internal/ports"
20 portsmocks "github.com/bnema/vev/internal/ports/mocks"
21 "github.com/bnema/vev/internal/protocol"
22 "github.com/bnema/vev/internal/protocol/wire"
23)
24
25type closeTrackingTransport struct {
26 mu sync.Mutex
27 closed bool
28 sends []wire.Envelope
29}
30
31func (t *closeTrackingTransport) Send(f wire.Envelope) error {
32 t.mu.Lock()
33 defer t.mu.Unlock()
34 t.sends = append(t.sends, f)
35 return nil
36}
37func (t *closeTrackingTransport) Recv() (wire.Envelope, error) {
38 return wire.Envelope{}, errors.New("closed")
39}
40func (t *closeTrackingTransport) Close() error {
41 t.mu.Lock()
42 t.closed = true
43 t.mu.Unlock()
44 return nil
45}
46func (t *closeTrackingTransport) Closed() bool {
47 t.mu.Lock()
48 defer t.mu.Unlock()
49 return t.closed
50}
51
52func (t *closeTrackingTransport) Sends() []wire.Envelope {
53 t.mu.Lock()
54 defer t.mu.Unlock()
55 return append([]wire.Envelope(nil), t.sends...)
56}
57
58type closeCountingBlockedTransport struct {
59 closeOnce sync.Once
60 closed chan struct{}
61 count int
62 mu sync.Mutex
63}
64
65func newCloseCountingBlockedTransport() *closeCountingBlockedTransport {
66 return &closeCountingBlockedTransport{closed: make(chan struct{})}
67}
68
69func (t *closeCountingBlockedTransport) Send(wire.Envelope) error {
70 <-t.closed
71 return errors.New("closed")
72}
73
74func (t *closeCountingBlockedTransport) Recv() (wire.Envelope, error) {
75 return wire.Envelope{}, errors.New("closed")
76}
77
78func (t *closeCountingBlockedTransport) Close() error {
79 t.mu.Lock()
80 t.count++
81 t.mu.Unlock()
82 t.closeOnce.Do(func() { close(t.closed) })
83 return nil
84}
85
86func (t *closeCountingBlockedTransport) CloseCount() int {
87 t.mu.Lock()
88 defer t.mu.Unlock()
89 return t.count
90}
91
92func helloResumeCapable(intent uint8, name string, token uint64) protocol.Hello {
93 return protocol.Hello{
94 Version: protocol.Version,
95 Intent: intent,
96 ClientID: [16]byte{1, 2, 3, 4},
97 ResumeToken: token,
98 Name: name,
99 Size: domain.Size{Cols: 80, Rows: 24},
100 TermEnv: "xterm-256color",
101 }
102}
103
104func TestTokenlessResumeIsRejectedBeforeNameRouting(t *testing.T) {
105 d := newTestDaemon(t, nil, stubClock{})
106 tr, _ := newCapturingTransport(t)
107 _, _, err := d.route(helloResumeCapable(protocol.IntentResume, "work", 0), tr)
108 var protocolErr *protoErr
109 require.ErrorAs(t, err, &protocolErr)
110 require.Equal(t, protocol.ErrNoSuchSession, protocolErr.code)
111 d.mu.Lock()
112 sessionCount := len(d.sessions)
113 d.mu.Unlock()
114 require.Zero(t, sessionCount)
115}
116
117func TestResumeRejectsMismatchedRemoteTargetBeforeOwnershipMutation(t *testing.T) {
118 pty, release := newBlockingPTY(t)
119 defer release()
120 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
121 oldTransport := &closeTrackingTransport{}
122 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTransport)
123 require.NoError(t, err)
124 token := ac.resumeToken
125
126 target := domain.RemoteSessionTarget{
127 LifecycleID: sess.incarnation, SessionName: sess.name, LiveTabID: "missing-tab",
128 }
129 resume := helloResumeCapable(protocol.IntentResume, "work", token)
130 resume.SessionTarget = ptrSessionAttachTarget(protocol.SessionAttachTargetFromRemote(target))
131 resume.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
132 _, _, err = d.route(resume, &closeTrackingTransport{})
133 var protocolErr *protoErr
134 require.ErrorAs(t, err, &protocolErr)
135 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
136 require.Same(t, oldTransport, ac.transport())
137 d.mu.Lock()
138 _, parked := d.resume.parked[token]
139 d.mu.Unlock()
140 require.False(t, parked, "mismatched target must not park the live attachment")
141
142 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
143}
144
145func TestParkedResumeRejectsMismatchedRemoteTargetBeforeClaim(t *testing.T) {
146 pty, release := newBlockingPTY(t)
147 defer release()
148 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
149 oldTransport := &closeTrackingTransport{}
150 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTransport)
151 require.NoError(t, err)
152 token := ac.resumeToken
153 d.clientGone(sess, ac, oldTransport, false)
154
155 target := domain.RemoteSessionTarget{
156 LifecycleID: sess.incarnation, SessionName: sess.name, LiveTabID: "missing-tab",
157 }
158 resume := helloResumeCapable(protocol.IntentResume, "work", token)
159 resume.SessionTarget = ptrSessionAttachTarget(protocol.SessionAttachTargetFromRemote(target))
160 resume.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
161 _, _, err = d.route(resume, &closeTrackingTransport{})
162 var protocolErr *protoErr
163 require.ErrorAs(t, err, &protocolErr)
164 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
165 d.mu.Lock()
166 parked := d.resume.parked[token]
167 claimed := parked != nil && parked.claimed
168 d.mu.Unlock()
169 require.NotNil(t, parked)
170 require.False(t, claimed, "mismatched target must not claim the parked attachment")
171
172 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
173}
174
175func TestFailedResumeRearmsParkExpiryAfterClaimTimerFires(t *testing.T) {
176 clock := &signalClock{timers: make(chan *signalTimer, 8)}
177 pty, release := newBlockingPTY(t)
178 defer release()
179 d := newTestDaemon(t, newFactory(t, pty), clock)
180
181 oldTransport := &closeTrackingTransport{}
182 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTransport)
183 require.NoError(t, err)
184 token := ac.resumeToken
185 d.clientGone(sess, ac, oldTransport, false)
186 parkTimer := <-clock.timers
187 d.mu.Lock()
188 parked := d.resume.parked[token]
189 d.mu.Unlock()
190 require.NotNil(t, parked)
191
192 resumed, resumedAC, ok, err := d.resumeParked(helloResumeCapable(protocol.IntentResume, "work", token), &closeTrackingTransport{}, defaultSize)
193 require.NoError(t, err)
194 require.True(t, ok)
195 require.Same(t, sess, resumed)
196 require.Same(t, ac, resumedAC)
197
198 // Drive the original fake timer after the claim. The old entry remains
199 // claimable only until abort; abort must install a fresh expiry rather than
200 // restoring a credential whose timer has already fired.
201 parkTimer.ch <- time.Time{}
202 d.expireParked(token, parked)
203 require.True(t, d.abortResumeClaim(ac))
204 rearmedTimer := <-clock.timers
205 require.NotSame(t, parkTimer, rearmedTimer)
206 d.mu.Lock()
207 rearmed := d.resume.parked[token]
208 claimed := rearmed != nil && rearmed.claimed
209 d.mu.Unlock()
210 require.NotNil(t, rearmed)
211 require.False(t, claimed)
212 rearmedTimer.ch <- time.Time{}
213 d.expireParked(token, rearmed)
214 d.mu.Lock()
215 _, retained := d.resume.parked[token]
216 d.mu.Unlock()
217 require.False(t, retained, "rearmed credential must still expire")
218
219 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, false))
220 d.sessWg.Wait()
221}
222
223func TestNamedLinkLossParks(t *testing.T) {
224 pty, release := newBlockingPTY(t)
225 defer release()
226 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
227
228 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
229 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), tr)
230 require.NoError(t, err)
231 token := ac.resumeToken
232 require.NotZero(t, token)
233
234 d.clientGone(sess, ac, ac.transport(), false)
235 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
236 require.Empty(t, sess.snapshotAttachments())
237 d.mu.Lock()
238 _, parked := d.resume.parked[token]
239 d.mu.Unlock()
240 require.True(t, parked, "named resume-capable link loss is parked")
241}
242
243func TestBoundedSendTimeoutCannotTargetResumedTransport(t *testing.T) {
244 pty, release := newBlockingPTY(t)
245 defer release()
246 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
247 oldTransport := &closeTrackingTransport{}
248 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "bounded-send-resume", 0), oldTransport)
249 require.NoError(t, err)
250 token := ac.resumeToken
251 d.clientGone(sess, ac, oldTransport, false)
252
253 clock := &signalClock{timers: make(chan *signalTimer, 1)}
254 d.clock = clock
255 ac.sendMu.Lock()
256 expected := ac.transportSnapshot()
257 orphanDone := make(chan struct{})
258 type boundedResult struct {
259 transport ports.ServerConnection
260 err error
261 }
262 result := make(chan boundedResult, 1)
263 go func() {
264 transport, sendErr := d.boundedSendWithTimeout(time.Second, expected.transport, func() error {
265 defer close(orphanDone)
266 return ac.sendExpectedTransport(expected, serverDetached(protocol.ReasonDetach))
267 })
268 result <- boundedResult{transport: transport, err: sendErr}
269 }()
270
271 // The worker is parked behind sendMu, so it has not observed a transport.
272 // Expire its deadline before resuming this same attachment with a new link.
273 timer := <-clock.timers
274 timer.ch <- time.Time{}
275 got := <-result
276 require.ErrorIs(t, got.err, errSendTimedOut)
277 require.Same(t, oldTransport, got.transport)
278 require.NoError(t, ac.closeCapturedTransport(got.transport))
279
280 newTransport := &closeTrackingTransport{}
281 d.mu.Lock()
282 resumedSess, resumedAC, ok, err := d.resumeParkedLocked(
283 helloResumeCapable(protocol.IntentResume, sess.name, token),
284 newTransport,
285 domain.Size{Cols: 80, Rows: 24},
286 )
287 d.mu.Unlock()
288 require.NoError(t, err)
289 require.True(t, ok)
290 require.Same(t, sess, resumedSess)
291 require.Same(t, ac, resumedAC)
292
293 ac.sendMu.Unlock()
294 <-orphanDone
295 require.Empty(t, newTransport.Sends(), "orphaned send must not write to the resumed transport")
296 require.False(t, newTransport.Closed(), "orphaned send must not close the resumed transport")
297}
298
299func TestDetachedNotificationTimeoutClosesCapturedTransportOnce(t *testing.T) {
300 clock := &signalClock{timers: make(chan *signalTimer, 1)}
301 d := newTestDaemon(t, nil, clock)
302 tr := newCloseCountingBlockedTransport()
303 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
304
305 d.notifyDetachedSnapshotAsync(detachedAttachmentSnapshot{
306 ac: ac,
307 transport: ac.transportSnapshot(),
308 }, protocol.ReasonDetach)
309 timer := <-clock.timers
310 timer.ch <- time.Time{}
311 d.waitNotifies()
312
313 require.Equal(t, 1, tr.CloseCount(), "timeout revocation must close the captured transport at most once")
314 require.Nil(t, ac.transport(), "timeout cleanup must revoke the captured transport")
315}
316
317func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
318 pty, releasePTY := newBlockingPTY(t)
319 defer releasePTY()
320 clock := newCoordinatorMockClock(t, 4)
321 d := newTestDaemon(t, newFactorySeq(t, pty), clock.clock)
322 oldTransport := &closeTrackingTransport{}
323 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "resume-welcome-gate", 0), oldTransport)
324 require.NoError(t, err)
325 token := ac.resumeToken
326 d.clientGone(sess, ac, oldTransport, false)
327
328 tr := newWelcomeBlockingTransport(t)
329 done := make(chan struct{})
330 resumeHello := helloResumeCapable(protocol.IntentResume, sess.name, token)
331 go func() {
332 d.handleHelloFrame(tr.tr, mustClientEnvelope(resumeHello))
333 close(done)
334 }()
335
336 <-tr.welcomeEntered
337 handshakeTimer := awaitHandshakeTimer(t, clock)
338 welcome := <-tr.sends
339 require.Equal(t, "Welcome", envelopeMessageName(t, welcome.Payload))
340 sess.mu.Lock()
341 resumed := sess.snapshotAttachmentsLocked()[0]
342 sess.mu.Unlock()
343 require.Same(t, ac, resumed)
344
345 d.invalidateRender(sess, resumed, true, "resume-welcome-gate-test")
346 timer := awaitLatestCoordinatorTimer(t, clock)
347 rc := sess.renderCoordinator()
348 rc.mu.Lock()
349 workerDone := rc.normalLane.token.done
350 rc.mu.Unlock()
351 require.NotNil(t, workerDone)
352 timer.ch <- time.Time{}
353 awaitTestCompletion(t, workerDone, "coordinator deadline worker did not complete")
354 requireNoCoordinatorOutputFrame(t, tr.sends)
355
356 tr.release()
357 output := awaitFrame(t, tr.sends, "Output")
358 first := unmarshalTestOutput(t, output.Payload)
359 require.Zero(t, first.Base)
360 tr.finish()
361 <-done
362 awaitTestCompletion(t, handshakeTimer.stopped, "handshake timer was not canceled after publication")
363 requireNoCoordinatorOutputFrame(t, tr.sends)
364}
365
366func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
367 pty, release := newBlockingPTY(t)
368 defer release()
369 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
370
371 tr := &closeTrackingTransport{}
372 initial := helloResumeCapable(protocol.IntentEphemeral, "", 0)
373 initial.Size = domain.Size{Cols: 120, Rows: 40}
374 sess, ac, err := d.route(initial, tr)
375 require.NoError(t, err)
376 require.True(t, sess.ephemeral)
377 token := ac.resumeToken
378 require.NotZero(t, token, "ephemeral sessions receive resume tokens")
379
380 peerHello := helloResumeCapable(protocol.IntentAttach, sess.name, 0)
381 peerHello.ClientID = [16]byte{9}
382 peerHello.Size = domain.Size{Cols: 100, Rows: 30}
383 peer := &closeTrackingTransport{}
384 _, peerAC, err := d.route(peerHello, peer)
385 require.NoError(t, err)
386 require.Same(t, sess, peerAC.currentSession())
387 d.firstPaint(sess, peerAC)
388 tb := sess.tabs[0]
389 tb.mu.Lock()
390 beforeParkingSize := tb.size
391 tb.mu.Unlock()
392 require.Equal(t, domain.Size{Cols: 100, Rows: 28}, beforeParkingSize, "the later attachment claim should be current before parking")
393
394 d.clientGone(sess, ac, ac.transport(), false)
395 require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive")
396 attachments := sess.snapshotAttachments()
397 var peerRegistered bool
398 for _, candidate := range attachments {
399 require.NotSame(t, ac, candidate, "parked attachment remained registered")
400 if candidate == peerAC {
401 peerRegistered = true
402 }
403 }
404 require.True(t, peerRegistered, "the live peer attachment was removed while parking")
405 d.mu.Lock()
406 _, parked := d.resume.parked[token]
407 d.mu.Unlock()
408 require.True(t, parked, "ephemeral resume-capable link loss is parked")
409
410 resumeHello := helloResumeCapable(protocol.IntentResume, sess.name, token)
411 resumeHello.Size = domain.Size{Cols: 80, Rows: 24}
412 newTr := &closeTrackingTransport{}
413 resumedSess, resumedAC, err := d.route(resumeHello, newTr)
414 require.NoError(t, err)
415 require.Same(t, sess, resumedSess)
416 require.Same(t, ac, resumedAC)
417 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")
418 require.True(t, slices.Contains(sess.snapshotAttachments(), resumedAC))
419 d.firstPaint(sess, resumedAC)
420 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, peerAC.sizeSnapshot())
421 tb.mu.Lock()
422 resumedSize := tb.size
423 tb.mu.Unlock()
424 require.Equal(t, domain.Size{Cols: 80, Rows: 22}, resumedSize, "resume must make its new size the latest geometry claim")
425}
426
427// TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race
428// where a parked credential is consumed or replaced while IntentResume waits on
429// the attachment send lock. The nonzero token must fail closed instead of
430// falling through to ordinary attach/create routing.
431func TestResumeParkedTokenReplacedDuringWaitFailsClosed(t *testing.T) {
432 pty, release := newBlockingPTY(t)
433 defer release()
434 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
435
436 oldTr := &closeTrackingTransport{}
437 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
438 require.NoError(t, err)
439 token := ac.resumeToken
440 require.NotZero(t, token)
441 d.clientGone(sess, ac, oldTr, false)
442 require.Empty(t, sess.snapshotAttachments())
443 d.mu.Lock()
444 parked := d.resume.parked[token]
445 sessionsBefore := len(d.sessions)
446 d.mu.Unlock()
447 require.NotNil(t, parked)
448
449 reachedLookup := make(chan struct{})
450 releaseLookup := make(chan struct{})
451 var releaseOnce sync.Once
452 t.Cleanup(func() { releaseOnce.Do(func() { close(releaseLookup) }) })
453 d.beforeResumeParkedSendMu = func() {
454 close(reachedLookup)
455 <-releaseLookup
456 }
457
458 resumeTr := &closeTrackingTransport{}
459 type routeResult struct {
460 sess *session
461 ac *attachedClient
462 err error
463 }
464 result := make(chan routeResult, 1)
465 go func() {
466 routedSess, routedAC, routeErr := d.route(helloResumeCapable(protocol.IntentResume, "work", token), resumeTr)
467 result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
468 }()
469 awaitTestCompletion(t, reachedLookup, "resumeParked did not pause after the parked lookup")
470
471 d.mu.Lock()
472 require.Same(t, parked, d.resume.parked[token], "fixture: token must still be parked at the seam")
473 d.removeParkedLocked(token, parked)
474 require.Nil(t, d.resume.parked[token])
475 require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")
476 d.mu.Unlock()
477
478 releaseOnce.Do(func() { close(releaseLookup) })
479 got := awaitTestValue(t, result, "IntentResume did not finish after parked token replacement")
480
481 require.Error(t, got.err)
482 require.Nil(t, got.sess)
483 require.Nil(t, got.ac)
484 var pe *protoErr
485 require.ErrorAs(t, got.err, &pe)
486 require.Equal(t, protocol.ErrNoSuchSession, pe.code)
487 require.Contains(t, pe.Error(), "resume token is no longer valid")
488 require.Empty(t, sess.snapshotAttachments(), "lifecycle-race resume must not take over the named session")
489 require.Empty(t, resumeTr.Sends(), "failed resume must not complete a Welcome handshake")
490 d.mu.Lock()
491 _, stillParked := d.resume.parked[token]
492 sessionsAfter := len(d.sessions)
493 d.mu.Unlock()
494 require.False(t, stillParked, "consumed token must stay invalid")
495 require.Equal(t, sessionsBefore, sessionsAfter, "lifecycle-race resume must not create a session")
496}
497
498// TestResumeLiveAttachmentParkedResumeRaceFailsClosed covers the live-recovery
499// path that parks a still-active credential and then loses a competing
500// resumeParked race before sendMu revalidation. The lifecycle sentinel must
501// surface as fail-closed ErrNoSuchSession, never as a raw ErrInternal leak to
502// handleHello.
503func TestResumeLiveAttachmentParkedResumeRaceFailsClosed(t *testing.T) {
504 pty, release := newBlockingPTY(t)
505 defer release()
506 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
507
508 oldTr := &closeTrackingTransport{}
509 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
510 require.NoError(t, err)
511 token := ac.resumeToken
512 require.NotZero(t, token)
513 require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
514 d.mu.Lock()
515 _, parkedAtStart := d.resume.parked[token]
516 sessionsBefore := len(d.sessions)
517 d.mu.Unlock()
518 require.False(t, parkedAtStart, "fixture: live attachment must not be parked yet")
519
520 reachedLookup := make(chan struct{})
521 releaseLookup := make(chan struct{})
522 var releaseOnce sync.Once
523 t.Cleanup(func() { releaseOnce.Do(func() { close(releaseLookup) }) })
524 d.beforeResumeParkedSendMu = func() {
525 close(reachedLookup)
526 <-releaseLookup
527 }
528
529 resumeTr := &closeTrackingTransport{}
530 type routeResult struct {
531 sess *session
532 ac *attachedClient
533 err error
534 }
535 result := make(chan routeResult, 1)
536 go func() {
537 routedSess, routedAC, routeErr := d.route(helloResumeCapable(protocol.IntentResume, "work", token), resumeTr)
538 result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
539 }()
540 awaitTestCompletion(t, reachedLookup, "live resume did not pause inside resumeParked after parking")
541
542 d.mu.Lock()
543 parked := d.resume.parked[token]
544 require.NotNil(t, parked, "fixture: live recovery must have parked before the sendMu seam")
545 require.Same(t, ac, parked.ac)
546 d.removeParkedLocked(token, parked)
547 require.Nil(t, d.resume.parked[token])
548 require.Same(t, sess, d.sessions[sess.id], "fixture: named session must remain registered")
549 d.mu.Unlock()
550
551 releaseOnce.Do(func() { close(releaseLookup) })
552 got := awaitTestValue(t, result, "live IntentResume did not finish after competing resume consumed the token")
553
554 require.Error(t, got.err)
555 require.Nil(t, got.sess)
556 require.Nil(t, got.ac)
557 require.False(t, errors.Is(got.err, errResumeTokenLifecycleRace), "raw lifecycle sentinel must not reach route callers")
558 var pe *protoErr
559 require.ErrorAs(t, got.err, &pe)
560 require.Equal(t, protocol.ErrNoSuchSession, pe.code)
561 require.Contains(t, pe.Error(), "resume token is no longer valid")
562 require.Empty(t, sess.snapshotAttachments(), "losing live resume must not take over the named session")
563 require.Empty(t, resumeTr.Sends(), "failed live resume must not complete a Welcome handshake")
564 d.mu.Lock()
565 _, stillParked := d.resume.parked[token]
566 sessionsAfter := len(d.sessions)
567 d.mu.Unlock()
568 require.False(t, stillParked, "consumed token must stay invalid")
569 require.Equal(t, sessionsBefore, sessionsAfter, "losing live resume must not create a session")
570}
571
572// TestResumeActiveTokenBeforeParkRecoversSameAttachment covers the laptop-sleep
573// reconnect race: IntentResume arrives with the live attachment's token before
574// transport teardown has parked it. Same client must recover; unknown and
575// mismatched credentials stay fail-closed.
576func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
577 pty, release := newBlockingPTY(t)
578 defer release()
579 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
580
581 oldTr := &closeTrackingTransport{}
582 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
583 require.NoError(t, err)
584 token := ac.resumeToken
585 require.NotZero(t, token)
586 require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
587
588 reachedGap := make(chan struct{})
589 releaseGap := make(chan struct{})
590 var releaseOnce sync.Once
591 t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) })
592 d.afterClientGoneDetach = func() {
593 close(reachedGap)
594 <-releaseGap
595 }
596
597 goneDone := make(chan struct{})
598 go func() {
599 d.clientGone(sess, ac, oldTr, false)
600 close(goneDone)
601 }()
602 awaitTestCompletion(t, reachedGap, "teardown did not pause after detach before park")
603
604 require.Empty(t, sess.snapshotAttachments(), "fixture: detach must have cleared the live owner")
605 d.mu.Lock()
606 parkingInGap := d.resume.parking[token]
607 _, parkedInGap := d.resume.parked[token]
608 d.mu.Unlock()
609 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
610 require.Same(t, ac, parkingInGap.ac)
611 require.Same(t, sess, parkingInGap.sess)
612 require.False(t, parkedInGap, "fixture: park must not have published yet")
613
614 _, _, err = d.route(helloResumeCapable(protocol.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
615 require.Error(t, err)
616 var unknownErr *protoErr
617 require.ErrorAs(t, err, &unknownErr)
618 require.Equal(t, protocol.ErrNoSuchSession, unknownErr.code)
619 require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
620
621 wrongClient := helloResumeCapable(protocol.IntentResume, "work", token)
622 wrongClient.ClientID = [16]byte{9, 9, 9, 9}
623 _, _, err = d.route(wrongClient, &closeTrackingTransport{})
624 require.Error(t, err)
625 var mismatchErr *protoErr
626 require.ErrorAs(t, err, &mismatchErr)
627 require.Equal(t, protocol.ErrNoSuchSession, mismatchErr.code)
628 require.Contains(t, mismatchErr.Error(), "resume token is no longer valid")
629
630 newTr := &closeTrackingTransport{}
631 type routeResult struct {
632 sess *session
633 ac *attachedClient
634 err error
635 }
636 waiterArmed := make(chan struct{})
637 d.afterParkingWaitArmed = func() { close(waiterArmed) }
638 result := make(chan routeResult, 1)
639 go func() {
640 routedSess, routedAC, routeErr := d.route(helloResumeCapable(protocol.IntentResume, "work", token), newTr)
641 result <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
642 }()
643 awaitTestCompletion(t, waiterArmed, "resume waiter did not arm on parking marker before park")
644
645 releaseOnce.Do(func() { close(releaseGap) })
646 awaitTestCompletion(t, goneDone, "teardown did not finish after park release")
647 got := awaitTestValue(t, result, "IntentResume did not finish after teardown-before-park gap")
648
649 require.NoError(t, got.err, "same-client token must recover across detach-before-park")
650 require.Same(t, sess, got.sess)
651 require.Same(t, ac, got.ac)
652 require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
653 require.Same(t, newTr, ac.transport())
654 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
655 require.True(t, d.commitResumeClaim(ac), "successful resume must consume its parked credential")
656 require.True(t, oldTr.Closed(), "teardown/resume must retire the old transport")
657 require.False(t, newTr.Closed(), "rebound transport must survive")
658 d.mu.Lock()
659 _, oldTokenParked := d.resume.parked[token]
660 _, stillParking := d.resume.parking[token]
661 d.mu.Unlock()
662 require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
663 require.False(t, stillParking, "parking marker must be consumed after park/resume")
664
665 d.clientGone(sess, ac, oldTr, false)
666 require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale old-link cleanup must not detach the rebound attachment")
667 require.Same(t, newTr, ac.transport())
668 require.False(t, newTr.Closed())
669}
670
671// TestConcurrentLiveResumesWaitParkingMarkerBeforePark covers two same-token
672// IntentResume handshakes where the winner of detachIfCurrentTransport pauses
673// before parkAttachment. The parking marker must already be published so the
674// loser waits instead of treating the credential as unknown across that gap.
675func TestConcurrentLiveResumesWaitParkingMarkerBeforePark(t *testing.T) {
676 pty, release := newBlockingPTY(t)
677 defer release()
678 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
679
680 oldTr := &closeTrackingTransport{}
681 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
682 require.NoError(t, err)
683 token := ac.resumeToken
684 require.NotZero(t, token)
685 require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
686
687 reachedGap := make(chan struct{})
688 releaseGap := make(chan struct{})
689 var releaseOnce sync.Once
690 t.Cleanup(func() { releaseOnce.Do(func() { close(releaseGap) }) })
691 d.afterResumeLiveDetach = func() {
692 close(reachedGap)
693 <-releaseGap
694 }
695
696 type routeResult struct {
697 sess *session
698 ac *attachedClient
699 err error
700 }
701 firstTr := &closeTrackingTransport{}
702 firstResult := make(chan routeResult, 1)
703 go func() {
704 routedSess, routedAC, routeErr := d.route(helloResumeCapable(protocol.IntentResume, "work", token), firstTr)
705 firstResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
706 }()
707 awaitTestCompletion(t, reachedGap, "first live resume did not pause after detach before park")
708
709 require.Empty(t, sess.snapshotAttachments(), "fixture: winning resume must have cleared the live owner")
710 d.mu.Lock()
711 parkingInGap := d.resume.parking[token]
712 _, parkedInGap := d.resume.parked[token]
713 d.mu.Unlock()
714 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach for concurrent resumes")
715 require.Same(t, ac, parkingInGap.ac)
716 require.False(t, parkedInGap, "fixture: park must not have published yet")
717
718 _, _, err = d.route(helloResumeCapable(protocol.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
719 require.Error(t, err)
720 var unknownErr *protoErr
721 require.ErrorAs(t, err, &unknownErr)
722 require.Equal(t, protocol.ErrNoSuchSession, unknownErr.code)
723
724 secondTr := &closeTrackingTransport{}
725 secondWaiting := make(chan struct{})
726 d.afterParkingWaitArmed = func() {
727 close(secondWaiting)
728 }
729 secondResult := make(chan routeResult, 1)
730 go func() {
731 routedSess, routedAC, routeErr := d.route(helloResumeCapable(protocol.IntentResume, "work", token), secondTr)
732 secondResult <- routeResult{sess: routedSess, ac: routedAC, err: routeErr}
733 }()
734 awaitTestCompletion(t, secondWaiting, "second live resume did not arm parking wait")
735
736 releaseOnce.Do(func() { close(releaseGap) })
737 first := awaitTestValue(t, firstResult, "first concurrent IntentResume did not finish")
738 second := awaitTestValue(t, secondResult, "second concurrent IntentResume did not finish")
739
740 var winner, loser routeResult
741 var winnerTr, loserTr *closeTrackingTransport
742 switch {
743 case first.err == nil && second.err != nil:
744 winner, loser = first, second
745 winnerTr, loserTr = firstTr, secondTr
746 case second.err == nil && first.err != nil:
747 winner, loser = second, first
748 winnerTr, loserTr = secondTr, firstTr
749 default:
750 t.Fatalf("expected exactly one concurrent resume to succeed; first=%v second=%v", first.err, second.err)
751 }
752
753 require.Same(t, sess, winner.sess)
754 require.Same(t, ac, winner.ac)
755 require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
756 require.Same(t, winnerTr, ac.transport())
757 require.NotEqual(t, token, ac.resumeToken, "successful resume rotates the credential")
758 require.True(t, oldTr.Closed(), "winning resume must retire the old transport")
759 require.False(t, winnerTr.Closed(), "winning rebound transport must survive")
760
761 var loserErr *protoErr
762 require.ErrorAs(t, loser.err, &loserErr)
763 require.Equal(t, protocol.ErrNoSuchSession, loserErr.code)
764 require.Contains(t, loserErr.Error(), "resume token is no longer valid")
765 require.True(t, loserTr.Closed() || len(loserTr.Sends()) == 0, "losing resume must not complete Welcome")
766
767 require.True(t, d.commitResumeClaim(ac), "successful concurrent resume must consume its parked credential")
768 d.mu.Lock()
769 _, oldTokenParked := d.resume.parked[token]
770 _, stillParking := d.resume.parking[token]
771 d.mu.Unlock()
772 require.False(t, oldTokenParked, "consumed credential must not remain resumeable")
773 require.False(t, stillParking, "parking marker must be consumed after park/resume")
774
775 d.clientGone(sess, ac, oldTr, false)
776 require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale old-link cleanup must not detach the rebound attachment")
777 require.Same(t, winnerTr, ac.transport())
778 require.False(t, winnerTr.Closed())
779}
780
781func TestExplicitDetachClearsOrphanedSameAttachmentParkingMarker(t *testing.T) {
782 workPTY, releaseWork := newBlockingPTY(t)
783 defer releaseWork()
784 otherPTY, releaseOther := newBlockingPTY(t)
785 defer releaseOther()
786 // Separate PTYs keep the other session alive while work detaches.
787 d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY), stubClock{})
788
789 oldTr := &closeTrackingTransport{}
790 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
791 require.NoError(t, err)
792 token := ac.resumeToken
793 require.NotZero(t, token)
794
795 otherTr := &closeTrackingTransport{}
796 otherSess, otherAC, err := d.route(helloResumeCapable(protocol.IntentNew, "other", 0), otherTr)
797 require.NoError(t, err)
798 otherToken := d.markParkingInFlight(otherSess, otherAC)
799 require.NotZero(t, otherToken)
800 require.NotEqual(t, token, otherToken)
801
802 // Pre-mark as a same-attachment non-explicit teardown would before detach.
803 require.Equal(t, token, d.markParkingInFlight(sess, ac))
804 d.mu.Lock()
805 orphaned := d.resume.parking[token]
806 otherMarker := d.resume.parking[otherToken]
807 d.mu.Unlock()
808 require.NotNil(t, orphaned, "fixture: non-explicit teardown must publish the parking marker")
809 require.Same(t, ac, orphaned.ac)
810 require.NotNil(t, otherMarker, "fixture: other attachment marker must remain published")
811 require.Same(t, otherAC, otherMarker.ac)
812
813 waiterArmed := make(chan struct{})
814 d.afterParkingWaitArmed = func() { close(waiterArmed) }
815 waiterDone := make(chan bool, 1)
816 go func() {
817 waiterDone <- d.waitParkingInFlight(helloResumeCapable(protocol.IntentResume, "work", token))
818 }()
819 awaitTestCompletion(t, waiterArmed, "parking waiter did not arm on the orphaned marker")
820
821 d.clientGone(sess, ac, oldTr, true)
822 require.Empty(t, sess.snapshotAttachments(), "explicit detach must clear the live owner")
823
824 waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after explicit detach")
825 require.True(t, waited, "waiter must observe the same-attachment marker before it is cleared")
826
827 d.mu.Lock()
828 _, stillParking := d.resume.parking[token]
829 otherAfter := d.resume.parking[otherToken]
830 d.mu.Unlock()
831 require.False(t, stillParking, "explicit winner must clear the same attachment's orphaned marker")
832 require.NotNil(t, otherAfter, "explicit winner must not clear another attachment's marker")
833 require.Same(t, otherAC, otherAfter.ac)
834
835 _, _, err = d.route(helloResumeCapable(protocol.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
836 require.Error(t, err)
837 var unknownErr *protoErr
838 require.ErrorAs(t, err, &unknownErr)
839 require.Equal(t, protocol.ErrNoSuchSession, unknownErr.code)
840 require.Contains(t, unknownErr.Error(), "resume token is no longer valid")
841
842 _, _, err = d.route(helloResumeCapable(protocol.IntentResume, "work", token), &closeTrackingTransport{})
843 require.Error(t, err)
844 var retiredErr *protoErr
845 require.ErrorAs(t, err, &retiredErr)
846 require.Equal(t, protocol.ErrNoSuchSession, retiredErr.code)
847 require.Contains(t, retiredErr.Error(), "resume token is no longer valid")
848
849 // A later parked lifecycle for another attachment must survive a clear
850 // scoped to the retired attachment identity.
851 require.True(t, d.parkAttachment(otherSess, otherAC))
852 d.mu.Lock()
853 parkedOther := d.resume.parked[otherToken]
854 _, otherStillParking := d.resume.parking[otherToken]
855 d.mu.Unlock()
856 require.NotNil(t, parkedOther, "subsequent park must publish the other attachment")
857 require.False(t, otherStillParking, "park consumes the other attachment's in-flight marker")
858
859 d.clearParkingInFlight(otherToken, ac)
860 d.mu.Lock()
861 parkedAfterMismatchedClear := d.resume.parked[otherToken]
862 d.mu.Unlock()
863 require.Same(t, parkedOther, parkedAfterMismatchedClear, "mismatched clear must not drop a subsequent parked lifecycle")
864}
865
866// TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins covers the race where
867// live resume has validated the same-client credential, then explicit detach or
868// session removal wins before markParkingInFlight. Late marker publication must
869// be rejected so IntentResume returns promptly ErrNoSuchSession with no parking
870// marker left to strand waiters. Direct parkAttachment after detach still works.
871func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
872 cases := []struct {
873 name string
874 win func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64)
875 }{
876 {
877 name: "explicit_detach",
878 win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) {
879 t.Helper()
880 d.clientGone(sess, ac, oldTr, true)
881 require.Empty(t, sess.snapshotAttachments(), "explicit detach must clear the live owner before late mark")
882 },
883 },
884 {
885 name: "session_removal",
886 win: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, oldTr *closeTrackingTransport, token uint64) {
887 t.Helper()
888 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
889 d.mu.Lock()
890 registered := d.sessions[sess.id] == sess
891 d.mu.Unlock()
892 require.False(t, registered, "killSession must unregister before late mark")
893 },
894 },
895 }
896 for _, tc := range cases {
897 t.Run(tc.name, func(t *testing.T) {
898 pty, release := newBlockingPTY(t)
899 defer release()
900 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
901
902 oldTr := &closeTrackingTransport{}
903 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
904 require.NoError(t, err)
905 token := ac.resumeToken
906 require.NotZero(t, token)
907 require.True(t, slices.Contains(sess.snapshotAttachments(), ac))
908
909 validated := make(chan struct{})
910 releaseMark := make(chan struct{})
911 var releaseOnce sync.Once
912 t.Cleanup(func() { releaseOnce.Do(func() { close(releaseMark) }) })
913 d.beforeMarkParkingInFlight = func() {
914 close(validated)
915 <-releaseMark
916 }
917
918 type routeResult struct {
919 err error
920 }
921 result := make(chan routeResult, 1)
922 go func() {
923 _, _, routeErr := d.route(helloResumeCapable(protocol.IntentResume, "work", token), &closeTrackingTransport{})
924 result <- routeResult{err: routeErr}
925 }()
926 awaitTestCompletion(t, validated, "live resume did not pause after credential validation before mark")
927
928 tc.win(t, d, sess, ac, oldTr, token)
929
930 d.mu.Lock()
931 _, parkingBeforeRelease := d.resume.parking[token]
932 d.mu.Unlock()
933 require.False(t, parkingBeforeRelease, "terminal cleanup must not leave a parking marker before late mark resumes")
934
935 releaseOnce.Do(func() { close(releaseMark) })
936 got := awaitTestValue(t, result, "live IntentResume stayed blocked after terminal cleanup won before mark")
937 require.Error(t, got.err)
938 var pe *protoErr
939 require.ErrorAs(t, got.err, &pe)
940 require.Equal(t, protocol.ErrNoSuchSession, pe.code)
941 require.Contains(t, pe.Error(), "resume token is no longer valid")
942
943 d.mu.Lock()
944 _, stillParking := d.resume.parking[token]
945 _, stillParked := d.resume.parked[token]
946 d.mu.Unlock()
947 require.False(t, stillParking, "late markParkingInFlight must not recreate a parking marker after terminal cleanup")
948 require.False(t, stillParked, "terminal cleanup must not leave a parked credential")
949 })
950 }
951
952 t.Run("direct_park_after_detach_without_marker", func(t *testing.T) {
953 pty, release := newBlockingPTY(t)
954 defer release()
955 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
956
957 oldTr := &closeTrackingTransport{}
958 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
959 require.NoError(t, err)
960 token := ac.resumeToken
961 require.NotZero(t, token)
962
963 require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot()))
964 require.Empty(t, sess.snapshotAttachments())
965 require.Zero(t, d.markParkingInFlight(sess, ac), "detached active attachment must not publish a late marker")
966
967 d.mu.Lock()
968 _, parkingBeforePark := d.resume.parking[token]
969 d.mu.Unlock()
970 require.False(t, parkingBeforePark)
971
972 require.True(t, d.parkAttachment(sess, ac), "direct parkAttachment after detach must still park without recreating an in-flight marker")
973 d.mu.Lock()
974 parked := d.resume.parked[token]
975 _, stillParking := d.resume.parking[token]
976 d.mu.Unlock()
977 require.NotNil(t, parked)
978 require.Same(t, ac, parked.ac)
979 require.False(t, stillParking, "park must not leave an in-flight marker")
980 })
981}
982
983// TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection
984// when the attachment is no longer the exact registered owner, while
985// still advertising for both live roles.
986func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {
987 pty, release := newBlockingPTY(t)
988 defer release()
989 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
990
991 oldTr := &closeTrackingTransport{}
992 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
993 require.NoError(t, err)
994 token := ac.resumeToken
995 require.NotZero(t, token)
996
997 passedPrecheck := make(chan struct{})
998 releaseDetach := make(chan struct{})
999 var releaseOnce sync.Once
1000 t.Cleanup(func() { releaseOnce.Do(func() { close(releaseDetach) }) })
1001 d.beforeClientGoneDetach = func() {
1002 close(passedPrecheck)
1003 <-releaseDetach
1004 }
1005
1006 goneDone := make(chan struct{})
1007 go func() {
1008 d.clientGone(sess, ac, oldTr, false)
1009 close(goneDone)
1010 }()
1011 awaitTestCompletion(t, passedPrecheck, "stale clientGone did not reach the post-precheck seam")
1012
1013 newTr := &closeTrackingTransport{}
1014 resumedSess, resumedAC, err := d.route(helloResumeCapable(protocol.IntentResume, "work", token), newTr)
1015 require.NoError(t, err)
1016 require.Same(t, sess, resumedSess)
1017 require.Same(t, ac, resumedAC)
1018 require.Same(t, newTr, ac.transport())
1019
1020 releaseOnce.Do(func() { close(releaseDetach) })
1021 awaitTestCompletion(t, goneDone, "stale clientGone did not finish after resume rebound")
1022
1023 require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale cleanup must not detach the rebound attachment")
1024 require.Same(t, newTr, ac.transport())
1025 require.False(t, newTr.Closed(), "rebound transport must survive stale old-link cleanup")
1026 require.True(t, oldTr.Closed(), "live resume retires the captured old transport")
1027}
1028
1029// TestResumeLiveAttachmentParkFailureRetiresOldTransport covers shutdown racing
1030// park after live detach: the old link must still be revoked/closed exactly once
1031// and the session must not keep a half-detached owner.
1032func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
1033 pty, release := newBlockingPTY(t)
1034 defer release()
1035 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1036
1037 oldTr := &closeTrackingTransport{}
1038 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
1039 require.NoError(t, err)
1040 token := ac.resumeToken
1041 require.NotZero(t, token)
1042
1043 d.afterDetachAttachmentEffectsFrozen = func() {
1044 d.mu.Lock()
1045 d.closing = true
1046 d.mu.Unlock()
1047 }
1048
1049 _, _, err = d.route(helloResumeCapable(protocol.IntentResume, "work", token), &closeTrackingTransport{})
1050 require.Error(t, err)
1051 var pe *protoErr
1052 require.ErrorAs(t, err, &pe)
1053 require.Equal(t, protocol.ErrServerShutdown, pe.code)
1054 require.Empty(t, sess.snapshotAttachments(), "failed live park must leave the session without an owner")
1055 require.Nil(t, ac.transport(), "failed live park must revoke the captured old transport")
1056 require.True(t, oldTr.Closed(), "failed live park must close the captured old transport")
1057 d.mu.Lock()
1058 _, parked := d.resume.parked[token]
1059 d.mu.Unlock()
1060 require.False(t, parked, "failed live park must not publish a resume credential")
1061}
1062
1063func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
1064 pty, release := newBlockingPTY(t)
1065 defer release()
1066 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
1067
1068 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1069 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), tr)
1070 require.NoError(t, err)
1071 oldToken := ac.resumeToken
1072 d.clientGone(sess, ac, ac.transport(), false)
1073
1074 tr2, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1075 resumedSess, resumedAC, err := d.route(helloResumeCapable(protocol.IntentResume, "work", oldToken), tr2)
1076 require.NoError(t, err)
1077 require.Same(t, sess, resumedSess)
1078 require.Same(t, ac, resumedAC)
1079 require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")
1080 require.True(t, slices.Contains(sess.snapshotAttachments(), resumedAC))
1081}
1082
1083func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
1084 ac := &attachedClient{output: newOutputStateStream()}
1085 ac.output.next = 3
1086 epoch := ac.output.currentEpoch()
1087 ac.ackOutputState(epoch, 3)
1088 ac.ackOutputState(epoch, 2)
1089 ac.ackOutputState(epoch, 4)
1090 require.Equal(t, uint64(3), ac.output.acked, "stale or future ACKs must not move output state incorrectly")
1091
1092 ac.sendMu.Lock()
1093 ac.output.next = 5
1094 reset := false
1095 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")
1096
1097 f := outputStateFrame(ac.output, []byte("incremental while reliable backlog drains"), reset, 0)
1098 ac.sendMu.Unlock()
1099 out := unmarshalTestOutput(t, f.Payload)
1100 require.Equal(t, uint64(5), out.Base, "output should remain incremental unless an explicit reset is requested")
1101 require.Equal(t, uint64(6), out.New)
1102
1103 ac.sendMu.Lock()
1104 reset = true
1105 require.True(t, reset, "explicit reset should still force full repaint")
1106 full := outputStateFrame(ac.output, []byte("explicit full repaint"), reset, 0)
1107 ac.sendMu.Unlock()
1108 fullOut := unmarshalTestOutput(t, full.Payload)
1109 require.Equal(t, uint64(0), fullOut.Base)
1110 require.Equal(t, uint64(7), fullOut.New)
1111}
1112
1113func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
1114 pty, release := newBlockingPTY(t)
1115 defer release()
1116 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
1117
1118 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1119 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), tr)
1120 require.NoError(t, err)
1121 token := ac.resumeToken
1122 d.clientGone(sess, ac, ac.transport(), false)
1123
1124 wrongClient := helloResumeCapable(protocol.IntentResume, "work", token)
1125 wrongClient.ClientID = [16]byte{9, 9, 9, 9}
1126 _, _, ok, err := d.resumeParked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
1127 d.mu.Lock()
1128 _, stillParked := d.resume.parked[token]
1129 d.mu.Unlock()
1130 require.Error(t, err)
1131 require.False(t, ok)
1132 require.True(t, stillParked, "mismatched client must not consume parked token")
1133 require.Equal(t, token, ac.resumeToken)
1134 require.True(t, ac.parked)
1135
1136 tr2, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1137 resumedSess, resumedAC, err := d.route(helloResumeCapable(protocol.IntentResume, "work", token), tr2)
1138 require.NoError(t, err)
1139 require.Same(t, sess, resumedSess)
1140 require.Same(t, ac, resumedAC)
1141}
1142
1143func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.T) {
1144 pty, release := newBlockingPTY(t)
1145 defer release()
1146 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
1147
1148 oldTr := &closeTrackingTransport{}
1149 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
1150 require.NoError(t, err)
1151 token := ac.resumeToken
1152 require.True(t, sess.detachIfCurrent(ac))
1153 require.True(t, d.parkAttachment(sess, ac))
1154 generation := ac.lifecycle.generationValue()
1155
1156 newTr := &closeTrackingTransport{}
1157 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(protocol.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})
1158 require.NoError(t, err)
1159 require.True(t, ok)
1160 require.Same(t, sess, resumedSess)
1161 require.Same(t, ac, resumedAC)
1162 require.Greater(t, ac.lifecycle.generationValue(), generation, "resume must publish active ownership through the attachment transition")
1163
1164 _ = ac.closeCapturedTransport(oldTr)
1165 require.True(t, oldTr.Closed(), "old transport is closed")
1166 require.False(t, newTr.Closed(), "newly rebound transport is not closed by old cleanup")
1167 require.Same(t, newTr, ac.transport())
1168}
1169
1170func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
1171 pty, release := newBlockingPTY(t)
1172 defer release()
1173 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
1174 oldTr := &closeTrackingTransport{}
1175 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
1176 require.NoError(t, err)
1177 ac.output.next = maxUnackedOutputStates
1178 token := ac.resumeToken
1179 require.True(t, sess.detachIfCurrent(ac))
1180 require.True(t, d.parkAttachment(sess, ac))
1181
1182 newTr := &closeTrackingTransport{}
1183 resumedSess, resumedAC, err := d.route(helloResumeCapable(protocol.IntentResume, "work", token), newTr)
1184 require.NoError(t, err)
1185 require.Same(t, ac, resumedAC)
1186 d.paint(resumedSess, resumedAC, true, nil)
1187
1188 sends := testFramesOfType(newTr.Sends(), "Output")
1189 require.Len(t, sends, 1)
1190 first := unmarshalTestOutput(t, sends[0].Payload)
1191 require.Zero(t, first.Base)
1192 require.Equal(t, uint64(1), first.New)
1193 resumedAC.ackOutputState(first.Epoch, first.New)
1194
1195 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
1196 d.paint(resumedSess, resumedAC, false, nil)
1197 sends = testFramesOfType(newTr.Sends(), "Output")
1198 require.Len(t, sends, 2)
1199 second := unmarshalTestOutput(t, sends[1].Payload)
1200 require.Equal(t, first.New, second.Base)
1201}
1202
1203func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1204 pty, release := newBlockingPTY(t)
1205 defer release()
1206 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
1207 oldTransport := &closeTrackingTransport{}
1208 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTransport)
1209 require.NoError(t, err)
1210
1211 tb := sess.tabs[0]
1212 survivor := tb.panes["pane-1"]
1213 closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23})
1214 tb.mu.Lock()
1215 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
1216 tb.panes[closed.id] = closed
1217 tb.bumpLayoutGenerationLocked()
1218 tb.mu.Unlock()
1219 sess.geometry.applyTabLayout(d, sess, tb)
1220 survivor.screen.Write([]byte("survivor"))
1221 closed.screen.Write([]byte("closed"))
1222 d.paint(sess, ac, true, nil)
1223
1224 ac.sendMu.Lock()
1225 require.Contains(t, ac.render.panes, closed, "fixture must render and capture the pane before parking")
1226 ac.sendMu.Unlock()
1227 token := ac.resumeToken
1228 require.True(t, sess.detachIfCurrent(ac))
1229 require.True(t, d.parkAttachment(sess, ac))
1230
1231 // Headless close cannot find the parked attachment through sess.snapshotAttachments()[0].
1232 // Its capture must already have been released before the attachment parked.
1233 require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))
1234 ac.sendMu.Lock()
1235 require.NotContains(t, ac.render.panes, closed, "parked attachment must not retain a pane closed while headless")
1236 ac.sendMu.Unlock()
1237
1238 newTransport := &closeTrackingTransport{}
1239 resumedSess, resumedAC, err := d.route(helloResumeCapable(protocol.IntentResume, sess.name, token), newTransport)
1240 require.NoError(t, err)
1241 require.Same(t, sess, resumedSess)
1242 require.Same(t, ac, resumedAC)
1243 require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
1244 d.firstPaint(resumedSess, resumedAC)
1245
1246 sends := testFramesOfType(newTransport.Sends(), "Output")
1247 require.Len(t, sends, 1)
1248 output := unmarshalTestOutput(t, sends[0].Payload)
1249 require.Zero(t, output.Base, "resume must start with a complete frame")
1250 terminal := vt.NewScreen(resumedAC.geometry.Size.Cols, resumedAC.geometry.Size.Rows)
1251 terminal.Write(output.Data)
1252 contents := strings.Join(frameRows(terminal), "\n")
1253 require.Contains(t, contents, "survivor", "resume first paint must contain current headless content")
1254 require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content")
1255}
1256
1257func TestExplicitDetachDoesNotPark(t *testing.T) {
1258 pty, release := newBlockingPTY(t)
1259 defer release()
1260 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1261 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1262 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), tr)
1263 require.NoError(t, err)
1264 d.clientGone(sess, ac, ac.transport(), true)
1265 d.mu.Lock()
1266 parked := len(d.resume.parked)
1267 d.mu.Unlock()
1268 require.Zero(t, parked)
1269}
1270
1271func TestResumeParkUsesConfiguredGraceAndExpiresOnlyAfterGrace(t *testing.T) {
1272 clk := &signalClock{timers: make(chan *signalTimer, 8)}
1273 pty, release := newBlockingPTY(t)
1274 defer release()
1275 d := newTestDaemon(t, newFactory(t, pty), clk)
1276 WithResumeParkGrace(20 * time.Minute)(d)
1277 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1278 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), tr)
1279 require.NoError(t, err)
1280 token := ac.resumeToken
1281
1282 d.clientGone(sess, ac, ac.transport(), false)
1283 timer := <-clk.timers
1284 require.Equal(t, 20*time.Minute, timer.duration)
1285 d.mu.Lock()
1286 _, parkedBeforeGrace := d.resume.parked[token]
1287 d.mu.Unlock()
1288 require.True(t, parkedBeforeGrace, "parked attachment remains before configured grace timer fires")
1289
1290 timer.ch <- clk.Now()
1291 require.Eventually(t, func() bool {
1292 d.mu.Lock()
1293 defer d.mu.Unlock()
1294 _, ok := d.resume.parked[token]
1295 return !ok
1296 }, 2*time.Second, 10*time.Millisecond)
1297}
1298
1299func TestParkExpiryAndShutdownCleanup(t *testing.T) {
1300 clk := &signalClock{timers: make(chan *signalTimer, 8)}
1301 pty, release := newBlockingPTY(t)
1302 defer release()
1303 d := newTestDaemon(t, newFactory(t, pty), clk)
1304 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1305 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), tr)
1306 require.NoError(t, err)
1307 token := ac.resumeToken
1308 d.clientGone(sess, ac, ac.transport(), false)
1309 timer := <-clk.timers
1310 timer.ch <- clk.Now()
1311 require.Eventually(t, func() bool {
1312 d.mu.Lock()
1313 defer d.mu.Unlock()
1314 _, ok := d.resume.parked[token]
1315 return !ok
1316 }, 2*time.Second, 10*time.Millisecond)
1317
1318 pty2, release2 := newBlockingPTY(t)
1319 defer release2()
1320 d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{})
1321 tr2, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1322 sess2, ac2, err := d2.route(helloResumeCapable(protocol.IntentNew, "other", 0), tr2)
1323 require.NoError(t, err)
1324 d2.clientGone(sess2, ac2, ac2.transport(), false)
1325 d2.shutdownAll(protocol.ReasonServerShutdown)
1326 d2.mu.Lock()
1327 parked := len(d2.resume.parked)
1328 d2.mu.Unlock()
1329 require.Zero(t, parked)
1330}
1331
1332func TestLiveParkAndResumeRetainsAttachmentWithoutDaemonHistory(t *testing.T) {
1333 pty, release := newBlockingPTY(t)
1334 defer release()
1335 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1336 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1337 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), tr)
1338 require.NoError(t, err)
1339
1340 d.clientGone(sess, ac, ac.transport(), false)
1341
1342 token := ac.resumeToken
1343 d.mu.Lock()
1344 parked := d.resume.parked[token]
1345 d.mu.Unlock()
1346 require.NotNil(t, parked)
1347
1348 tr2, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1349 resumedSess, resumedAC, err := d.route(helloResumeCapable(protocol.IntentResume, "work", token), tr2)
1350 require.NoError(t, err)
1351 require.Same(t, sess, resumedSess)
1352 require.Same(t, ac, resumedAC)
1353}
1354
1355func TestDiscardingParkedAttachmentDoesNotOwnPreviousSession(t *testing.T) {
1356 for _, tc := range []struct {
1357 name string
1358 discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement
1359 }{
1360 {
1361 name: "expiry",
1362 discard: func(d *Daemon, token uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
1363 d.removeParkedLocked(token, parked)
1364 return nil
1365 },
1366 },
1367 {
1368 name: "session purge",
1369 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
1370 return d.purgeParkedForSessionLocked(parked.sess)
1371 },
1372 },
1373 } {
1374 t.Run(tc.name, func(t *testing.T) {
1375 pty, release := newBlockingPTY(t)
1376 defer release()
1377 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1378 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1379 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), tr)
1380 require.NoError(t, err)
1381
1382 d.clientGone(sess, ac, ac.transport(), false)
1383
1384 d.mu.Lock()
1385 parked := d.resume.parked[ac.resumeToken]
1386 require.NotNil(t, parked)
1387 retirements := tc.discard(d, ac.resumeToken, parked)
1388 d.mu.Unlock()
1389 d.finishParkedAttachmentRetirements(retirements)
1390
1391 })
1392 }
1393}
1394
1395func TestEphemeralParkExpiryKeepsSessionWhenCloseOnExitOff(t *testing.T) {
1396 clk := &signalClock{timers: make(chan *signalTimer, 8)}
1397 pty, release := newBlockingPTY(t)
1398 defer release()
1399 d := newTestDaemon(t, newFactory(t, pty), clk)
1400 updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: false} })
1401
1402 tr := &closeTrackingTransport{}
1403 sess, ac, err := d.route(helloResumeCapable(protocol.IntentEphemeral, "", 0), tr)
1404 require.NoError(t, err)
1405 token := ac.resumeToken
1406 require.NotZero(t, token)
1407
1408 d.clientGone(sess, ac, ac.transport(), false)
1409 timer := <-clk.timers
1410 timer.ch <- clk.Now()
1411
1412 require.Eventually(t, func() bool {
1413 d.mu.Lock()
1414 defer d.mu.Unlock()
1415 _, ok := d.resume.parked[token]
1416 return !ok
1417 }, 2*time.Second, 10*time.Millisecond)
1418
1419 require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session")
1420 sess.mu.Lock()
1421 require.Empty(t, sess.snapshotAttachmentsLocked())
1422 sess.mu.Unlock()
1423}
1424
1425func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
1426 pty, release := newBlockingPTY(t)
1427 defer release()
1428 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1429 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1430 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), tr)
1431 require.NoError(t, err)
1432 token := ac.resumeToken
1433 d.clientGone(sess, ac, ac.transport(), false)
1434
1435 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
1436 d.mu.Lock()
1437 _, parked := d.resume.parked[token]
1438 d.mu.Unlock()
1439 _, _, ok, err := d.resumeParked(helloResumeCapable(protocol.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
1440 require.False(t, parked, "killSession purges parked token")
1441 require.NoError(t, err)
1442 require.False(t, ok, "killed session cannot be resumed")
1443}
1444
1445// TestKilledSessionUnblocksParkingWaiterFailsClosed covers session removal while
1446// a same-token IntentResume is waiting on an in-flight parking marker. killSession
1447// must purge only that session's markers so the waiter unblocks and fails closed,
1448// without touching another session's parking marker.
1449func TestKilledSessionUnblocksParkingWaiterFailsClosed(t *testing.T) {
1450 workPTY, releaseWork := newBlockingPTY(t)
1451 defer releaseWork()
1452 otherPTY, releaseOther := newBlockingPTY(t)
1453 defer releaseOther()
1454 // Separate PTYs are required: a shared mock PTY makes killSession(work)
1455 // close other's pane and cascade-remove the other session under -race,
1456 // falsely failing the other-marker / fail-closed assertions.
1457 d := newTestDaemon(t, newFactorySeq(t, workPTY, otherPTY), stubClock{})
1458
1459 oldTr := &closeTrackingTransport{}
1460 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
1461 require.NoError(t, err)
1462 token := ac.resumeToken
1463 require.NotZero(t, token)
1464
1465 otherTr := &closeTrackingTransport{}
1466 otherSess, otherAC, err := d.route(helloResumeCapable(protocol.IntentNew, "other", 0), otherTr)
1467 require.NoError(t, err)
1468 otherToken := d.markParkingInFlight(otherSess, otherAC)
1469 require.NotZero(t, otherToken)
1470 require.NotEqual(t, token, otherToken)
1471
1472 require.Equal(t, token, d.markParkingInFlight(sess, ac))
1473 d.mu.Lock()
1474 parkingMarker := d.resume.parking[token]
1475 otherMarker := d.resume.parking[otherToken]
1476 d.mu.Unlock()
1477 require.NotNil(t, parkingMarker, "fixture: parking marker must be published before kill")
1478 require.Same(t, ac, parkingMarker.ac)
1479 require.NotNil(t, otherMarker, "fixture: other session marker must remain published")
1480 require.Same(t, otherAC, otherMarker.ac)
1481
1482 waiterArmed := make(chan struct{})
1483 d.afterParkingWaitArmed = func() { close(waiterArmed) }
1484 waiterDone := make(chan bool, 1)
1485 go func() {
1486 waiterDone <- d.waitParkingInFlight(helloResumeCapable(protocol.IntentResume, "work", token))
1487 }()
1488 awaitTestCompletion(t, waiterArmed, "parking waiter did not arm before session kill")
1489
1490 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
1491
1492 waited := awaitTestValue(t, waiterDone, "parking waiter stayed blocked after session kill")
1493 require.True(t, waited, "waiter must observe the session marker before it is purged")
1494
1495 d.mu.Lock()
1496 _, stillParking := d.resume.parking[token]
1497 otherAfter := d.resume.parking[otherToken]
1498 otherRegistered := d.sessions[otherSess.id] == otherSess
1499 closing := d.closing
1500 d.mu.Unlock()
1501 require.False(t, stillParking, "killSession must purge the killed session's parking marker")
1502 require.NotNil(t, otherAfter, "killSession must not purge another session's parking marker")
1503 require.Same(t, otherAC, otherAfter.ac)
1504 require.True(t, otherRegistered, "other session must survive work kill when PTYs are isolated")
1505 require.False(t, closing, "daemon must not shut down while the other session remains")
1506
1507 _, _, err = d.route(helloResumeCapable(protocol.IntentResume, "work", token), &closeTrackingTransport{})
1508 require.Error(t, err)
1509 var killedErr *protoErr
1510 require.ErrorAs(t, err, &killedErr)
1511 require.Equal(t, protocol.ErrNoSuchSession, killedErr.code)
1512 require.Contains(t, killedErr.Error(), "resume token is no longer valid")
1513}
1514
1515func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
1516 p, release := newBlockingPTY(t)
1517 defer release()
1518 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1519
1520 oldTr := &closeTrackingTransport{}
1521 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTr)
1522 require.NoError(t, err)
1523 freshTr := &closeTrackingTransport{}
1524 ac.replaceTransport(freshTr)
1525
1526 d.clientGone(sess, ac, oldTr, false)
1527
1528 require.True(t, slices.Contains(sess.snapshotAttachments(), ac), "stale connection must not detach current client")
1529 require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler")
1530 require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop")
1531}
1532
1533func TestRawTerminalSideEffectsAreOutputStateNeutral(t *testing.T) {
1534 d := newTestDaemon(t, nil, stubClock{})
1535 tr := &closeTrackingTransport{}
1536 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1537 ac.initOverlays()
1538
1539 require.NoError(t, d.boundedSendOutputErr(ac, []byte("copy")))
1540 require.NoError(t, d.boundedSendOutputErr(ac, []byte("more")))
1541
1542 sends := tr.Sends()
1543 require.Len(t, sends, 2)
1544 first := unmarshalTestOutput(t, sends[0].Payload)
1545 second := unmarshalTestOutput(t, sends[1].Payload)
1546 require.Zero(t, first.Base)
1547 require.Zero(t, first.New)
1548 require.Zero(t, second.Base)
1549 require.Zero(t, second.New)
1550}
1551
1552func TestSequencedInputDoesNotPrematurelyEcho(t *testing.T) {
1553 p, release := newBlockingPTY(t)
1554 defer release()
1555 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1556 tr := &closeTrackingTransport{}
1557 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), tr)
1558 require.NoError(t, err)
1559
1560 d.handleSequencedInput(sess, ac, 42, []byte("x"))
1561
1562 require.Zero(t, ac.echoAck.Load())
1563}
1564
1565func TestResumeParkedRefreshesSessionBoundFuturePTYEnvironment(t *testing.T) {
1566 initialPTY, releaseInitial := newBlockingPTY(t)
1567 futurePTY, releaseFuture := newBlockingPTY(t)
1568 defer releaseInitial()
1569 defer releaseFuture()
1570 var commands []string
1571 var envs [][]string
1572 factory := portsmocks.NewMockPTYFactory(t)
1573 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1574 func(_ context.Context, command string, _ []string, env []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
1575 if geometry.Size != (domain.Size{Cols: 80, Rows: 22}) {
1576 return newQuietPTY(), nil
1577 }
1578 commands = append(commands, command)
1579 envs = append(envs, append([]string(nil), env...))
1580 if len(commands) == 1 {
1581 return initialPTY, nil
1582 }
1583 return futurePTY, nil
1584 },
1585 ).Maybe()
1586 d := newTestDaemon(t, factory, stubClock{})
1587 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1588 hello := helloResumeCapable(protocol.IntentNew, "work", 0)
1589 hello.Env = []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old", "SSH_AUTH_SOCK=/run/old-agent"}
1590 sess, ac, err := d.route(hello, tr)
1591 require.NoError(t, err)
1592 token := ac.resumeToken
1593 require.True(t, sess.detachIfCurrent(ac))
1594 require.True(t, d.parkAttachment(sess, ac))
1595
1596 resumeHello := helloResumeCapable(protocol.IntentResume, "work", token)
1597 resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"}
1598 resumeHello.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}
1599 _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
1600 require.NoError(t, err)
1601 require.True(t, ok)
1602 require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))
1603
1604 sess.mu.Lock()
1605 future := sess.tabs[1].focusedPane()
1606 futureTabID, futurePaneID := sess.tabs[1].stableID, future.stableID
1607 require.Equal(t, []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"}, sess.env)
1608 sess.mu.Unlock()
1609 require.Equal(t, []string{"/bin/sh", "/bin/sh"}, commands)
1610 require.Equal(t, []string{"SECRET=before", "SHELL=/bin/sh", "WAYLAND_DISPLAY=wayland-1", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])
1611}
1612
1613func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
1614 pty, release := newBlockingPTY(t)
1615 defer release()
1616 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1617 d.baseEnv = []string{"DAEMON=owned"}
1618 oldTransport := &closeTrackingTransport{}
1619 created := helloResumeCapable(protocol.IntentNew, "work", 0)
1620 created.Env = []string{"LOCAL=client"}
1621 sess, ac, err := d.route(created, oldTransport)
1622 require.NoError(t, err)
1623 localEnv, owned := ac.clientEnvironment()
1624 require.True(t, owned, "a client-owned route records the attachment environment")
1625 require.Equal(t, []string{"LOCAL=client"}, localEnv)
1626 sess.mu.Lock()
1627 sess.env = copyEnvironment(d.baseEnv)
1628 sess.mu.Unlock()
1629 token := ac.resumeToken
1630 require.True(t, sess.detachIfCurrent(ac))
1631 require.True(t, d.parkAttachment(sess, ac))
1632
1633 resume := helloResumeCapable(protocol.IntentResume, "work", token)
1634 resume.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
1635 resume.Env = []string{"UNTRUSTED=client"}
1636 _, _, ok, err := d.resumeParked(resume, &closeTrackingTransport{}, defaultSize)
1637 require.NoError(t, err)
1638 require.True(t, ok)
1639 sess.mu.Lock()
1640 require.Equal(t, []string{"DAEMON=owned"}, sess.env)
1641 sess.mu.Unlock()
1642 _, owned = ac.clientEnvironment()
1643 require.False(t, owned, "a daemon-owned resume clears the attachment's client environment")
1644
1645 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
1646}
1647
1648func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {
1649 pty, release := newBlockingPTY(t)
1650 defer release()
1651 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
1652 firstHello := helloResumeCapable(protocol.IntentNew, "work", 0)
1653 firstHello.MaxOutputInFlight = 8
1654 sess, ac, err := d.route(firstHello, &closeTrackingTransport{})
1655 require.NoError(t, err)
1656 require.Equal(t, uint64(8), ac.output.maxOutstanding)
1657 stream := ac.output
1658 token := ac.resumeToken
1659 require.True(t, sess.detachIfCurrent(ac))
1660 require.True(t, d.parkAttachment(sess, ac))
1661
1662 resumeOne := helloResumeCapable(protocol.IntentResume, "work", token)
1663 resumeOne.MaxOutputInFlight = 1
1664 _, resumed, err := d.route(resumeOne, &closeTrackingTransport{})
1665 require.NoError(t, err)
1666 require.Same(t, stream, resumed.output)
1667 require.Equal(t, uint64(1), resumed.output.maxOutstanding)
1668
1669 token = resumed.resumeToken
1670 require.True(t, sess.detachIfCurrent(resumed))
1671 require.True(t, d.parkAttachment(sess, resumed))
1672 resumeEight := helloResumeCapable(protocol.IntentResume, "work", token)
1673 resumeEight.MaxOutputInFlight = 8
1674 _, resumed, err = d.route(resumeEight, &closeTrackingTransport{})
1675 require.NoError(t, err)
1676 require.Same(t, stream, resumed.output)
1677 require.Equal(t, uint64(8), resumed.output.maxOutstanding)
1678}