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}