v0.2.0 80c61e3

transactional_resize_test.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"io"
6	"strings"
7	"sync"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/require"
12
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/ports"
15	"github.com/bnema/vev/internal/usecase/layout"
16	"github.com/bnema/vev/internal/usecase/ui"
17	"github.com/bnema/vev/internal/usecase/visualsearch"
18)
19
20// transactionalResizePTY is deliberately channel-scripted: a test can stop a
21// transaction at PTY.Resize without relying on scheduler timing.
22type transactionalResizePTY struct {
23	mu        sync.Mutex
24	sizes     []domain.Size
25	writeData [][]byte
26	errs      []error
27	onResize  func()
28	onWrite   func([]byte)
29}
30
31func (p *transactionalResizePTY) Resize(size domain.Size) error {
32	p.mu.Lock()
33	p.sizes = append(p.sizes, size)
34	n := len(p.sizes)
35	hook := p.onResize
36	var err error
37	if n <= len(p.errs) {
38		err = p.errs[n-1]
39	}
40	p.mu.Unlock()
41	if hook != nil {
42		hook()
43	}
44	return err
45}
46func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
47func (p *transactionalResizePTY) Write(b []byte) (int, error) {
48	p.mu.Lock()
49	copied := append([]byte(nil), b...)
50	p.writeData = append(p.writeData, copied)
51	hook := p.onWrite
52	p.mu.Unlock()
53	if hook != nil {
54		hook(copied)
55	}
56	return len(b), nil
57}
58func (*transactionalResizePTY) Close() error                 { return nil }
59func (*transactionalResizePTY) Pid() int                     { return 0 }
60func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil }
61func (p *transactionalResizePTY) requested() []domain.Size {
62	p.mu.Lock()
63	defer p.mu.Unlock()
64	return append([]domain.Size(nil), p.sizes...)
65}
66
67func (p *transactionalResizePTY) writes() [][]byte {
68	p.mu.Lock()
69	defer p.mu.Unlock()
70	writes := make([][]byte, len(p.writeData))
71	for i := range p.writeData {
72		writes[i] = append([]byte(nil), p.writeData[i]...)
73	}
74	return writes
75}
76
77// resizeReaderPTY is a deterministic child: Resize releases its redraw only
78// after the daemon has installed resizeApplying, and waits until ptyReader has
79// accepted that read. It deliberately has no timing dependency.
80type resizeReaderPTY struct {
81	redraw    []byte
82	reads     chan []byte
83	accepted  chan struct{}
84	closed    chan struct{}
85	closeOnce sync.Once
86	mu        sync.Mutex
87	applying  func() bool
88	delivered bool
89}
90
91func newResizeReaderPTY(redraw []byte) *resizeReaderPTY {
92	return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})}
93}
94
95func (p *resizeReaderPTY) Resize(domain.Size) error {
96	p.reads <- p.redraw
97	<-p.accepted
98	// A second, empty read cannot begin until ptyReader has processed the
99	// redraw from the first read. This is a channel rendezvous, not a timing
100	// assumption, and proves the redraw entered resizePending before Resize
101	// returns to replay it.
102	p.reads <- nil
103	<-p.accepted
104	p.mu.Lock()
105	p.delivered = p.applying != nil && p.applying()
106	p.mu.Unlock()
107	return nil
108}
109func (p *resizeReaderPTY) Read(dst []byte) (int, error) {
110	select {
111	case data := <-p.reads:
112		n := copy(dst, data)
113		p.accepted <- struct{}{}
114		return n, nil
115	case <-p.closed:
116		return 0, io.EOF
117	}
118}
119func (*resizeReaderPTY) Write(b []byte) (int, error)  { return len(b), nil }
120func (*resizeReaderPTY) Pid() int                     { return 0 }
121func (*resizeReaderPTY) ForegroundPgid() (int, error) { return 0, nil }
122func (p *resizeReaderPTY) Close() error               { p.close(); return nil }
123func (p *resizeReaderPTY) close()                     { p.closeOnce.Do(func() { close(p.closed) }) }
124func (p *resizeReaderPTY) deliveredWhileApplying() bool {
125	p.mu.Lock()
126	defer p.mu.Unlock()
127	return p.delivered
128}
129
130func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
131	t.Run("success resizes then replays", func(t *testing.T) {
132		pty := &transactionalResizePTY{}
133		d, sess, _, _ := newManualSessionWithPTYs(t, pty)
134		tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
135		p.resizeApplying = true
136		p.resizePending = []byte("A\x1b[1;81HB")
137		d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})
138		require.Equal(t, 120, p.screen.Frame.Width)
139		require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune)
140		require.False(t, p.resizeApplying)
141	})
142	t.Run("failure retains old parser width", func(t *testing.T) {
143		pty := &transactionalResizePTY{}
144		d, sess, _, _ := newManualSessionWithPTYs(t, pty)
145		tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
146		p.resizeApplying = true
147		p.resizePending = []byte("\x1b[1;81HB")
148		d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})
149		require.Equal(t, 80, p.screen.Frame.Width)
150		require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune)
151	})
152	t.Run("batches retain read order", func(t *testing.T) {
153		pty := &transactionalResizePTY{}
154		d, sess, _, _ := newManualSessionWithPTYs(t, pty)
155		tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
156		p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') }
157		pty.onWrite = func([]byte) {
158			p.mu.Lock()
159			p.resizePending = append(p.resizePending, 'B')
160			p.mu.Unlock()
161		}
162		p.resizeApplying = true
163		p.resizePending = []byte("A\x1b[6n")
164		d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
165		require.Equal(t, "AB", screenLineText(p.screen, 0)[:2])
166	})
167}
168
169func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
170	responses := make(chan []byte, 1)
171	pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}
172	d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
173	publishActiveClipboardCapability(d, sess, ac, ac.transport())
174	tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
175	p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
176	p.screen.OnBell = func() { p.ptyAttention = true }
177	p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
178	p.resizeApplying = true
179	p.resizePending = []byte("\x1b]2;resized\a\a\x1b]52;c;YQ==\a\x1b[6n")
180	d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
181
182	p.mu.Lock()
183	title := p.title.terminalTitle
184	p.mu.Unlock()
185	require.Equal(t, "resized", title)
186	require.True(t, tb.attention)
187	require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child")
188	// DSR is flushed back to the child and OSC 52 is forwarded through the
189	// normal asynchronous client path, proving replay uses processPTYData.
190	var clipboardOutput string
191	for {
192		frame := awaitFrame(t, sends, ports.MsgOutput)
193		out, err := ports.UnmarshalOutput(frame.Payload)
194		require.NoError(t, err)
195		clipboardOutput = string(out.Data)
196		if strings.Contains(clipboardOutput, "\x1b]52;c;YQ==\a") {
197			break
198		}
199	}
200	require.Contains(t, clipboardOutput, "\x1b]52;c;YQ==\a")
201}
202
203// S3 acceptance: prepare may inspect layout under its locks, but apply must
204// invoke PTY.Resize after all session/tab/pane locks are released. This is the
205// deadlock boundary with PTY readers and scripts which synchronously re-enter
206// daemon state.
207func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
208	pty := &transactionalResizePTY{}
209	d, sess, _, _ := newManualSessionWithPTYs(t, pty)
210	tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
211	d.beforeSessionResizePublication = sess.cancel
212
213	_, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
214
215	require.False(t, ok)
216	require.Len(t, pty.requested(), 1, "cancellation must stop before a stale retry applies another PTY resize")
217	tb.mu.Lock()
218	require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "canceled session published a tab size")
219	tb.mu.Unlock()
220	p.mu.Lock()
221	require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle")
222	require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "canceled session published a VT size")
223	p.mu.Unlock()
224}
225
226func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
227	pty := &transactionalResizePTY{}
228	d, sess, _, _ := newManualSessionWithPTYs(t, pty)
229	tb := testAttachmentTab(sess)
230	p := tb.focusedPane()
231	var tabUnlocked, paneUnlocked bool
232	pty.onResize = func() {
233		tabUnlocked = tb.mu.TryLock()
234		if tabUnlocked {
235			tb.mu.Unlock()
236		}
237		paneUnlocked = p.mu.TryLock()
238		if paneUnlocked {
239			p.mu.Unlock()
240		}
241	}
242
243	d.resize(sess, nil, domain.Size{Cols: 100, Rows: 30})
244
245	require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
246	require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
247}
248
249// S3 acceptance: a failed member never publishes speculative parser/screen or
250// rectangle state. Successful members and the client layout commit together;
251// composition clips/pads the retained failed screen against that new layout.
252func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) {
253	for _, tc := range []struct {
254		name              string
255		failureCount      int
256		wantOK            bool
257		wantFailed        int
258		wantFailureNotice int
259	}{
260		{name: "zero failures", wantOK: true},
261		{name: "one failure", failureCount: 1, wantOK: true, wantFailed: 1, wantFailureNotice: 1},
262		{name: "two failures", failureCount: 2, wantOK: true, wantFailed: 2, wantFailureNotice: 1},
263	} {
264		t.Run(tc.name, func(t *testing.T) {
265			first := &transactionalResizePTY{}
266			second := &transactionalResizePTY{}
267			if tc.failureCount >= 1 {
268				first.errs = []error{errors.New("first failure")}
269			}
270			if tc.failureCount >= 2 {
271				second.errs = []error{errors.New("second failure")}
272			}
273			d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
274
275			failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
276
277			require.Equal(t, tc.wantOK, ok)
278			require.Len(t, failed, tc.wantFailed)
279			count := 0
280			for _, notification := range d.notices.history() {
281				if notification.Code == domain.NoticeResizeFailed {
282					count++
283				}
284			}
285			require.Equal(t, tc.wantFailureNotice, count)
286		})
287	}
288}
289
290func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) {
291	ok := &transactionalResizePTY{}
292	failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
293	d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
294	tb := testAttachmentTab(sess)
295	second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})
296	tb.mu.Lock()
297	tb.panes[second.id] = second
298	tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
299	first := tb.panes["pane-1"]
300	oldFailedRect := second.rect
301	oldFailedScreen := domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height}
302	tb.mu.Unlock()
303
304	d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})
305
306	require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size, "client layout commits as one epoch")
307	require.Equal(t, domain.Rect{Width: 30, Height: 18}, first.rect)
308	require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, second.rect,
309		"all pane rectangles publish with the committed layout")
310	require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())
311	require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())
312	require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height},
313		"failed PTY retains its parser/screen size for clipped padded composition")
314	require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
315}
316
317// A retry changes VT state after the committed layout, so it needs a distinct
318// snapshot generation. A failed retry must retain the prior screen and dirty
319// generation.
320func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing.T) {
321	resizeErr := errors.New("scripted resize failure")
322	for _, tc := range []struct {
323		name           string
324		errs           []error
325		wantGeneration uint64
326		wantScreen     domain.Size
327	}{
328		{name: "success", errs: []error{resizeErr, nil}, wantGeneration: 2, wantScreen: domain.Size{Cols: 100, Rows: 28}},
329		{name: "failure", errs: []error{resizeErr, resizeErr}, wantGeneration: 1, wantScreen: domain.Size{Cols: 80, Rows: 23}},
330	} {
331		t.Run(tc.name, func(t *testing.T) {
332			pty := &transactionalResizePTY{errs: tc.errs}
333			d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
334			sess.snapEligible.Store(true)
335			tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
336
337			require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
338			rc := sess.renderCoordinator()
339			snapshot := rc.resizeSnapshot()
340			d.retryResizeMembers(sess, ac, rc.attachmentLease(ac), snapshot.epoch, []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}})
341
342			sess.snapshotMu.Lock()
343			generation := sess.snapshotGeneration
344			sess.snapshotMu.Unlock()
345			require.Equal(t, tc.wantGeneration, generation)
346			require.True(t, sess.snapDirty.Load())
347			require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
348		})
349	}
350}
351
352// S3 acceptance: a failed member is retried only for the newest committed
353// epoch. An intervening request supersedes the old retry, and a successful
354// retry must resize its VT before the forced full-frame emission.
355func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
356	ok := &transactionalResizePTY{}
357	failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}
358	d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
359	tb := testAttachmentTab(sess)
360	retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})
361	tb.mu.Lock()
362	tb.panes[retry.id] = retry
363	tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("retry")}}, Focus: "pane-1"}
364	tb.mu.Unlock()
365
366	d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) // failed epoch
367	d.resize(sess, ac, domain.Size{Cols: 120, Rows: 34}) // supersedes it
368
369	// The failed pane must not be retried at 49x28 from the obsolete epoch.
370	// S3 retries 59x32, then commits the parser/screen and requests a reset.
371	require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),
372		"the failed old apply is historical; only the authoritative newest epoch retries it")
373	require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})
374	require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,
375		"the retry belongs to the newest committed epoch")
376}
377
378// Stale lifecycle callbacks may never advance a transaction owned by a
379// replacement attachment. The coordinator test clock drives callbacks through
380// channels elsewhere in this package; this ownership assertion is deliberately
381// synchronous so it cannot hide a race behind a sleep.
382func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
383	// Each closure is a callback captured before its lifecycle transition.  The
384	// coordinator must reject it, rather than merely coalescing its paint.
385	for _, transition := range []struct {
386		name string
387		run  func(*renderCoordinator, *attachedClient, *attachedClient)
388	}{
389		{"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
390		{"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
391	} {
392		t.Run(transition.name, func(t *testing.T) {
393			rc := newRenderCoordinator(renderCoordinatorOptions{})
394			old, next := &attachedClient{}, &attachedClient{}
395			rc.attach(old)
396			require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
397			stale := func() uint64 { return rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old) }
398			transition.run(rc, old, next)
399			require.Zero(t, stale(), "a stale %s callback must not publish", transition.name)
400		})
401	}
402}
403
404// S3 acceptance: three callbacks can be delivered even after their timers were
405// stopped.  Only the newest prepared epoch may apply, commit, and emit one full
406// frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
407// channels make callback order explicit and do not use wall-clock waits.
408func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {
409	first, second := &transactionalResizePTY{}, &transactionalResizePTY{}
410	d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
411	observer := &daemonRuntimeObserver{}
412	d.runtimeObserver = observer
413	rc := d.attachCoordinator(sess, nil, ac, true)
414	lease := rc.attachmentLease(ac)
415	var newer uint64
416	d.beforeSessionResizePublication = func() {
417		// Final external PTY validation is complete, but epoch admission and
418		// every session-visible publication must still be pending.
419		for _, tb := range sess.tabs {
420			require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size)
421			p := tb.focusedPane()
422			p.mu.Lock()
423			require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect)
424			require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
425			p.mu.Unlock()
426		}
427		require.False(t, sess.snapDirty.Load())
428		for _, mark := range observer.marks {
429			require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind)
430		}
431		newer = rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
432		require.NotZero(t, newer)
433	}
434
435	require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
436	d.beforeSessionResizePublication = nil
437	require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot")
438	for _, tb := range sess.tabs {
439		require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size")
440		p := tb.focusedPane()
441		require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle")
442		require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size")
443	}
444
445	require.True(t, d.runResizeTransaction(sess, ac, lease, newer))
446	for _, tb := range sess.tabs {
447		require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)
448		p := tb.focusedPane()
449		require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect)
450		require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
451	}
452	committed := 0
453	for _, mark := range observer.marks {
454		if mark.Kind == ports.RuntimeResizeCommitted {
455			committed++
456		}
457	}
458	require.Equal(t, 1, committed, "only the current epoch emits commit telemetry")
459	require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, first.requested())
460	require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())
461}
462
463func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
464	for _, tc := range []struct {
465		name   string
466		effect resizeOwnerPostEffect
467	}{
468		{name: "before snapshot effects", effect: resizeOwnerPostSnapshotDirty},
469		{name: "before attachment size", effect: resizeOwnerPostCommitPublication},
470	} {
471		t.Run(tc.name, func(t *testing.T) {
472			pty := &transactionalResizePTY{}
473			d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
474			rc := d.attachCoordinator(sess, nil, ac, true)
475			lease := rc.attachmentLease(ac)
476			epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
477			require.NotZero(t, epoch)
478			initialSize := ac.size
479
480			entered := make(chan struct{})
481			release := make(chan struct{})
482			d.beforeResizeOwnerPostEffect = func(effect resizeOwnerPostEffect) {
483				if effect != tc.effect {
484					return
485				}
486				close(entered)
487				<-release
488			}
489			done := make(chan bool, 1)
490			go func() { done <- d.runResizeTransaction(sess, ac, lease, epoch) }()
491			<-entered
492			newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
493			require.NotZero(t, newer)
494			close(release)
495			require.False(t, <-done, "stale resize generation must be rejected")
496			require.Equal(t, initialSize, ac.size, "stale resize must not publish attachment size")
497			if tc.effect == resizeOwnerPostSnapshotDirty {
498				require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness")
499			}
500		})
501	}
502}
503
504func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
505	pty := &transactionalResizePTY{}
506	d, sess, _, _ := newManualSessionWithPTYs(t, pty)
507	tb := testAttachmentTab(sess)
508	p := tb.focusedPane()
509	p.resizeApplying = true
510	p.resizePending = []byte("x")
511	plan := preparedTabLayout{members: []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect, screenResized: true}}}
512	tb.mu.Lock()
513	delete(tb.panes, p.id)
514	tb.bumpLayoutGenerationLocked()
515	tb.mu.Unlock()
516
517	d.finishPreparedTabMembers(&plan, false)
518	p.mu.Lock()
519	require.True(t, p.resizeApplying, "stale finalization must leave cancellation to the fresh-plan path")
520	p.mu.Unlock()
521	d.cancelStalePreparedGates(sess, tb, &plan)
522	p.mu.Lock()
523	require.False(t, p.resizeApplying)
524	require.Empty(t, p.resizePending)
525	p.mu.Unlock()
526}
527
528func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
529	pty := &transactionalResizePTY{}
530	d, sess, _, _ := newManualSessionWithPTYs(t, pty)
531	tb := testAttachmentTab(sess)
532	var invalidate sync.Once
533	pty.onResize = func() {
534		invalidate.Do(func() {
535			tb.mu.Lock()
536			tb.bumpLayoutGenerationLocked()
537			tb.mu.Unlock()
538		})
539	}
540
541	require.True(t, d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true))
542	require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 100, Rows: 28}}, pty.requested())
543	tb.mu.Lock()
544	require.Equal(t, domain.Size{Cols: 100, Rows: 28}, tb.size)
545	tb.mu.Unlock()
546}
547
548func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
549	popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}}
550	d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
551	tb := testAttachmentTab(source)
552	popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
553	tb.mu.Lock()
554	tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
555	tb.mu.Unlock()
556	owner := publishPaneOwner(popup, source, tb, 7)
557
558	rc := d.attachCoordinator(source, nil, ac, true)
559	lease := rc.attachmentLease(ac)
560	epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
561	require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
562
563	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}}
564	publishPaneOwner(popup, destination, tb, 7)
565	destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9}
566	popup.mu.Lock()
567	popup.rect = destinationRect
568	popup.popupGeometry = floatingGeometry{Inner: destinationRect}
569	popup.mu.Unlock()
570	source.snapshotMu.Lock()
571	sourceGeneration := source.snapshotGeneration
572	source.snapshotMu.Unlock()
573	requested := popupPTY.requested()
574
575	d.retryResizeMembers(source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}})
576
577	require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY")
578	popup.mu.Lock()
579	require.Equal(t, destinationRect, popup.rect, "stale floating retry published source geometry")
580	require.Equal(t, destinationRect, popup.popupGeometry.Inner, "stale floating retry published source popup geometry")
581	popup.mu.Unlock()
582	source.snapshotMu.Lock()
583	require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale floating retry dirtied its former session")
584	source.snapshotMu.Unlock()
585}
586
587func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
588	cfg := domain.FloatingConfig{Width: 100, Height: 100}
589	for _, tc := range []struct {
590		name     string
591		from     domain.Size
592		to       domain.Size
593		wantMode ui.PresentationMode
594	}{
595		{name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer},
596		{name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating},
597	} {
598		t.Run(tc.name, func(t *testing.T) {
599			d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
600			d.ApplyConfig(domain.Config{Floating: cfg})
601			require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true))
602
603			tb := testAttachmentTab(sess)
604			fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
605			toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
606			entered, release := make(chan struct{}), make(chan struct{})
607			var once sync.Once
608			popupPTY := &transactionalResizePTY{onResize: func() {
609				once.Do(func() { close(entered) })
610				<-release
611			}}
612			popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner))
613			popup.rect = fromGeometry.Inner
614			popup.popupGeometry = fromGeometry
615			installTestFloating(tb, popup, true)
616
617			done := make(chan bool, 1)
618			go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }()
619			<-entered
620			popup.mu.Lock()
621			require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
622			require.Equal(t, fromGeometry.Inner, popup.rect)
623			require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
624			popup.mu.Unlock()
625			close(release)
626			require.True(t, <-done)
627
628			require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested())
629			popup.mu.Lock()
630			require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
631			require.Equal(t, toGeometry, popup.popupGeometry)
632			require.Equal(t, toGeometry.Inner, popup.rect)
633			require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
634			popup.mu.Unlock()
635		})
636	}
637}
638
639func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) {
640	cfg := domain.FloatingConfig{Width: 50, Height: 50}
641	committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg)
642	requestedSize := domain.Size{Cols: 79, Rows: 40}
643	requested := calculateContentFloatingGeometry(requestedSize, cfg)
644	require.Equal(t, ui.PresentationFloating, committed.Mode)
645	require.Equal(t, ui.PresentationDrawer, requested.Mode)
646	resizeErr := errors.New("floating resize failed")
647	pty := &transactionalResizePTY{errs: []error{resizeErr}}
648	popup := newPane("floating", pty, rectSize(committed.Inner))
649	popup.rect = committed.Inner
650	popup.popupGeometry = committed
651	tb := newTab(nil, requestedSize)
652	installTestFloating(tb, popup, true)
653	d := newTestDaemon(t, nil, stubClock{})
654	d.ApplyConfig(domain.Config{Floating: cfg})
655	var tabUnlocked, paneUnlocked bool
656	pty.onResize = func() {
657		tabUnlocked = tb.mu.TryLock()
658		if tabUnlocked {
659			tb.mu.Unlock()
660		}
661		paneUnlocked = popup.mu.TryLock()
662		if paneUnlocked {
663			popup.mu.Unlock()
664		}
665	}
666
667	failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
668
669	require.True(t, ok)
670	require.Len(t, failed, 1)
671	require.ErrorIs(t, failed[0].err, resizeErr)
672	require.Equal(t, []domain.Size{rectSize(requested.Inner)}, pty.requested())
673	require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
674	require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
675	popup.mu.Lock()
676	require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle")
677	require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry")
678	require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
679	require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable")
680	require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate")
681	popup.mu.Unlock()
682	tb.mu.Lock()
683	_, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
684	tb.mu.Unlock()
685	require.True(t, visible)
686	require.Equal(t, committed, inputGeometry, "input mapping must retain committed geometry")
687}
688
689func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *testing.T) {
690	cfg := domain.FloatingConfig{Width: 50, Height: 50}
691	size := domain.Size{Cols: 79, Rows: 20}
692	requested := calculateContentFloatingGeometry(size, cfg)
693	require.Equal(t, ui.PresentationDrawer, requested.Mode)
694	committed := requested
695	committed.Mode = ui.PresentationFloating
696	committed.Bounds.X++
697	committed.Inner.X++
698	pty := &transactionalResizePTY{}
699	popup := newPane("floating", pty, rectSize(committed.Inner))
700	popup.rect = committed.Inner
701	popup.popupGeometry = committed
702	tb := newTab(nil, size)
703	installTestFloating(tb, popup, true)
704	d := newTestDaemon(t, nil, stubClock{})
705	d.ApplyConfig(domain.Config{Floating: cfg})
706
707	failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
708
709	require.True(t, ok)
710	require.Empty(t, failed)
711	require.Empty(t, pty.requested(), "same-size geometry publication must not resize the PTY")
712	popup.mu.Lock()
713	require.Equal(t, requested.Inner, popup.rect)
714	require.Equal(t, requested, popup.popupGeometry)
715	require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
716	require.False(t, popup.resizeRetry)
717	popup.mu.Unlock()
718}
719
720func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) {
721	cfg := domain.FloatingConfig{Width: 100, Height: 100}
722	committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
723	pty := &transactionalResizePTY{}
724	popup := newPane("floating", pty, rectSize(committed.Inner))
725	popup.rect = committed.Inner
726	popup.popupGeometry = committed
727	tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
728	installTestFloating(tb, popup, true)
729	d := newTestDaemon(t, nil, stubClock{})
730	d.ApplyConfig(domain.Config{Floating: cfg})
731	calls := 0
732
733	failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool {
734		calls++
735		return calls == 1
736	})
737
738	require.True(t, ok, "a publication accepted while current must not later report stale")
739	require.Equal(t, 1, calls, "current must not be rechecked after publication")
740	require.Empty(t, failed)
741	require.Empty(t, pty.requested())
742	requested := calculateContentFloatingGeometry(tb.size, cfg)
743	popup.mu.Lock()
744	require.Equal(t, requested, popup.popupGeometry)
745	require.Equal(t, committed.Inner, popup.rect)
746	require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
747	popup.mu.Unlock()
748}
749
750func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) {
751	cfg := domain.FloatingConfig{Width: 100, Height: 100}
752	committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
753	pty := &transactionalResizePTY{}
754	popup := newPane("floating", pty, rectSize(committed.Inner))
755	popup.rect = committed.Inner
756	popup.popupGeometry = committed
757	tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
758	installTestFloating(tb, popup, true)
759	d := newTestDaemon(t, nil, stubClock{})
760	d.ApplyConfig(domain.Config{Floating: cfg})
761
762	failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false })
763
764	require.False(t, ok)
765	require.Empty(t, failed)
766	require.Empty(t, pty.requested())
767	popup.mu.Lock()
768	require.Equal(t, committed, popup.popupGeometry)
769	require.Equal(t, committed.Inner, popup.rect)
770	require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
771	popup.mu.Unlock()
772}
773
774func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
775	popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
776	d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
777	tb := testAttachmentTab(sess)
778	popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
779	tb.mu.Lock()
780	tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
781	tb.mu.Unlock()
782
783	rc := d.attachCoordinator(sess, nil, ac, true)
784	lease := rc.attachmentLease(ac)
785	epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
786	require.NotZero(t, epoch)
787	require.True(t, d.runResizeTransaction(sess, ac, lease, epoch))
788	first := popupPTY.requested()
789	require.Len(t, first, 1)
790
791	d.retryResizeMembers(sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
792	retried := popupPTY.requested()
793	require.Len(t, retried, 2)
794	require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry")
795	popup.mu.Lock()
796	require.False(t, popup.resizeRetry)
797	popup.mu.Unlock()
798
799	replacementPTY := &transactionalResizePTY{}
800	replacement := newPane("replacement", replacementPTY, domain.Size{Cols: 80, Rows: 23})
801	replacement.resizeRetry = true
802	tb.mu.Lock()
803	tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8}
804	tb.mu.Unlock()
805	d.retryResizeMembers(sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
806	require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry")
807}
808
809func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {
810	clock := newCoordinatorMockClock(t, 8)
811	pty := &transactionalResizePTY{}
812	d, sess, ac, frames := newManualSessionWithPTYs(t, pty)
813	d.clock = clock.clock
814
815	for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {
816		d.resize(sess, ac, size)
817	}
818	// Epoch scheduling leaves PTY geometry untouched until the final
819	// prepare/apply/commit callback.
820	require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")
821
822	var timers []*coordinatorMockTimer
823	for len(timers) < 3 {
824		timers = append(timers, <-clock.timers)
825	}
826	done := captureResizeCallbackDone(t, sess.renderCoordinator())
827	for _, timer := range timers {
828		timer.ch <- time.Time{}
829	}
830	awaitTestCompletion(t, done, "latest resize callback did not complete")
831	require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
832	snapshot := sess.renderCoordinator().resizeSnapshot()
833	require.Equal(t, uint64(3), snapshot.epoch)
834	require.Equal(t, snapshot.epoch, snapshot.committed)
835	// The resize deadline is the only debounce: commit fires its sticky reset
836	// through S2 immediately (subject to ACK/sync gates).
837	frame := <-frames
838	require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
839	output, err := ports.UnmarshalOutput(frame.Payload)
840	require.NoError(t, err)
841	require.Zero(t, output.Base, "the accepted resize frame must reset the renderer state")
842	select {
843	case extra := <-frames:
844		t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
845	default:
846	}
847}
848
849// Entering copy mode is invalidated at transaction prepare time, while a
850// blocked apply keeps the old committed pane rectangle visible to mouse and
851// composition. Channels, rather than a delay, make the publication boundary
852// deterministic.
853func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
854	for _, tc := range []struct {
855		name string
856		errs []error
857	}{
858		{name: "success"},
859		{name: "partial failure", errs: []error{errors.New("resize failed")}},
860	} {
861		t.Run(tc.name, func(t *testing.T) {
862			entered, release := make(chan struct{}), make(chan struct{})
863			pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}
864			d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
865			tb := testAttachmentTab(sess)
866			p := tb.focusedPane()
867			old := p.rect
868			d.enterCopyMode(sess, ac)
869			ac.overlays.copyMu.Lock()
870			ac.overlays.copySearch = &visualsearch.Model{}
871			ac.overlays.copyMu.Unlock()
872			done := make(chan struct{})
873			go func() {
874				d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
875				close(done)
876			}()
877			<-entered
878			require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode")
879			require.False(t, ac.overlays.copySearchActive(), "prepare invalidates visual search")
880			require.Equal(t, old, p.rect, "mouse uses old committed geometry before publication")
881			close(release)
882			<-done
883		})
884	}
885}

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