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}