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