render_coordinator_test.go RawHistoryFolder

1package daemon
2
3import (
4	"os"
5	"path/filepath"
6	"sync"
7	"sync/atomic"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13
14	"github.com/bnema/vev/internal/domain"
15	"github.com/bnema/vev/internal/ports"
16	portsmocks "github.com/bnema/vev/internal/ports/mocks"
17	"github.com/bnema/vev/internal/protocol"
18	"github.com/bnema/vev/internal/protocol/wire"
19	"github.com/bnema/vev/internal/usecase/keys"
20	"github.com/bnema/vev/internal/usecase/layout"
21)
22
23// --- coordinator harness ------------------------------------------------------
24
25// coordinatorMockClock uses generated mocks while retaining deterministic timer
26// channels and recorded deadlines for coordinator scheduling assertions.
27type coordinatorMockClock struct {
28	clock           *portsmocks.MockClock
29	timers          chan *coordinatorMockTimer
30	handshakeTimers chan *coordinatorMockTimer
31}
32
33type coordinatorMockTimer struct {
34	mock     *portsmocks.MockTimer
35	ch       chan time.Time
36	duration time.Duration
37	stopped  chan struct{}
38	stopOnce sync.Once
39}
40
41func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
42	t.Helper()
43	clk := &coordinatorMockClock{
44		clock:           portsmocks.NewMockClock(t),
45		timers:          make(chan *coordinatorMockTimer, capacity),
46		handshakeTimers: make(chan *coordinatorMockTimer, 1),
47	}
48	clk.clock.EXPECT().Now().Return(time.Time{}).Maybe()
49	clk.clock.EXPECT().NewTimer(mock.MatchedBy(func(d time.Duration) bool {
50		return d == protocol.HandshakeTimeout ||
51			d == urgentRenderDeadline ||
52			(d >= minOutputRenderDeadline && d <= maxOutputRenderDeadline) ||
53			d == maxSyncUpdateDuration ||
54			d == time.Second ||
55			d == 15*time.Minute
56	})).RunAndReturn(func(d time.Duration) ports.Timer {
57		timer := &coordinatorMockTimer{
58			mock:     portsmocks.NewMockTimer(t),
59			duration: d,
60			stopped:  make(chan struct{}),
61		}
62		timer.ch = make(chan time.Time, 1)
63		// A normal deadline reads C both in its worker and in the inert-clock
64		// guard; a watchdog reads it in its worker. The generated expectation
65		// makes every channel access observable without a hand-written Timer.
66		timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
67		timer.mock.EXPECT().Stop().Run(func() {
68			timer.stopOnce.Do(func() { close(timer.stopped) })
69		}).Maybe().Return(true)
70		if d == protocol.HandshakeTimeout {
71			clk.handshakeTimers <- timer
72		} else {
73			clk.timers <- timer
74		}
75		return timer.mock
76	}).Maybe()
77	return clk
78}
79
80// TestRenderCoordinatorTimerCallbacksReenterCoordinator is deliberately a
81// deadlock regression: every external timer method synchronously reads
82// coordinator metadata. Before two-phase timer ownership this blocked while
83// c.mu was held during normal arming, urgent replacement, sync arming, and
84// detach cancellation.
85// Detach cancels and stops a selected worker but never synchronously joins it;
86// the stale worker must be rejected when its external readiness probe returns.
87func TestRenderCoordinatorDetachDoesNotJoinSelectedDeadlineWorker(t *testing.T) {
88	clock := portsmocks.NewMockClock(t)
89	timer := portsmocks.NewMockTimer(t)
90	timerC := make(chan time.Time, 1)
91	stopCalled := make(chan struct{})
92	clock.EXPECT().NewTimer(minOutputRenderDeadline).Return(timer).Once()
93	timer.EXPECT().C().Return((<-chan time.Time)(timerC)).Once()
94	timer.EXPECT().Stop().Run(func() { close(stopCalled) }).Return(true).Once()
95
96	entered := make(chan struct{})
97	release := make(chan struct{})
98	rc := newRenderCoordinator(renderCoordinatorOptions{
99		clock: clock,
100		ackReady: func() bool {
101			close(entered)
102			<-release
103			return true
104		},
105	})
106	ac := &attachedClient{}
107	rc.attach(ac)
108	rc.invalidate(renderInvalidation{class: invalidateOutput})
109	timerC <- time.Time{}
110	<-entered
111
112	detached := make(chan struct{})
113	go func() {
114		rc.noteDetach(ac)
115		close(detached)
116	}()
117	<-stopCalled
118	awaitTestCompletion(t, detached, "detach synchronously waited for a selected timer worker")
119	close(release)
120}
121
122func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {
123	clock := portsmocks.NewMockClock(t)
124	var rc *renderCoordinator
125	clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
126		timer := portsmocks.NewMockTimer(t)
127		ch := make(chan time.Time)
128		timer.EXPECT().C().Run(func() {
129			_ = rc.resizeSnapshot()
130			_ = rc.burstMetricsSnapshot()
131		}).Return((<-chan time.Time)(ch)).Once()
132		timer.EXPECT().Stop().Run(func() {
133			_ = rc.resizeSnapshot()
134			_ = rc.burstMetricsSnapshot()
135		}).Return(true).Once()
136		return timer
137	}).Times(3)
138	rc = newRenderCoordinator(renderCoordinatorOptions{clock: clock})
139	ac := &attachedClient{}
140	rc.attach(ac)
141
142	rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm
143	rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion
144	rc.noteSyncBegin(nil, 1)                                   // sync arm
145	rc.noteDetach(ac)                                          // cancel normal deadline only
146	rc.noteSyncPaneRemoved(nil)                                // pane destruction cancels sync watchdog
147}
148
149// coordinatorHarness wires one coordinator to generated clock/timer mocks and
150// recording hooks. Every assertion is channel- or counter-based; nothing sleeps.
151type coordinatorHarness struct {
152	clk        *coordinatorMockClock
153	wakes      chan renderWake
154	previews   chan renderWake
155	ackReady   atomic.Bool
156	syncActive atomic.Bool
157	rc         *renderCoordinator
158}
159
160func newCoordinatorHarness(t *testing.T) *coordinatorHarness {
161	t.Helper()
162	h := &coordinatorHarness{
163		clk:      newCoordinatorMockClock(t, 16),
164		wakes:    make(chan renderWake, 16),
165		previews: make(chan renderWake, 16),
166	}
167	h.ackReady.Store(true)
168	h.rc = newRenderCoordinator(renderCoordinatorOptions{
169		clock:      h.clk.clock,
170		wake:       func(w renderWake) { h.wakes <- w },
171		ackReady:   func() bool { return h.ackReady.Load() },
172		syncActive: func() bool { return h.syncActive.Load() },
173	})
174	return h
175}
176
177// armedTimers drains every deadline armed so far. Arming happens
178// synchronously inside the coordinator call, so a non-blocking drain is
179// deterministic.
180func (h *coordinatorHarness) armedTimers(t *testing.T) []*coordinatorMockTimer {
181	t.Helper()
182	var timers []*coordinatorMockTimer
183	for {
184		select {
185		case tm := <-h.clk.timers:
186			timers = append(timers, tm)
187		default:
188			return timers
189		}
190	}
191}
192
193// Coordinator calls and fake-clock advancement are synchronous test steps.
194// These helpers deliberately never wait on wall time: a missing event is a
195// deterministic contract failure, not a slow behavior to poll for.
196func awaitWake(t *testing.T, ch chan renderWake) renderWake {
197	t.Helper()
198	wake := awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")
199	wake.attachmentLeases = nil
200	wake.generation = 0
201	return wake
202}
203
204func requireNoWake(t *testing.T, ch chan renderWake) {
205	t.Helper()
206	select {
207	case w := <-ch:
208		t.Fatalf("unexpected coordinator wake: %+v", w)
209	default:
210	}
211}
212
213func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation {
214	t.Helper()
215	return awaitTestValue(t, ch, "producer did not publish a coordinator invalidation")
216}
217
218func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
219	t.Helper()
220	select {
221	case inv := <-ch:
222		t.Fatalf("unexpected coordinator invalidation: %+v", inv)
223	default:
224	}
225}
226
227func captureResizeCallbackDone(t *testing.T, rc *renderCoordinator) <-chan struct{} {
228	t.Helper()
229	rc.mu.Lock()
230	defer rc.mu.Unlock()
231	if rc.resizeLane.token == nil {
232		t.Fatal("coordinator did not publish a resize callback completion")
233	}
234	return rc.resizeLane.token.done
235}
236
237func awaitHandshakeTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
238	t.Helper()
239	select {
240	case tm := <-clk.handshakeTimers:
241		return tm
242	default:
243		t.Fatal("handshake did not synchronously arm a fake-clock timer")
244		return nil
245	}
246}
247
248func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
249	t.Helper()
250	select {
251	case tm := <-clk.timers:
252		return tm
253	default:
254		t.Fatal("coordinator did not synchronously arm a fake-clock timer")
255		return nil
256	}
257}
258
259func awaitLatestCoordinatorTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
260	t.Helper()
261	timer := awaitCoordinatorScheduledTimer(t, clk)
262	for {
263		select {
264		case timer = <-clk.timers:
265		default:
266			return timer
267		}
268	}
269}
270
271func requireNoCoordinatorOutputFrame(t *testing.T, sends chan wire.Envelope) {
272	t.Helper()
273	for {
274		select {
275		case frame := <-sends:
276			if envelopeMessageName(t, frame.Payload) == "RoutePosition" {
277				continue
278			}
279			t.Fatalf("unexpected output frame: %+v", frame)
280		default:
281			return
282		}
283	}
284}
285
286// --- wake coalescing and deadlines --------------------------------------------
287
288func TestRenderCoordinatorCoalescesWakesUnderOneDeadline(t *testing.T) {
289	cases := []struct {
290		name          string
291		invalidations []renderInvalidation
292		wantTimers    int
293		wantMin       time.Duration
294		wantMax       time.Duration
295		wantWake      renderWake
296	}{
297		{
298			name:          "urgent transition wakes within two milliseconds",
299			invalidations: []renderInvalidation{{class: invalidateUrgent, reset: true, producer: "input.go"}},
300			wantTimers:    1,
301			wantMax:       urgentRenderDeadline,
302			wantWake:      renderWake{reset: true, urgent: true, coalesced: 1},
303		},
304		{
305			name: "output burst coalesces into one adaptive deadline",
306			invalidations: []renderInvalidation{
307				{class: invalidateOutput, producer: "render.go"},
308				{class: invalidateOutput, producer: "render.go"},
309				{class: invalidateOutput, producer: "render.go"},
310			},
311			wantTimers: 1,
312			wantMin:    minOutputRenderDeadline,
313			wantMax:    maxOutputRenderDeadline,
314			wantWake:   renderWake{coalesced: 3},
315		},
316		{
317			name: "full redraw stays sticky across later incremental transitions",
318			invalidations: []renderInvalidation{
319				{class: invalidateOutput},
320				{class: invalidateOutput, reset: true},
321				{class: invalidateOutput},
322			},
323			wantTimers: 1,
324			wantMin:    minOutputRenderDeadline,
325			wantMax:    maxOutputRenderDeadline,
326			wantWake:   renderWake{reset: true, coalesced: 3},
327		},
328		{
329			name: "urgent transition tightens a pending output deadline",
330			invalidations: []renderInvalidation{
331				{class: invalidateOutput},
332				{class: invalidateUrgent},
333			},
334			wantTimers: 2,
335			wantMax:    urgentRenderDeadline,
336			wantWake:   renderWake{urgent: true, coalesced: 2},
337		},
338	}
339	for _, tc := range cases {
340		t.Run(tc.name, func(t *testing.T) {
341			h := newCoordinatorHarness(t)
342			for _, inv := range tc.invalidations {
343				h.rc.invalidate(inv)
344			}
345			timers := h.armedTimers(t)
346			require.Lenf(t, timers, tc.wantTimers,
347				"cap-1 coalescing must arm exactly %d deadline timer(s) for %d invalidations",
348				tc.wantTimers, len(tc.invalidations))
349			last := timers[len(timers)-1]
350			require.GreaterOrEqual(t, last.duration, tc.wantMin)
351			require.LessOrEqual(t, last.duration, tc.wantMax)
352
353			last.ch <- time.Time{}
354			require.Equal(t, tc.wantWake, awaitWake(t, h.wakes),
355				"one wake must deliver the latest coalesced state")
356			requireNoWake(t, h.wakes)
357		})
358	}
359}
360
361func TestRenderCoordinatorAdaptsOutputDeadlineAfterBurstAndDecays(t *testing.T) {
362	h := newCoordinatorHarness(t)
363
364	// A burst establishes pressure without extending its already-armed first
365	// deadline; the following output deadline grows to the 16ms ceiling.
366	for range 9 {
367		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
368	}
369	burst := h.armedTimers(t)
370	require.Len(t, burst, 1)
371	require.Equal(t, minOutputRenderDeadline, burst[0].duration)
372	burst[0].ch <- time.Time{}
373	awaitWake(t, h.wakes)
374
375	h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
376	atCap := h.armedTimers(t)
377	require.Len(t, atCap, 1)
378	require.Equal(t, maxOutputRenderDeadline, atCap[0].duration,
379		"burst pressure must be capped at the 16ms output deadline")
380	atCap[0].ch <- time.Time{}
381	awaitWake(t, h.wakes)
382
383	// Quiet singleton batches decay pressure toward the 8ms floor.
384	var previous = maxOutputRenderDeadline
385	for range 8 {
386		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
387		timers := h.armedTimers(t)
388		require.Len(t, timers, 1)
389		require.LessOrEqual(t, timers[0].duration, previous)
390		require.GreaterOrEqual(t, timers[0].duration, minOutputRenderDeadline)
391		previous = timers[0].duration
392		timers[0].ch <- time.Time{}
393		awaitWake(t, h.wakes)
394	}
395	require.Equal(t, minOutputRenderDeadline, previous)
396}
397
398func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
399	h := newCoordinatorHarness(t)
400	h.rc.invalidate(renderInvalidation{class: invalidateOutput})
401	h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
402	for range 32 {
403		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
404		h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
405	}
406	timers := h.armedTimers(t)
407	require.Len(t, timers, 2, "only the urgent promotion may replace the output timer")
408	require.Equal(t, urgentRenderDeadline, timers[len(timers)-1].duration)
409}
410
411// --- ACK gating ----------------------------------------------------------------
412
413func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
414	slow, healthy := &attachedClient{}, &attachedClient{}
415	var slowReady atomic.Bool
416	wakes := make(chan renderWake, 2)
417	rc := newRenderCoordinator(renderCoordinatorOptions{
418		ackReadyFor: func(ac *attachedClient) bool { return ac == healthy || slowReady.Load() },
419		wake:        func(w renderWake) { wakes <- w },
420	})
421	rc.attach(slow)
422	rc.attach(healthy)
423	rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
424	rc.fireCurrent(false)
425
426	wake := awaitTestValue(t, wakes, "healthy attachment did not receive an independent wake")
427	require.Contains(t, wake.attachmentLeases, healthy)
428	_, slowSelected := wake.attachmentLeases[slow]
429	require.False(t, slowSelected)
430	rc.mu.Lock()
431	require.True(t, rc.pending, "slow attachment must retain the shared mutation until its ACK window opens")
432	rc.mu.Unlock()
433
434	slowReady.Store(true)
435	rc.notifyAckForLease(rc.attachmentLease(slow))
436	wake = awaitTestValue(t, wakes, "slow attachment did not receive its deferred wake")
437	require.Contains(t, wake.attachmentLeases, slow)
438	rc.mu.Lock()
439	require.False(t, rc.pending, "the shared mutation must clear after every attachment catches up")
440	rc.mu.Unlock()
441}
442
443func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) {
444	owner := &attachedClient{}
445	wakes := make(chan renderWake, 1)
446	ackEntered := make(chan struct{})
447	resizeDone := make(chan uint64, 1)
448	fireDone := make(chan struct{})
449
450	var rc *renderCoordinator
451	rc = newRenderCoordinator(renderCoordinatorOptions{
452		ackReady: func() bool {
453			close(ackEntered)
454			// The readiness probe models the output send path reading coordinator
455			// metadata after sendMu is held. It must never run under c.mu.
456			_ = rc.resizeSnapshot()
457			return true
458		},
459		wake: func(w renderWake) { wakes <- w },
460	})
461	rc.attach(owner)
462	rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
463
464	go func() {
465		rc.fire(1, false, true)
466		close(fireDone)
467	}()
468	awaitTestCompletion(t, ackEntered, "ack readiness was not evaluated")
469
470	go func() { resizeDone <- rc.recordResizeRequest(domain.Size{Cols: 120, Rows: 40}, owner) }()
471	epoch := awaitTestValue(t, resizeDone, "ack readiness re-entry blocked resize metadata")
472	require.Equal(t, uint64(1), epoch)
473	wake := awaitWake(t, wakes)
474	require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
475	require.Equal(t, renderWake{coalesced: 1}, wake)
476	awaitTestCompletion(t, fireDone, "fire did not return after wake")
477}
478
479func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) {
480	h := newCoordinatorHarness(t)
481	h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
482	timer := awaitCoordinatorScheduledTimer(t, h.clk)
483	require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
484
485	// An unrelated ACK must not turn a normal output deadline into an
486	// immediate wake or cancel its timer.
487	h.rc.notifyAck()
488	requireNoWake(t, h.wakes)
489	timer.mock.AssertNotCalled(t, "Stop")
490
491	timer.ch <- time.Time{}
492	awaitWake(t, h.wakes)
493	requireNoWake(t, h.wakes)
494}
495
496func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
497	t.Run("expired deadline flushes exactly once after readiness", func(t *testing.T) {
498		h := newCoordinatorHarness(t)
499		h.ackReady.Store(false)
500		h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "render.go"})
501		timer := awaitCoordinatorScheduledTimer(t, h.clk)
502		timer.ch <- time.Time{}
503		requireNoWake(t, h.wakes)
504
505		// Repeated ACKs while capacity remains unavailable are no-ops.
506		h.rc.notifyAck()
507		requireNoWake(t, h.wakes)
508		h.ackReady.Store(true)
509		h.rc.notifyAck()
510		w := awaitWake(t, h.wakes)
511		require.True(t, w.reset)
512		requireNoWake(t, h.wakes)
513		h.rc.notifyAck()
514		requireNoWake(t, h.wakes)
515	})
516
517	t.Run("lifecycle clears deferred work and urgent explicit fires stay immediate", func(t *testing.T) {
518		h := newCoordinatorHarness(t)
519		h.ackReady.Store(false)
520		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
521		timer := awaitCoordinatorScheduledTimer(t, h.clk)
522		timer.ch <- time.Time{}
523		requireNoWake(t, h.wakes)
524		h.rc.beginSessionTeardown().finish()
525		h.ackReady.Store(true)
526		h.rc.notifyAck()
527		requireNoWake(t, h.wakes)
528
529		immediate := newCoordinatorHarness(t)
530		immediate.rc.invalidate(renderInvalidation{class: invalidateUrgent})
531		immediate.rc.fireCurrent(false)
532		w := awaitWake(t, immediate.wakes)
533		require.True(t, w.urgent)
534	})
535}
536
537func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {
538	h := newCoordinatorHarness(t)
539	h.ackReady.Store(false)
540
541	h.rc.invalidate(renderInvalidation{class: invalidateOutput})
542	h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
543	h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
544	for _, timer := range h.armedTimers(t) {
545		timer.ch <- time.Time{}
546	}
547	requireNoWake(t, h.wakes)
548
549	h.ackReady.Store(true)
550	h.rc.notifyAck()
551	w := awaitWake(t, h.wakes)
552	require.True(t, w.reset, "the deferred full redraw must stay sticky through the ack gate")
553	require.Equal(t, 3, w.coalesced, "the post-ack wake must carry every deferred transition")
554	requireNoWake(t, h.wakes)
555
556	h.rc.notifyAck()
557	requireNoWake(t, h.wakes)
558}
559
560// --- synchronized output ---------------------------------------------------------
561
562func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) {
563	t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) {
564		h := newCoordinatorHarness(t)
565		firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
566		firstPane, secondPane := &pane{}, &pane{}
567
568		// Distinct stable pane owners must remain independent.
569		h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
570		firstWatchdog := h.armedTimers(t)[0]
571		h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
572		secondWatchdog := h.armedTimers(t)[0]
573		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
574
575		// Completing either pane leaves the aggregate gate closed.
576		h.rc.noteSyncEnd(firstPane, 11)
577		requireNoWake(t, h.wakes)
578		// A stale callback for the completed pane must not force it or wake.
579		firstWatchdog.ch <- time.Time{}
580		select {
581		case <-firstForced:
582			t.Fatal("stale watchdog forced a completed pane")
583		default:
584		}
585		requireNoWake(t, h.wakes)
586
587		// The remaining pane's watchdog forces only its own batch, then the
588		// aggregate completion produces exactly one urgent wake.
589		secondWatchdog.ch <- time.Time{}
590		<-secondForced
591		w := awaitWake(t, h.wakes)
592		require.True(t, w.watchdog)
593		require.True(t, w.urgent, "aggregate completion must wake urgently")
594		requireNoWake(t, h.wakes)
595	})
596
597	t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) {
598		h := newCoordinatorHarness(t)
599		firstForced := make(chan struct{}, 1)
600		firstPane, secondPane := &pane{}, &pane{}
601		h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
602		firstWatchdog := h.armedTimers(t)[0]
603		h.rc.noteSyncBegin(secondPane, 22)
604		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
605
606		h.rc.noteSyncEnd(secondPane, 22)
607		requireNoWake(t, h.wakes)
608		firstWatchdog.ch <- time.Time{}
609		<-firstForced
610		w := awaitWake(t, h.wakes)
611		require.True(t, w.watchdog)
612		require.True(t, w.urgent, "final pane completion must wake urgently")
613		requireNoWake(t, h.wakes)
614	})
615
616	t.Run("pane removal cancels only that pane watchdog", func(t *testing.T) {
617		h := newCoordinatorHarness(t)
618		firstPane, secondPane := &pane{}, &pane{}
619		firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
620		h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
621		firstWatchdog := h.armedTimers(t)[0]
622		h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
623		secondWatchdog := h.armedTimers(t)[0]
624		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
625
626		h.rc.noteSyncPaneRemoved(firstPane)
627		firstWatchdog.ch <- time.Time{}
628		select {
629		case <-firstForced:
630			t.Fatal("removed pane watchdog forced output")
631		default:
632		}
633		requireNoWake(t, h.wakes)
634		secondWatchdog.ch <- time.Time{}
635		<-secondForced
636		require.True(t, awaitWake(t, h.wakes).watchdog)
637	})
638}
639
640func TestRenderCoordinatorSyncGateFollowsDynamicRenderability(t *testing.T) {
641	t.Run("inactive batch that becomes active gates activation output until end", func(t *testing.T) {
642		h := newCoordinatorHarness(t)
643		var visible atomic.Bool
644		p := &pane{}
645		h.rc.noteSyncBeginWithRenderability(p, 1, visible.Load)
646		visible.Store(true)
647		h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "activation"})
648		for _, timer := range h.armedTimers(t) {
649			timer.ch <- time.Time{}
650		}
651		requireNoWake(t, h.wakes)
652
653		h.rc.noteSyncEnd(p, 1)
654		w := awaitWake(t, h.wakes)
655		require.True(t, w.urgent)
656		requireNoWake(t, h.wakes)
657	})
658
659	t.Run("hidden batch does not stall newly active output or wake on late end", func(t *testing.T) {
660		h := newCoordinatorHarness(t)
661		var visible atomic.Bool
662		visible.Store(true)
663		p := &pane{}
664		h.rc.noteSyncBeginWithRenderability(p, 1, visible.Load)
665		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "batch"})
666		visible.Store(false)
667		h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "new-active-tab"})
668		timers := h.armedTimers(t)
669		require.NotEmpty(t, timers)
670		timers[len(timers)-1].ch <- time.Time{}
671		w := awaitWake(t, h.wakes)
672		require.False(t, w.watchdog)
673		h.rc.noteSyncEnd(p, 1)
674		requireNoWake(t, h.wakes)
675	})
676}
677
678func TestRenderCoordinatorSyncGateRetriesWhenBatchPublishesDuringPredicate(t *testing.T) {
679	h := newCoordinatorHarness(t)
680	first, second := &pane{}, &pane{}
681	gateEvaluated := make(chan struct{})
682	releaseGate := make(chan struct{})
683	var hookOnce sync.Once
684	h.rc.opts.afterSyncGateEvaluated = func() {
685		hookOnce.Do(func() {
686			close(gateEvaluated)
687			<-releaseGate
688		})
689	}
690	h.rc.noteSyncBeginWithRenderability(first, 1, func() bool { return false })
691	h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "test"})
692
693	fireDone := make(chan struct{})
694	go func() {
695		h.rc.fireCurrent(false)
696		close(fireDone)
697	}()
698	<-gateEvaluated
699	// This publication is deliberately between an open predicate result and
700	// pending consumption. A stale gate result must be rejected and retried.
701	h.rc.noteSyncBeginWithRenderability(second, 2, func() bool { return true })
702	close(releaseGate)
703	awaitTestCompletion(t, fireDone, "fire did not finish after retrying the registry snapshot")
704
705	requireNoWake(t, h.wakes)
706	h.rc.noteSyncEnd(second, 2)
707	require.Equal(t, renderWake{urgent: true, coalesced: 1}, awaitWake(t, h.wakes))
708}
709
710func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
711	t.Run("completion flushes pending state in one wake", func(t *testing.T) {
712		h := newCoordinatorHarness(t)
713		h.syncActive.Store(true)
714		h.rc.noteSyncBegin(nil, 1)
715		watchdogs := h.armedTimers(t)
716		require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
717		require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)
718
719		h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
720		timers := h.armedTimers(t)
721		require.Len(t, timers, 1)
722		h.rc.mu.Lock()
723		normalWorkerDone := h.rc.normalLane.token.done
724		h.rc.mu.Unlock()
725		require.NotNil(t, normalWorkerDone)
726		timers[0].ch <- time.Time{}
727		requireWorkerExit(t, normalWorkerDone)
728		requireNoWake(t, h.wakes)
729
730		h.syncActive.Store(false)
731		h.rc.noteSyncEnd(nil, 1)
732		w := awaitWake(t, h.wakes)
733		require.True(t, w.reset)
734		requireNoWake(t, h.wakes)
735	})
736
737	t.Run("watchdog ends a wedged batch before it flushes", func(t *testing.T) {
738		h := newCoordinatorHarness(t)
739		h.syncActive.Store(true)
740		forced := make(chan struct{}, 1)
741		h.rc.noteSyncBegin(nil, 7, func() { forced <- struct{}{} })
742		watchdogs := h.armedTimers(t)
743		require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
744		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
745
746		watchdogs[0].ch <- time.Time{}
747		<-forced
748		w := awaitWake(t, h.wakes)
749		require.True(t, w.watchdog, "a wedged synchronized batch must be force-flushed by the watchdog")
750		requireNoWake(t, h.wakes)
751	})
752
753	t.Run("watchdog retains gate while force closes the VT batch", func(t *testing.T) {
754		h := newCoordinatorHarness(t)
755		entered := make(chan struct{})
756		release := make(chan struct{})
757		h.rc.noteSyncBegin(nil, 1, func() {
758			close(entered)
759			<-release
760		})
761		watchdogs := h.armedTimers(t)
762		require.NotEmpty(t, watchdogs)
763		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
764		watchdogs[0].ch <- time.Time{}
765		<-entered
766
767		// A concurrent deadline/fire sees the retained batch gate and cannot
768		// publish the partial VT state while force is blocked.
769		h.rc.fireCurrent(false)
770		requireNoWake(t, h.wakes)
771		close(release)
772		w := awaitWake(t, h.wakes)
773		require.True(t, w.watchdog)
774		require.True(t, w.urgent)
775		requireNoWake(t, h.wakes)
776	})
777
778	t.Run("watchdog flushes after the snapshotted generation is replaced", func(t *testing.T) {
779		h := newCoordinatorHarness(t)
780		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
781		h.rc.mu.Lock()
782		staleGeneration := h.rc.normalLane.generation
783		h.rc.mu.Unlock()
784
785		// Simulate a new urgent publication after fireCurrent(true) snapshots its
786		// generation but before fire validates it.
787		h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
788		h.rc.fire(staleGeneration, true, true)
789
790		w := awaitWake(t, h.wakes)
791		require.True(t, w.watchdog)
792		require.True(t, w.urgent)
793		require.Equal(t, 2, w.coalesced)
794		requireNoWake(t, h.wakes)
795	})
796
797	t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
798		h := newCoordinatorHarness(t)
799		h.syncActive.Store(true)
800		h.rc.noteSyncBegin(nil, 1)
801		watchdogs := h.armedTimers(t)
802		require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
803		h.syncActive.Store(false)
804		h.rc.noteSyncEnd(nil, 1)
805
806		watchdogs[0].ch <- time.Time{}
807		requireNoWake(t, h.wakes)
808	})
809}
810
811// --- preview subscription --------------------------------------------------------
812
813func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
814	h := newCoordinatorHarness(t)
815	viewer := &attachedClient{}
816	h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
817
818	h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
819	timers := h.armedTimers(t)
820	require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
821	timers[len(timers)-1].ch <- time.Time{}
822	owner := awaitWake(t, h.wakes)
823	preview := awaitWake(t, h.previews)
824	require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")
825	requireNoWake(t, h.previews)
826
827	h.rc.teardownPreviewFor(viewer, 1)
828	h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
829	timers = h.armedTimers(t)
830	require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
831	timers[len(timers)-1].ch <- time.Time{}
832	awaitWake(t, h.wakes)
833	requireNoWake(t, h.previews)
834}
835
836// --- lifecycle and stale callbacks -----------------------------------------------
837
838func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
839	cases := []struct {
840		name   string
841		attach bool
842	}{
843		{name: "attached cross-session target blocked on ack", attach: true},
844		{name: "headless cross-session target blocked on ack"},
845	}
846	for _, tc := range cases {
847		t.Run(tc.name, func(t *testing.T) {
848			h := newCoordinatorHarness(t)
849			h.ackReady.Store(false)
850			if tc.attach {
851				h.rc.attach(&attachedClient{})
852			}
853			viewer := &attachedClient{}
854			h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
855
856			h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"})
857			timers := h.armedTimers(t)
858			require.Len(t, timers, 1)
859			timers[0].ch <- time.Time{}
860
861			preview := awaitWake(t, h.previews)
862			require.Equal(t, renderWake{coalesced: 1}, preview,
863				"the viewer preview must receive target output without target ACK capacity")
864			requireNoWake(t, h.previews)
865			if tc.attach {
866				requireNoWake(t, h.wakes)
867			} else {
868				wake := awaitWake(t, h.wakes)
869				require.Equal(t, renderWake{coalesced: 1}, wake)
870				require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
871			}
872
873			h.ackReady.Store(true)
874			h.rc.notifyAck()
875			if tc.attach {
876				wake := awaitWake(t, h.wakes)
877				require.Equal(t, renderWake{coalesced: 1}, wake,
878					"the target frame remains pending for its ACK")
879			}
880			requireNoWake(t, h.previews)
881		})
882	}
883}
884
885func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *testing.T) {
886	h := newCoordinatorHarness(t)
887	target, viewer := &attachedClient{}, &attachedClient{}
888	h.ackReady.Store(false)
889	h.rc.attach(target)
890	h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
891	h.rc.noteDetach(target)
892
893	// A detached target only accepts PTY/session-owned invalidations. The old
894	// attachment remains stale and must not revive its render path.
895	h.rc.invalidateForAttachment(target, renderInvalidation{class: invalidateOutput, producer: "stale attachment"})
896	require.Empty(t, h.armedTimers(t))
897
898	for _, producer := range []string{"first PTY output", "second PTY output"} {
899		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: producer})
900		timers := h.armedTimers(t)
901		require.Len(t, timers, 1)
902		timers[0].ch <- time.Time{}
903		preview := awaitWake(t, h.previews)
904		require.Equal(t, renderWake{coalesced: 1}, preview)
905		wake := awaitWake(t, h.wakes)
906		require.Equal(t, renderWake{coalesced: 1}, wake)
907		require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
908
909		h.rc.mu.Lock()
910		require.False(t, h.rc.pending)
911		require.False(t, h.rc.pendingPreview)
912		require.False(t, h.rc.ackDeferred)
913		require.False(t, h.rc.deadlineDue)
914		require.Zero(t, h.rc.coalesced)
915		require.Empty(t, h.rc.attachments)
916		h.rc.mu.Unlock()
917	}
918
919	// A later attach retains the normal first-paint reset path.
920	replacement := &attachedClient{}
921	h.ackReady.Store(true)
922	h.rc.attach(replacement)
923	h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "first paint"})
924	timers := h.armedTimers(t)
925	require.Len(t, timers, 1)
926	timers[0].ch <- time.Time{}
927	wake := awaitWake(t, h.wakes)
928	require.True(t, wake.reset)
929	require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
930}
931
932func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
933	cases := []struct {
934		name       string
935		transition func(*renderCoordinator, *attachedClient, *attachedClient)
936		// teardownWake is the single revalidation wake a session teardown
937		// gives every preview observer, so a watcher notices the dead target
938		// without waiting for its revalidation timer.
939		teardownWake bool
940	}{
941		{name: "target detach", transition: func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
942		{name: "target detach and attach", transition: func(rc *renderCoordinator, target, replacement *attachedClient) {
943			rc.noteDetach(target)
944			rc.attach(replacement)
945		}},
946		{name: "target teardown", transition: func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }, teardownWake: true},
947	}
948	for _, tc := range cases {
949		t.Run(tc.name, func(t *testing.T) {
950			h := newCoordinatorHarness(t)
951			target, replacement, viewer := &attachedClient{}, &attachedClient{}, &attachedClient{}
952			h.rc.attach(target)
953			h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
954			h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"})
955			stale := h.armedTimers(t)
956			require.Len(t, stale, 1)
957
958			tc.transition(h.rc, target, replacement)
959			if tc.teardownWake {
960				require.Equal(t, renderWake{}, awaitWake(t, h.previews))
961				require.False(t, h.rc.hasPreviewSubscribers(), "teardown drops every preview subscription")
962			}
963			stale[0].ch <- time.Time{}
964			requireNoWake(t, h.wakes)
965			requireNoWake(t, h.previews)
966		})
967	}
968}
969
970// TestRenderCoordinatorPreviewObserverGenerations pins the observer-keyed
971// subscription table: a newer generation replaces, an older one never does,
972// and teardown only removes the exact generation it names.
973func TestRenderCoordinatorPreviewObserverGenerations(t *testing.T) {
974	type step struct {
975		subscribe bool
976		gen       uint64
977		wantOK    bool
978	}
979	cases := []struct {
980		name        string
981		steps       []step
982		wantPresent bool
983	}{
984		{name: "subscribe", steps: []step{{subscribe: true, gen: 1, wantOK: true}}, wantPresent: true},
985		{name: "newer generation replaces", steps: []step{{subscribe: true, gen: 1, wantOK: true}, {subscribe: true, gen: 2, wantOK: true}}, wantPresent: true},
986		{name: "older generation refused", steps: []step{{subscribe: true, gen: 2, wantOK: true}, {subscribe: true, gen: 1}}, wantPresent: true},
987		{name: "exact teardown removes", steps: []step{{subscribe: true, gen: 1, wantOK: true}, {gen: 1}}, wantPresent: false},
988		{name: "stale teardown keeps newer", steps: []step{{subscribe: true, gen: 2, wantOK: true}, {gen: 1}}, wantPresent: true},
989	}
990	for _, tc := range cases {
991		t.Run(tc.name, func(t *testing.T) {
992			h := newCoordinatorHarness(t)
993			observer := &remotePreviewWatch{wake: make(chan struct{}, 1)}
994			for _, s := range tc.steps {
995				if s.subscribe {
996					require.Equal(t, s.wantOK, h.rc.subscribePreviewFor(observer, s.gen, observer.notify))
997				} else {
998					h.rc.teardownPreviewFor(observer, s.gen)
999				}
1000			}
1001			require.Equal(t, tc.wantPresent, h.rc.hasPreviewSubscribers())
1002		})
1003	}
1004	t.Run("torn down coordinator refuses", func(t *testing.T) {
1005		h := newCoordinatorHarness(t)
1006		h.rc.beginSessionTeardown().finish()
1007		observer := &remotePreviewWatch{wake: make(chan struct{}, 1)}
1008		require.False(t, h.rc.subscribePreviewFor(observer, 1, observer.notify))
1009	})
1010}
1011
1012func TestRenderCoordinatorPreviewSubscriptionsAreIndependent(t *testing.T) {
1013	h := newCoordinatorHarness(t)
1014	one, two := &attachedClient{}, &attachedClient{}
1015	first, second := make(chan renderWake, 1), make(chan renderWake, 1)
1016	h.rc.subscribePreviewFor(one, 1, func(w renderWake) { first <- w })
1017	h.rc.subscribePreviewFor(two, 1, func(w renderWake) { second <- w })
1018	h.rc.attach(&attachedClient{})
1019	h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"})
1020	last := h.armedTimers(t)
1021	last[len(last)-1].ch <- time.Time{}
1022	awaitWake(t, first)
1023	awaitWake(t, second)
1024
1025	h.rc.teardownPreviewFor(one, 1)
1026	h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"})
1027	last = h.armedTimers(t)
1028	last[len(last)-1].ch <- time.Time{}
1029	requireNoWake(t, first)
1030	awaitWake(t, second)
1031}
1032
1033func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
1034	cases := []struct {
1035		name     string
1036		teardown func(rc *renderCoordinator, owner *attachedClient)
1037	}{
1038		{"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
1039		{"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
1040	}
1041	for _, tc := range cases {
1042		t.Run(tc.name, func(t *testing.T) {
1043			h := newCoordinatorHarness(t)
1044			owner := &attachedClient{}
1045			h.rc.attach(owner)
1046			h.rc.invalidate(renderInvalidation{class: invalidateOutput})
1047			stale := h.armedTimers(t)
1048			require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
1049
1050			tc.teardown(h.rc, owner)
1051			for _, timer := range stale {
1052				timer.ch <- time.Time{}
1053			}
1054			requireNoWake(t, h.wakes)
1055
1056			h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
1057			for _, timer := range h.armedTimers(t) {
1058				timer.ch <- time.Time{}
1059			}
1060			requireNoWake(t, h.wakes)
1061		})
1062	}
1063
1064	t.Run("replacement attachment wakes independently", func(t *testing.T) {
1065		h := newCoordinatorHarness(t)
1066		owner := &attachedClient{}
1067		replacement := &attachedClient{}
1068		h.rc.attach(owner)
1069		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
1070		stale := h.armedTimers(t)
1071		require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
1072
1073		h.rc.noteDetach(owner)
1074		h.rc.attach(replacement)
1075		for _, timer := range stale {
1076			timer.ch <- time.Time{}
1077		}
1078		requireNoWake(t, h.wakes)
1079
1080		h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true})
1081		fresh := h.armedTimers(t)
1082		require.NotEmpty(t, fresh, "a replacement attachment must arm its own deadline")
1083		fresh[len(fresh)-1].ch <- time.Time{}
1084		w := awaitWake(t, h.wakes)
1085		require.True(t, w.reset, "the replacement starts from an independent full state")
1086		requireNoWake(t, h.wakes)
1087	})
1088}
1089
1090// --- resize metadata ownership -----------------------------------------------------
1091
1092func TestRenderCoordinatorResizeMetadata(t *testing.T) {
1093	sz := func(cols, rows int) domain.Size { return domain.Size{Cols: cols, Rows: rows} }
1094	cases := []struct {
1095		name string
1096		run  func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient)
1097	}{
1098		{
1099			name: "latest request wins with strictly increasing epochs",
1100			run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
1101				require.Equal(t, uint64(1), rc.recordResizeRequest(sz(100, 30), owner))
1102				require.Equal(t, uint64(2), rc.recordResizeRequest(sz(110, 32), owner))
1103				require.Equal(t, uint64(3), rc.recordResizeRequest(sz(120, 40), owner))
1104				snap := rc.resizeSnapshot()
1105				require.Equal(t, sz(120, 40), snap.size)
1106				require.Same(t, owner, snap.source)
1107				require.Equal(t, uint64(3), snap.epoch)
1108
1109				// Resize state is exclusively coordinator-owned.
1110				require.Zero(t, snap.committed)
1111			},
1112		},
1113		{
1114			name: "duplicate sizes still advance the epoch deterministically",
1115			run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
1116				require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
1117				require.Equal(t, uint64(2), rc.recordResizeRequest(sz(120, 40), owner))
1118				snap := rc.resizeSnapshot()
1119				require.Equal(t, sz(120, 40), snap.size)
1120				require.Equal(t, uint64(2), snap.epoch)
1121			},
1122		},
1123		{
1124			name: "stale resize callbacks preserve latest metadata through lifecycle transitions",
1125			run: func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient) {
1126				type lifecycleCase struct {
1127					name       string
1128					installNew bool
1129				}
1130				for _, tc := range []lifecycleCase{
1131					{name: "detach"},
1132					{name: "park"},
1133					{name: "session teardown"},
1134					{name: "replacement", installNew: true},
1135				} {
1136					t.Run(tc.name, func(t *testing.T) {
1137						h := newCoordinatorHarness(t)
1138						staleOwner := &attachedClient{}
1139						freshOwner := &attachedClient{}
1140						h.rc.attach(staleOwner)
1141						require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))
1142
1143						// This models a stale callback which captured the old
1144						// attachment before lifecycle ownership changed.
1145						staleCallback := func() uint64 {
1146							return h.rc.recordResizeRequest(sz(80, 20), staleOwner)
1147						}
1148
1149						switch tc.name {
1150						case "detach":
1151							h.rc.noteDetach(staleOwner)
1152						case "park":
1153							h.rc.noteDetach(staleOwner)
1154						case "session teardown":
1155							h.rc.beginSessionTeardown().finish()
1156						case "replacement":
1157							h.rc.noteDetach(staleOwner)
1158							h.rc.attach(freshOwner)
1159							require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))
1160						}
1161
1162						require.Zero(t, staleCallback(), "stale callback must not advance the resize epoch")
1163						snap := h.rc.resizeSnapshot()
1164						if tc.installNew {
1165							require.Equal(t, sz(100, 50), snap.size)
1166							require.Equal(t, uint64(2), snap.epoch)
1167							require.Same(t, freshOwner, snap.source)
1168							return
1169						}
1170						require.Equal(t, sz(120, 40), snap.size)
1171						require.Equal(t, uint64(1), snap.epoch)
1172						require.Same(t, staleOwner, snap.source,
1173							"the stale callback must not replace the recorded attachment identity")
1174					})
1175				}
1176			},
1177		},
1178	}
1179	for _, tc := range cases {
1180		t.Run(tc.name, func(t *testing.T) {
1181			h := newCoordinatorHarness(t)
1182			owner := &attachedClient{}
1183			replacement := &attachedClient{}
1184			h.rc.attach(owner)
1185			tc.run(t, h.rc, owner, replacement)
1186		})
1187	}
1188}
1189
1190// --- producer fan-in ------------------------------------------------------------
1191
1192// producerFiles is the current production direct-paint inventory. Every file
1193// gets exactly one exercised state transition in TestProducerInvalidations.
1194var producerFiles = []string{
1195	"attention.go", "client.go", "copymode.go", "floating.go", "input.go",
1196	"palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",
1197	"session.go",
1198}
1199
1200func TestProducerInvalidations(t *testing.T) {
1201	cases := []struct {
1202		file     string
1203		name     string
1204		tabs     int
1205		producer string
1206		// interaction marks producers that publish their own control frames
1207		// (the picker offer and snapshot): the coordinator-invalidation
1208		// assertion below covers their repaint, and those frames are pinned
1209		// by the interaction suites.
1210		interaction bool
1211		run         func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
1212	}{
1213		{
1214			file: "attention.go",
1215			name: "attention repaint",
1216			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1217				d.repaintAttachedClients(sess)
1218			},
1219		},
1220		{
1221			file: "client.go",
1222			name: "client theme application",
1223			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1224				d.applyTheme(sess, ac, protocol.Theme{SchemeKnown: true})
1225			},
1226		},
1227		{
1228			file: "copymode.go",
1229			name: "copy mode entry",
1230			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1231				d.enterCopyMode(sess, ac)
1232			},
1233		},
1234		{
1235			file: "floating.go",
1236			name: "floating toggle to visible",
1237			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1238				fp := newPaneWithStableID(layout.PaneID("floating"), "float-producer", newQuietPTY(), domain.Size{Cols: 40, Rows: 10})
1239				tb := testAttachmentTab(sess)
1240				tb.mu.Lock()
1241				tb.floating.pane = fp
1242				tb.floating.state = floatingHidden
1243				tb.floating.generation = 1
1244				tb.mu.Unlock()
1245				require.NoError(t, d.toggleFloating(sess, ac))
1246			},
1247		},
1248		{
1249			file:     "input.go",
1250			name:     "jump attention switches a local tab",
1251			tabs:     2,
1252			producer: "attention.go",
1253			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1254				sess.tabs[1].attention = true
1255				daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil)
1256				require.Equal(t, 1, testAttachmentTabIndex(sess))
1257			},
1258		},
1259		{
1260			file: "palette.go",
1261			name: "palette entry",
1262			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1263				d.enterPalette(sess, ac)
1264			},
1265		},
1266		{
1267			file: "pane_actions.go",
1268			name: "pane split",
1269			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1270				require.NoError(t, d.splitPane(sess, ac, layout.Right))
1271			},
1272		},
1273		{
1274			file:        "picker.go",
1275			name:        "picker entry",
1276			interaction: true,
1277			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1278				effect := admitPickerEffectForTest(t, sess, ac)
1279				require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMovePane, moveSourceLocator{}, 0))
1280			},
1281		},
1282		{
1283			file: "prompt.go",
1284			name: "prompt entry",
1285			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1286				d.enterPrompt(sess, ac, "rename", "", func(string) error { return nil })
1287			},
1288		},
1289		{
1290			file: "render.go",
1291			name: "retained resize dispatch",
1292			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1293				clk := newCoordinatorMockClock(t, 2)
1294				// The coordinator owns deadline time; install the deterministic
1295				// clock on the already-attached coordinator rather than changing
1296				// Daemon's construction-time clock afterwards.
1297				sess.renderCoordinator().opts.clock = clk.clock
1298				d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
1299				timer := awaitCoordinatorScheduledTimer(t, clk)
1300				done := captureResizeCallbackDone(t, sess.renderCoordinator())
1301				timer.ch <- time.Time{}
1302				awaitTestCompletion(t, done, "resize callback did not complete")
1303			},
1304		},
1305		{
1306			file: "session.go",
1307			name: "tab close repaint",
1308			tabs: 2,
1309			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1310				sess.mu.Lock()
1311				tb := sess.tabs[1]
1312				sess.mu.Unlock()
1313				require.NoError(t, d.closeTab(sess, tb, true))
1314			},
1315		},
1316	}
1317
1318	exercised := make([]string, 0, len(cases))
1319	for _, tc := range cases {
1320		exercised = append(exercised, tc.file)
1321	}
1322	require.ElementsMatch(t, producerFiles, exercised,
1323		"every current direct-paint producer file needs exactly one exercised transition")
1324
1325	for _, tc := range cases {
1326		t.Run(tc.file+"/"+tc.name, func(t *testing.T) {
1327			tabs := tc.tabs
1328			if tabs == 0 {
1329				tabs = 1
1330			}
1331			d, sess, ac, sends, releases := newManualTabSession(t, tabs)
1332			defer releaseAll(releases)
1333			invs := make(chan renderInvalidation, 8)
1334			sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
1335				clock:        d.clock,
1336				wake:         func(renderWake) {},
1337				onInvalidate: func(inv renderInvalidation) { invs <- inv },
1338			}))
1339
1340			tc.run(t, d, sess, ac)
1341
1342			inv := awaitInvalidation(t, invs)
1343			if tc.producer != "" {
1344				require.Equal(t, tc.producer, inv.producer)
1345			}
1346			requireNoInvalidation(t, invs)
1347			if tc.interaction {
1348				// The picker publishes offer/snapshot/restore frames of its own;
1349				// drain them so the frame assertion below covers the repaint.
1350				drainAllFrames(sends)
1351			}
1352			requireNoCoordinatorOutputFrame(t, sends)
1353		})
1354	}
1355}
1356
1357// TestProducerInvalidationInventory is a supplementary inventory guard only:
1358// it keeps producerFiles aligned with the daemon source layout so the
1359// behavioral table above cannot drift silently. It is never acceptance
1360// evidence for coordinator behavior.
1361func TestProducerInvalidationInventory(t *testing.T) {
1362	for _, name := range producerFiles {
1363		_, err := os.Stat(filepath.Join(".", name))
1364		require.NoErrorf(t, err, "producer file %s is gone; update TestProducerInvalidations", name)
1365	}
1366}
1367
1368// --- coordinator resize dispatch ----------------------------------------------
1369
1370func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
1371	p, release := newBlockingPTY(t)
1372	defer release()
1373	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1374	// Exercise the original seam: two fallback paints reach lazy initialization
1375	// together before either can compose.
1376	ac.overlays = nil
1377	ac.overlayOnce = sync.Once{}
1378	start := make(chan struct{})
1379	var wg sync.WaitGroup
1380	wg.Add(2)
1381	for range 2 {
1382		go func() {
1383			defer wg.Done()
1384			<-start
1385			d.paint(sess, ac, true, nil)
1386		}()
1387	}
1388	close(start)
1389	wg.Wait()
1390	require.NotNil(t, ac.overlays)
1391}
1392
1393func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) {
1394	p, release := newBlockingPTY(t)
1395	d, sess, _, _ := newManualSessionWithPTYs(t, p)
1396	tb := testAttachmentTab(sess)
1397	require.NotNil(t, tb)
1398	d.startPaneGoroutines(sess, tb, tb.focusedPane())
1399	release()
1400	select {
1401	case <-waitGroupDone(&d.sessWg):
1402	case <-time.After(time.Second):
1403		t.Fatal("one reader must balance exactly one WaitGroup count")
1404	}
1405}
1406
1407func requireWorkerExit(t *testing.T, done <-chan struct{}) {
1408	t.Helper()
1409	awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit")
1410}
1411
1412func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
1413	rc := newRenderCoordinator(renderCoordinatorOptions{})
1414	newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}
1415	stale := &timerToken{generation: 1}
1416
1417	rc.mu.Lock()
1418	rc.resizeLane.generation = 2
1419	rc.resizeLane.token = newer
1420	require.False(t, rc.resizeLane.clearLocked(stale))
1421	require.Same(t, newer, rc.resizeLane.token)
1422	require.True(t, rc.resizeLane.clearLocked(newer))
1423	require.Nil(t, rc.resizeLane.token)
1424	rc.mu.Unlock()
1425}
1426
1427func TestRenderCoordinatorNilClockRetryRunsSynchronously(t *testing.T) {
1428	rc := newRenderCoordinator(renderCoordinatorOptions{})
1429	owner := &attachedClient{}
1430	rc.attach(owner)
1431	lease := rc.attachmentLease(owner)
1432	epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 40}, owner, lease)
1433	require.NotZero(t, epoch)
1434	require.True(t, rc.resizeCurrentForLease(epoch, owner, lease, true))
1435
1436	called := false
1437	rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })
1438	require.True(t, called, "a nil clock must run the retry without a timer")
1439	rc.mu.Lock()
1440	retryToken := rc.retryLane.token
1441	rc.mu.Unlock()
1442	require.Nil(t, retryToken, "a disabled retry lane must not retain stale completion ownership")
1443}
1444
1445func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
1446	clock := portsmocks.NewMockClock(t)
1447	timer := portsmocks.NewMockTimer(t)
1448	clock.EXPECT().NewTimer(minOutputRenderDeadline).Return(timer).Once()
1449	timer.EXPECT().C().Return((<-chan time.Time)(nil)).Once()
1450	timer.EXPECT().Stop().Return(true).Once()
1451
1452	wakes := make(chan renderWake, 1)
1453	rc := newRenderCoordinator(renderCoordinatorOptions{
1454		clock:    clock,
1455		ackReady: func() bool { return true },
1456		wake:     func(w renderWake) { wakes <- w },
1457	})
1458	rc.invalidate(renderInvalidation{class: invalidateOutput})
1459
1460	require.Equal(t, renderWake{coalesced: 1}, <-wakes)
1461	require.Equal(t, renderCoordinatorBurstMetricsSnapshot{invalidations: 1, wakes: 1, coalesced: 1}, rc.burstMetricsSnapshot())
1462	rc.mu.Lock()
1463	require.Nil(t, rc.normalLane.token)
1464	rc.mu.Unlock()
1465}
1466
1467func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1468	t.Run("detach retains a gated headless preview until complete", func(t *testing.T) {
1469		for _, transition := range []struct {
1470			name string
1471			run  func(*renderCoordinator, *attachedClient)
1472		}{
1473			{"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1474		} {
1475			t.Run(transition.name, func(t *testing.T) {
1476				h := newCoordinatorHarness(t)
1477				target, viewer, p := &attachedClient{}, &attachedClient{}, &pane{}
1478				h.rc.attach(target)
1479				h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
1480				h.rc.noteSyncBegin(p, 1)
1481				watchdog := h.armedTimers(t)[0]
1482				transition.run(h.rc, target)
1483
1484				// PTY-owned output remains visible to picker observers while the
1485				// target attachment is parked. Its deadline must retain the sync gate.
1486				h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "complete preview"})
1487				timers := h.armedTimers(t)
1488				require.Len(t, timers, 1)
1489				h.rc.mu.Lock()
1490				normalWorkerDone := h.rc.normalLane.token.done
1491				h.rc.mu.Unlock()
1492				require.NotNil(t, normalWorkerDone)
1493				timers[0].ch <- time.Time{}
1494				requireWorkerExit(t, normalWorkerDone)
1495				requireNoWake(t, h.previews)
1496
1497				h.rc.mu.Lock()
1498				require.Same(t, h.rc.syncBatches[p].lane.token.timer, watchdog.mock)
1499				h.rc.mu.Unlock()
1500				h.rc.noteSyncEnd(p, 1)
1501				preview := awaitWake(t, h.previews)
1502				require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)
1503				requireNoWake(t, h.previews)
1504				wake := awaitWake(t, h.wakes)
1505				require.Equal(t, renderWake{urgent: true, coalesced: 1}, wake)
1506				require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
1507			})
1508		}
1509	})
1510
1511	t.Run("replacement first paint waits for active batch and rejects stale owner", func(t *testing.T) {
1512		h := newCoordinatorHarness(t)
1513		old, replacement, p := &attachedClient{}, &attachedClient{}, &pane{}
1514		h.rc.attach(old)
1515		h.rc.noteSyncBegin(p, 1)
1516		watchdog := h.armedTimers(t)[0]
1517		h.rc.noteDetach(old)
1518		h.rc.attachWithReadiness(replacement, false)
1519		require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")
1520		require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))
1521
1522		h.rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, producer: "stale old attachment"})
1523		require.Empty(t, h.armedTimers(t))
1524		h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
1525		timers := h.armedTimers(t)
1526		require.Len(t, timers, 1)
1527		h.rc.mu.Lock()
1528		normalWorkerDone := h.rc.normalLane.token.done
1529		h.rc.mu.Unlock()
1530		require.NotNil(t, normalWorkerDone)
1531		timers[0].ch <- time.Time{}
1532		requireWorkerExit(t, normalWorkerDone)
1533		requireNoWake(t, h.wakes)
1534
1535		h.rc.mu.Lock()
1536		require.Same(t, h.rc.syncBatches[p].lane.token.timer, watchdog.mock)
1537		h.rc.mu.Unlock()
1538		h.rc.noteSyncEnd(p, 1)
1539		wake := awaitWake(t, h.wakes)
1540		require.True(t, wake.reset)
1541		require.True(t, wake.urgent)
1542		require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
1543		requireNoWake(t, h.wakes)
1544	})
1545
1546	t.Run("pane removal and session teardown cancel sync workers", func(t *testing.T) {
1547		for _, lifecycle := range []struct {
1548			name string
1549			end  func(*renderCoordinator, *pane)
1550		}{
1551			{"pane removal", func(rc *renderCoordinator, p *pane) { rc.noteSyncPaneRemoved(p) }},
1552			{"session teardown", func(rc *renderCoordinator, _ *pane) { rc.beginSessionTeardown().finish() }},
1553		} {
1554			t.Run(lifecycle.name, func(t *testing.T) {
1555				clk := newCoordinatorMockClock(t, 2)
1556				rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock})
1557				p := &pane{}
1558				rc.noteSyncBegin(p, 1)
1559				syncTimer := <-clk.timers
1560				rc.mu.Lock()
1561				syncDone := rc.syncBatches[p].lane.token.done
1562				rc.mu.Unlock()
1563				lifecycle.end(rc, p)
1564				syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
1565				requireWorkerExit(t, syncDone)
1566				if lifecycle.name == "session teardown" {
1567					rc.waitForTimerWorkers()
1568				}
1569			})
1570		}
1571	})
1572}
1573
1574func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1575	newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan wire.Envelope, *coordinatorMockClock, chan renderInvalidation) {
1576		t.Helper()
1577		p, releasePTY := newBlockingPTY(t)
1578		t.Cleanup(releasePTY)
1579		d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1580		clk := newCoordinatorMockClock(t, 4)
1581		d.clock = clk.clock
1582		invs := make(chan renderInvalidation, 4)
1583		rc := newRenderCoordinator(renderCoordinatorOptions{
1584			clock:        clk.clock,
1585			wake:         func(renderWake) {},
1586			onInvalidate: func(inv renderInvalidation) { invs <- inv },
1587		})
1588		rc.attach(ac)
1589		sess.installRenderCoordinator(rc)
1590		return d, sess, ac, sends, clk, invs
1591	}
1592
1593	t.Run("bounded timer records metadata and dispatches through the coordinator", func(t *testing.T) {
1594		d, sess, ac, sends, clk, invs := newResizeFixture(t)
1595
1596		d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
1597		timer := awaitCoordinatorScheduledTimer(t, clk)
1598		require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
1599		require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)
1600		snap := sess.renderCoordinator().resizeSnapshot()
1601		require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,
1602			"the coordinator must record the latest requested resize before delegating")
1603		require.Same(t, ac, snap.source)
1604		require.Equal(t, uint64(1), snap.epoch)
1605		requireNoCoordinatorOutputFrame(t, sends)
1606
1607		done := captureResizeCallbackDone(t, sess.renderCoordinator())
1608		timer.ch <- time.Time{}
1609		awaitTestCompletion(t, done, "resize callback did not complete")
1610		inv := awaitInvalidation(t, invs)
1611		require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
1612		requireNoInvalidation(t, invs)
1613		requireNoCoordinatorOutputFrame(t, sends)
1614	})
1615
1616	t.Run("stale generations stay rejected", func(t *testing.T) {
1617		d, sess, ac, sends, clk, invs := newResizeFixture(t)
1618
1619		d.resize(sess, ac, domain.Size{Cols: 100, Rows: 24})
1620		first := awaitCoordinatorScheduledTimer(t, clk)
1621		d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
1622		latest := awaitCoordinatorScheduledTimer(t, clk)
1623		require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().epoch,
1624			"every resize request must advance the coordinator epoch")
1625
1626		done := captureResizeCallbackDone(t, sess.renderCoordinator())
1627		first.ch <- time.Time{}
1628		requireNoInvalidation(t, invs)
1629		requireNoCoordinatorOutputFrame(t, sends)
1630
1631		latest.ch <- time.Time{}
1632		awaitTestCompletion(t, done, "latest resize callback did not complete")
1633		inv := awaitInvalidation(t, invs)
1634		require.True(t, inv.reset)
1635		requireNoInvalidation(t, invs)
1636		requireNoCoordinatorOutputFrame(t, sends)
1637		require.Equal(t, domain.Size{Cols: 120, Rows: 24}, sess.renderCoordinator().resizeSnapshot().size)
1638	})
1639
1640	t.Run("cancellation still drops the pending dispatch", func(t *testing.T) {
1641		d, sess, ac, sends, clk, invs := newResizeFixture(t)
1642
1643		d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
1644		timer := awaitCoordinatorScheduledTimer(t, clk)
1645		done := captureResizeCallbackDone(t, sess.renderCoordinator())
1646		sess.renderCoordinator().noteDetach(ac)
1647
1648		timer.ch <- time.Time{}
1649		awaitTestCompletion(t, done, "cancelled resize callback did not complete")
1650		requireNoInvalidation(t, invs)
1651		requireNoCoordinatorOutputFrame(t, sends)
1652	})
1653
1654	t.Run("request reports immediate completion and async schedule acceptance", func(t *testing.T) {
1655		t.Run("immediate reset invalidation", func(t *testing.T) {
1656			d, sess, ac, _, _, invs := newResizeFixture(t)
1657
1658			require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, true))
1659			inv := awaitInvalidation(t, invs)
1660			require.True(t, inv.reset)
1661		})
1662
1663		t.Run("superseded immediate transaction", func(t *testing.T) {
1664			resizeStarted := make(chan struct{})
1665			releaseResize := make(chan struct{})
1666			pty := &transactionalResizePTY{onResize: func() {
1667				close(resizeStarted)
1668				<-releaseResize
1669			}}
1670			d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
1671			clk := newCoordinatorMockClock(t, 2)
1672			d.clock = clk.clock
1673			invs := make(chan renderInvalidation, 1)
1674			rc := newRenderCoordinator(renderCoordinatorOptions{
1675				clock:        clk.clock,
1676				wake:         func(renderWake) {},
1677				onInvalidate: func(inv renderInvalidation) { invs <- inv },
1678			})
1679			rc.attach(ac)
1680			sess.installRenderCoordinator(rc)
1681
1682			result := make(chan bool, 1)
1683			go func() {
1684				result <- sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, true)
1685			}()
1686			<-resizeStarted
1687			require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac))
1688			close(releaseResize)
1689
1690			require.False(t, <-result)
1691			requireNoInvalidation(t, invs)
1692		})
1693
1694		t.Run("async schedule and teardown rejection", func(t *testing.T) {
1695			d, sess, ac, _, clk, _ := newResizeFixture(t)
1696
1697			require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, false))
1698			awaitCoordinatorScheduledTimer(t, clk)
1699
1700			// A torn-down coordinator cannot accept a stale attachment's schedule.
1701			sess.renderCoordinator().beginSessionTeardown().finish()
1702			require.False(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1703		})
1704	})
1705}

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