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