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