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

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