test(daemon): cover concurrent synchronized panes
- commit
- 6ad62fa0e72daf4397922b06698fad94d1f99074
- author
- brice · · 2026-07-11 18:41 UTC
- parent
- 0a4727b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +51 -0
M internal/usecase/daemon/render_coordinator_test.go +51 -0
| @@ -338,6 +338,57 @@ func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) { | |||
| 338 | 338 | ||
| 339 | 339 | // --- synchronized output --------------------------------------------------------- | |
| 340 | 340 | ||
| 341 | + | func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) { | |
| 342 | + | t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) { | |
| 343 | + | h := newCoordinatorHarness(t) | |
| 344 | + | firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1) | |
| 345 | + | ||
| 346 | + | // Distinct generations model distinct panes. The coordinator must retain | |
| 347 | + | // both batches rather than letting the later begin replace the first. | |
| 348 | + | h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} }) | |
| 349 | + | firstWatchdog := h.armedTimers(t)[0] | |
| 350 | + | h.rc.noteSyncBegin(22, func() { secondForced <- struct{}{} }) | |
| 351 | + | secondWatchdog := h.armedTimers(t)[0] | |
| 352 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 353 | + | ||
| 354 | + | // Completing either pane leaves the aggregate gate closed. | |
| 355 | + | h.rc.noteSyncEnd(11) | |
| 356 | + | requireNoWake(t, h.wakes) | |
| 357 | + | // A stale callback for the completed pane must not force it or wake. | |
| 358 | + | firstWatchdog.ch <- time.Time{} | |
| 359 | + | select { | |
| 360 | + | case <-firstForced: | |
| 361 | + | t.Fatal("stale watchdog forced a completed pane") | |
| 362 | + | default: | |
| 363 | + | } | |
| 364 | + | requireNoWake(t, h.wakes) | |
| 365 | + | ||
| 366 | + | // The remaining pane's watchdog forces only its own batch, then the | |
| 367 | + | // aggregate completion produces exactly one urgent wake. | |
| 368 | + | secondWatchdog.ch <- time.Time{} | |
| 369 | + | <-secondForced | |
| 370 | + | w := awaitWake(t, h.wakes) | |
| 371 | + | require.True(t, w.watchdog) | |
| 372 | + | requireNoWake(t, h.wakes) | |
| 373 | + | }) | |
| 374 | + | ||
| 375 | + | t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) { | |
| 376 | + | h := newCoordinatorHarness(t) | |
| 377 | + | firstForced := make(chan struct{}, 1) | |
| 378 | + | h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} }) | |
| 379 | + | firstWatchdog := h.armedTimers(t)[0] | |
| 380 | + | h.rc.noteSyncBegin(22) | |
| 381 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 382 | + | ||
| 383 | + | h.rc.noteSyncEnd(22) | |
| 384 | + | requireNoWake(t, h.wakes) | |
| 385 | + | firstWatchdog.ch <- time.Time{} | |
| 386 | + | <-firstForced | |
| 387 | + | require.True(t, awaitWake(t, h.wakes).watchdog) | |
| 388 | + | requireNoWake(t, h.wakes) | |
| 389 | + | }) | |
| 390 | + | } | |
| 391 | + | ||
| 341 | 392 | func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |
| 342 | 393 | t.Run("completion flushes pending state in one wake", func(t *testing.T) { | |
| 343 | 394 | h := newCoordinatorHarness(t) | |
| @@ -338,6 +338,57 @@ func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) { | |||||
| 338 | 338 | ||||
| 339 | // --- synchronized output --------------------------------------------------------- | 339 | // --- synchronized output --------------------------------------------------------- | ||
| 340 | 340 | ||||
| 341 | + | func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) { | |||
| 342 | + | t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) { | |||
| 343 | + | h := newCoordinatorHarness(t) | |||
| 344 | + | firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1) | |||
| 345 | + | ||||
| 346 | + | // Distinct generations model distinct panes. The coordinator must retain | |||
| 347 | + | // both batches rather than letting the later begin replace the first. | |||
| 348 | + | h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} }) | |||
| 349 | + | firstWatchdog := h.armedTimers(t)[0] | |||
| 350 | + | h.rc.noteSyncBegin(22, func() { secondForced <- struct{}{} }) | |||
| 351 | + | secondWatchdog := h.armedTimers(t)[0] | |||
| 352 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |||
| 353 | + | ||||
| 354 | + | // Completing either pane leaves the aggregate gate closed. | |||
| 355 | + | h.rc.noteSyncEnd(11) | |||
| 356 | + | requireNoWake(t, h.wakes) | |||
| 357 | + | // A stale callback for the completed pane must not force it or wake. | |||
| 358 | + | firstWatchdog.ch <- time.Time{} | |||
| 359 | + | select { | |||
| 360 | + | case <-firstForced: | |||
| 361 | + | t.Fatal("stale watchdog forced a completed pane") | |||
| 362 | + | default: | |||
| 363 | + | } | |||
| 364 | + | requireNoWake(t, h.wakes) | |||
| 365 | + | ||||
| 366 | + | // The remaining pane's watchdog forces only its own batch, then the | |||
| 367 | + | // aggregate completion produces exactly one urgent wake. | |||
| 368 | + | secondWatchdog.ch <- time.Time{} | |||
| 369 | + | <-secondForced | |||
| 370 | + | w := awaitWake(t, h.wakes) | |||
| 371 | + | require.True(t, w.watchdog) | |||
| 372 | + | requireNoWake(t, h.wakes) | |||
| 373 | + | }) | |||
| 374 | + | ||||
| 375 | + | t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) { | |||
| 376 | + | h := newCoordinatorHarness(t) | |||
| 377 | + | firstForced := make(chan struct{}, 1) | |||
| 378 | + | h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} }) | |||
| 379 | + | firstWatchdog := h.armedTimers(t)[0] | |||
| 380 | + | h.rc.noteSyncBegin(22) | |||
| 381 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |||
| 382 | + | ||||
| 383 | + | h.rc.noteSyncEnd(22) | |||
| 384 | + | requireNoWake(t, h.wakes) | |||
| 385 | + | firstWatchdog.ch <- time.Time{} | |||
| 386 | + | <-firstForced | |||
| 387 | + | require.True(t, awaitWake(t, h.wakes).watchdog) | |||
| 388 | + | requireNoWake(t, h.wakes) | |||
| 389 | + | }) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 341 | func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | 392 | func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | ||
| 342 | t.Run("completion flushes pending state in one wake", func(t *testing.T) { | 393 | t.Run("completion flushes pending state in one wake", func(t *testing.T) { | ||
| 343 | h := newCoordinatorHarness(t) | 394 | h := newCoordinatorHarness(t) | ||