resume_test.go RawHistoryFolder

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 TestResumeParkedRefreshesSessionBoundFuturePTYEnvironment(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", "SSH_AUTH_SOCK=/run/old-agent"}
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", "WAYLAND_DISPLAY=wayland-1"}
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, []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"}, sess.env)
1607	sess.mu.Unlock()
1608	require.Equal(t, []string{"/bin/sh", "/bin/sh"}, commands)
1609	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])
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	created := helloResumeCapable(protocol.IntentNew, "work", 0)
1619	created.Env = []string{"LOCAL=client"}
1620	sess, ac, err := d.route(created, oldTransport)
1621	require.NoError(t, err)
1622	localEnv, owned := ac.clientEnvironment()
1623	require.True(t, owned, "a client-owned route records the attachment environment")
1624	require.Equal(t, []string{"LOCAL=client"}, localEnv)
1625	sess.mu.Lock()
1626	sess.env = copyEnvironment(d.baseEnv)
1627	sess.mu.Unlock()
1628	token := ac.resumeToken
1629	require.True(t, sess.detachIfCurrent(ac))
1630	require.True(t, d.parkAttachment(sess, ac))
1631
1632	resume := helloResumeCapable(protocol.IntentResume, "work", token)
1633	resume.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
1634	resume.Env = []string{"UNTRUSTED=client"}
1635	_, _, ok, err := d.resumeParked(resume, &closeTrackingTransport{}, defaultSize)
1636	require.NoError(t, err)
1637	require.True(t, ok)
1638	sess.mu.Lock()
1639	require.Equal(t, []string{"DAEMON=owned"}, sess.env)
1640	sess.mu.Unlock()
1641	_, owned = ac.clientEnvironment()
1642	require.False(t, owned, "a daemon-owned resume clears the attachment's client environment")
1643
1644	require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
1645}
1646
1647func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {
1648	pty, release := newBlockingPTY(t)
1649	defer release()
1650	d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
1651	firstHello := helloResumeCapable(protocol.IntentNew, "work", 0)
1652	firstHello.MaxOutputInFlight = 8
1653	sess, ac, err := d.route(firstHello, &closeTrackingTransport{})
1654	require.NoError(t, err)
1655	require.Equal(t, uint64(8), ac.output.maxOutstanding)
1656	stream := ac.output
1657	token := ac.resumeToken
1658	require.True(t, sess.detachIfCurrent(ac))
1659	require.True(t, d.parkAttachment(sess, ac))
1660
1661	resumeOne := helloResumeCapable(protocol.IntentResume, "work", token)
1662	resumeOne.MaxOutputInFlight = 1
1663	_, resumed, err := d.route(resumeOne, &closeTrackingTransport{})
1664	require.NoError(t, err)
1665	require.Same(t, stream, resumed.output)
1666	require.Equal(t, uint64(1), resumed.output.maxOutstanding)
1667
1668	token = resumed.resumeToken
1669	require.True(t, sess.detachIfCurrent(resumed))
1670	require.True(t, d.parkAttachment(sess, resumed))
1671	resumeEight := helloResumeCapable(protocol.IntentResume, "work", token)
1672	resumeEight.MaxOutputInFlight = 8
1673	_, resumed, err = d.route(resumeEight, &closeTrackingTransport{})
1674	require.NoError(t, err)
1675	require.Same(t, stream, resumed.output)
1676	require.Equal(t, uint64(8), resumed.output.maxOutstanding)
1677}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close