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