v0.3.0 99dc266

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

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