floating_open_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close