detached ece7682

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/domain/layout"
16	"github.com/bnema/vev/internal/ports"
17	portsmocks "github.com/bnema/vev/internal/ports/mocks"
18	"github.com/bnema/vev/internal/protocol"
19	"github.com/bnema/vev/internal/protocol/wire"
20	"github.com/bnema/vev/internal/usecase/keys"
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		ackReadyFor: func(*attachedClient) 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		ackReadyFor: func(*attachedClient) 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		ackReadyFor: func(*attachedClient) 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.rc.attach(&attachedClient{})
500		h.ackReady.Store(false)
501		h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "render.go"})
502		timer := awaitCoordinatorScheduledTimer(t, h.clk)
503		timer.ch <- time.Time{}
504		requireNoWake(t, h.wakes)
505
506		// Repeated ACKs while capacity remains unavailable are no-ops.
507		h.rc.notifyAck()
508		requireNoWake(t, h.wakes)
509		h.ackReady.Store(true)
510		h.rc.notifyAck()
511		w := awaitWake(t, h.wakes)
512		require.True(t, w.reset)
513		requireNoWake(t, h.wakes)
514		h.rc.notifyAck()
515		requireNoWake(t, h.wakes)
516	})
517
518	t.Run("lifecycle clears deferred work and urgent explicit fires stay immediate", func(t *testing.T) {
519		h := newCoordinatorHarness(t)
520		h.rc.attach(&attachedClient{})
521		h.ackReady.Store(false)
522		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
523		timer := awaitCoordinatorScheduledTimer(t, h.clk)
524		timer.ch <- time.Time{}
525		requireNoWake(t, h.wakes)
526		h.rc.beginSessionTeardown().finish()
527		h.ackReady.Store(true)
528		h.rc.notifyAck()
529		requireNoWake(t, h.wakes)
530
531		immediate := newCoordinatorHarness(t)
532		immediate.rc.invalidate(renderInvalidation{class: invalidateUrgent})
533		immediate.rc.fireCurrent(false)
534		w := awaitWake(t, immediate.wakes)
535		require.True(t, w.urgent)
536	})
537}
538
539func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {
540	h := newCoordinatorHarness(t)
541	h.rc.attach(&attachedClient{})
542	h.ackReady.Store(false)
543
544	h.rc.invalidate(renderInvalidation{class: invalidateOutput})
545	h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
546	h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
547	for _, timer := range h.armedTimers(t) {
548		timer.ch <- time.Time{}
549	}
550	requireNoWake(t, h.wakes)
551
552	h.ackReady.Store(true)
553	h.rc.notifyAck()
554	w := awaitWake(t, h.wakes)
555	require.True(t, w.reset, "the deferred full redraw must stay sticky through the ack gate")
556	require.Equal(t, 3, w.coalesced, "the post-ack wake must carry every deferred transition")
557	requireNoWake(t, h.wakes)
558
559	h.rc.notifyAck()
560	requireNoWake(t, h.wakes)
561}
562
563// --- synchronized output ---------------------------------------------------------
564
565func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) {
566	t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) {
567		h := newCoordinatorHarness(t)
568		firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
569		firstPane, secondPane := &pane{}, &pane{}
570
571		// Distinct stable pane owners must remain independent.
572		h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
573		firstWatchdog := h.armedTimers(t)[0]
574		h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
575		secondWatchdog := h.armedTimers(t)[0]
576		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
577
578		// Completing either pane leaves the aggregate gate closed.
579		h.rc.noteSyncEnd(firstPane, 11)
580		requireNoWake(t, h.wakes)
581		// A stale callback for the completed pane must not force it or wake.
582		firstWatchdog.ch <- time.Time{}
583		select {
584		case <-firstForced:
585			t.Fatal("stale watchdog forced a completed pane")
586		default:
587		}
588		requireNoWake(t, h.wakes)
589
590		// The remaining pane's watchdog forces only its own batch, then the
591		// aggregate completion produces exactly one urgent wake.
592		secondWatchdog.ch <- time.Time{}
593		<-secondForced
594		w := awaitWake(t, h.wakes)
595		require.True(t, w.watchdog)
596		require.True(t, w.urgent, "aggregate completion must wake urgently")
597		requireNoWake(t, h.wakes)
598	})
599
600	t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) {
601		h := newCoordinatorHarness(t)
602		firstForced := make(chan struct{}, 1)
603		firstPane, secondPane := &pane{}, &pane{}
604		h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
605		firstWatchdog := h.armedTimers(t)[0]
606		h.rc.noteSyncBegin(secondPane, 22)
607		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
608
609		h.rc.noteSyncEnd(secondPane, 22)
610		requireNoWake(t, h.wakes)
611		firstWatchdog.ch <- time.Time{}
612		<-firstForced
613		w := awaitWake(t, h.wakes)
614		require.True(t, w.watchdog)
615		require.True(t, w.urgent, "final pane completion must wake urgently")
616		requireNoWake(t, h.wakes)
617	})
618
619	t.Run("pane removal cancels only that pane watchdog", func(t *testing.T) {
620		h := newCoordinatorHarness(t)
621		firstPane, secondPane := &pane{}, &pane{}
622		firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
623		h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
624		firstWatchdog := h.armedTimers(t)[0]
625		h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
626		secondWatchdog := h.armedTimers(t)[0]
627		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
628
629		h.rc.noteSyncPaneRemoved(firstPane)
630		firstWatchdog.ch <- time.Time{}
631		select {
632		case <-firstForced:
633			t.Fatal("removed pane watchdog forced output")
634		default:
635		}
636		requireNoWake(t, h.wakes)
637		secondWatchdog.ch <- time.Time{}
638		<-secondForced
639		require.True(t, awaitWake(t, h.wakes).watchdog)
640	})
641}
642
643func TestRenderCoordinatorSyncGateFollowsDynamicRenderability(t *testing.T) {
644	t.Run("inactive batch that becomes active gates activation output until end", func(t *testing.T) {
645		h := newCoordinatorHarness(t)
646		var visible atomic.Bool
647		p := &pane{}
648		h.rc.noteSyncBeginWithRenderability(p, 1, visible.Load)
649		visible.Store(true)
650		h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "activation"})
651		for _, timer := range h.armedTimers(t) {
652			timer.ch <- time.Time{}
653		}
654		requireNoWake(t, h.wakes)
655
656		h.rc.noteSyncEnd(p, 1)
657		w := awaitWake(t, h.wakes)
658		require.True(t, w.urgent)
659		requireNoWake(t, h.wakes)
660	})
661
662	t.Run("hidden batch does not stall newly active output or wake on late end", func(t *testing.T) {
663		h := newCoordinatorHarness(t)
664		var visible atomic.Bool
665		visible.Store(true)
666		p := &pane{}
667		h.rc.noteSyncBeginWithRenderability(p, 1, visible.Load)
668		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "batch"})
669		visible.Store(false)
670		h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "new-active-tab"})
671		timers := h.armedTimers(t)
672		require.NotEmpty(t, timers)
673		timers[len(timers)-1].ch <- time.Time{}
674		w := awaitWake(t, h.wakes)
675		require.False(t, w.watchdog)
676		h.rc.noteSyncEnd(p, 1)
677		requireNoWake(t, h.wakes)
678	})
679}
680
681func TestRenderCoordinatorSyncGateRetriesWhenBatchPublishesDuringPredicate(t *testing.T) {
682	h := newCoordinatorHarness(t)
683	first, second := &pane{}, &pane{}
684	gateEvaluated := make(chan struct{})
685	releaseGate := make(chan struct{})
686	var hookOnce sync.Once
687	h.rc.opts.afterSyncGateEvaluated = func() {
688		hookOnce.Do(func() {
689			close(gateEvaluated)
690			<-releaseGate
691		})
692	}
693	h.rc.noteSyncBeginWithRenderability(first, 1, func() bool { return false })
694	h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "test"})
695
696	fireDone := make(chan struct{})
697	go func() {
698		h.rc.fireCurrent(false)
699		close(fireDone)
700	}()
701	<-gateEvaluated
702	// This publication is deliberately between an open predicate result and
703	// pending consumption. A stale gate result must be rejected and retried.
704	h.rc.noteSyncBeginWithRenderability(second, 2, func() bool { return true })
705	close(releaseGate)
706	awaitTestCompletion(t, fireDone, "fire did not finish after retrying the registry snapshot")
707
708	requireNoWake(t, h.wakes)
709	h.rc.noteSyncEnd(second, 2)
710	require.Equal(t, renderWake{urgent: true, coalesced: 1}, awaitWake(t, h.wakes))
711}
712
713func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
714	t.Run("completion flushes pending state in one wake", func(t *testing.T) {
715		h := newCoordinatorHarness(t)
716		h.syncActive.Store(true)
717		h.rc.noteSyncBegin(nil, 1)
718		watchdogs := h.armedTimers(t)
719		require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
720		require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)
721
722		h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
723		timers := h.armedTimers(t)
724		require.Len(t, timers, 1)
725		h.rc.mu.Lock()
726		normalWorkerDone := h.rc.normalLane.token.done
727		h.rc.mu.Unlock()
728		require.NotNil(t, normalWorkerDone)
729		timers[0].ch <- time.Time{}
730		requireWorkerExit(t, normalWorkerDone)
731		requireNoWake(t, h.wakes)
732
733		h.syncActive.Store(false)
734		h.rc.noteSyncEnd(nil, 1)
735		w := awaitWake(t, h.wakes)
736		require.True(t, w.reset)
737		requireNoWake(t, h.wakes)
738	})
739
740	t.Run("watchdog ends a wedged batch before it flushes", func(t *testing.T) {
741		h := newCoordinatorHarness(t)
742		h.syncActive.Store(true)
743		forced := make(chan struct{}, 1)
744		h.rc.noteSyncBegin(nil, 7, func() { forced <- struct{}{} })
745		watchdogs := h.armedTimers(t)
746		require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
747		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
748
749		watchdogs[0].ch <- time.Time{}
750		<-forced
751		w := awaitWake(t, h.wakes)
752		require.True(t, w.watchdog, "a wedged synchronized batch must be force-flushed by the watchdog")
753		requireNoWake(t, h.wakes)
754	})
755
756	t.Run("watchdog retains gate while force closes the VT batch", func(t *testing.T) {
757		h := newCoordinatorHarness(t)
758		entered := make(chan struct{})
759		release := make(chan struct{})
760		h.rc.noteSyncBegin(nil, 1, func() {
761			close(entered)
762			<-release
763		})
764		watchdogs := h.armedTimers(t)
765		require.NotEmpty(t, watchdogs)
766		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
767		watchdogs[0].ch <- time.Time{}
768		<-entered
769
770		// A concurrent deadline/fire sees the retained batch gate and cannot
771		// publish the partial VT state while force is blocked.
772		h.rc.fireCurrent(false)
773		requireNoWake(t, h.wakes)
774		close(release)
775		w := awaitWake(t, h.wakes)
776		require.True(t, w.watchdog)
777		require.True(t, w.urgent)
778		requireNoWake(t, h.wakes)
779	})
780
781	t.Run("watchdog flushes after the snapshotted generation is replaced", func(t *testing.T) {
782		h := newCoordinatorHarness(t)
783		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
784		h.rc.mu.Lock()
785		staleGeneration := h.rc.normalLane.generation
786		h.rc.mu.Unlock()
787
788		// Simulate a new urgent publication after fireCurrent(true) snapshots its
789		// generation but before fire validates it.
790		h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
791		h.rc.fire(staleGeneration, true, true)
792
793		w := awaitWake(t, h.wakes)
794		require.True(t, w.watchdog)
795		require.True(t, w.urgent)
796		require.Equal(t, 2, w.coalesced)
797		requireNoWake(t, h.wakes)
798	})
799
800	t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
801		h := newCoordinatorHarness(t)
802		h.syncActive.Store(true)
803		h.rc.noteSyncBegin(nil, 1)
804		watchdogs := h.armedTimers(t)
805		require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
806		h.syncActive.Store(false)
807		h.rc.noteSyncEnd(nil, 1)
808
809		watchdogs[0].ch <- time.Time{}
810		requireNoWake(t, h.wakes)
811	})
812}
813
814// --- preview subscription --------------------------------------------------------
815
816func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
817	h := newCoordinatorHarness(t)
818	viewer := &attachedClient{}
819	h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
820
821	h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
822	timers := h.armedTimers(t)
823	require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
824	timers[len(timers)-1].ch <- time.Time{}
825	owner := awaitWake(t, h.wakes)
826	preview := awaitWake(t, h.previews)
827	require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")
828	requireNoWake(t, h.previews)
829
830	h.rc.teardownPreviewFor(viewer, 1)
831	h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
832	timers = h.armedTimers(t)
833	require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
834	timers[len(timers)-1].ch <- time.Time{}
835	awaitWake(t, h.wakes)
836	requireNoWake(t, h.previews)
837}
838
839// --- lifecycle and stale callbacks -----------------------------------------------
840
841func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
842	cases := []struct {
843		name   string
844		attach bool
845	}{
846		{name: "attached cross-session target blocked on ack", attach: true},
847		{name: "headless cross-session target blocked on ack"},
848	}
849	for _, tc := range cases {
850		t.Run(tc.name, func(t *testing.T) {
851			h := newCoordinatorHarness(t)
852			h.ackReady.Store(false)
853			if tc.attach {
854				h.rc.attach(&attachedClient{})
855			}
856			viewer := &attachedClient{}
857			h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
858
859			h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"})
860			timers := h.armedTimers(t)
861			require.Len(t, timers, 1)
862			timers[0].ch <- time.Time{}
863
864			preview := awaitWake(t, h.previews)
865			require.Equal(t, renderWake{coalesced: 1}, preview,
866				"the viewer preview must receive target output without target ACK capacity")
867			requireNoWake(t, h.previews)
868			if tc.attach {
869				requireNoWake(t, h.wakes)
870			} else {
871				wake := awaitWake(t, h.wakes)
872				require.Equal(t, renderWake{coalesced: 1}, wake)
873				require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
874			}
875
876			h.ackReady.Store(true)
877			h.rc.notifyAck()
878			if tc.attach {
879				wake := awaitWake(t, h.wakes)
880				require.Equal(t, renderWake{coalesced: 1}, wake,
881					"the target frame remains pending for its ACK")
882			}
883			requireNoWake(t, h.previews)
884		})
885	}
886}
887
888func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *testing.T) {
889	h := newCoordinatorHarness(t)
890	target, viewer := &attachedClient{}, &attachedClient{}
891	h.ackReady.Store(false)
892	h.rc.attach(target)
893	h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
894	h.rc.noteDetach(target)
895
896	// A detached target only accepts PTY/session-owned invalidations. The old
897	// attachment remains stale and must not revive its render path.
898	h.rc.invalidateForAttachment(target, renderInvalidation{class: invalidateOutput, producer: "stale attachment"})
899	require.Empty(t, h.armedTimers(t))
900
901	for _, producer := range []string{"first PTY output", "second PTY output"} {
902		h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: producer})
903		timers := h.armedTimers(t)
904		require.Len(t, timers, 1)
905		timers[0].ch <- time.Time{}
906		preview := awaitWake(t, h.previews)
907		require.Equal(t, renderWake{coalesced: 1}, preview)
908		wake := awaitWake(t, h.wakes)
909		require.Equal(t, renderWake{coalesced: 1}, wake)
910		require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
911
912		h.rc.mu.Lock()
913		require.False(t, h.rc.pending)
914		require.False(t, h.rc.pendingPreview)
915		require.False(t, h.rc.ackDeferred)
916		require.False(t, h.rc.deadlineDue)
917		require.Zero(t, h.rc.coalesced)
918		require.Empty(t, h.rc.attachments)
919		h.rc.mu.Unlock()
920	}
921
922	// A later attach retains the normal first-paint reset path.
923	replacement := &attachedClient{}
924	h.ackReady.Store(true)
925	h.rc.attach(replacement)
926	h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "first paint"})
927	timers := h.armedTimers(t)
928	require.Len(t, timers, 1)
929	timers[0].ch <- time.Time{}
930	wake := awaitWake(t, h.wakes)
931	require.True(t, wake.reset)
932	require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
933}
934
935func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
936	cases := []struct {
937		name       string
938		transition func(*renderCoordinator, *attachedClient, *attachedClient)
939		// teardownWake is the single revalidation wake a session teardown
940		// gives every preview observer, so a watcher notices the dead target
941		// without waiting for its revalidation timer.
942		teardownWake bool
943	}{
944		{name: "target detach", transition: func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
945		{name: "target detach and attach", transition: func(rc *renderCoordinator, target, replacement *attachedClient) {
946			rc.noteDetach(target)
947			rc.attach(replacement)
948		}},
949		{name: "target teardown", transition: func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.beginSessionTeardown().finish() }, teardownWake: true},
950	}
951	for _, tc := range cases {
952		t.Run(tc.name, func(t *testing.T) {
953			h := newCoordinatorHarness(t)
954			target, replacement, viewer := &attachedClient{}, &attachedClient{}, &attachedClient{}
955			h.rc.attach(target)
956			h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
957			h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"})
958			stale := h.armedTimers(t)
959			require.Len(t, stale, 1)
960
961			tc.transition(h.rc, target, replacement)
962			if tc.teardownWake {
963				require.Equal(t, renderWake{}, awaitWake(t, h.previews))
964				require.False(t, h.rc.hasPreviewSubscribers(), "teardown drops every preview subscription")
965			}
966			stale[0].ch <- time.Time{}
967			requireNoWake(t, h.wakes)
968			requireNoWake(t, h.previews)
969		})
970	}
971}
972
973// TestRenderCoordinatorPreviewObserverGenerations pins the observer-keyed
974// subscription table: a newer generation replaces, an older one never does,
975// and teardown only removes the exact generation it names.
976func TestRenderCoordinatorPreviewObserverGenerations(t *testing.T) {
977	type step struct {
978		subscribe bool
979		gen       uint64
980		wantOK    bool
981	}
982	cases := []struct {
983		name        string
984		steps       []step
985		wantPresent bool
986	}{
987		{name: "subscribe", steps: []step{{subscribe: true, gen: 1, wantOK: true}}, wantPresent: true},
988		{name: "newer generation replaces", steps: []step{{subscribe: true, gen: 1, wantOK: true}, {subscribe: true, gen: 2, wantOK: true}}, wantPresent: true},
989		{name: "older generation refused", steps: []step{{subscribe: true, gen: 2, wantOK: true}, {subscribe: true, gen: 1}}, wantPresent: true},
990		{name: "exact teardown removes", steps: []step{{subscribe: true, gen: 1, wantOK: true}, {gen: 1}}, wantPresent: false},
991		{name: "stale teardown keeps newer", steps: []step{{subscribe: true, gen: 2, wantOK: true}, {gen: 1}}, wantPresent: true},
992	}
993	for _, tc := range cases {
994		t.Run(tc.name, func(t *testing.T) {
995			h := newCoordinatorHarness(t)
996			observer := &remotePreviewWatch{wake: make(chan struct{}, 1)}
997			for _, s := range tc.steps {
998				if s.subscribe {
999					require.Equal(t, s.wantOK, h.rc.subscribePreviewFor(observer, s.gen, observer.notify))
1000				} else {
1001					h.rc.teardownPreviewFor(observer, s.gen)
1002				}
1003			}
1004			require.Equal(t, tc.wantPresent, h.rc.hasPreviewSubscribers())
1005		})
1006	}
1007	t.Run("torn down coordinator refuses", func(t *testing.T) {
1008		h := newCoordinatorHarness(t)
1009		h.rc.beginSessionTeardown().finish()
1010		observer := &remotePreviewWatch{wake: make(chan struct{}, 1)}
1011		require.False(t, h.rc.subscribePreviewFor(observer, 1, observer.notify))
1012	})
1013}
1014
1015func TestRenderCoordinatorPreviewSubscriptionsAreIndependent(t *testing.T) {
1016	h := newCoordinatorHarness(t)
1017	one, two := &attachedClient{}, &attachedClient{}
1018	first, second := make(chan renderWake, 1), make(chan renderWake, 1)
1019	h.rc.subscribePreviewFor(one, 1, func(w renderWake) { first <- w })
1020	h.rc.subscribePreviewFor(two, 1, func(w renderWake) { second <- w })
1021	h.rc.attach(&attachedClient{})
1022	h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"})
1023	last := h.armedTimers(t)
1024	last[len(last)-1].ch <- time.Time{}
1025	awaitWake(t, first)
1026	awaitWake(t, second)
1027
1028	h.rc.teardownPreviewFor(one, 1)
1029	h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"})
1030	last = h.armedTimers(t)
1031	last[len(last)-1].ch <- time.Time{}
1032	requireNoWake(t, first)
1033	awaitWake(t, second)
1034}
1035
1036func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
1037	cases := []struct {
1038		name     string
1039		teardown func(rc *renderCoordinator, owner *attachedClient)
1040	}{
1041		{"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
1042		{"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.beginSessionTeardown().finish() }},
1043	}
1044	for _, tc := range cases {
1045		t.Run(tc.name, func(t *testing.T) {
1046			h := newCoordinatorHarness(t)
1047			owner := &attachedClient{}
1048			h.rc.attach(owner)
1049			h.rc.invalidate(renderInvalidation{class: invalidateOutput})
1050			stale := h.armedTimers(t)
1051			require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
1052
1053			tc.teardown(h.rc, owner)
1054			for _, timer := range stale {
1055				timer.ch <- time.Time{}
1056			}
1057			requireNoWake(t, h.wakes)
1058
1059			h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
1060			for _, timer := range h.armedTimers(t) {
1061				timer.ch <- time.Time{}
1062			}
1063			requireNoWake(t, h.wakes)
1064		})
1065	}
1066
1067	t.Run("replacement attachment wakes independently", func(t *testing.T) {
1068		h := newCoordinatorHarness(t)
1069		owner := &attachedClient{}
1070		replacement := &attachedClient{}
1071		h.rc.attach(owner)
1072		h.rc.invalidate(renderInvalidation{class: invalidateOutput})
1073		stale := h.armedTimers(t)
1074		require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
1075
1076		h.rc.noteDetach(owner)
1077		h.rc.attach(replacement)
1078		for _, timer := range stale {
1079			timer.ch <- time.Time{}
1080		}
1081		requireNoWake(t, h.wakes)
1082
1083		h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true})
1084		fresh := h.armedTimers(t)
1085		require.NotEmpty(t, fresh, "a replacement attachment must arm its own deadline")
1086		fresh[len(fresh)-1].ch <- time.Time{}
1087		w := awaitWake(t, h.wakes)
1088		require.True(t, w.reset, "the replacement starts from an independent full state")
1089		requireNoWake(t, h.wakes)
1090	})
1091}
1092
1093// --- resize metadata ownership -----------------------------------------------------
1094
1095func TestRenderCoordinatorResizeMetadata(t *testing.T) {
1096	sz := func(cols, rows int) domain.Size { return domain.Size{Cols: cols, Rows: rows} }
1097	cases := []struct {
1098		name string
1099		run  func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient)
1100	}{
1101		{
1102			name: "latest request wins with strictly increasing epochs",
1103			run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
1104				require.Equal(t, uint64(1), rc.recordResizeRequest(sz(100, 30), owner))
1105				require.Equal(t, uint64(2), rc.recordResizeRequest(sz(110, 32), owner))
1106				require.Equal(t, uint64(3), rc.recordResizeRequest(sz(120, 40), owner))
1107				snap := rc.resizeSnapshot()
1108				require.Equal(t, sz(120, 40), snap.size)
1109				require.Same(t, owner, snap.source)
1110				require.Equal(t, uint64(3), snap.epoch)
1111
1112				// Resize state is exclusively coordinator-owned.
1113				require.Zero(t, snap.committed)
1114			},
1115		},
1116		{
1117			name: "duplicate sizes still advance the epoch deterministically",
1118			run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
1119				require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
1120				require.Equal(t, uint64(2), rc.recordResizeRequest(sz(120, 40), owner))
1121				snap := rc.resizeSnapshot()
1122				require.Equal(t, sz(120, 40), snap.size)
1123				require.Equal(t, uint64(2), snap.epoch)
1124			},
1125		},
1126		{
1127			name: "stale resize callbacks preserve latest metadata through lifecycle transitions",
1128			run: func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient) {
1129				type lifecycleCase struct {
1130					name       string
1131					installNew bool
1132				}
1133				for _, tc := range []lifecycleCase{
1134					{name: "detach"},
1135					{name: "park"},
1136					{name: "session teardown"},
1137					{name: "replacement", installNew: true},
1138				} {
1139					t.Run(tc.name, func(t *testing.T) {
1140						h := newCoordinatorHarness(t)
1141						staleOwner := &attachedClient{}
1142						freshOwner := &attachedClient{}
1143						h.rc.attach(staleOwner)
1144						require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))
1145
1146						// This models a stale callback which captured the old
1147						// attachment before lifecycle ownership changed.
1148						staleCallback := func() uint64 {
1149							return h.rc.recordResizeRequest(sz(80, 20), staleOwner)
1150						}
1151
1152						switch tc.name {
1153						case "detach":
1154							h.rc.noteDetach(staleOwner)
1155						case "park":
1156							h.rc.noteDetach(staleOwner)
1157						case "session teardown":
1158							h.rc.beginSessionTeardown().finish()
1159						case "replacement":
1160							h.rc.noteDetach(staleOwner)
1161							h.rc.attach(freshOwner)
1162							require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))
1163						}
1164
1165						require.Zero(t, staleCallback(), "stale callback must not advance the resize epoch")
1166						snap := h.rc.resizeSnapshot()
1167						if tc.installNew {
1168							require.Equal(t, sz(100, 50), snap.size)
1169							require.Equal(t, uint64(2), snap.epoch)
1170							require.Same(t, freshOwner, snap.source)
1171							return
1172						}
1173						require.Equal(t, sz(120, 40), snap.size)
1174						require.Equal(t, uint64(1), snap.epoch)
1175						require.Same(t, staleOwner, snap.source,
1176							"the stale callback must not replace the recorded attachment identity")
1177					})
1178				}
1179			},
1180		},
1181	}
1182	for _, tc := range cases {
1183		t.Run(tc.name, func(t *testing.T) {
1184			h := newCoordinatorHarness(t)
1185			owner := &attachedClient{}
1186			replacement := &attachedClient{}
1187			h.rc.attach(owner)
1188			tc.run(t, h.rc, owner, replacement)
1189		})
1190	}
1191}
1192
1193// --- producer fan-in ------------------------------------------------------------
1194
1195// producerFiles is the current production direct-paint inventory. Every file
1196// gets exactly one exercised state transition in TestProducerInvalidations.
1197var producerFiles = []string{
1198	"attention.go", "client.go", "copymode.go", "floating.go", "input.go",
1199	"palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",
1200	"session.go",
1201}
1202
1203func TestProducerInvalidations(t *testing.T) {
1204	cases := []struct {
1205		file     string
1206		name     string
1207		tabs     int
1208		producer string
1209		// interaction marks producers that publish their own control frames
1210		// (the picker offer and snapshot): the coordinator-invalidation
1211		// assertion below covers their repaint, and those frames are pinned
1212		// by the interaction suites.
1213		interaction bool
1214		run         func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
1215	}{
1216		{
1217			file: "attention.go",
1218			name: "attention repaint",
1219			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1220				d.repaintAttachedClients(sess)
1221			},
1222		},
1223		{
1224			file: "client.go",
1225			name: "client theme application",
1226			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1227				d.applyTheme(sess, ac, protocol.Theme{SchemeKnown: true})
1228			},
1229		},
1230		{
1231			file: "copymode.go",
1232			name: "copy mode entry",
1233			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1234				d.enterCopyMode(sess, ac)
1235			},
1236		},
1237		{
1238			file: "floating.go",
1239			name: "floating toggle to visible",
1240			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1241				fp := newPaneWithStableID(layout.PaneID("floating"), "float-producer", newQuietPTY(), domain.Size{Cols: 40, Rows: 10})
1242				tb := testAttachmentTab(sess)
1243				tb.mu.Lock()
1244				tb.floating.pane = fp
1245				tb.floating.state = floatingHidden
1246				tb.floating.generation = 1
1247				tb.mu.Unlock()
1248				require.NoError(t, d.toggleFloating(sess, ac))
1249			},
1250		},
1251		{
1252			file:     "input.go",
1253			name:     "jump attention switches a local tab",
1254			tabs:     2,
1255			producer: "attention.go",
1256			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1257				sess.tabs[1].attention = true
1258				daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil)
1259				require.Equal(t, 1, testAttachmentTabIndex(sess))
1260			},
1261		},
1262		{
1263			file: "palette.go",
1264			name: "palette entry",
1265			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1266				d.enterPalette(sess, ac)
1267			},
1268		},
1269		{
1270			file: "pane_actions.go",
1271			name: "pane split",
1272			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1273				require.NoError(t, d.splitPane(sess, ac, layout.Right))
1274			},
1275		},
1276		{
1277			file:        "picker.go",
1278			name:        "picker entry",
1279			interaction: true,
1280			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1281				effect := admitPickerEffectForTest(t, sess, ac)
1282				require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMovePane, moveSourceLocator{}, 0))
1283			},
1284		},
1285		{
1286			file: "prompt.go",
1287			name: "prompt entry",
1288			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1289				d.enterPrompt(sess, ac, "rename", "", func(string) error { return nil })
1290			},
1291		},
1292		{
1293			file: "render.go",
1294			name: "retained resize dispatch",
1295			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1296				clk := newCoordinatorMockClock(t, 2)
1297				// The coordinator owns deadline time; install the deterministic
1298				// clock on the already-attached coordinator rather than changing
1299				// Daemon's construction-time clock afterwards.
1300				sess.renderCoordinator().opts.clock = clk.clock
1301				d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
1302				timer := awaitCoordinatorScheduledTimer(t, clk)
1303				done := captureResizeCallbackDone(t, sess.renderCoordinator())
1304				timer.ch <- time.Time{}
1305				awaitTestCompletion(t, done, "resize callback did not complete")
1306			},
1307		},
1308		{
1309			file: "session.go",
1310			name: "tab close repaint",
1311			tabs: 2,
1312			run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1313				sess.mu.Lock()
1314				tb := sess.tabs[1]
1315				sess.mu.Unlock()
1316				require.NoError(t, d.closeTab(sess, tb, true))
1317			},
1318		},
1319	}
1320
1321	exercised := make([]string, 0, len(cases))
1322	for _, tc := range cases {
1323		exercised = append(exercised, tc.file)
1324	}
1325	require.ElementsMatch(t, producerFiles, exercised,
1326		"every current direct-paint producer file needs exactly one exercised transition")
1327
1328	for _, tc := range cases {
1329		t.Run(tc.file+"/"+tc.name, func(t *testing.T) {
1330			tabs := tc.tabs
1331			if tabs == 0 {
1332				tabs = 1
1333			}
1334			d, sess, ac, sends, releases := newManualTabSession(t, tabs)
1335			defer releaseAll(releases)
1336			invs := make(chan renderInvalidation, 8)
1337			sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
1338				clock:        d.clock,
1339				wake:         func(renderWake) {},
1340				onInvalidate: func(inv renderInvalidation) { invs <- inv },
1341			}))
1342
1343			tc.run(t, d, sess, ac)
1344
1345			inv := awaitInvalidation(t, invs)
1346			if tc.producer != "" {
1347				require.Equal(t, tc.producer, inv.producer)
1348			}
1349			requireNoInvalidation(t, invs)
1350			if tc.interaction {
1351				// The picker publishes offer/snapshot/restore frames of its own;
1352				// drain them so the frame assertion below covers the repaint.
1353				drainAllFrames(sends)
1354			}
1355			requireNoCoordinatorOutputFrame(t, sends)
1356		})
1357	}
1358}
1359
1360// TestProducerInvalidationInventory is a supplementary inventory guard only:
1361// it keeps producerFiles aligned with the daemon source layout so the
1362// behavioral table above cannot drift silently. It is never acceptance
1363// evidence for coordinator behavior.
1364func TestProducerInvalidationInventory(t *testing.T) {
1365	for _, name := range producerFiles {
1366		_, err := os.Stat(filepath.Join(".", name))
1367		require.NoErrorf(t, err, "producer file %s is gone; update TestProducerInvalidations", name)
1368	}
1369}
1370
1371// --- coordinator resize dispatch ----------------------------------------------
1372
1373func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
1374	p, release := newBlockingPTY(t)
1375	defer release()
1376	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1377	// Exercise the original seam: two fallback paints reach lazy initialization
1378	// together before either can compose.
1379	ac.overlays = nil
1380	ac.overlayOnce = sync.Once{}
1381	start := make(chan struct{})
1382	var wg sync.WaitGroup
1383	wg.Add(2)
1384	for range 2 {
1385		go func() {
1386			defer wg.Done()
1387			<-start
1388			d.paint(sess, ac, true, nil)
1389		}()
1390	}
1391	close(start)
1392	wg.Wait()
1393	require.NotNil(t, ac.overlays)
1394}
1395
1396func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) {
1397	p, release := newBlockingPTY(t)
1398	d, sess, _, _ := newManualSessionWithPTYs(t, p)
1399	tb := testAttachmentTab(sess)
1400	require.NotNil(t, tb)
1401	d.startPaneGoroutines(sess, tb, tb.focusedPane())
1402	release()
1403	select {
1404	case <-waitGroupDone(&d.sessWg):
1405	case <-time.After(time.Second):
1406		t.Fatal("one reader must balance exactly one WaitGroup count")
1407	}
1408}
1409
1410func requireWorkerExit(t *testing.T, done <-chan struct{}) {
1411	t.Helper()
1412	awaitTestCompletion(t, done, "cancelled coordinator timer worker did not exit")
1413}
1414
1415func TestRenderCoordinatorResizeLaneRejectsStaleToken(t *testing.T) {
1416	rc := newRenderCoordinator(renderCoordinatorOptions{})
1417	newer := &timerToken{generation: 2, timer: &portsmocks.MockTimer{}, cancel: make(chan struct{}), done: make(chan struct{})}
1418	stale := &timerToken{generation: 1}
1419
1420	rc.mu.Lock()
1421	rc.resizeLane.generation = 2
1422	rc.resizeLane.token = newer
1423	require.False(t, rc.resizeLane.clearLocked(stale))
1424	require.Same(t, newer, rc.resizeLane.token)
1425	require.True(t, rc.resizeLane.clearLocked(newer))
1426	require.Nil(t, rc.resizeLane.token)
1427	rc.mu.Unlock()
1428}
1429
1430func TestRenderCoordinatorNilClockRetryRunsSynchronously(t *testing.T) {
1431	rc := newRenderCoordinator(renderCoordinatorOptions{})
1432	owner := &attachedClient{}
1433	rc.attach(owner)
1434	lease := rc.attachmentLease(owner)
1435	epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 40}, owner, lease)
1436	require.NotZero(t, epoch)
1437	require.True(t, rc.resizeCurrentForLease(epoch, owner, lease, true))
1438
1439	called := false
1440	rc.scheduleResizeRetryForLease(epoch, owner, lease, func() { called = true })
1441	require.True(t, called, "a nil clock must run the retry without a timer")
1442	rc.mu.Lock()
1443	retryToken := rc.retryLane.token
1444	rc.mu.Unlock()
1445	require.Nil(t, retryToken, "a disabled retry lane must not retain stale completion ownership")
1446}
1447
1448func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
1449	clock := portsmocks.NewMockClock(t)
1450	timer := portsmocks.NewMockTimer(t)
1451	clock.EXPECT().NewTimer(minOutputRenderDeadline).Return(timer).Once()
1452	timer.EXPECT().C().Return((<-chan time.Time)(nil)).Once()
1453	timer.EXPECT().Stop().Return(true).Once()
1454
1455	wakes := make(chan renderWake, 1)
1456	rc := newRenderCoordinator(renderCoordinatorOptions{
1457		clock:       clock,
1458		ackReadyFor: func(*attachedClient) bool { return true },
1459		wake:        func(w renderWake) { wakes <- w },
1460	})
1461	rc.invalidate(renderInvalidation{class: invalidateOutput})
1462
1463	require.Equal(t, renderWake{coalesced: 1}, <-wakes)
1464	require.Equal(t, renderCoordinatorBurstMetricsSnapshot{invalidations: 1, wakes: 1, coalesced: 1}, rc.burstMetricsSnapshot())
1465	rc.mu.Lock()
1466	require.Nil(t, rc.normalLane.token)
1467	rc.mu.Unlock()
1468}
1469
1470func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1471	t.Run("detach retains a gated headless preview until complete", func(t *testing.T) {
1472		for _, transition := range []struct {
1473			name string
1474			run  func(*renderCoordinator, *attachedClient)
1475		}{
1476			{"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1477		} {
1478			t.Run(transition.name, func(t *testing.T) {
1479				h := newCoordinatorHarness(t)
1480				target, viewer, p := &attachedClient{}, &attachedClient{}, &pane{}
1481				h.rc.attach(target)
1482				h.rc.subscribePreviewFor(viewer, 1, func(w renderWake) { h.previews <- w })
1483				h.rc.noteSyncBegin(p, 1)
1484				watchdog := h.armedTimers(t)[0]
1485				transition.run(h.rc, target)
1486
1487				// PTY-owned output remains visible to picker observers while the
1488				// target attachment is parked. Its deadline must retain the sync gate.
1489				h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "complete preview"})
1490				timers := h.armedTimers(t)
1491				require.Len(t, timers, 1)
1492				h.rc.mu.Lock()
1493				normalWorkerDone := h.rc.normalLane.token.done
1494				h.rc.mu.Unlock()
1495				require.NotNil(t, normalWorkerDone)
1496				timers[0].ch <- time.Time{}
1497				requireWorkerExit(t, normalWorkerDone)
1498				requireNoWake(t, h.previews)
1499
1500				h.rc.mu.Lock()
1501				require.Same(t, h.rc.syncBatches[p].lane.token.timer, watchdog.mock)
1502				h.rc.mu.Unlock()
1503				h.rc.noteSyncEnd(p, 1)
1504				preview := awaitWake(t, h.previews)
1505				require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)
1506				requireNoWake(t, h.previews)
1507				wake := awaitWake(t, h.wakes)
1508				require.Equal(t, renderWake{urgent: true, coalesced: 1}, wake)
1509				require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
1510			})
1511		}
1512	})
1513
1514	t.Run("replacement first paint waits for active batch and rejects stale owner", func(t *testing.T) {
1515		h := newCoordinatorHarness(t)
1516		old, replacement, p := &attachedClient{}, &attachedClient{}, &pane{}
1517		h.rc.attach(old)
1518		h.rc.noteSyncBegin(p, 1)
1519		watchdog := h.armedTimers(t)[0]
1520		h.rc.noteDetach(old)
1521		h.rc.attachWithReadiness(replacement, false)
1522		require.False(t, h.rc.markAttachmentReady(h.rc.attachmentLease(old)), "old attachment Welcome is stale")
1523		require.True(t, h.rc.markAttachmentReady(h.rc.attachmentLease(replacement)))
1524
1525		h.rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, producer: "stale old attachment"})
1526		require.Empty(t, h.armedTimers(t))
1527		h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
1528		timers := h.armedTimers(t)
1529		require.Len(t, timers, 1)
1530		h.rc.mu.Lock()
1531		normalWorkerDone := h.rc.normalLane.token.done
1532		h.rc.mu.Unlock()
1533		require.NotNil(t, normalWorkerDone)
1534		timers[0].ch <- time.Time{}
1535		requireWorkerExit(t, normalWorkerDone)
1536		requireNoWake(t, h.wakes)
1537
1538		h.rc.mu.Lock()
1539		require.Same(t, h.rc.syncBatches[p].lane.token.timer, watchdog.mock)
1540		h.rc.mu.Unlock()
1541		h.rc.noteSyncEnd(p, 1)
1542		wake := awaitWake(t, h.wakes)
1543		require.True(t, wake.reset)
1544		require.True(t, wake.urgent)
1545		require.Nil(t, wake.lease, "shared wakes do not carry an attachment lease")
1546		requireNoWake(t, h.wakes)
1547	})
1548
1549	t.Run("pane removal and session teardown cancel sync workers", func(t *testing.T) {
1550		for _, lifecycle := range []struct {
1551			name string
1552			end  func(*renderCoordinator, *pane)
1553		}{
1554			{"pane removal", func(rc *renderCoordinator, p *pane) { rc.noteSyncPaneRemoved(p) }},
1555			{"session teardown", func(rc *renderCoordinator, _ *pane) { rc.beginSessionTeardown().finish() }},
1556		} {
1557			t.Run(lifecycle.name, func(t *testing.T) {
1558				clk := newCoordinatorMockClock(t, 2)
1559				rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock})
1560				p := &pane{}
1561				rc.noteSyncBegin(p, 1)
1562				syncTimer := <-clk.timers
1563				rc.mu.Lock()
1564				syncDone := rc.syncBatches[p].lane.token.done
1565				rc.mu.Unlock()
1566				lifecycle.end(rc, p)
1567				syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
1568				requireWorkerExit(t, syncDone)
1569				if lifecycle.name == "session teardown" {
1570					rc.waitForTimerWorkers()
1571				}
1572			})
1573		}
1574	})
1575}
1576
1577func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1578	newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan wire.Envelope, *coordinatorMockClock, chan renderInvalidation) {
1579		t.Helper()
1580		p, releasePTY := newBlockingPTY(t)
1581		t.Cleanup(releasePTY)
1582		d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1583		clk := newCoordinatorMockClock(t, 4)
1584		d.clock = clk.clock
1585		invs := make(chan renderInvalidation, 4)
1586		rc := newRenderCoordinator(renderCoordinatorOptions{
1587			clock:        clk.clock,
1588			wake:         func(renderWake) {},
1589			onInvalidate: func(inv renderInvalidation) { invs <- inv },
1590		})
1591		rc.attach(ac)
1592		sess.installRenderCoordinator(rc)
1593		return d, sess, ac, sends, clk, invs
1594	}
1595
1596	t.Run("bounded timer records metadata and dispatches through the coordinator", func(t *testing.T) {
1597		d, sess, ac, sends, clk, invs := newResizeFixture(t)
1598
1599		d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
1600		timer := awaitCoordinatorScheduledTimer(t, clk)
1601		require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
1602		require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)
1603		snap := sess.renderCoordinator().resizeSnapshot()
1604		require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,
1605			"the coordinator must record the latest requested resize before delegating")
1606		require.Same(t, ac, snap.source)
1607		require.Equal(t, uint64(1), snap.epoch)
1608		requireNoCoordinatorOutputFrame(t, sends)
1609
1610		done := captureResizeCallbackDone(t, sess.renderCoordinator())
1611		timer.ch <- time.Time{}
1612		awaitTestCompletion(t, done, "resize callback did not complete")
1613		inv := awaitInvalidation(t, invs)
1614		require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
1615		requireNoInvalidation(t, invs)
1616		requireNoCoordinatorOutputFrame(t, sends)
1617	})
1618
1619	t.Run("stale generations stay rejected", func(t *testing.T) {
1620		d, sess, ac, sends, clk, invs := newResizeFixture(t)
1621
1622		d.resize(sess, ac, domain.Size{Cols: 100, Rows: 24})
1623		first := awaitCoordinatorScheduledTimer(t, clk)
1624		d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
1625		latest := awaitCoordinatorScheduledTimer(t, clk)
1626		require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().epoch,
1627			"every resize request must advance the coordinator epoch")
1628
1629		done := captureResizeCallbackDone(t, sess.renderCoordinator())
1630		first.ch <- time.Time{}
1631		requireNoInvalidation(t, invs)
1632		requireNoCoordinatorOutputFrame(t, sends)
1633
1634		latest.ch <- time.Time{}
1635		awaitTestCompletion(t, done, "latest resize callback did not complete")
1636		inv := awaitInvalidation(t, invs)
1637		require.True(t, inv.reset)
1638		requireNoInvalidation(t, invs)
1639		requireNoCoordinatorOutputFrame(t, sends)
1640		require.Equal(t, domain.Size{Cols: 120, Rows: 24}, sess.renderCoordinator().resizeSnapshot().size)
1641	})
1642
1643	t.Run("cancellation still drops the pending dispatch", func(t *testing.T) {
1644		d, sess, ac, sends, clk, invs := newResizeFixture(t)
1645
1646		d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
1647		timer := awaitCoordinatorScheduledTimer(t, clk)
1648		done := captureResizeCallbackDone(t, sess.renderCoordinator())
1649		sess.renderCoordinator().noteDetach(ac)
1650
1651		timer.ch <- time.Time{}
1652		awaitTestCompletion(t, done, "cancelled resize callback did not complete")
1653		requireNoInvalidation(t, invs)
1654		requireNoCoordinatorOutputFrame(t, sends)
1655	})
1656
1657	t.Run("request reports immediate completion and async schedule acceptance", func(t *testing.T) {
1658		t.Run("immediate reset invalidation", func(t *testing.T) {
1659			d, sess, ac, _, _, invs := newResizeFixture(t)
1660
1661			require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, true))
1662			inv := awaitInvalidation(t, invs)
1663			require.True(t, inv.reset)
1664		})
1665
1666		t.Run("superseded immediate transaction", func(t *testing.T) {
1667			resizeStarted := make(chan struct{})
1668			releaseResize := make(chan struct{})
1669			pty := &transactionalResizePTY{onResize: func() {
1670				close(resizeStarted)
1671				<-releaseResize
1672			}}
1673			d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
1674			clk := newCoordinatorMockClock(t, 2)
1675			d.clock = clk.clock
1676			invs := make(chan renderInvalidation, 1)
1677			rc := newRenderCoordinator(renderCoordinatorOptions{
1678				clock:        clk.clock,
1679				wake:         func(renderWake) {},
1680				onInvalidate: func(inv renderInvalidation) { invs <- inv },
1681			})
1682			rc.attach(ac)
1683			sess.installRenderCoordinator(rc)
1684
1685			result := make(chan bool, 1)
1686			go func() {
1687				result <- sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, true)
1688			}()
1689			<-resizeStarted
1690			require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac))
1691			close(releaseResize)
1692
1693			require.False(t, <-result)
1694			requireNoInvalidation(t, invs)
1695		})
1696
1697		t.Run("async schedule and teardown rejection", func(t *testing.T) {
1698			d, sess, ac, _, clk, _ := newResizeFixture(t)
1699
1700			require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, false))
1701			awaitCoordinatorScheduledTimer(t, clk)
1702
1703			// A torn-down coordinator cannot accept a stale attachment's schedule.
1704			sess.renderCoordinator().beginSessionTeardown().finish()
1705			require.False(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1706		})
1707	})
1708}

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