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