v0.3.0 99dc266

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)
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}

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