1package daemon
2
3import (
4 "context"
5 "io"
6 "sync"
7 "sync/atomic"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17)
18
19type contextAwareFloatingPTY struct {
20 ctx context.Context
21 readerStarted chan struct{}
22 closed chan struct{}
23 readerOnce sync.Once
24 closeOnce sync.Once
25 closeCount int
26}
27
28func newContextAwareFloatingPTY() *contextAwareFloatingPTY {
29 return &contextAwareFloatingPTY{
30 readerStarted: make(chan struct{}),
31 closed: make(chan struct{}),
32 }
33}
34
35func (p *contextAwareFloatingPTY) Read([]byte) (int, error) {
36 p.readerOnce.Do(func() { close(p.readerStarted) })
37 <-p.ctx.Done()
38 return 0, p.ctx.Err()
39}
40
41func (*contextAwareFloatingPTY) Write(b []byte) (int, error) { return len(b), nil }
42func (*contextAwareFloatingPTY) Resize(domain.Size) error { return nil }
43func (*contextAwareFloatingPTY) Pid() int { return 1 }
44func (*contextAwareFloatingPTY) ForegroundPgid() (int, error) {
45 return 1, nil
46}
47
48func (p *contextAwareFloatingPTY) Close() error {
49 p.closeOnce.Do(func() {
50 p.closeCount++
51 close(p.closed)
52 })
53 return nil
54}
55
56type contextAwareFloatingFactory struct {
57 pty *contextAwareFloatingPTY
58 opened chan context.Context
59 err error
60 onOpen func()
61}
62
63func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
64 if f.pty != nil {
65 f.pty.ctx = ctx
66 }
67 f.opened <- ctx
68 if f.onOpen != nil {
69 f.onOpen()
70 }
71 return f.pty, f.err
72}
73
74func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
75 teardowns := []struct {
76 name string
77 desiredVisible bool
78 teardown func(*Daemon, *session, *tab)
79 }{
80 {
81 name: "hidden floating pane",
82 teardown: func(d *Daemon, _ *session, tb *tab) {
83 d.teardownFloating(tb, nil)
84 },
85 },
86 {
87 name: "visible tab",
88 desiredVisible: true,
89 teardown: func(_ *Daemon, _ *session, tb *tab) {
90 tb.closeAllPanes()
91 },
92 },
93 {
94 name: "visible session",
95 desiredVisible: true,
96 teardown: func(d *Daemon, sess *session, _ *tab) {
97 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
98 },
99 },
100 }
101 for _, tt := range teardowns {
102 t.Run(tt.name, func(t *testing.T) {
103 pty := newContextAwareFloatingPTY()
104 factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}
105 d := newTestDaemon(t, factory, stubClock{})
106 tb := newFloatingTestTab(t)
107 tabCtx, cancelTab := context.WithCancel(t.Context())
108 tb.ctx, tb.cancel = tabCtx, cancelTab
109 sessCtx, cancelSession := context.WithCancel(t.Context())
110 sess := &session{sessionCore: sessionCore{id: "context-" + domain.SessionID(tt.name), name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
111 d.mu.Lock()
112 d.sessions[sess.id] = sess
113 d.mu.Unlock()
114 tb.mu.Lock()
115 generation := tb.beginFloatingWarmLocked(tt.desiredVisible)
116 tb.mu.Unlock()
117
118 d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
119 sessionName: "work",
120 size: domain.Size{Cols: 20, Rows: 8},
121 geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
122 paneStableID: "p_floating",
123 parentCtx: tabCtx,
124 }, generation)
125
126 openCtx := <-factory.opened
127 require.NoError(t, openCtx.Err(), "the PTY context must outlive the launch worker")
128 tb.mu.Lock()
129 floating := tb.floating.pane
130 tb.mu.Unlock()
131 require.NotNil(t, floating)
132 sess.mu.Lock()
133 sess.registerAttachmentLocked(&attachedClient{})
134 sess.mu.Unlock()
135 require.Equal(t, tt.desiredVisible, d.paneRenderable(sess, tb, floating), "installed popup renderability must follow its retained visibility")
136 sess.mu.Lock()
137 clearAttachmentsForTestLocked(sess)
138 sess.mu.Unlock()
139 select {
140 case <-pty.readerStarted:
141 case <-time.After(time.Second):
142 t.Fatal("floating reader did not start")
143 }
144
145 tt.teardown(d, sess, tb)
146 select {
147 case <-openCtx.Done():
148 case <-time.After(time.Second):
149 t.Fatal("floating PTY context was not cancelled by teardown")
150 }
151 select {
152 case <-pty.closed:
153 case <-time.After(time.Second):
154 t.Fatal("floating PTY was not closed by teardown")
155 }
156 d.sessWg.Wait()
157 require.Equal(t, 1, pty.closeCount, "floating PTY must close exactly once")
158 })
159 }
160}
161
162type failedOpenPTY struct {
163 ctx context.Context
164 readerStarts atomic.Int32
165 closes atomic.Int32
166 closed chan struct{}
167 closeOnce sync.Once
168 onClose func()
169}
170
171func newFailedOpenPTY() *failedOpenPTY {
172 return &failedOpenPTY{closed: make(chan struct{})}
173}
174
175func (p *failedOpenPTY) Read([]byte) (int, error) {
176 p.readerStarts.Add(1)
177 select {
178 case <-p.ctx.Done():
179 return 0, p.ctx.Err()
180 case <-p.closed:
181 return 0, io.EOF
182 }
183}
184
185func (*failedOpenPTY) Write(b []byte) (int, error) { return len(b), nil }
186func (*failedOpenPTY) Resize(domain.Size) error { return nil }
187func (*failedOpenPTY) Pid() int { return 1 }
188func (*failedOpenPTY) ForegroundPgid() (int, error) {
189 return 1, nil
190}
191
192func (p *failedOpenPTY) Close() error {
193 p.closes.Add(1)
194 if p.onClose != nil {
195 p.onClose()
196 }
197 p.closeOnce.Do(func() { close(p.closed) })
198 return nil
199}
200
201func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.T) {
202 tests := []struct {
203 name string
204 userOpen bool
205 }{
206 {name: "user open", userOpen: true},
207 {name: "background prewarm", userOpen: false},
208 }
209 for _, tt := range tests {
210 t.Run(tt.name, func(t *testing.T) {
211 cause := io.ErrUnexpectedEOF
212 pty := newFailedOpenPTY()
213 factory := portsmocks.NewMockPTYFactory(t)
214 cancelCallbacks := atomic.Int32{}
215 cancelled := make(chan struct{})
216 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
217 func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
218 pty.ctx = ctx
219 context.AfterFunc(ctx, func() {
220 cancelCallbacks.Add(1)
221 close(cancelled)
222 })
223 return pty, cause
224 }).Once()
225 d := newTestDaemon(t, factory, stubClock{})
226 tb := newFloatingTestTab(t)
227 tr, _ := newCapturingTransport(t)
228 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
229 ac.initOverlays()
230 sessCtx, cancelSession := context.WithCancel(t.Context())
231 sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
232 ac.setSession(sess)
233
234 // Close must be safe to run before failure publication and without
235 // either ownership lock held.
236 var unpublishedAtClose atomic.Bool
237 pty.onClose = func() {
238 tb.mu.Lock()
239 launchUnpublished := tb.floating.pane == nil && tb.floating.state == floatingWarming
240 tb.mu.Unlock()
241 sess.mu.Lock()
242 sessionName := sess.name
243 sess.mu.Unlock()
244 unpublishedAtClose.Store(launchUnpublished && sessionName == "work")
245 }
246
247 d.startFloating(sess, tb, tt.userOpen)
248 d.sessWg.Wait()
249 select {
250 case <-cancelled:
251 case <-time.After(time.Second):
252 t.Fatal("failed floating Open process context was not cancelled")
253 }
254
255 // Exercise later cancellation/stale cleanup paths; neither may find
256 // ownership to release a second time.
257 cancelSession()
258 d.teardownFloating(tb, ac)
259 require.Equal(t, int32(1), cancelCallbacks.Load(), "process context must be released exactly once")
260 require.Equal(t, int32(1), pty.closes.Load(), "returned PTY must be closed exactly once")
261 require.True(t, unpublishedAtClose.Load(), "PTY must close before any failure publication")
262 require.Zero(t, pty.readerStarts.Load(), "failed Open must not start a reader")
263
264 tb.mu.Lock()
265 floating := tb.floating.pane
266 publishedFailedPTY := false
267 for _, published := range tb.panes {
268 publishedFailedPTY = publishedFailedPTY || published.pty == pty
269 }
270 tb.mu.Unlock()
271 require.Nil(t, floating, "failed Open must not publish the floating slot")
272 require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner")
273 if tt.userOpen {
274 toasts := awaitToastCount(t, ac, 1)
275 require.Equal(t, domain.NoticeFloatingSpawn, toasts[0].Code)
276 require.Equal(t, "couldn't open floating pane: command failed to start", toasts[0].Message)
277 } else {
278 toasts, _ := visibleToasts(ac)
279 require.Empty(t, toasts, "background prewarm failure must stay silent")
280 }
281 })
282 }
283}
284
285func TestInstallFloatingRejectsNilPublicationInputs(t *testing.T) {
286 tests := []struct {
287 name string
288 nilSession bool
289 nilTab bool
290 nilPane bool
291 wantCloses int
292 }{
293 {name: "nil session", nilSession: true, wantCloses: 1},
294 {name: "nil tab", nilTab: true, wantCloses: 1},
295 {name: "nil pane", nilPane: true},
296 {name: "all nil", nilSession: true, nilTab: true, nilPane: true},
297 }
298 for _, tt := range tests {
299 t.Run(tt.name, func(t *testing.T) {
300 d := &Daemon{}
301 sess := &session{}
302 tb := newFloatingTestTab(t)
303 tb.mu.Lock()
304 generation := tb.beginFloatingWarmLocked(true)
305 tb.mu.Unlock()
306 pty := newContextAwareFloatingPTY()
307 p := newPane("floating", pty, domain.Size{Cols: 20, Rows: 8})
308
309 if tt.nilSession {
310 sess = nil
311 }
312 if tt.nilTab {
313 tb = nil
314 }
315 if tt.nilPane {
316 p = nil
317 }
318
319 require.NotPanics(t, func() { d.installFloating(sess, tb, p, generation) })
320 require.Equal(t, tt.wantCloses, pty.closeCount)
321 })
322 }
323}
324
325func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) {
326 tests := []struct {
327 name string
328 setup func(*tab, *contextAwareFloatingFactory)
329 }{
330 {
331 name: "failed Open",
332 setup: func(_ *tab, factory *contextAwareFloatingFactory) {
333 factory.err = context.Canceled
334 },
335 },
336 {
337 name: "stale install",
338 setup: func(tb *tab, factory *contextAwareFloatingFactory) {
339 factory.onOpen = func() {
340 tb.mu.Lock()
341 tb.takeFloatingLocked()
342 tb.mu.Unlock()
343 }
344 },
345 },
346 }
347 for _, tt := range tests {
348 t.Run(tt.name, func(t *testing.T) {
349 pty := newContextAwareFloatingPTY()
350 factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}
351 d := newTestDaemon(t, factory, stubClock{})
352 tb := newFloatingTestTab(t)
353 sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
354 tb.mu.Lock()
355 generation := tb.beginFloatingWarmLocked(true)
356 tb.mu.Unlock()
357 tt.setup(tb, factory)
358
359 d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
360 sessionName: "work",
361 size: domain.Size{Cols: 20, Rows: 8},
362 geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
363 paneStableID: "p_floating",
364 parentCtx: tb.ctx,
365 }, generation)
366
367 openCtx := <-factory.opened
368 select {
369 case <-openCtx.Done():
370 case <-time.After(time.Second):
371 t.Fatal("failed or stale floating Open retained its context")
372 }
373 tb.mu.Lock()
374 require.Nil(t, tb.floating.pane)
375 tb.mu.Unlock()
376 if tt.name == "stale install" {
377 select {
378 case <-pty.closed:
379 case <-time.After(time.Second):
380 t.Fatal("stale floating PTY was not closed")
381 }
382 require.Equal(t, 1, pty.closeCount)
383 }
384 })
385 }
386}
387
388func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {
389 factory := portsmocks.NewMockPTYFactory(t)
390 opened := make(chan struct{})
391 latePTY := portsmocks.NewMockPTY(t)
392 closed := make(chan struct{})
393 latePTY.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
394 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
395 func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
396 close(opened)
397 <-ctx.Done()
398 return latePTY, nil
399 }).Once()
400 d := newTestDaemon(t, factory, stubClock{})
401 tb := newFloatingTestTab(t)
402 ctx, cancel := context.WithCancel(t.Context())
403 sess := &session{sessionCore: sessionCore{id: "blocked-open", name: "work"}, tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
404 d.mu.Lock()
405 d.sessions[sess.id] = sess
406 d.mu.Unlock()
407 d.startFloating(sess, tb, true)
408 select {
409 case <-opened:
410 case <-time.After(time.Second):
411 t.Fatal("floating Open did not begin")
412 }
413
414 killed := make(chan error, 1)
415 go func() { killed <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
416 select {
417 case err := <-killed:
418 require.NoError(t, err)
419 case <-time.After(time.Second):
420 t.Fatal("session teardown waited for blocked Open")
421 }
422 select {
423 case <-closed:
424 case <-time.After(time.Second):
425 t.Fatal("late Open PTY was not closed")
426 }
427 tb.mu.Lock()
428 require.Nil(t, tb.floating.pane, "late PTY must not be installed")
429 tb.mu.Unlock()
430}
431
432func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) {
433 factory := portsmocks.NewMockPTYFactory(t)
434 entered := make(chan struct{}, 2)
435 release := make(chan struct{})
436 var once sync.Once
437 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
438 func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
439 entered <- struct{}{}
440 <-release
441 return newQuietPTY(), nil
442 }).Twice()
443 d := newTestDaemon(t, factory, stubClock{})
444 tabs := make([]*tab, 0, 2)
445 cancels := make([]context.CancelFunc, 0, 2)
446 for _, id := range []domain.SessionID{"one", "two"} {
447 tb := newFloatingTestTab(t)
448 ctx, cancel := context.WithCancel(t.Context())
449 sess := &session{sessionCore: sessionCore{id: id, name: string(id)}, tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
450 tabs = append(tabs, tb)
451 cancels = append(cancels, cancel)
452 d.startFloating(sess, tb, false)
453 }
454 for range 2 {
455 select {
456 case <-entered:
457 case <-time.After(time.Second):
458 t.Fatal("unrelated floating launch was serialized")
459 }
460 }
461 once.Do(func() { close(release) })
462 for i, cancel := range cancels {
463 cancel()
464 d.teardownFloating(tabs[i], nil)
465 }
466 d.sessWg.Wait()
467}