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/domain/layout"
15	"github.com/bnema/vev/internal/ports"
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(geometry domain.Geometry) error {
32	p.mu.Lock()
33	p.sizes = append(p.sizes, geometry.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.Geometry) 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.Columns())
139		require.Equal(t, 'B', p.screen.Cell(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.Columns())
150		require.Equal(t, 'B', p.screen.Cell(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, "Output")
193		out := unmarshalTestOutput(t, frame.Payload)
194		clipboardOutput = string(out.Data)
195		if strings.Contains(clipboardOutput, "\x1b]52;c;YQ==\a") {
196			break
197		}
198	}
199	require.Contains(t, clipboardOutput, "\x1b]52;c;YQ==\a")
200}
201
202// S3 acceptance: prepare may inspect layout under its locks, but apply must
203// invoke PTY.Resize after all session/tab/pane locks are released. This is the
204// deadlock boundary with PTY readers and scripts which synchronously re-enter
205// daemon state.
206func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
207	pty := &transactionalResizePTY{}
208	d, sess, _, _ := newManualSessionWithPTYs(t, pty)
209	tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
210	d.beforeSessionResizePublication = sess.cancel
211
212	_, ok := sess.geometry.applySessionLayout(d, sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
213
214	require.False(t, ok)
215	require.Len(t, pty.requested(), 1, "cancellation must stop before a stale retry applies another PTY resize")
216	tb.mu.Lock()
217	require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "canceled session published a tab size")
218	tb.mu.Unlock()
219	p.mu.Lock()
220	require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle")
221	require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "canceled session published a VT size")
222	p.mu.Unlock()
223}
224
225func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
226	pty := &transactionalResizePTY{}
227	d, sess, _, _ := newManualSessionWithPTYs(t, pty)
228	tb := testAttachmentTab(sess)
229	p := tb.focusedPane()
230	var tabUnlocked, paneUnlocked bool
231	pty.onResize = func() {
232		tabUnlocked = tb.mu.TryLock()
233		if tabUnlocked {
234			tb.mu.Unlock()
235		}
236		paneUnlocked = p.mu.TryLock()
237		if paneUnlocked {
238			p.mu.Unlock()
239		}
240	}
241
242	d.resize(sess, nil, domain.Size{Cols: 100, Rows: 30})
243
244	require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
245	require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
246}
247
248// S3 acceptance: a failed member never publishes speculative parser/screen or
249// rectangle state. Successful members and the client layout commit together;
250// composition clips/pads the retained failed screen against that new layout.
251func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) {
252	for _, tc := range []struct {
253		name              string
254		failureCount      int
255		wantOK            bool
256		wantFailed        int
257		wantFailureNotice int
258	}{
259		{name: "zero failures", wantOK: true},
260		{name: "one failure", failureCount: 1, wantOK: true, wantFailed: 1, wantFailureNotice: 1},
261		{name: "two failures", failureCount: 2, wantOK: true, wantFailed: 2, wantFailureNotice: 1},
262	} {
263		t.Run(tc.name, func(t *testing.T) {
264			first := &transactionalResizePTY{}
265			second := &transactionalResizePTY{}
266			if tc.failureCount >= 1 {
267				first.errs = []error{errors.New("first failure")}
268			}
269			if tc.failureCount >= 2 {
270				second.errs = []error{errors.New("second failure")}
271			}
272			d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
273
274			failed, ok := sess.geometry.applySessionLayout(d, sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
275
276			require.Equal(t, tc.wantOK, ok)
277			require.Len(t, failed, tc.wantFailed)
278			count := 0
279			for _, notification := range d.notices.history() {
280				if notification.Code == domain.NoticeResizeFailed {
281					count++
282				}
283			}
284			require.Equal(t, tc.wantFailureNotice, count)
285		})
286	}
287}
288
289func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) {
290	ok := &transactionalResizePTY{}
291	failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
292	d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
293	tb := testAttachmentTab(sess)
294	second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})
295	tb.mu.Lock()
296	tb.panes[second.id] = second
297	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"}
298	first := tb.panes["pane-1"]
299	oldFailedRect := second.rect
300	oldFailedScreen := domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()}
301	tb.mu.Unlock()
302
303	d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})
304
305	require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size, "client layout commits as one epoch")
306	require.Equal(t, domain.Rect{Width: 30, Height: 18}, first.rect)
307	require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, second.rect,
308		"all pane rectangles publish with the committed layout")
309	require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())
310	require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())
311	require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()},
312		"failed PTY retains its parser/screen size for clipped padded composition")
313	require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
314}
315
316// A retry changes VT state after the committed layout, so it needs a distinct
317// snapshot generation. A failed retry must retain the prior screen and dirty
318// generation.
319func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing.T) {
320	resizeErr := errors.New("scripted resize failure")
321	for _, tc := range []struct {
322		name           string
323		errs           []error
324		wantGeneration uint64
325		wantScreen     domain.Size
326	}{
327		{name: "success", errs: []error{resizeErr, nil}, wantGeneration: 2, wantScreen: domain.Size{Cols: 100, Rows: 28}},
328		{name: "failure", errs: []error{resizeErr, resizeErr}, wantGeneration: 1, wantScreen: domain.Size{Cols: 80, Rows: 23}},
329	} {
330		t.Run(tc.name, func(t *testing.T) {
331			pty := &transactionalResizePTY{errs: tc.errs}
332			d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
333			sess.snapEligible.Store(true)
334			tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
335
336			require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
337			rc := sess.renderCoordinator()
338			snapshot := rc.resizeSnapshot()
339			attachmentResize{d: d, sess: sess, ac: ac, lease: rc.attachmentLease(ac), epoch: snapshot.epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}})
340
341			sess.snapshotMu.Lock()
342			generation := sess.snapshotGeneration
343			sess.snapshotMu.Unlock()
344			require.Equal(t, tc.wantGeneration, generation)
345			require.True(t, sess.snapDirty.Load())
346			require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
347		})
348	}
349}
350
351// S3 acceptance: a failed member is retried only for the newest committed
352// epoch. An intervening request supersedes the old retry, and a successful
353// retry must resize its VT before the forced full-frame emission.
354func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
355	ok := &transactionalResizePTY{}
356	failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}
357	d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
358	tb := testAttachmentTab(sess)
359	retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})
360	tb.mu.Lock()
361	tb.panes[retry.id] = retry
362	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"}
363	tb.mu.Unlock()
364
365	d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) // failed epoch
366	d.resize(sess, ac, domain.Size{Cols: 120, Rows: 34}) // supersedes it
367
368	// The failed pane must not be retried at 49x28 from the obsolete epoch.
369	// S3 retries 59x32, then commits the parser/screen and requests a reset.
370	require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),
371		"the failed old apply is historical; only the authoritative newest epoch retries it")
372	require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Columns(), Rows: retry.screen.Rows()})
373	require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,
374		"the retry belongs to the newest committed epoch")
375}
376
377// Stale lifecycle callbacks may never advance a transaction owned by a
378// replacement attachment. The coordinator test clock drives callbacks through
379// channels elsewhere in this package; this ownership assertion is deliberately
380// synchronous so it cannot hide a race behind a sleep.
381func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
382	// Each closure is a callback captured before its lifecycle transition.  The
383	// coordinator must reject it, rather than merely coalescing its paint.
384	for _, transition := range []struct {
385		name string
386		run  func(*renderCoordinator, *attachedClient, *attachedClient)
387	}{
388		{"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
389		{"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
390	} {
391		t.Run(transition.name, func(t *testing.T) {
392			rc := newRenderCoordinator(renderCoordinatorOptions{})
393			old, next := &attachedClient{}, &attachedClient{}
394			rc.attach(old)
395			require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
396			stale := func() uint64 { return rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old) }
397			transition.run(rc, old, next)
398			require.Zero(t, stale(), "a stale %s callback must not publish", transition.name)
399		})
400	}
401}
402
403// S3 acceptance: three callbacks can be delivered even after their timers were
404// stopped.  Only the newest prepared epoch may apply, commit, and emit one full
405// frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
406// channels make callback order explicit and do not use wall-clock waits.
407func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {
408	first, second := &transactionalResizePTY{}, &transactionalResizePTY{}
409	d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
410	observer := &daemonRuntimeObserver{}
411	d.runtimeObserver = observer
412	rc := d.attachCoordinator(sess, nil, ac, true)
413	lease := rc.attachmentLease(ac)
414	var newer uint64
415	d.beforeSessionResizePublication = func() {
416		// Final external PTY validation is complete, but epoch admission and
417		// every session-visible publication must still be pending.
418		for _, tb := range sess.tabs {
419			require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size)
420			p := tb.focusedPane()
421			p.mu.Lock()
422			require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect)
423			require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
424			p.mu.Unlock()
425		}
426		require.False(t, sess.snapDirty.Load())
427		for _, mark := range observer.marks {
428			require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind)
429		}
430		newer = rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
431		require.NotZero(t, newer)
432	}
433
434	require.False(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
435	d.beforeSessionResizePublication = nil
436	require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot")
437	for _, tb := range sess.tabs {
438		require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size")
439		p := tb.focusedPane()
440		require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle")
441		require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "rejected epoch published a VT size")
442	}
443
444	require.True(t, attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: newer}.run())
445	for _, tb := range sess.tabs {
446		require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)
447		p := tb.focusedPane()
448		require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect)
449		require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
450	}
451	committed := 0
452	for _, mark := range observer.marks {
453		if mark.Kind == ports.RuntimeResizeCommitted {
454			committed++
455		}
456	}
457	require.Equal(t, 1, committed, "only the current epoch emits commit telemetry")
458	require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, first.requested())
459	require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())
460}
461
462func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
463	for _, tc := range []struct {
464		name   string
465		effect resizeOwnerPostEffect
466	}{
467		{name: "before snapshot effects", effect: resizeOwnerPostSnapshotDirty},
468		{name: "before attachment size", effect: resizeOwnerPostCommitPublication},
469	} {
470		t.Run(tc.name, func(t *testing.T) {
471			pty := &transactionalResizePTY{}
472			d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
473			rc := d.attachCoordinator(sess, nil, ac, true)
474			lease := rc.attachmentLease(ac)
475			epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
476			require.NotZero(t, epoch)
477			initialSize := ac.geometry.Size
478
479			entered := make(chan struct{})
480			release := make(chan struct{})
481			d.beforeResizeOwnerPostEffect = func(effect resizeOwnerPostEffect) {
482				if effect != tc.effect {
483					return
484				}
485				close(entered)
486				<-release
487			}
488			done := make(chan bool, 1)
489			go func() { done <- attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.run() }()
490			<-entered
491			newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
492			require.NotZero(t, newer)
493			close(release)
494			require.False(t, <-done, "stale resize generation must be rejected")
495			require.Equal(t, initialSize, ac.geometry.Size, "stale resize must not publish attachment size")
496			if tc.effect == resizeOwnerPostSnapshotDirty {
497				require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness")
498			}
499		})
500	}
501}
502
503func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
504	pty := &transactionalResizePTY{}
505	d, sess, _, _ := newManualSessionWithPTYs(t, pty)
506	tb := testAttachmentTab(sess)
507	p := tb.focusedPane()
508	p.resizeApplying = true
509	p.resizePending = []byte("x")
510	plan := preparedTabLayout{members: []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect, screenResized: true}}}
511	tb.mu.Lock()
512	delete(tb.panes, p.id)
513	tb.bumpLayoutGenerationLocked()
514	tb.mu.Unlock()
515
516	sess.geometry.finishPreparedTabMembers(d, &plan, false)
517	p.mu.Lock()
518	require.True(t, p.resizeApplying, "stale finalization must leave cancellation to the fresh-plan path")
519	p.mu.Unlock()
520	sess.geometry.cancelStalePreparedGates(d, sess, tb, &plan)
521	p.mu.Lock()
522	require.False(t, p.resizeApplying)
523	require.Empty(t, p.resizePending)
524	p.mu.Unlock()
525}
526
527func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
528	pty := &transactionalResizePTY{}
529	d, sess, _, _ := newManualSessionWithPTYs(t, pty)
530	tb := testAttachmentTab(sess)
531	var invalidate sync.Once
532	pty.onResize = func() {
533		invalidate.Do(func() {
534			tb.mu.Lock()
535			tb.bumpLayoutGenerationLocked()
536			tb.mu.Unlock()
537		})
538	}
539
540	require.True(t, sess.geometry.requestResize(d, sess, nil, domain.Size{Cols: 100, Rows: 30}, true))
541	require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 100, Rows: 28}}, pty.requested())
542	tb.mu.Lock()
543	require.Equal(t, domain.Size{Cols: 100, Rows: 28}, tb.size)
544	tb.mu.Unlock()
545}
546
547func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
548	popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}}
549	d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
550	tb := testAttachmentTab(source)
551	popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
552	tb.mu.Lock()
553	tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
554	tb.mu.Unlock()
555	owner := publishPaneOwner(popup, source, tb, 7)
556
557	rc := d.attachCoordinator(source, nil, ac, true)
558	lease := rc.attachmentLease(ac)
559	epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
560	require.True(t, attachmentResize{d: d, sess: source, ac: ac, lease: lease, epoch: epoch}.run())
561
562	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}}
563	publishPaneOwner(popup, destination, tb, 7)
564	destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9}
565	popup.mu.Lock()
566	popup.rect = destinationRect
567	popup.popupGeometry = floatingGeometry{Inner: destinationRect}
568	popup.mu.Unlock()
569	source.snapshotMu.Lock()
570	sourceGeneration := source.snapshotGeneration
571	source.snapshotMu.Unlock()
572	requested := popupPTY.requested()
573
574	attachmentResize{d: d, sess: source, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}})
575
576	require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY")
577	popup.mu.Lock()
578	require.Equal(t, destinationRect, popup.rect, "stale floating retry published source geometry")
579	require.Equal(t, destinationRect, popup.popupGeometry.Inner, "stale floating retry published source popup geometry")
580	popup.mu.Unlock()
581	source.snapshotMu.Lock()
582	require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale floating retry dirtied its former session")
583	source.snapshotMu.Unlock()
584}
585
586func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
587	cfg := domain.FloatingConfig{Width: 100, Height: 100}
588	for _, tc := range []struct {
589		name     string
590		from     domain.Size
591		to       domain.Size
592		wantMode ui.PresentationMode
593	}{
594		{name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer},
595		{name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating},
596	} {
597		t.Run(tc.name, func(t *testing.T) {
598			d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
599			d.ApplyConfig(domain.Config{Floating: cfg})
600			require.True(t, sess.geometry.requestResize(d, sess, nil, tc.from, true))
601
602			tb := testAttachmentTab(sess)
603			fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
604			toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
605			entered, release := make(chan struct{}), make(chan struct{})
606			var once sync.Once
607			popupPTY := &transactionalResizePTY{onResize: func() {
608				once.Do(func() { close(entered) })
609				<-release
610			}}
611			popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner))
612			popup.rect = fromGeometry.Inner
613			popup.popupGeometry = fromGeometry
614			installTestFloating(tb, popup, true)
615
616			done := make(chan bool, 1)
617			go func() { done <- sess.geometry.requestResize(d, sess, nil, tc.to, true) }()
618			<-entered
619			popup.mu.Lock()
620			require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
621			require.Equal(t, fromGeometry.Inner, popup.rect)
622			require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
623			popup.mu.Unlock()
624			close(release)
625			require.True(t, <-done)
626
627			require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested())
628			popup.mu.Lock()
629			require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
630			require.Equal(t, toGeometry, popup.popupGeometry)
631			require.Equal(t, toGeometry.Inner, popup.rect)
632			require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
633			popup.mu.Unlock()
634		})
635	}
636}
637
638func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) {
639	cfg := domain.FloatingConfig{Width: 50, Height: 50}
640	committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg)
641	requestedSize := domain.Size{Cols: 79, Rows: 40}
642	requested := calculateContentFloatingGeometry(requestedSize, cfg)
643	require.Equal(t, ui.PresentationFloating, committed.Mode)
644	require.Equal(t, ui.PresentationDrawer, requested.Mode)
645	resizeErr := errors.New("floating resize failed")
646	pty := &transactionalResizePTY{errs: []error{resizeErr}}
647	popup := newPane("floating", pty, rectSize(committed.Inner))
648	popup.rect = committed.Inner
649	popup.popupGeometry = committed
650	tb := newTab(nil, requestedSize)
651	installTestFloating(tb, popup, true)
652	d := newTestDaemon(t, nil, stubClock{})
653	d.ApplyConfig(domain.Config{Floating: cfg})
654	var tabUnlocked, paneUnlocked bool
655	pty.onResize = func() {
656		tabUnlocked = tb.mu.TryLock()
657		if tabUnlocked {
658			tb.mu.Unlock()
659		}
660		paneUnlocked = popup.mu.TryLock()
661		if paneUnlocked {
662			popup.mu.Unlock()
663		}
664	}
665
666	sess := &session{tabs: []*tab{tb}}
667	failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, 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.Columns(), Rows: popup.screen.Rows()})
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	sess := &session{tabs: []*tab{tb}}
708	failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
709
710	require.True(t, ok)
711	require.Empty(t, failed)
712	require.Empty(t, pty.requested(), "same-size geometry publication must not resize the PTY")
713	popup.mu.Lock()
714	require.Equal(t, requested.Inner, popup.rect)
715	require.Equal(t, requested, popup.popupGeometry)
716	require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
717	require.False(t, popup.resizeRetry)
718	popup.mu.Unlock()
719}
720
721func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) {
722	cfg := domain.FloatingConfig{Width: 100, Height: 100}
723	committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
724	pty := &transactionalResizePTY{}
725	popup := newPane("floating", pty, rectSize(committed.Inner))
726	popup.rect = committed.Inner
727	popup.popupGeometry = committed
728	tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
729	installTestFloating(tb, popup, true)
730	d := newTestDaemon(t, nil, stubClock{})
731	d.ApplyConfig(domain.Config{Floating: cfg})
732	calls := 0
733
734	sess := &session{tabs: []*tab{tb}}
735	failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, func() bool {
736		calls++
737		return calls == 1
738	})
739
740	require.True(t, ok, "a publication accepted while current must not later report stale")
741	require.Equal(t, 1, calls, "current must not be rechecked after publication")
742	require.Empty(t, failed)
743	require.Empty(t, pty.requested())
744	requested := calculateContentFloatingGeometry(tb.size, cfg)
745	popup.mu.Lock()
746	require.Equal(t, requested, popup.popupGeometry)
747	require.Equal(t, committed.Inner, popup.rect)
748	require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
749	popup.mu.Unlock()
750}
751
752func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) {
753	cfg := domain.FloatingConfig{Width: 100, Height: 100}
754	committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
755	pty := &transactionalResizePTY{}
756	popup := newPane("floating", pty, rectSize(committed.Inner))
757	popup.rect = committed.Inner
758	popup.popupGeometry = committed
759	tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
760	installTestFloating(tb, popup, true)
761	d := newTestDaemon(t, nil, stubClock{})
762	d.ApplyConfig(domain.Config{Floating: cfg})
763
764	sess := &session{tabs: []*tab{tb}}
765	failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, func() bool { return false })
766
767	require.False(t, ok)
768	require.Empty(t, failed)
769	require.Empty(t, pty.requested())
770	popup.mu.Lock()
771	require.Equal(t, committed, popup.popupGeometry)
772	require.Equal(t, committed.Inner, popup.rect)
773	require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
774	popup.mu.Unlock()
775}
776
777func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
778	popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
779	d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
780	tb := testAttachmentTab(sess)
781	popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
782	tb.mu.Lock()
783	tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
784	tb.mu.Unlock()
785
786	rc := d.attachCoordinator(sess, nil, ac, true)
787	lease := rc.attachmentLease(ac)
788	epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
789	require.NotZero(t, epoch)
790	require.True(t, attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.run())
791	first := popupPTY.requested()
792	require.Len(t, first, 1)
793
794	attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
795	retried := popupPTY.requested()
796	require.Len(t, retried, 2)
797	require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry")
798	popup.mu.Lock()
799	require.False(t, popup.resizeRetry)
800	popup.mu.Unlock()
801
802	replacementPTY := &transactionalResizePTY{}
803	replacement := newPane("replacement", replacementPTY, domain.Size{Cols: 80, Rows: 23})
804	replacement.resizeRetry = true
805	tb.mu.Lock()
806	tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8}
807	tb.mu.Unlock()
808	attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
809	require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry")
810}
811
812func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {
813	clock := newCoordinatorMockClock(t, 8)
814	pty := &transactionalResizePTY{}
815	d, sess, ac, frames := newManualSessionWithPTYs(t, pty)
816	d.clock = clock.clock
817
818	for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {
819		d.resize(sess, ac, size)
820	}
821	// Epoch scheduling leaves PTY geometry untouched until the final
822	// prepare/apply/commit callback.
823	require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")
824
825	var timers []*coordinatorMockTimer
826	for len(timers) < 3 {
827		timers = append(timers, <-clock.timers)
828	}
829	done := captureResizeCallbackDone(t, sess.renderCoordinator())
830	for _, timer := range timers {
831		timer.ch <- time.Time{}
832	}
833	awaitTestCompletion(t, done, "latest resize callback did not complete")
834	require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
835	snapshot := sess.renderCoordinator().resizeSnapshot()
836	require.Equal(t, uint64(3), snapshot.epoch)
837	require.Equal(t, snapshot.epoch, snapshot.committed)
838	// The resize deadline is the only debounce: commit fires its sticky reset
839	// through S2 immediately (subject to ACK/sync gates).
840	frame := <-frames
841	require.Equal(t, "Output", envelopeMessageName(t, frame.Payload), "the committed epoch emits a full frame")
842	output := unmarshalTestOutput(t, frame.Payload)
843	require.Zero(t, output.Base, "the accepted resize frame must reset the renderer state")
844	select {
845	case extra := <-frames:
846		t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
847	default:
848	}
849}
850
851// Entering copy mode is invalidated at transaction prepare time, while a
852// blocked apply keeps the old committed pane rectangle visible to mouse and
853// composition. Channels, rather than a delay, make the publication boundary
854// deterministic.
855func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
856	for _, tc := range []struct {
857		name string
858		errs []error
859	}{
860		{name: "success"},
861		{name: "partial failure", errs: []error{errors.New("resize failed")}},
862	} {
863		t.Run(tc.name, func(t *testing.T) {
864			entered, release := make(chan struct{}), make(chan struct{})
865			pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}
866			d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
867			tb := testAttachmentTab(sess)
868			p := tb.focusedPane()
869			old := p.rect
870			d.enterCopyMode(sess, ac)
871			ac.overlays.copyMu.Lock()
872			ac.overlays.copySearch = &visualsearch.Model{}
873			ac.overlays.copyMu.Unlock()
874			done := make(chan struct{})
875			go func() {
876				d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
877				close(done)
878			}()
879			<-entered
880			require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode")
881			require.False(t, ac.overlays.copySearchActive(), "prepare invalidates visual search")
882			require.Equal(t, old, p.rect, "mouse uses old committed geometry before publication")
883			close(release)
884			<-done
885		})
886	}
887}

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