session_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"io"
7	"log/slog"
8	"math"
9	"strings"
10	"sync"
11	"sync/atomic"
12	"testing"
13	"time"
14
15	"github.com/stretchr/testify/mock"
16	"github.com/stretchr/testify/require"
17
18	"github.com/bnema/vev/internal/domain"
19	"github.com/bnema/vev/internal/persist"
20	"github.com/bnema/vev/internal/ports"
21	portsmocks "github.com/bnema/vev/internal/ports/mocks"
22	"github.com/bnema/vev/internal/protocol"
23	"github.com/bnema/vev/internal/protocol/wire"
24	"github.com/bnema/vev/internal/usecase/picker"
25	themeui "github.com/bnema/vev/internal/usecase/theme"
26)
27
28// --- test doubles -----------------------------------------------------------
29func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize domain.Size, floating ports.PTY) {
30	factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool {
31		return got.Size != normalSize && got.Size.Valid()
32	})).Return(floating, nil).Maybe()
33}
34
35// stubClock returns timers whose channel never fires, so a scheduler under it
36// blocks in its debounce loop until the session context is cancelled. Used by
37
38func TestHandshakeEphemeralHappy(t *testing.T) {
39	p, releasePTY := newBlockingPTY(t)
40	d := newTestDaemon(t, newFactory(t, p), stubClock{})
41	tr, sends, releaseConn := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
42
43	var hg sync.WaitGroup
44	hg.Go(func() { d.handleConn(tr) })
45
46	w := awaitFrame(t, sends, "Welcome")
47	welcome := decodeServerMessage(t, w).(protocol.Welcome)
48	require.Equal(t, "0", welcome.SessionName)
49	require.True(t, welcome.Ephemeral)
50	awaitFrame(t, sends, "Output") // guaranteed first paint
51	require.Equal(t, 1, sessionCount(d))
52
53	releaseConn()
54	releasePTY()
55	hg.Wait()
56	d.sessWg.Wait()
57}
58
59func TestHandshakeNewHappy(t *testing.T) {
60	p, releasePTY := newBlockingPTY(t)
61	d := newTestDaemon(t, newFactory(t, p), stubClock{})
62	tr, sends, releaseConn := newConn(t, mustHello(protocol.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
63
64	var hg sync.WaitGroup
65	hg.Go(func() { d.handleConn(tr) })
66
67	w := awaitFrame(t, sends, "Welcome")
68	welcome := decodeServerMessage(t, w).(protocol.Welcome)
69	require.Equal(t, "work", welcome.SessionName)
70	require.False(t, welcome.Ephemeral)
71
72	sess := firstSession(d)
73	require.NotNil(t, sess)
74
75	releaseConn()
76	_ = d.killSession(sess, protocol.ReasonServerShutdown, false)
77	releasePTY()
78	hg.Wait()
79	d.sessWg.Wait()
80	d.waitNotifies()
81}
82
83func TestHandshakeVersionMismatch(t *testing.T) {
84	// No Open expectation: the factory must never be asked to spawn a PTY.
85	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
86	bad := mustClientEnvelope(protocol.Hello{
87		Version: protocol.Version + 99, Intent: protocol.IntentEphemeral, Size: domain.Size{Cols: 80, Rows: 24},
88	})
89	tr, sends, _ := newConn(t, bad)
90
91	d.handleConn(tr) // returns after the rejection; no session, no goroutines
92
93	e := awaitFrame(t, sends, "Error")
94	em, ok := decodeServerMessage(t, e).(protocol.ErrorMsg)
95	require.True(t, ok)
96	require.Equal(t, protocol.ErrVersionMismatch, em.Code)
97	require.Equal(t, 0, sessionCount(d))
98}
99
100func TestHandshakeGarbageFirstFrameReportsInternalError(t *testing.T) {
101	// The legacy binary layout is gone: an undecodable first envelope is
102	// malformed input, answered without mutation and without a version.
103	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
104	tr, sends, _ := newConn(t, wire.Envelope{Payload: []byte{0x00, byte(protocol.Version - 1), protocol.IntentEphemeral}})
105	d.handleConn(tr)
106
107	e := awaitFrame(t, sends, "Error")
108	em, ok := decodeServerMessage(t, e).(protocol.ErrorMsg)
109	require.True(t, ok)
110	require.Equal(t, protocol.ErrInternal, em.Code)
111	require.Equal(t, 0, sessionCount(d))
112}
113
114func TestHandshakeNameTaken(t *testing.T) {
115	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
116	// Pre-populate a bare session (no goroutines) to collide with.
117	d.sessions[domain.SessionID("x")] = &session{sessionCore: sessionCore{id: "x", name: "work"}}
118
119	tr, sends, _ := newConn(t, mustHello(protocol.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
120	d.handleConn(tr)
121
122	e := awaitFrame(t, sends, "Error")
123	em, ok := decodeServerMessage(t, e).(protocol.ErrorMsg)
124	require.True(t, ok)
125	require.Equal(t, protocol.ErrNameTaken, em.Code)
126}
127
128func TestHandshakeNoSuchSession(t *testing.T) {
129	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
130	tr, sends, _ := newConn(t, mustHello(protocol.IntentAttach, "ghost", domain.Size{Cols: 80, Rows: 24}))
131	d.handleConn(tr)
132
133	e := awaitFrame(t, sends, "Error")
134	em, ok := decodeServerMessage(t, e).(protocol.ErrorMsg)
135	require.True(t, ok)
136	require.Equal(t, protocol.ErrNoSuchSession, em.Code)
137}
138
139// TestKillAllEmptyDaemonKeepsServing proves KillAll no longer ends the daemon:
140// purging an empty registry leaves closing, move admission, the daemon-wide
141// pane process context, and d.done untouched, and the same daemon then admits a
142// fresh session.
143func TestKillAllEmptyDaemonKeepsServing(t *testing.T) {
144	pty, release := newBlockingPTY(t)
145	defer release()
146	d := newTestDaemon(t, newFactory(t, pty), stubClock{})
147	tr, _, _ := newConn(t, mustClientEnvelope(protocol.Kill{RequestID: 1, Scope: protocol.KillAll}))
148	d.handleConn(tr)
149
150	select {
151	case <-d.done:
152		t.Fatal("kill all on empty daemon must not signal shutdown")
153	default:
154	}
155	d.mu.Lock()
156	closing := d.closing
157	d.mu.Unlock()
158	require.False(t, closing, "kill all must not mark the daemon closing")
159	d.moveLifecycleMu.Lock()
160	moveClosing := d.moveLifecycleClosing
161	d.moveLifecycleMu.Unlock()
162	require.False(t, moveClosing, "kill all must not close move admission")
163	select {
164	case <-d.paneProcessCtx.Done():
165		t.Fatal("kill all must not cancel the daemon-wide pane process context")
166	default:
167	}
168
169	// The purge reopened admission: the same daemon creates a fresh session.
170	tr2, sends2, releaseConn2 := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
171	var hg sync.WaitGroup
172	hg.Go(func() { d.handleConn(tr2) })
173	welcome := decodeServerMessage(t, awaitFrame(t, sends2, "Welcome")).(protocol.Welcome)
174	require.Equal(t, "0", welcome.SessionName)
175	awaitFrame(t, sends2, "Output")
176	require.Equal(t, 1, sessionCount(d))
177
178	releaseConn2()
179	_ = d.killSession(firstSession(d), protocol.ReasonSessionKilled, true)
180	release()
181	hg.Wait()
182	d.sessWg.Wait()
183	d.waitNotifies()
184}
185
186func TestBeginTeardownRequestResolvesShutdownPreservation(t *testing.T) {
187	tests := []struct {
188		name       string
189		reserve    bool
190		wantReason uint8
191		wantPurge  bool
192	}{
193		{name: "natural exit purges", wantReason: protocol.ReasonSessionKilled, wantPurge: true},
194		{name: "shutdown reservation preserves", reserve: true, wantReason: protocol.ReasonServerShutdown},
195	}
196
197	for _, tt := range tests {
198		t.Run(tt.name, func(t *testing.T) {
199			sess := &session{}
200			if tt.reserve {
201				sess.reserveShutdownTeardown()
202			}
203			reason, purge, acquired := sess.beginTeardownRequest(nil, protocol.ReasonSessionKilled, true)
204			require.True(t, acquired)
205			require.Equal(t, tt.wantReason, reason)
206			require.Equal(t, tt.wantPurge, purge)
207			sess.finishTeardown()
208		})
209	}
210}
211
212func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testing.T) {
213	p, release := newBlockingPTY(t)
214	defer release()
215	d := newTestDaemon(t, newFactory(t, p), stubClock{})
216	firstTransport := &closeTrackingTransport{}
217	sess, first, err := d.route(protocol.Hello{
218		Version:  protocol.Version,
219		Intent:   protocol.IntentNew,
220		Name:     "work",
221		ClientID: [16]byte{1},
222		Size:     domain.Size{Cols: 80, Rows: 24},
223	}, firstTransport)
224	require.NoError(t, err)
225	secondTransport := &closeTrackingTransport{}
226	_, second, err := d.route(protocol.Hello{
227		Version:  protocol.Version,
228		Intent:   protocol.IntentAttach,
229		Name:     "work",
230		ClientID: [16]byte{2},
231		Size:     domain.Size{Cols: 80, Rows: 24},
232	}, secondTransport)
233	require.NoError(t, err)
234	require.NotSame(t, first, second)
235
236	requesterTheme := themeui.Theme{
237		Foreground: themeui.BuiltinLight.Foreground,
238		Background: themeui.BuiltinLight.Background,
239		HasFG:      true,
240		HasBG:      true,
241		Known:      true,
242	}
243	second.setClientTheme(requesterTheme)
244	require.NoError(t, d.createTabForAttachment(sess, second, domain.Size{Cols: 80, Rows: 24}))
245
246	tb := sess.tabs[len(sess.tabs)-1]
247	tb.mu.Lock()
248	gotSize := tb.size
249	pane := tb.focusedPane()
250	tb.mu.Unlock()
251	require.Equal(t, contentSize(domain.Size{Cols: 80, Rows: 24}), gotSize, "sizing must come from the requesting attachment")
252	assertPaneDefaultColors(t, pane, requesterTheme.Foreground, requesterTheme.Background)
253
254	require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, false))
255	d.sessWg.Wait()
256}
257
258func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
259	p1, release1 := newBlockingPTY(t)
260	defer release1()
261	p2 := portsmocks.NewMockPTY(t)
262	openCtx := make(chan context.Context, 1)
263	releaseOpen := make(chan struct{})
264	closed := make(chan struct{})
265	p2.EXPECT().Close().RunAndReturn(func() error {
266		close(closed)
267		return nil
268	}).Once()
269	p2.EXPECT().Pid().Return(4242).Maybe()
270
271	f := portsmocks.NewMockPTYFactory(t)
272	normalSize := domain.Size{Cols: 80, Rows: 22}
273	f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).Return(p1, nil).Once()
274	floating := newQuietPTY()
275	expectFloatingPrewarmOpen(f, normalSize, floating)
276	f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
277		func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
278			openCtx <- ctx
279			select {
280			case <-ctx.Done():
281			case <-releaseOpen:
282			}
283			return p2, nil
284		},
285	).Once()
286
287	d := newTestDaemon(t, f, stubClock{})
288	tr, sends, releaseConn := newConn(t, mustHello(protocol.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
289	var hg sync.WaitGroup
290	hg.Go(func() { d.handleConn(tr) })
291	awaitFrame(t, sends, "Welcome")
292	awaitFrame(t, sends, "Output")
293	sess := firstSession(d)
294	require.NotNil(t, sess)
295
296	errCh := make(chan error, 1)
297	go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
298	ctx := <-openCtx
299	_ = d.killSession(sess, protocol.ReasonSessionKilled, false)
300
301	cancelled := true
302	select {
303	case <-ctx.Done():
304	case <-time.After(time.Second):
305		cancelled = false
306	}
307	close(releaseOpen)
308
309	if !cancelled {
310		t.Error("killSession did not cancel the context passed to PTYFactory.Open")
311	}
312	require.Error(t, <-errCh)
313	select {
314	case <-closed:
315	case <-time.After(time.Second):
316		t.Fatal("new PTY was not closed after session died during tab creation")
317	}
318	require.Equal(t, 0, sessionCount(d))
319	releaseConn()
320	hg.Wait()
321	d.sessWg.Wait()
322	// A queued prewarm may be cancelled before Open during teardown.
323}
324
325// --- ephemeral numbering ----------------------------------------------------
326
327func TestEphemeralNumberingReuse(t *testing.T) {
328	d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
329	// Emulate two ephemeral sessions "0" and "1".
330	d.sessions["a"] = &session{sessionCore: sessionCore{id: "a", name: "0"}}
331	d.sessions["b"] = &session{sessionCore: sessionCore{id: "b", name: "1"}}
332	require.Equal(t, "2", d.allocEphemeralNameLocked())
333
334	// Kill "0": the freed number is reused before "2".
335	delete(d.sessions, "a")
336	require.Equal(t, "0", d.allocEphemeralNameLocked())
337}
338
339// --- attach replace ---------------------------------------------------------
340
341// --- detach semantics -------------------------------------------------------
342
343func TestDetachKeepsEphemeralHeadless(t *testing.T) {
344	p, releasePTY := newBlockingPTY(t)
345	d := newTestDaemon(t, newFactory(t, p), stubClock{})
346	tr, sends, _ := newConn(t,
347		mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}),
348		mustClientEnvelope(protocol.Detach{}),
349	)
350
351	var hg sync.WaitGroup
352	hg.Go(func() { d.handleConn(tr) })
353	awaitFrame(t, sends, "Welcome")
354	awaitFrame(t, sends, "Detached")
355	hg.Wait()
356
357	require.Equal(t, 1, sessionCount(d), "ephemeral session survives explicit detach")
358	sess := firstSession(d)
359	require.NotNil(t, sess)
360	sess.mu.Lock()
361	require.True(t, sess.ephemeral)
362	require.Empty(t, sess.snapshotAttachmentsLocked(), "ephemeral session is headless after detach")
363	sess.mu.Unlock()
364
365	_ = d.killSession(sess, protocol.ReasonServerShutdown, false)
366	releasePTY()
367	d.sessWg.Wait()
368	d.waitNotifies()
369}
370
371func TestListShowsDetachedEphemeralSession(t *testing.T) {
372	p, releasePTY := newBlockingPTY(t)
373	defer releasePTY()
374	d := newTestDaemon(t, newFactory(t, p), stubClock{})
375
376	tr := &closeTrackingTransport{}
377	sess, ac, err := d.route(helloResumeCapable(protocol.IntentEphemeral, "", 0), tr)
378	require.NoError(t, err)
379	d.clientGone(sess, ac, ac.transport(), true)
380
381	got := listSessions(t, d)
382	require.Len(t, got.Sessions, 1)
383	require.Equal(t, sess.name, got.Sessions[0].Name)
384	require.True(t, got.Sessions[0].Ephemeral)
385	require.False(t, got.Sessions[0].Attached)
386
387	_ = d.killSession(sess, protocol.ReasonServerShutdown, false)
388	d.sessWg.Wait()
389	d.waitNotifies()
390}
391
392func TestDetachKeepsNamed(t *testing.T) {
393	p, releasePTY := newBlockingPTY(t)
394	d := newTestDaemon(t, newFactory(t, p), stubClock{})
395	tr, sends, _ := newConn(t,
396		mustHello(protocol.IntentNew, "keep", domain.Size{Cols: 80, Rows: 24}),
397		mustClientEnvelope(protocol.Detach{}),
398	)
399
400	var hg sync.WaitGroup
401	hg.Go(func() { d.handleConn(tr) })
402	awaitFrame(t, sends, "Welcome")
403	awaitFrame(t, sends, "Detached") // ack for explicit detach
404	hg.Wait()                        // handler returns after detach
405
406	require.Equal(t, 1, sessionCount(d), "named session survives detach")
407	sess := firstSession(d)
408	sess.mu.Lock()
409	require.Empty(t, sess.snapshotAttachmentsLocked(), "named session is headless after detach")
410	sess.mu.Unlock()
411
412	_ = d.killSession(sess, protocol.ReasonServerShutdown, false)
413	releasePTY()
414	d.sessWg.Wait()
415	d.waitNotifies()
416}
417
418// --- render scheduler debounce ----------------------------------------------
419
420// TestReaderEOFRemovesSessionAndDaemonSurvives proves final session removal is
421// independent of daemon shutdown: the last session's EOF empties the registry
422// without signalling shutdown or marking the daemon closing, and the same
423// daemon can be occupied again by a fresh session.
424func TestReaderEOFRemovesSessionAndDaemonSurvives(t *testing.T) {
425	eofPTY := portsmocks.NewMockPTY(t)
426	// Read returns EOF immediately (child already gone).
427	eofPTY.EXPECT().Read(mock.Anything).Return(0, io.EOF).Maybe()
428	eofPTY.EXPECT().Close().Return(nil).Maybe()
429	eofPTY.EXPECT().Pid().Return(1).Maybe()
430
431	secondPTY, releaseSecond := newBlockingPTY(t)
432	defer releaseSecond()
433	d := newTestDaemon(t, newFactorySeq(t, eofPTY, secondPTY), stubClock{})
434
435	tr, sends, _ := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
436
437	var hg sync.WaitGroup
438	hg.Go(func() { d.handleConn(tr) })
439
440	// The client is detached with ReasonSessionKilled when the child exits.
441	det := awaitFrame(t, sends, "Detached")
442	dm := decodeServerMessage(t, det).(protocol.Detached)
443	require.Equal(t, protocol.ReasonSessionKilled, dm.Reason)
444
445	// The registry is empty, yet the daemon has not begun shutdown and has not
446	// signalled Serve. The Detached notice is scheduled only after registry
447	// removal has published, so these assertions are deterministic.
448	select {
449	case <-d.done:
450		t.Fatal("final session removal must not signal daemon shutdown")
451	default:
452	}
453	d.mu.Lock()
454	closing := d.closing
455	d.mu.Unlock()
456	require.False(t, closing, "empty registry must not mark the daemon closing")
457	require.Equal(t, 0, sessionCount(d))
458
459	// The empty registry can be occupied again by a brand-new session.
460	tr2, sends2, releaseConn2 := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
461	var hg2 sync.WaitGroup
462	hg2.Go(func() { d.handleConn(tr2) })
463	welcome := decodeServerMessage(t, awaitFrame(t, sends2, "Welcome")).(protocol.Welcome)
464	require.Equal(t, "0", welcome.SessionName)
465	awaitFrame(t, sends2, "Output")
466	require.Equal(t, 1, sessionCount(d))
467
468	_ = d.killSession(firstSession(d), protocol.ReasonSessionKilled, true)
469	releaseConn2()
470	hg.Wait()
471	hg2.Wait()
472	d.sessWg.Wait()
473	d.waitNotifies()
474}
475
476// TestConcurrentFinalRemovalAndCreationLinearize proves final-session removal
477// no longer opens a shutdown window: a create racing the removal is always
478// admitted (never rejected with ErrServerShutdown) and the two registry
479// transitions linearize under d.mu, leaving exactly the newly created session.
480func TestConcurrentFinalRemovalAndCreationLinearize(t *testing.T) {
481	for attempt := 0; attempt < 32; attempt++ {
482		firstPTY, releaseFirst := newBlockingPTY(t)
483		secondPTY, releaseSecond := newBlockingPTY(t)
484		d := newTestDaemon(t, newFactorySeq(t, firstPTY, secondPTY), stubClock{})
485
486		sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
487		require.NoError(t, err)
488
489		start := make(chan struct{})
490		var wg sync.WaitGroup
491		wg.Add(2)
492		var createErr error
493		go func() {
494			defer wg.Done()
495			<-start
496			_, _, createErr = d.route(helloResumeCapable(protocol.IntentEphemeral, "", 0), &closeTrackingTransport{})
497		}()
498		go func() {
499			defer wg.Done()
500			<-start
501			_ = d.killSession(sess, protocol.ReasonSessionKilled, true)
502		}()
503		close(start)
504		wg.Wait()
505
506		require.NoError(t, createErr, "attempt %d: creation racing final removal must be admitted", attempt)
507		require.Equal(t, 1, sessionCount(d), "attempt %d: exactly the new session must remain", attempt)
508		d.mu.Lock()
509		closing := d.closing
510		d.mu.Unlock()
511		require.False(t, closing, "attempt %d: empty→occupied must not mark the daemon closing", attempt)
512
513		_ = d.killSession(firstSession(d), protocol.ReasonSessionKilled, true)
514		releaseFirst()
515		releaseSecond()
516		d.sessWg.Wait()
517		d.waitNotifies()
518	}
519}
520
521// TestRestoreAfterEmptyRegistryWorks proves a stopped named session can be
522// restored by a fresh attach once the registry has returned to empty: the
523// daemon kept serving and the inactive authority survived the final removal.
524func TestRestoreAfterEmptyRegistryWorks(t *testing.T) {
525	firstPTY, releaseFirst := newBlockingPTY(t)
526	defer releaseFirst()
527	secondPTY, releaseSecond := newBlockingPTY(t)
528	defer releaseSecond()
529	d := newTestDaemon(t, newFactorySeq(t, firstPTY, secondPTY), stubClock{})
530
531	sess, err := createSessionForTest(d, "work", false, t.TempDir(), domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
532	require.NoError(t, err)
533
534	// Stop the only session; the registry returns to empty without shutdown.
535	require.NoError(t, d.killSession(sess, protocol.ReasonServerShutdown, false))
536	require.Equal(t, 0, sessionCount(d))
537	select {
538	case <-d.done:
539		t.Fatal("stopped final session must not signal daemon shutdown")
540	default:
541	}
542
543	// Restore it inside the same daemon.
544	tr := newMockServerConnection(t)
545	tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
546	tr.EXPECT().Close().Return(nil).Maybe()
547	restored, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
548	require.NoError(t, err)
549	require.NotNil(t, ac)
550	require.Equal(t, "work", restored.name)
551	require.Equal(t, 1, sessionCount(d))
552
553	_ = d.killSession(restored, protocol.ReasonServerShutdown, false)
554	d.sessWg.Wait()
555	d.waitNotifies()
556}
557
558// --- full Serve lifecycle ---------------------------------------------------
559
560// TestServeSurvivesEmptyRegistryAndAcceptsNewSession proves the accept loop and
561// listener are not torn down by an empty registry: after the last session
562// exits, the same Serve loop still admits a brand-new session, and only ctx
563// cancellation ends it.
564func TestServeSurvivesEmptyRegistryAndAcceptsNewSession(t *testing.T) {
565	firstPTY, releaseFirst := newBlockingPTY(t)
566	defer releaseFirst()
567	secondPTY, releaseSecond := newBlockingPTY(t)
568	defer releaseSecond()
569
570	firstTr, firstSends, _ := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
571	secondTr, secondSends, _ := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
572
573	l := newMockServerListener(t)
574	// Feed connections one at a time: the second is offered only after the
575	// first session has exited, so the mock factory's PTY order is deterministic
576	// and the reuse handshake proves the accept loop survived.
577	connCh := make(chan wire.Transport, 1)
578	connCh <- firstTr
579	closed := make(chan struct{})
580	var once sync.Once
581	l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
582		select {
583		case c := <-connCh:
584			return c, nil
585		case <-closed:
586			return nil, io.EOF
587		}
588	}).Maybe()
589	l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
590	l.EXPECT().Addr().Return("mock").Maybe()
591
592	d := New(newFactorySeq(t, firstPTY, secondPTY), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
593	ctx, cancel := context.WithCancel(context.Background())
594	defer cancel()
595
596	served := make(chan error, 1)
597	go func() { served <- d.Serve(ctx, l) }()
598
599	awaitFrame(t, firstSends, "Welcome")
600	awaitFrame(t, firstSends, "Output")
601
602	// Child exits -> session removed -> registry empties -> Serve must survive.
603	releaseFirst()
604	det := awaitFrame(t, firstSends, "Detached")
605	require.Equal(t, protocol.ReasonSessionKilled, decodeServerMessage(t, det).(protocol.Detached).Reason)
606	require.Equal(t, 0, sessionCount(d))
607
608	// The same accept loop admits a fresh connection into the empty registry.
609	connCh <- secondTr
610	awaitFrame(t, secondSends, "Welcome")
611	awaitFrame(t, secondSends, "Output")
612	require.Equal(t, 1, sessionCount(d))
613
614	// Only explicit cancellation stops Serve.
615	cancel()
616	select {
617	case err := <-served:
618		require.NoError(t, err)
619	case <-time.After(3 * time.Second):
620		t.Fatal("Serve did not return after context cancel")
621	}
622}
623
624func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
625	p, releasePTY := newBlockingPTY(t)
626	defer releasePTY()
627	tr, sends, _ := newConn(t, mustHello(protocol.IntentNew, "long", domain.Size{Cols: 80, Rows: 24}))
628
629	l := newMockServerListener(t)
630	connCh := make(chan wire.Transport, 1)
631	connCh <- tr
632	closed := make(chan struct{})
633	var once sync.Once
634	l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
635		select {
636		case c := <-connCh:
637			return c, nil
638		case <-closed:
639			return nil, io.EOF
640		}
641	}).Maybe()
642	l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
643	l.EXPECT().Addr().Return("mock").Maybe()
644
645	d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
646	ctx, cancel := context.WithCancel(context.Background())
647
648	served := make(chan error, 1)
649	go func() { served <- d.Serve(ctx, l) }()
650
651	awaitFrame(t, sends, "Welcome")
652	awaitFrame(t, sends, "Output")
653
654	cancel() // graceful shutdown
655
656	det := awaitFrame(t, sends, "Detached")
657	dm := decodeServerMessage(t, det).(protocol.Detached)
658	require.Equal(t, protocol.ReasonServerShutdown, dm.Reason)
659
660	select {
661	case err := <-served:
662		require.NoError(t, err)
663	case <-time.After(3 * time.Second):
664		t.Fatal("Serve did not return after context cancel")
665	}
666}
667
668// --- shutdown/create interlock ------------------------------------------------
669
670// TestHelloAfterExplicitShutdownIsRejected covers the shutdown/create
671// interlock that survives P4.1: an explicit shutdownAll still marks the daemon
672// closing synchronously and signals Serve, so Hellos arriving afterward are
673// rejected with a clean typed error instead of creating a session the teardown
674// snapshot missed. Final session removal alone no longer triggers this.
675func TestHelloAfterExplicitShutdownIsRejected(t *testing.T) {
676	p, releasePTY := newBlockingPTY(t)
677	floating := newQuietPTY()
678	f := portsmocks.NewMockPTYFactory(t)
679	normalSize := domain.Size{Cols: 80, Rows: 22}
680	var d *Daemon
681	var opensAfterShutdown atomic.Int32
682	f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
683		func(_ context.Context, _ string, _ []string, _ []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
684			select {
685			case <-d.done:
686				opensAfterShutdown.Add(1)
687			default:
688			}
689			if geometry.Size == normalSize {
690				return p, nil
691			}
692			return floating, nil
693		},
694	).Maybe()
695	d = newTestDaemon(t, f, stubClock{})
696
697	tr1, sends1, release1 := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
698	var hg sync.WaitGroup
699	hg.Go(func() { d.handleConn(tr1) })
700	awaitFrame(t, sends1, "Welcome")
701	awaitFrame(t, sends1, "Output")
702	require.Equal(t, 1, sessionCount(d))
703
704	// Explicit shutdown begins irreversibly and signals Serve synchronously.
705	require.False(t, d.shutdownAll(protocol.ReasonServerShutdown))
706	select {
707	case <-d.done:
708	default:
709		t.Fatal("explicit shutdown must signal Serve synchronously")
710	}
711	require.Equal(t, 0, sessionCount(d))
712
713	// Hellos after shutdown — both creation intents — must be rejected with a
714	// clean typed error, and nothing may be inserted into the registry.
715	for _, intent := range []struct {
716		name   string
717		intent uint8
718		sess   string
719	}{
720		{"ephemeral", protocol.IntentEphemeral, ""},
721		{"named", protocol.IntentNew, "latecomer"},
722	} {
723		tr2, sends2, _ := newConn(t, mustHello(intent.intent, intent.sess, domain.Size{Cols: 80, Rows: 24}))
724		d.handleConn(tr2)
725		e := awaitFrame(t, sends2, "Error")
726		em, ok := decodeServerMessage(t, e).(protocol.ErrorMsg)
727		require.True(t, ok)
728		require.Equal(t, protocol.ErrServerShutdown, em.Code, "%s hello after shutdown must be rejected", intent.name)
729	}
730	require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
731
732	release1()
733	releasePTY()
734	hg.Wait()
735	d.sessWg.Wait()
736	d.waitNotifies()
737	require.Zero(t, opensAfterShutdown.Load(), "shutdown must not be followed by a PTY launch")
738}
739
740// --- wedged-client teardown ---------------------------------------------------
741
742func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
743	p, _ := newBlockingPTY(t) // Close unblocks the parked reader
744
745	tr := newMockServerConnection(t)
746	sends := make(chan wire.Envelope, 64)
747	recvCh := make(chan wire.Envelope, 1)
748	recvCh <- mustHello(protocol.IntentNew, "wedge", domain.Size{Cols: 80, Rows: 24})
749	connDone := make(chan struct{})
750	var connOnce sync.Once
751	closeConn := func() { connOnce.Do(func() { close(connDone) }) }
752
753	tr.EXPECT().Recv().RunAndReturn(func() (wire.Envelope, error) {
754		select {
755		case f := <-recvCh:
756			return f, nil
757		case <-connDone:
758			return wire.Envelope{}, io.EOF
759		}
760	}).Maybe()
761	tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f wire.Envelope) error {
762		if envelopeMessageName(nil, f.Payload) == "Detached" {
763			// Wedged: blocks until the transport is force-closed.
764			<-connDone
765			return io.ErrClosedPipe
766		}
767		sends <- f
768		return nil
769	}).Maybe()
770	tr.EXPECT().Close().RunAndReturn(func() error { closeConn(); return nil }).Maybe()
771
772	l := newMockServerListener(t)
773	connCh := make(chan wire.Transport, 1)
774	connCh <- tr
775	lnClosed := make(chan struct{})
776	var lnOnce sync.Once
777	l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
778		select {
779		case c := <-connCh:
780			return c, nil
781		case <-lnClosed:
782			return nil, io.EOF
783		}
784	}).Maybe()
785	l.EXPECT().Close().RunAndReturn(func() error { lnOnce.Do(func() { close(lnClosed) }); return nil }).Maybe()
786	l.EXPECT().Addr().Return("mock").Maybe()
787
788	d := New(newFactory(t, p), notifyClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
789	ctx, cancel := context.WithCancel(context.Background())
790
791	served := make(chan error, 1)
792	go func() { served <- d.Serve(ctx, l) }()
793
794	awaitFrame(t, sends, "Welcome")
795	awaitFrame(t, sends, "Output")
796
797	cancel() // shutdown with the wedged client still attached
798
799	select {
800	case err := <-served:
801		require.NoError(t, err)
802	case <-time.After(3 * time.Second):
803		t.Fatal("Serve hung on a wedged client during teardown")
804	}
805	require.Equal(t, 0, sessionCount(d), "session must be torn down despite the wedged client")
806}
807
808type mockStoreState struct {
809	mu         sync.Mutex
810	data       map[string][]byte
811	sets       int
812	dels       []string
813	deleteErr  func(string) error
814	deleteDone chan struct{}
815	syncs      int
816	closed     bool
817}
818
819func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
820	t.Helper()
821	state := &mockStoreState{data: make(map[string][]byte)}
822	store := portsmocks.NewMockStore(t)
823	store.EXPECT().Get(mock.Anything).RunAndReturn(func(k []byte) ([]byte, bool) {
824		state.mu.Lock()
825		defer state.mu.Unlock()
826		v, ok := state.data[string(k)]
827		return append([]byte(nil), v...), ok
828	}).Maybe()
829	store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(k, v []byte) error {
830		state.mu.Lock()
831		defer state.mu.Unlock()
832		state.sets++
833		state.data[string(k)] = append([]byte(nil), v...)
834		return nil
835	}).Maybe()
836	store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
837		state.mu.Lock()
838		key := string(k)
839		state.dels = append(state.dels, key)
840		if state.deleteErr != nil {
841			if err := state.deleteErr(key); err != nil {
842				state.mu.Unlock()
843				return err
844			}
845		}
846		delete(state.data, key)
847		deleteDone := state.deleteDone
848		state.deleteDone = nil
849		state.mu.Unlock()
850		if deleteDone != nil {
851			close(deleteDone)
852		}
853		return nil
854	}).Maybe()
855	store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) {
856		state.mu.Lock()
857		defer state.mu.Unlock()
858		for k, v := range state.data {
859			if !fn([]byte(k), append([]byte(nil), v...)) {
860				return
861			}
862		}
863	}).Maybe()
864	store.EXPECT().Sync().RunAndReturn(func() error {
865		state.mu.Lock()
866		defer state.mu.Unlock()
867		state.syncs++
868		return nil
869	}).Maybe()
870	store.EXPECT().Close().RunAndReturn(func() error {
871		state.mu.Lock()
872		defer state.mu.Unlock()
873		state.closed = true
874		return nil
875	}).Maybe()
876	return store, state
877}
878
879func (s *mockStoreState) has(name string) bool {
880	s.mu.Lock()
881	defer s.mu.Unlock()
882	_, ok := s.data[name]
883	return ok
884}
885
886func (s *mockStoreState) record(t *testing.T, name string) persist.Record {
887	t.Helper()
888	s.mu.Lock()
889	defer s.mu.Unlock()
890	records, err := persist.New(newStaticStore(s.data)).LoadAll()
891	require.NoError(t, err)
892	for _, r := range records {
893		if r.Name == name {
894			return r
895		}
896	}
897	t.Fatalf("record %q not found", name)
898	return persist.Record{}
899}
900
901type newStaticStore map[string][]byte
902
903func (s newStaticStore) Get(key []byte) ([]byte, bool) {
904	v, ok := s[string(key)]
905	return append([]byte(nil), v...), ok
906}
907func (s newStaticStore) Set(key, value []byte) error {
908	s[string(key)] = append([]byte(nil), value...)
909	return nil
910}
911func (s newStaticStore) Delete(key []byte) error {
912	delete(s, string(key))
913	return nil
914}
915func (s newStaticStore) Range(fn func(k, v []byte) bool) {
916	for k, v := range s {
917		if !fn([]byte(k), append([]byte(nil), v...)) {
918			return
919		}
920	}
921}
922func (s newStaticStore) Sync() error             { return nil }
923func (s newStaticStore) CloseWithoutSync() error { return nil }
924func (s newStaticStore) Close() error            { return nil }
925
926func TestNamedSessionCreationIsAllowedWithNilStore(t *testing.T) {
927	pty, release := newBlockingPTY(t)
928	defer release()
929	d := newTestDaemon(t, newFactory(t, pty), stubClock{})
930	WithStore(t, nil)(d)
931
932	sess, err := createSessionForTest(d, "work", false, "/tmp/work", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
933	require.NoError(t, err)
934	require.Equal(t, "work", sess.name)
935	require.False(t, d.persistEnabled)
936}
937
938func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
939	store, _ := newMockStore(t)
940	seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
941	incarnation := domain.IncarnationID{1}
942	require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9}))
943
944	d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
945	d.mu.Lock()
946	stopped := d.inactive["work"]
947	d.mu.Unlock()
948	require.Equal(t, "work", stopped.name)
949	require.Equal(t, "/tmp/work", stopped.cwd)
950	require.Equal(t, int64(7), stopped.createdAt)
951	require.Equal(t, incarnation, stopped.incarnation)
952	require.Equal(t, uint64(9), stopped.lastUsedSeq)
953	require.Equal(t, protocol.SessionDown, stopped.state)
954	require.NotNil(t, stopped.restoreDone)
955	require.Equal(t, uint64(9), d.mruSeq.Load())
956}
957
958// TestDaemonNewWithoutPersistenceLogsNoLoadWarning covers the default,
959// persistence-free construction path (no WithStore/WithCatalogue option):
960// testPersister(t, d) is the persist.New(nil) sentinel, so LoadAll now returns
961// errPersistenceUnavailable instead of an empty slice. That must not surface
962// as a "loading persisted sessions failed" warning, since nothing failed —
963// persistence simply was never configured.
964func TestDaemonNewWithoutPersistenceLogsNoLoadWarning(t *testing.T) {
965	var logBuf strings.Builder
966	d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil)))
967	require.NotNil(t, d)
968	require.False(t, d.persistEnabled)
969	require.Empty(t, d.inactive)
970	require.NotContains(t, logBuf.String(), "loading persisted sessions failed")
971}
972
973func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
974	d := &Daemon{clock: stubClock{}}
975	sess := &session{}
976	const goroutines = 16
977	const iterations = 1000
978	start := make(chan struct{})
979	done := make(chan struct{})
980	var decreased atomic.Bool
981	var wg sync.WaitGroup
982	var observer sync.WaitGroup
983
984	observer.Go(func() {
985		previous := sess.mruAt.Load()
986		for {
987			select {
988			case <-done:
989				return
990			default:
991			}
992			current := sess.mruAt.Load()
993			if current < previous {
994				decreased.Store(true)
995				return
996			}
997			previous = current
998		}
999	})
1000
1001	for range goroutines {
1002		wg.Go(func() {
1003			<-start
1004			for range iterations {
1005				d.touchMRU(sess)
1006			}
1007		})
1008	}
1009	close(start)
1010	wg.Wait()
1011	close(done)
1012	observer.Wait()
1013
1014	require.False(t, decreased.Load())
1015	require.Equal(t, d.mruSeq.Load(), sess.mruAt.Load())
1016}
1017
1018func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
1019	store, state := newMockStore(t)
1020	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1021	WithStore(t, store)(d)
1022	named := &session{sessionCore: sessionCore{name: "work", createdAt: 1, incarnation: domain.IncarnationID{1}}, tabs: []*tab{{}}}
1023	require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
1024
1025	d.touchMRU(named)
1026	require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
1027	setsAfterNamed := state.sets
1028
1029	ephemeral := &session{sessionCore: sessionCore{name: "0", ephemeral: true}, tabs: []*tab{{}}}
1030	d.touchMRU(ephemeral)
1031	require.False(t, state.has("0"))
1032	require.Equal(t, setsAfterNamed, state.sets)
1033}
1034
1035func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
1036	sz := domain.Size{Cols: 80, Rows: 24}
1037	p, release := newBlockingPTY(t)
1038	defer release()
1039	store, state := newMockStore(t)
1040	d := newTestDaemon(t, newFactory(t, p), stubClock{})
1041	WithStore(t, store)(d)
1042	d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
1043	d.mruSeq.Store(42)
1044
1045	sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1046	require.NoError(t, err)
1047	require.Equal(t, uint64(42), sess.mruAt.Load())
1048	require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)
1049}
1050
1051func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
1052	sz := domain.Size{Cols: 80, Rows: 24}
1053	p1, release1 := newBlockingPTY(t)
1054	p2, release2 := newBlockingPTY(t)
1055	defer release1()
1056	defer release2()
1057	store, state := newMockStore(t)
1058	d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1059	WithStore(t, store)(d)
1060
1061	sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1062	require.NoError(t, err)
1063	require.True(t, state.has("work"))
1064	created := state.record(t, "work")
1065	require.NotEqual(t, domain.IncarnationID{}, created.IncarnationID)
1066	require.NoError(t, created.Validate())
1067	require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
1068	require.NoError(t, d.createTab(sess, sz))
1069	require.NoError(t, d.renameTab(sess, sess.tabs[1], "logs"))
1070
1071	require.NoError(t, d.renameSession(sess, "renamed"))
1072	require.False(t, state.has("work"))
1073	require.True(t, state.has("renamed"))
1074	renamed := state.record(t, "renamed")
1075	require.Equal(t, []string{"shell", "logs"}, renamed.TabNames)
1076	require.Equal(t, created.IncarnationID, renamed.IncarnationID)
1077	require.NoError(t, renamed.Validate())
1078
1079	require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
1080	require.False(t, state.has("renamed"))
1081}
1082
1083func TestRenameTabPersistsForNamedSession(t *testing.T) {
1084	sz := domain.Size{Cols: 80, Rows: 24}
1085	p, release := newBlockingPTY(t)
1086	defer release()
1087	store, _ := newMockStore(t)
1088	d := newTestDaemon(t, newFactory(t, p), stubClock{})
1089	WithStore(t, store)(d)
1090
1091	sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1092	require.NoError(t, err)
1093	require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
1094
1095	records, err := testPersister(t, d).LoadAll()
1096	require.NoError(t, err)
1097	require.Len(t, records, 1)
1098	require.Equal(t, "work", records[0].Name)
1099	require.Equal(t, "/tmp/work", records[0].Cwd)
1100	require.Equal(t, sess.createdAt, records[0].CreatedAt)
1101	require.GreaterOrEqual(t, records[0].UpdatedAt, records[0].CreatedAt)
1102	require.Equal(t, []string{"shell"}, records[0].TabNames)
1103}
1104
1105func TestRenameTabBuffersMetadataUnderStateLocks(t *testing.T) {
1106	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1107	sess := newSnapshotTestSession(t, "work", false, "/work")
1108	d.sessions[sess.id] = sess
1109	catalogue := portsmocks.NewMockCatalogue(t)
1110	WithCatalogue(catalogue, nil)(d)
1111	catalogue.EXPECT().UpdateMetadata(mock.Anything).RunAndReturn(func(domain.CatalogueMetadataUpdate) error {
1112		require.False(t, d.mu.TryLock(), "daemon mutex must guard the metadata snapshot")
1113		require.False(t, sess.mu.TryLock(), "session mutex must guard the metadata snapshot")
1114		return nil
1115	}).Once()
1116
1117	require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
1118}
1119
1120func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
1121	tests := []struct {
1122		name       string
1123		tabNames   []string
1124		closeIndex int
1125		want       []string
1126	}{
1127		{name: "close before named tab", tabNames: []string{"shell", "", "logs"}, closeIndex: 0, want: []string{"", "logs"}},
1128		{name: "close after named tab", tabNames: []string{"shell", "logs", ""}, closeIndex: 2, want: []string{"shell", "logs"}},
1129	}
1130	for _, tt := range tests {
1131		t.Run(tt.name, func(t *testing.T) {
1132			ptys := make([]ports.PTY, 3)
1133			for i := range ptys {
1134				p, release := newBlockingPTY(t)
1135				ptys[i] = p
1136				t.Cleanup(release)
1137			}
1138			store, _ := newMockStore(t)
1139			d := newTestDaemon(t, newFactorySeq(t, ptys...), stubClock{})
1140			WithStore(t, store)(d)
1141			sz := domain.Size{Cols: 80, Rows: 24}
1142
1143			sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1144			require.NoError(t, err)
1145			require.NoError(t, d.createTab(sess, sz))
1146			require.NoError(t, d.createTab(sess, sz))
1147			for i, name := range tt.tabNames {
1148				require.NoError(t, d.renameTab(sess, sess.tabs[i], name))
1149			}
1150
1151			require.NoError(t, d.closeTab(sess, sess.tabs[tt.closeIndex], false))
1152			records, err := testPersister(t, d).LoadAll()
1153			require.NoError(t, err)
1154			require.Len(t, records, 1)
1155			require.Equal(t, tt.want, records[0].TabNames)
1156		})
1157	}
1158}
1159
1160func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
1161	sz := domain.Size{Cols: 80, Rows: 24}
1162	p, release := newBlockingPTY(t)
1163	defer release()
1164	store, state := newMockStore(t)
1165	d := newTestDaemon(t, newFactory(t, p), stubClock{})
1166	WithStore(t, store)(d)
1167
1168	sess, err := createSessionForTest(d, "0", true, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1169	require.NoError(t, err)
1170	require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
1171	require.Equal(t, "shell", sess.tabs[0].name)
1172
1173	require.False(t, state.has("0"))
1174	records, err := testPersister(t, d).LoadAll()
1175	require.NoError(t, err)
1176	require.Empty(t, records)
1177}
1178
1179func TestAttachRestoresPersistedTabNames(t *testing.T) {
1180	sz := domain.Size{Cols: 80, Rows: 24}
1181	p1, release1 := newBlockingPTY(t)
1182	p2, release2 := newBlockingPTY(t)
1183	defer release1()
1184	defer release2()
1185	store, _ := newMockStore(t)
1186	d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1187	WithStore(t, store)(d)
1188	require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
1189	record, ok, err := d.catalogueRecord("work")
1190	require.NoError(t, err)
1191	require.True(t, ok)
1192	d.inactive["work"] = inactiveSessionFromRecord(record, protocol.SessionDown, nil)
1193	tr := newMockServerConnection(t)
1194	tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1195	tr.EXPECT().Close().Return(nil).Maybe()
1196
1197	sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz}, tr)
1198	require.NoError(t, err)
1199	require.NotNil(t, ac)
1200	require.Len(t, sess.tabs, 2)
1201	require.Equal(t, "shell", sess.tabs[0].name)
1202	require.Equal(t, "logs", sess.tabs[1].name)
1203}
1204
1205func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
1206	sz := domain.Size{Cols: 80, Rows: 24}
1207	p, release := newBlockingPTY(t)
1208	defer release()
1209	store, state := newMockStore(t)
1210	d := newTestDaemon(t, newFactory(t, p), stubClock{})
1211	WithStore(t, store)(d)
1212	d.inactive["taken"] = inactiveSession{name: "taken", cwd: "/tmp", createdAt: 1}
1213
1214	sess, err := createSessionForTest(d, "0", true, "/tmp/e", sz, terminalEnv{}, d.baseEnv)
1215	require.NoError(t, err)
1216	require.False(t, state.has("0"))
1217	require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
1218	require.NoError(t, d.renameSession(sess, "named"))
1219	require.True(t, state.has("named"))
1220}
1221
1222func TestEphemeralPromotionAssignsAndPersistsLifecycleIdentity(t *testing.T) {
1223	p, release := newBlockingPTY(t)
1224	defer release()
1225	store, state := newMockStore(t)
1226	clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}}
1227	d := newTestDaemon(t, newFactory(t, p), clock)
1228	WithStore(t, store)(d)
1229
1230	sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1231	require.NoError(t, err)
1232	require.Zero(t, sess.createdAt, "regression fixture: ephemerals have no lifecycle identity")
1233
1234	require.NoError(t, d.renameSession(sess, "named"))
1235
1236	require.False(t, sess.ephemeral)
1237	require.Equal(t, "named", sess.name)
1238	require.Equal(t, int64(100), sess.createdAt)
1239	require.Equal(t, sess.createdAt, state.record(t, "named").CreatedAt)
1240}
1241
1242func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testing.T) {
1243	fromPTY, releaseFrom := newBlockingPTY(t)
1244	firstPTY, releaseFirst := newBlockingPTY(t)
1245	secondPTY, releaseSecond := newBlockingPTY(t)
1246	defer releaseFrom()
1247	defer releaseFirst()
1248	defer releaseSecond()
1249
1250	d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1251	store, _ := newMockStore(t)
1252	clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
1253	d.clock = clock
1254	d.ptys = newFactorySeq(t, firstPTY, secondPTY)
1255	WithStore(t, store)(d)
1256
1257	first, err := createSessionForTest(d, "0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
1258	require.NoError(t, err)
1259	require.NoError(t, d.renameSession(first, "named"))
1260	staleCreatedAt := first.createdAt
1261	require.NotZero(t, staleCreatedAt)
1262	require.NoError(t, d.killSession(first, protocol.ReasonSessionKilled, true))
1263	d.sessWg.Wait()
1264
1265	second, err := createSessionForTest(d, "0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
1266	require.NoError(t, err)
1267	require.NoError(t, d.renameSession(second, "named"))
1268	require.NotEqual(t, staleCreatedAt, second.createdAt)
1269
1270	require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt}))
1271	require.Same(t, from, ac.currentSession())
1272	releaseSecond()
1273	d.sessWg.Wait()
1274}
1275
1276func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T) {
1277	t.Run("allocator exhaustion", func(t *testing.T) {
1278		p, release := newBlockingPTY(t)
1279		defer release()
1280		store, state := newMockStore(t)
1281		d := newTestDaemon(t, newFactory(t, p), stubClock{})
1282		WithStore(t, store)(d)
1283		d.lastAllocatedCreatedAt = math.MaxInt64
1284
1285		sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1286		require.NoError(t, err)
1287		require.EqualError(t, d.renameSession(sess, "named"), "daemon: lifecycle identities exhausted")
1288		require.Equal(t, "0", sess.name)
1289		require.True(t, sess.ephemeral)
1290		require.Zero(t, sess.createdAt)
1291		require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt)
1292		require.False(t, state.has("named"))
1293	})
1294
1295	t.Run("persistence failure", func(t *testing.T) {
1296		p, release := newBlockingPTY(t)
1297		defer release()
1298		store := portsmocks.NewMockStore(t)
1299		var attempted map[string][]byte
1300		store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {}).Times(2)
1301		store.EXPECT().Get([]byte("named")).Return(nil, false).Times(3)
1302		store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
1303			attempted = map[string][]byte{string(key): append([]byte(nil), value...)}
1304			return errors.New("disk full")
1305		}).Once()
1306		store.EXPECT().Delete([]byte("named")).Return(nil).Once()
1307		clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
1308		d := newTestDaemon(t, newFactory(t, p), clock)
1309		WithStore(t, store)(d)
1310
1311		sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1312		require.NoError(t, err)
1313		err = d.renameSession(sess, "named")
1314		require.ErrorIs(t, err, persist.ErrCatalogueDurability)
1315		require.ErrorContains(t, err, "disk full")
1316		require.Equal(t, "0", sess.name)
1317		require.True(t, sess.ephemeral)
1318		require.Zero(t, sess.createdAt)
1319		require.Equal(t, int64(100), d.lastAllocatedCreatedAt, "a failed durable write may be ambiguous, so its lifecycle identity remains reserved")
1320		records, err := persist.New(newStaticStore(attempted)).LoadAll()
1321		require.NoError(t, err)
1322		require.Len(t, records, 1)
1323		require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity")
1324
1325		require.ErrorIs(t, d.renameSession(sess, "named"), persist.ErrCatalogueDurability)
1326		require.Equal(t, int64(101), d.lastAllocatedCreatedAt, "the lifecycle identity is allocated before the fenced catalogue rejects promotion")
1327	})
1328}
1329
1330func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
1331	sz := domain.Size{Cols: 80, Rows: 24}
1332	p, release := newBlockingPTY(t)
1333	defer release()
1334	store, state := newMockStore(t)
1335	d := newTestDaemon(t, newFactory(t, p), stubClock{})
1336	WithStore(t, store)(d)
1337	cwd := "/tmp/work"
1338	WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
1339
1340	sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1341	require.NoError(t, err)
1342	state.mu.Lock()
1343	setsAfterCreate := state.sets
1344	state.mu.Unlock()
1345
1346	d.refreshSessionCwd(sess)
1347	state.mu.Lock()
1348	require.Equal(t, setsAfterCreate, state.sets)
1349	state.mu.Unlock()
1350
1351	cwd = "/tmp/next"
1352	d.refreshSessionCwd(sess)
1353	state.mu.Lock()
1354	require.Equal(t, setsAfterCreate+1, state.sets)
1355	state.mu.Unlock()
1356}
1357
1358func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) {
1359	sz := domain.Size{Cols: 80, Rows: 24}
1360	p, release := newBlockingPTY(t)
1361	defer release()
1362	store, _ := newMockStore(t)
1363	d := newTestDaemon(t, newFactory(t, p), stubClock{})
1364	WithStore(t, store)(d)
1365	cwd := t.TempDir()
1366	d.inactive["work"] = inactiveSession{name: "work", cwd: cwd, createdAt: 1, state: protocol.SessionDown}
1367	tr := newMockServerConnection(t)
1368	tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1369	tr.EXPECT().Close().Return(nil).Maybe()
1370
1371	sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz}, tr)
1372	require.NoError(t, err)
1373	require.NotNil(t, ac)
1374	require.Equal(t, "work", sess.name)
1375	require.Equal(t, cwd, sess.cwd)
1376	d.mu.Lock()
1377	_, stillStopped := d.inactive["work"]
1378	d.mu.Unlock()
1379	require.False(t, stillStopped)
1380}
1381
1382func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) {
1383	sz := domain.Size{Cols: 80, Rows: 24}
1384	p, release := newBlockingPTY(t)
1385	defer release()
1386	store, _ := newMockStore(t)
1387	d := newTestDaemon(t, newFactory(t, p), stubClock{})
1388	WithStore(t, store)(d)
1389	d.inactive["work"] = inactiveSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1, state: protocol.SessionDown}
1390	tr := newMockServerConnection(t)
1391	tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1392	tr.EXPECT().Close().Return(nil).Maybe()
1393
1394	sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz}, tr)
1395	require.NoError(t, err)
1396	require.NotEqual(t, "/definitely/missing/vev", sess.cwd)
1397}
1398
1399func TestPickerStoppedTargetKillPurges(t *testing.T) {
1400	store, state := newMockStore(t)
1401	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1402	WithStore(t, store)(d)
1403	require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
1404	d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp", createdAt: 1, incarnation: domain.IncarnationID{1}}
1405	require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))
1406	require.False(t, state.has("old"))
1407	d.mu.Lock()
1408	_, ok := d.inactive["old"]
1409	d.mu.Unlock()
1410	require.False(t, ok)
1411}
1412
1413func TestChildEnvEscapesLegacySessionName(t *testing.T) {
1414	d := newTestDaemon(t, nil, stubClock{})
1415
1416	got := d.childEnv("legacy,name=value", "t_alpha", "p_beta")
1417
1418	require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")
1419}
1420
1421func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
1422	sz := domain.Size{Cols: 80, Rows: 24}
1423	p, releasePTY := newBlockingPTY(t)
1424	defer releasePTY()
1425
1426	var gotEnv []string
1427	f := portsmocks.NewMockPTYFactory(t)
1428	f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1429		func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1430			gotEnv = append([]string(nil), env...)
1431			return p, nil
1432		},
1433	).Once()
1434	d := newTestDaemon(t, f, stubClock{})
1435	d.baseEnv = []string{"KEEP=1", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
1436
1437	sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{TrueColor: true}, d.baseEnv)
1438	require.NoError(t, err)
1439	defer func() {
1440		_ = d.killSession(sess, protocol.ReasonServerShutdown, false)
1441		releasePTY()
1442		d.sessWg.Wait()
1443	}()
1444
1445	sess.mu.Lock()
1446	require.Len(t, sess.tabs, 1)
1447	tb := sess.tabs[0]
1448	sess.mu.Unlock()
1449	tb.mu.Lock()
1450	paneStableID := tb.panes["pane-1"].stableID
1451	tabStableID := tb.stableID
1452	tb.mu.Unlock()
1453
1454	require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID)
1455	require.True(t, strings.HasPrefix(paneStableID, "p_"), paneStableID)
1456	require.NotEqual(t, tabStableID, paneStableID)
1457	require.Contains(t, gotEnv, "KEEP=1")
1458	require.NotContains(t, gotEnv, "TERM=old")
1459	require.NotContains(t, gotEnv, "COLORTERM=old")
1460	require.NotContains(t, gotEnv, "TERM_PROGRAM=old")
1461	require.NotContains(t, gotEnv, "VEV=old")
1462	require.Contains(t, gotEnv, "TERM=xterm-256color")
1463	require.Contains(t, gotEnv, "COLORTERM=truecolor")
1464	require.Contains(t, gotEnv, "TERM_PROGRAM=vev")
1465	require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)
1466}
1467
1468func TestIntentNewStoppedNameRejected(t *testing.T) {
1469	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1470	d.inactive["taken"] = inactiveSession{name: "taken", cwd: "/tmp", createdAt: 1}
1471	tr := newMockServerConnection(t)
1472	_, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
1473	require.ErrorContains(t, err, "name already in use")
1474}
1475
1476func TestIntentNewUnsafeNameRejected(t *testing.T) {
1477	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1478	tr := newMockServerConnection(t)
1479	_, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "my work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
1480	require.ErrorContains(t, err, domain.ErrInvalidSessionName.Error())
1481}
1482
1483func TestCreateSessionAndSwitchUnsafeNameRejected(t *testing.T) {
1484	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1485	require.ErrorIs(t, d.createSessionAndSwitch(nil, nil, "my work"), domain.ErrInvalidSessionName)
1486}
1487
1488func TestRenameSessionUnsafeNameRejected(t *testing.T) {
1489	sz := domain.Size{Cols: 80, Rows: 24}
1490	p, release := newBlockingPTY(t)
1491	defer release()
1492	d := newTestDaemon(t, newFactory(t, p), stubClock{})
1493	sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1494	require.NoError(t, err)
1495	require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
1496}
1497
1498func TestCreateTabPtyFailureIsUserError(t *testing.T) {
1499	sz := domain.Size{Cols: 80, Rows: 24}
1500	p, release := newBlockingPTY(t)
1501	defer release()
1502	d := newTestDaemon(t, newFactory(t, p), stubClock{})
1503	sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1504	require.NoError(t, err)
1505
1506	cause := errors.New("fork/exec: no such file")
1507	failing := portsmocks.NewMockPTYFactory(t)
1508	failing.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
1509	d.ptys = failing
1510
1511	err = d.createTab(sess, sz)
1512
1513	var ue *domain.UserError
1514	require.ErrorAs(t, err, &ue)
1515	require.Equal(t, domain.NoticeTabSpawn, ue.Code)
1516	require.Equal(t, domain.NoticeError, ue.Severity)
1517	require.Equal(t, "couldn't open tab: shell failed to start", ue.Msg)
1518	require.ErrorIs(t, err, cause)
1519	require.NotContains(t, ue.Msg, cause.Error())
1520}
1521
1522func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
1523	sz := domain.Size{Cols: 80, Rows: 24}
1524	p1, release1 := newBlockingPTY(t)
1525	defer release1()
1526	p2, release2 := newBlockingPTY(t)
1527	defer release2()
1528	store, state := newMockStore(t)
1529	d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1530	WithStore(t, store)(d)
1531	WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
1532
1533	natural, err := createSessionForTest(d, "natural", false, "/tmp/old", sz, terminalEnv{}, d.baseEnv)
1534	require.NoError(t, err)
1535	other, err := createSessionForTest(d, "other", false, "/tmp/other", sz, terminalEnv{}, d.baseEnv)
1536	require.NoError(t, err)
1537	_ = d.killSession(natural, protocol.ReasonSessionKilled, false)
1538	require.True(t, state.has("natural"))
1539	d.mu.Lock()
1540	stopped := d.inactive["natural"]
1541	d.mu.Unlock()
1542	require.Equal(t, "/tmp/latest", stopped.cwd)
1543
1544	_ = d.killSession(other, protocol.ReasonSessionKilled, true)
1545	require.False(t, state.has("other"))
1546}
1547
1548func TestChildEnvTrueColorCapability(t *testing.T) {
1549	tests := []struct {
1550		name           string
1551		baseEnv        []string
1552		term           terminalEnv
1553		wantContain    []string
1554		wantNotContain []string
1555	}{
1556		{
1557			name:    "exports truecolor",
1558			baseEnv: []string{"KEEP=1", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"},
1559			term:    terminalEnv{TrueColor: true},
1560			wantContain: []string{
1561				"KEEP=1",
1562				"TERM=xterm-256color",
1563				"COLORTERM=truecolor",
1564				"TERM_PROGRAM=vev",
1565				"VEV=session=work,tab=t_alpha,pane=p_beta",
1566			},
1567			wantNotContain: []string{"TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"},
1568		},
1569		{
1570			name:           "uses the fixed pane contract",
1571			baseEnv:        []string{"COLORTERM=old"},
1572			wantContain:    []string{"TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
1573			wantNotContain: []string{"TERM=xterm-direct"},
1574		},
1575	}
1576
1577	for _, tt := range tests {
1578		t.Run(tt.name, func(t *testing.T) {
1579			d := newTestDaemon(t, nil, stubClock{})
1580			d.baseEnv = tt.baseEnv
1581
1582			got := d.childEnv("work", "t_alpha", "p_beta")
1583
1584			for _, want := range tt.wantContain {
1585				require.Contains(t, got, want)
1586			}
1587			for _, notWant := range tt.wantNotContain {
1588				require.NotContains(t, got, notWant)
1589			}
1590		})
1591	}
1592}
1593
1594func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1595	sz := domain.Size{Cols: 80, Rows: 24}
1596	p1, release1 := newBlockingPTY(t)
1597	defer release1()
1598	p2, release2 := newBlockingPTY(t)
1599	defer release2()
1600
1601	var opens [][]string
1602	f := portsmocks.NewMockPTYFactory(t)
1603	normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1604	f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1605		func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1606			opens = append(opens, append([]string(nil), env...))
1607			if len(opens) == 1 {
1608				return p1, nil
1609			}
1610			return p2, nil
1611		},
1612	).Twice()
1613	floating := newQuietPTY()
1614	expectFloatingPrewarmOpen(f, normalSize, floating)
1615
1616	d := newTestDaemon(t, f, stubClock{})
1617	d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
1618	tr := newMockServerConnection(t)
1619	tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1620	tr.EXPECT().Close().Return(nil).Maybe()
1621	sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1622	require.NoError(t, err)
1623	defer func() {
1624		_ = d.killSession(sess, protocol.ReasonServerShutdown, false)
1625		d.sessWg.Wait()
1626	}()
1627
1628	require.Contains(t, opens[0], "TERM=xterm-256color")
1629	require.Contains(t, opens[0], "COLORTERM=truecolor")
1630	require.Contains(t, opens[0], "TERM_PROGRAM=vev")
1631	require.NoError(t, d.createTab(sess, ac.size))
1632	require.Contains(t, opens[1], "TERM=xterm-256color")
1633	require.Contains(t, opens[1], "COLORTERM=truecolor")
1634	require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1635}
1636
1637func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1638	sz := domain.Size{Cols: 80, Rows: 24}
1639	p1, release1 := newBlockingPTY(t)
1640	defer release1()
1641	p2, release2 := newBlockingPTY(t)
1642	defer release2()
1643
1644	var opens [][]string
1645	f := portsmocks.NewMockPTYFactory(t)
1646	normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1647	f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1648		func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1649			opens = append(opens, append([]string(nil), env...))
1650			if len(opens) == 1 {
1651				return p1, nil
1652			}
1653			return p2, nil
1654		},
1655	).Twice()
1656	floating := newQuietPTY()
1657	expectFloatingPrewarmOpen(f, normalSize, floating)
1658
1659	d := newTestDaemon(t, f, stubClock{})
1660	tr1 := newMockServerConnection(t)
1661	tr1.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1662	tr1.EXPECT().Close().Return(nil).Maybe()
1663	tr2 := newMockServerConnection(t)
1664	tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1665	tr2.EXPECT().Close().Return(nil).Maybe()
1666	sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: false}, tr1)
1667	require.NoError(t, err)
1668	_, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz, TrueColor: true}, tr2)
1669	require.NoError(t, err)
1670	defer func() {
1671		_ = d.killSession(sess, protocol.ReasonServerShutdown, false)
1672		d.sessWg.Wait()
1673	}()
1674
1675	require.Contains(t, opens[0], "TERM=xterm-256color")
1676	require.Contains(t, opens[0], "COLORTERM=truecolor")
1677	require.NoError(t, d.createTab(sess, ac.size))
1678	require.Contains(t, opens[1], "TERM=xterm-256color")
1679	require.Contains(t, opens[1], "COLORTERM=truecolor")
1680	require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1681}
1682
1683func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1684	sz := domain.Size{Cols: 80, Rows: 24}
1685	p1, release1 := newBlockingPTY(t)
1686	defer release1()
1687	p2, release2 := newBlockingPTY(t)
1688	defer release2()
1689	var opens [][]string
1690	f := portsmocks.NewMockPTYFactory(t)
1691	normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1692	f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1693		func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1694			opens = append(opens, append([]string(nil), env...))
1695			if len(opens) == 1 {
1696				return p1, nil
1697			}
1698			return p2, nil
1699		},
1700	).Twice()
1701	floating := newQuietPTY()
1702	expectFloatingPrewarmOpen(f, normalSize, floating)
1703	d := newTestDaemon(t, f, stubClock{})
1704	tr := newMockServerConnection(t)
1705	tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1706	tr.EXPECT().Close().Return(nil).Maybe()
1707	sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1708	require.NoError(t, err)
1709	// The source coordinator is deliberately made pending so the switch must
1710	// invalidate it rather than letting its stale callback render for ac.
1711	sourceCoordinator := sess.renderCoordinator()
1712	require.NotNil(t, sourceCoordinator)
1713	sourceCoordinator.invalidate(renderInvalidation{class: invalidateOutput})
1714	ac.sendMu.Lock()
1715	ac.output.next = 3
1716	ac.output.acked = 1
1717	ac.sendMu.Unlock()
1718
1719	require.NoError(t, d.createSessionAndSwitch(sess, ac, "next"))
1720	got := ac.currentSession()
1721	require.NotNil(t, got)
1722	sourceCoordinator.mu.Lock()
1723	require.Empty(t, sourceCoordinator.attachments, "source callbacks must be stale after handoff")
1724	require.Empty(t, sourceCoordinator.leases, "source leases must be retired after handoff")
1725	sourceCoordinator.mu.Unlock()
1726	destinationCoordinator := got.renderCoordinator()
1727	require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint")
1728	destinationCoordinator.mu.Lock()
1729	require.Contains(t, destinationCoordinator.attachments, ac)
1730	lease := destinationCoordinator.leases[ac]
1731	require.NotNil(t, lease)
1732	require.Same(t, ac, lease.attachment)
1733	destinationCoordinator.mu.Unlock()
1734	ac.sendMu.Lock()
1735	require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")
1736	ac.sendMu.Unlock()
1737	got.mu.Lock()
1738	got.mu.Unlock()
1739	require.Len(t, opens, 2)
1740	require.Contains(t, opens[1], "TERM=xterm-256color")
1741	require.Contains(t, opens[1], "COLORTERM=truecolor")
1742	require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1743	_ = d.killSession(got, protocol.ReasonSessionKilled, false)
1744	release1()
1745	release2()
1746	d.sessWg.Wait()
1747}
1748
1749func TestAttachEnvironmentRefreshesOnlySessionBoundFuturePTYInputs(t *testing.T) {
1750	sz := domain.Size{Cols: 80, Rows: 24}
1751	p1, release1 := newBlockingPTY(t)
1752	defer release1()
1753	p2, release2 := newBlockingPTY(t)
1754	defer release2()
1755
1756	var commands []string
1757	var envs [][]string
1758	f := portsmocks.NewMockPTYFactory(t)
1759	normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1760	f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1761		func(_ context.Context, command string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1762			commands = append(commands, command)
1763			envs = append(envs, append([]string(nil), env...))
1764			if len(envs) == 1 {
1765				return p1, nil
1766			}
1767			return p2, nil
1768		},
1769	).Twice()
1770	floating := newQuietPTY()
1771	expectFloatingPrewarmOpen(f, normalSize, floating)
1772	d := newTestDaemon(t, f, stubClock{})
1773	tr1 := newMockServerConnection(t)
1774	tr1.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1775	tr1.EXPECT().Close().Return(nil).Maybe()
1776	tr2 := newMockServerConnection(t)
1777	tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1778	tr2.EXPECT().Close().Return(nil).Maybe()
1779
1780	first := []string{"SECRET=first", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "WAYLAND_DISPLAY=wayland-1", "SSH_AUTH_SOCK=/run/first-agent"}
1781	sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, Env: first}, tr1)
1782	require.NoError(t, err)
1783	second := []string{"SECRET=second", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "DISPLAY=:0"}
1784	_, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz, Env: second}, tr2)
1785	require.NoError(t, err)
1786	defer func() {
1787		_ = d.killSession(sess, protocol.ReasonServerShutdown, false)
1788		d.sessWg.Wait()
1789	}()
1790
1791	require.Equal(t, "/usr/bin/fish", commands[0])
1792	require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "WAYLAND_DISPLAY=wayland-1", "SSH_AUTH_SOCK=/run/first-agent", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
1793	require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")
1794	require.NoError(t, d.createTab(sess, ac.size))
1795	// Only session-bound variables follow the attaching client: the X11-only
1796	// display group replaces the Wayland one, the absent agent socket is
1797	// removed, and the creator's shell and ordinary variables stay.
1798	require.Equal(t, "/usr/bin/fish", commands[1])
1799	require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "DISPLAY=:0", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
1800	require.Equal(t, "fish", sess.tabs[1].focusedPaneTitle(false), "later tabs keep the session creator's shell")
1801}
1802
1803func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {
1804	env := []string{"SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "EMPTY="}
1805	got := childEnvFrom(env, "work", "tab", "pane")
1806	require.Equal(t, []string{
1807		"SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "EMPTY=",
1808		"TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
1809	}, got)
1810}
1811
1812func TestShellFromEnvironmentFallsBackOnlyWhenAbsentOrEmpty(t *testing.T) {
1813	tests := []struct {
1814		name string
1815		env  []string
1816		want string
1817	}{
1818		{name: "absent", want: "/bin/sh"},
1819		{name: "empty", env: []string{"SHELL="}, want: "/bin/sh"},
1820		{name: "set", env: []string{"SHELL=/usr/bin/fish"}, want: "/usr/bin/fish"},
1821	}
1822
1823	for _, tt := range tests {
1824		t.Run(tt.name, func(t *testing.T) {
1825			require.Equal(t, tt.want, shellFromEnvironment(tt.env))
1826		})
1827	}
1828}
1829
1830type lifecycleClock struct {
1831	nows []time.Time
1832	next int
1833}
1834
1835func (c *lifecycleClock) Now() time.Time {
1836	i := min(c.next, len(c.nows)-1)
1837	c.next++
1838	return c.nows[i]
1839}
1840func (*lifecycleClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
1841
1842func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *testing.T) {
1843	ptys := make([]ports.PTY, 3)
1844	var releases []func()
1845	for i := range ptys {
1846		p, release := newBlockingPTY(t)
1847		ptys[i] = p
1848		releases = append(releases, release)
1849	}
1850	defer releaseAll(releases)
1851	clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100), time.Unix(0, 50)}}
1852	d := newTestDaemon(t, newFactorySeq(t, ptys...), clock)
1853	sz := domain.Size{Cols: 80, Rows: 24}
1854
1855	var got []int64
1856	for _, name := range []string{"one", "two", "three"} {
1857		sess, err := createSessionForTest(d, name, false, "/tmp", sz, terminalEnv{}, d.baseEnv)
1858		require.NoError(t, err)
1859		got = append(got, sess.createdAt)
1860	}
1861	require.Equal(t, []int64{100, 101, 102}, got)
1862}
1863
1864func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T) {
1865	d := newTestDaemon(t, nil, stubClock{})
1866	d.lastAllocatedCreatedAt = math.MaxInt64
1867	d.nextID = 17
1868	d.inactive["retained"] = inactiveSession{name: "retained", cwd: "/tmp", createdAt: 9}
1869
1870	sess, err := createSessionForTest(d, "new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1871
1872	require.ErrorContains(t, err, "lifecycle identities exhausted")
1873	require.Nil(t, sess)
1874	require.Empty(t, d.sessions)
1875	require.Equal(t, uint64(17), d.nextID)
1876	require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt)
1877	require.Equal(t, inactiveSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.inactive["retained"])
1878
1879	_, _, err = d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil)
1880	require.ErrorContains(t, err, "lifecycle identities exhausted")
1881	require.Empty(t, d.sessions)
1882	require.Equal(t, uint64(17), d.nextID)
1883	require.Equal(t, inactiveSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.inactive["retained"])
1884}
1885
1886func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
1887	alphaRef := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
1888	workRef := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
1889	records := []domain.CatalogueRecord{
1890		{Name: "alpha", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/alpha", CreatedAt: 7, LastUsedSeq: 11, TabNames: []string{"shell"}, Committed: alphaRef},
1891		{Name: "work", IncarnationID: domain.IncarnationID{2}, Cwd: "/tmp/work", CreatedAt: 9, LastUsedSeq: 13, TabNames: []string{"editor", "logs"}, Committed: workRef, DegradedReason: "uncertain legacy checkpoint"},
1892	}
1893
1894	d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records))
1895
1896	alpha := d.inactive["alpha"]
1897	require.Equal(t, records[0], alpha.record)
1898	require.Equal(t, protocol.SessionDown, alpha.state)
1899	require.NotNil(t, alpha.restoreDone)
1900	select {
1901	case <-alpha.restoreDone:
1902		t.Fatal("healthy catalogue record must begin in restoring state")
1903	default:
1904	}
1905	work := d.inactive["work"]
1906	require.Equal(t, records[1], work.record)
1907	require.Equal(t, protocol.SessionBroken, work.state)
1908	require.NotNil(t, work.restoreDone)
1909	select {
1910	case <-work.restoreDone:
1911	default:
1912		t.Fatal("degraded catalogue record must not wait for restoration")
1913	}
1914	require.Equal(t, int64(9), d.lastAllocatedCreatedAt)
1915	require.Equal(t, uint64(13), d.mruSeq.Load())
1916}
1917
1918func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
1919	store, _ := newMockStore(t)
1920	seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
1921	require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))
1922
1923	p, release := newBlockingPTY(t)
1924	defer release()
1925	clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}}
1926	// Constructing with persistence must establish the lifecycle high-water mark.
1927	d := New(newFactory(t, p), clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
1928	d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
1929	t.Cleanup(d.serveCancel)
1930
1931	sess, err := createSessionForTest(d, "new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1932	require.NoError(t, err)
1933	require.Equal(t, int64(901), sess.createdAt)
1934}
1935
1936func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testing.T) {
1937	fromPTY, releaseFrom := newBlockingPTY(t)
1938	targetPTY, releaseTarget := newBlockingPTY(t)
1939	defer releaseFrom()
1940	defer releaseTarget()
1941	d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1942	store, state := newMockStore(t)
1943	WithStore(t, store)(d)
1944	d.ptys = newFactory(t, targetPTY)
1945	d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 77, state: protocol.SessionDown}
1946
1947	require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true}))
1948	resumed := ac.currentSession()
1949	require.Equal(t, int64(77), resumed.createdAt)
1950	require.Equal(t, int64(77), state.record(t, "stopped").CreatedAt)
1951}
1952
1953func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing.T) {
1954	t.Run("active replacement is rejected", func(t *testing.T) {
1955		d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1956		defer releaseAll(releases)
1957		target := mustLocalSession(t, d.sessions["recent"])
1958		target.createdAt = 22
1959		expected := int64(21)
1960
1961		require.Error(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))
1962		require.Same(t, from, ac.currentSession())
1963	})
1964
1965	t.Run("active target that stopped resumes same lifecycle", func(t *testing.T) {
1966		fromPTY, releaseFrom := newBlockingPTY(t)
1967		targetPTY, releaseTarget := newBlockingPTY(t)
1968		defer releaseFrom()
1969		defer releaseTarget()
1970		d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1971		d.ptys = newFactory(t, targetPTY)
1972		expected := int64(31)
1973		d.inactive["target"] = inactiveSession{name: "target", cwd: "/tmp", createdAt: expected, state: protocol.SessionDown}
1974
1975		require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
1976		require.Equal(t, "target", ac.currentSession().name)
1977		require.Equal(t, expected, ac.currentSession().createdAt)
1978	})
1979
1980	t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) {
1981		d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1982		defer releaseAll(releases)
1983		target := mustLocalSession(t, d.sessions["recent"])
1984		target.createdAt = 41
1985		expected := int64(41)
1986
1987		require.NoError(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1988		require.Same(t, target, ac.currentSession())
1989	})
1990
1991	t.Run("stopped deletion and recreation is rejected", func(t *testing.T) {
1992		fromPTY, releaseFrom := newBlockingPTY(t)
1993		defer releaseFrom()
1994		d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1995		d.inactive["target"] = inactiveSession{name: "target", cwd: "/tmp", createdAt: 52, state: protocol.SessionDown}
1996		expected := int64(51)
1997
1998		require.Error(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1999		require.Same(t, from, ac.currentSession())
2000	})
2001}

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