v0.7.0 4c16dd8

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

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