test(daemon): cover sync batches across attachment lifecycle
- commit
- 8d4da102a21eb78a45162def2d067cc228804521
- author
- brice · · 2026-07-11 21:50 UTC
- parent
- 5d071d6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +94 -26
M internal/usecase/daemon/render_coordinator_test.go +94 -26
| @@ -101,7 +101,8 @@ func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) { | |||
| 101 | 101 | rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm | |
| 102 | 102 | rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion | |
| 103 | 103 | rc.noteSyncBegin(nil, 1) // sync arm | |
| 104 | - | rc.noteDetach(ac) // cancel and stop | |
| 104 | + | rc.noteDetach(ac) // cancel normal deadline only | |
| 105 | + | rc.noteSyncPaneRemoved(nil) // pane destruction cancels sync watchdog | |
| 105 | 106 | } | |
| 106 | 107 | ||
| 107 | 108 | // coordinatorHarness wires one coordinator to generated clock/timer mocks and | |
| @@ -1330,31 +1331,98 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T | |||
| 1330 | 1331 | rc.mu.Unlock() | |
| 1331 | 1332 | } | |
| 1332 | 1333 | ||
| 1333 | - | func TestRenderCoordinatorStopsInertSyncTimerWorkers(t *testing.T) { | |
| 1334 | - | lifecycle := []struct { | |
| 1335 | - | name string | |
| 1336 | - | end func(*renderCoordinator, *attachedClient) | |
| 1337 | - | }{ | |
| 1338 | - | {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }}, | |
| 1339 | - | {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }}, | |
| 1340 | - | {"teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.noteSessionTeardown() }}, | |
| 1341 | - | } | |
| 1342 | - | for _, tc := range lifecycle { | |
| 1343 | - | t.Run(tc.name, func(t *testing.T) { | |
| 1344 | - | clk := newInertCoordinatorMockClock(t, 2) | |
| 1345 | - | rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock}) | |
| 1346 | - | ac := &attachedClient{} | |
| 1347 | - | rc.attach(ac) | |
| 1348 | - | rc.noteSyncBegin(nil, 1) | |
| 1349 | - | syncTimer := <-clk.timers | |
| 1350 | - | rc.mu.Lock() | |
| 1351 | - | syncDone := rc.syncBatches[nil].done | |
| 1352 | - | rc.mu.Unlock() | |
| 1353 | - | tc.end(rc, ac) | |
| 1354 | - | syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | |
| 1355 | - | requireWorkerExit(t, syncDone) | |
| 1356 | - | }) | |
| 1357 | - | } | |
| 1334 | + | func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) { | |
| 1335 | + | t.Run("detach and park retain a gated headless preview until complete", func(t *testing.T) { | |
| 1336 | + | for _, transition := range []struct { | |
| 1337 | + | name string | |
| 1338 | + | run func(*renderCoordinator, *attachedClient) | |
| 1339 | + | }{ | |
| 1340 | + | {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }}, | |
| 1341 | + | {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }}, | |
| 1342 | + | } { | |
| 1343 | + | t.Run(transition.name, func(t *testing.T) { | |
| 1344 | + | h := newCoordinatorHarness(t) | |
| 1345 | + | target, viewer, p := &attachedClient{}, &attachedClient{}, &pane{} | |
| 1346 | + | h.rc.attach(target) | |
| 1347 | + | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | |
| 1348 | + | h.rc.noteSyncBegin(p, 1) | |
| 1349 | + | watchdog := h.armedTimers(t)[0] | |
| 1350 | + | transition.run(h.rc, target) | |
| 1351 | + | ||
| 1352 | + | // PTY-owned output remains visible to picker observers while the | |
| 1353 | + | // target attachment is parked. Its deadline must retain the sync gate. | |
| 1354 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "complete preview"}) | |
| 1355 | + | timers := h.armedTimers(t) | |
| 1356 | + | require.Len(t, timers, 1) | |
| 1357 | + | timers[0].ch <- time.Time{} | |
| 1358 | + | requireNoWake(t, h.previews) | |
| 1359 | + | ||
| 1360 | + | h.rc.mu.Lock() | |
| 1361 | + | require.Same(t, h.rc.syncBatches[p].timer, watchdog.mock) | |
| 1362 | + | h.rc.mu.Unlock() | |
| 1363 | + | h.rc.noteSyncEnd(p, 1) | |
| 1364 | + | preview := awaitWake(t, h.previews) | |
| 1365 | + | preview.attachment = nil | |
| 1366 | + | preview.attachmentEpoch = 0 | |
| 1367 | + | require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview) | |
| 1368 | + | requireNoWake(t, h.previews) | |
| 1369 | + | requireNoWake(t, h.wakes) | |
| 1370 | + | }) | |
| 1371 | + | } | |
| 1372 | + | }) | |
| 1373 | + | ||
| 1374 | + | t.Run("replacement first paint waits for active batch and rejects stale owner", func(t *testing.T) { | |
| 1375 | + | h := newCoordinatorHarness(t) | |
| 1376 | + | old, replacement, p := &attachedClient{}, &attachedClient{}, &pane{} | |
| 1377 | + | h.rc.attach(old) | |
| 1378 | + | h.rc.noteSyncBegin(p, 1) | |
| 1379 | + | watchdog := h.armedTimers(t)[0] | |
| 1380 | + | h.rc.noteReplace(old, replacement, false) | |
| 1381 | + | require.False(t, h.rc.markAttachmentReady(old), "old attachment Welcome is stale") | |
| 1382 | + | require.True(t, h.rc.markAttachmentReady(replacement)) | |
| 1383 | + | ||
| 1384 | + | h.rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, producer: "stale old attachment"}) | |
| 1385 | + | require.Empty(t, h.armedTimers(t)) | |
| 1386 | + | h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"}) | |
| 1387 | + | timers := h.armedTimers(t) | |
| 1388 | + | require.Len(t, timers, 1) | |
| 1389 | + | timers[0].ch <- time.Time{} | |
| 1390 | + | requireNoWake(t, h.wakes) | |
| 1391 | + | ||
| 1392 | + | h.rc.mu.Lock() | |
| 1393 | + | require.Same(t, h.rc.syncBatches[p].timer, watchdog.mock) | |
| 1394 | + | h.rc.mu.Unlock() | |
| 1395 | + | h.rc.noteSyncEnd(p, 1) | |
| 1396 | + | wake := awaitWake(t, h.wakes) | |
| 1397 | + | require.True(t, wake.reset) | |
| 1398 | + | require.True(t, wake.urgent) | |
| 1399 | + | require.Same(t, replacement, wake.attachment) | |
| 1400 | + | requireNoWake(t, h.wakes) | |
| 1401 | + | }) | |
| 1402 | + | ||
| 1403 | + | t.Run("pane removal and session teardown cancel sync workers", func(t *testing.T) { | |
| 1404 | + | for _, lifecycle := range []struct { | |
| 1405 | + | name string | |
| 1406 | + | end func(*renderCoordinator, *pane) | |
| 1407 | + | }{ | |
| 1408 | + | {"pane removal", func(rc *renderCoordinator, p *pane) { rc.noteSyncPaneRemoved(p) }}, | |
| 1409 | + | {"session teardown", func(rc *renderCoordinator, _ *pane) { rc.noteSessionTeardown() }}, | |
| 1410 | + | } { | |
| 1411 | + | t.Run(lifecycle.name, func(t *testing.T) { | |
| 1412 | + | clk := newInertCoordinatorMockClock(t, 2) | |
| 1413 | + | rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock}) | |
| 1414 | + | p := &pane{} | |
| 1415 | + | rc.noteSyncBegin(p, 1) | |
| 1416 | + | syncTimer := <-clk.timers | |
| 1417 | + | rc.mu.Lock() | |
| 1418 | + | syncDone := rc.syncBatches[p].done | |
| 1419 | + | rc.mu.Unlock() | |
| 1420 | + | lifecycle.end(rc, p) | |
| 1421 | + | syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | |
| 1422 | + | requireWorkerExit(t, syncDone) | |
| 1423 | + | }) | |
| 1424 | + | } | |
| 1425 | + | }) | |
| 1358 | 1426 | } | |
| 1359 | 1427 | ||
| 1360 | 1428 | func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | |
| @@ -101,7 +101,8 @@ func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) { | |||||
| 101 | rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm | 101 | rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm | ||
| 102 | rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion | 102 | rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion | ||
| 103 | rc.noteSyncBegin(nil, 1) // sync arm | 103 | rc.noteSyncBegin(nil, 1) // sync arm | ||
| 104 | - | rc.noteDetach(ac) // cancel and stop | 104 | + | rc.noteDetach(ac) // cancel normal deadline only |
| 105 | + | rc.noteSyncPaneRemoved(nil) // pane destruction cancels sync watchdog | |||
| 105 | } | 106 | } | ||
| 106 | 107 | ||||
| 107 | // coordinatorHarness wires one coordinator to generated clock/timer mocks and | 108 | // coordinatorHarness wires one coordinator to generated clock/timer mocks and | ||
| @@ -1330,31 +1331,98 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T | |||||
| 1330 | rc.mu.Unlock() | 1331 | rc.mu.Unlock() | ||
| 1331 | } | 1332 | } | ||
| 1332 | 1333 | ||||
| 1333 | - | func TestRenderCoordinatorStopsInertSyncTimerWorkers(t *testing.T) { | 1334 | + | func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) { |
| 1334 | - | lifecycle := []struct { | 1335 | + | t.Run("detach and park retain a gated headless preview until complete", func(t *testing.T) { |
| 1335 | - | name string | 1336 | + | for _, transition := range []struct { |
| 1336 | - | end func(*renderCoordinator, *attachedClient) | 1337 | + | name string |
| 1337 | - | }{ | 1338 | + | run func(*renderCoordinator, *attachedClient) |
| 1338 | - | {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }}, | 1339 | + | }{ |
| 1339 | - | {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }}, | 1340 | + | {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }}, |
| 1340 | - | {"teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.noteSessionTeardown() }}, | 1341 | + | {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }}, |
| 1341 | - | } | 1342 | + | } { |
| 1342 | - | for _, tc := range lifecycle { | 1343 | + | t.Run(transition.name, func(t *testing.T) { |
| 1343 | - | t.Run(tc.name, func(t *testing.T) { | 1344 | + | h := newCoordinatorHarness(t) |
| 1344 | - | clk := newInertCoordinatorMockClock(t, 2) | 1345 | + | target, viewer, p := &attachedClient{}, &attachedClient{}, &pane{} |
| 1345 | - | rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock}) | 1346 | + | h.rc.attach(target) |
| 1346 | - | ac := &attachedClient{} | 1347 | + | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) |
| 1347 | - | rc.attach(ac) | 1348 | + | h.rc.noteSyncBegin(p, 1) |
| 1348 | - | rc.noteSyncBegin(nil, 1) | 1349 | + | watchdog := h.armedTimers(t)[0] |
| 1349 | - | syncTimer := <-clk.timers | 1350 | + | transition.run(h.rc, target) |
| 1350 | - | rc.mu.Lock() | 1351 | + | |
| 1351 | - | syncDone := rc.syncBatches[nil].done | 1352 | + | // PTY-owned output remains visible to picker observers while the |
| 1352 | - | rc.mu.Unlock() | 1353 | + | // target attachment is parked. Its deadline must retain the sync gate. |
| 1353 | - | tc.end(rc, ac) | 1354 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "complete preview"}) |
| 1354 | - | syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | 1355 | + | timers := h.armedTimers(t) |
| 1355 | - | requireWorkerExit(t, syncDone) | 1356 | + | require.Len(t, timers, 1) |
| 1356 | - | }) | 1357 | + | timers[0].ch <- time.Time{} |
| 1357 | - | } | 1358 | + | requireNoWake(t, h.previews) |
| 1359 | + | ||||
| 1360 | + | h.rc.mu.Lock() | |||
| 1361 | + | require.Same(t, h.rc.syncBatches[p].timer, watchdog.mock) | |||
| 1362 | + | h.rc.mu.Unlock() | |||
| 1363 | + | h.rc.noteSyncEnd(p, 1) | |||
| 1364 | + | preview := awaitWake(t, h.previews) | |||
| 1365 | + | preview.attachment = nil | |||
| 1366 | + | preview.attachmentEpoch = 0 | |||
| 1367 | + | require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview) | |||
| 1368 | + | requireNoWake(t, h.previews) | |||
| 1369 | + | requireNoWake(t, h.wakes) | |||
| 1370 | + | }) | |||
| 1371 | + | } | |||
| 1372 | + | }) | |||
| 1373 | + | ||||
| 1374 | + | t.Run("replacement first paint waits for active batch and rejects stale owner", func(t *testing.T) { | |||
| 1375 | + | h := newCoordinatorHarness(t) | |||
| 1376 | + | old, replacement, p := &attachedClient{}, &attachedClient{}, &pane{} | |||
| 1377 | + | h.rc.attach(old) | |||
| 1378 | + | h.rc.noteSyncBegin(p, 1) | |||
| 1379 | + | watchdog := h.armedTimers(t)[0] | |||
| 1380 | + | h.rc.noteReplace(old, replacement, false) | |||
| 1381 | + | require.False(t, h.rc.markAttachmentReady(old), "old attachment Welcome is stale") | |||
| 1382 | + | require.True(t, h.rc.markAttachmentReady(replacement)) | |||
| 1383 | + | ||||
| 1384 | + | h.rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, producer: "stale old attachment"}) | |||
| 1385 | + | require.Empty(t, h.armedTimers(t)) | |||
| 1386 | + | h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"}) | |||
| 1387 | + | timers := h.armedTimers(t) | |||
| 1388 | + | require.Len(t, timers, 1) | |||
| 1389 | + | timers[0].ch <- time.Time{} | |||
| 1390 | + | requireNoWake(t, h.wakes) | |||
| 1391 | + | ||||
| 1392 | + | h.rc.mu.Lock() | |||
| 1393 | + | require.Same(t, h.rc.syncBatches[p].timer, watchdog.mock) | |||
| 1394 | + | h.rc.mu.Unlock() | |||
| 1395 | + | h.rc.noteSyncEnd(p, 1) | |||
| 1396 | + | wake := awaitWake(t, h.wakes) | |||
| 1397 | + | require.True(t, wake.reset) | |||
| 1398 | + | require.True(t, wake.urgent) | |||
| 1399 | + | require.Same(t, replacement, wake.attachment) | |||
| 1400 | + | requireNoWake(t, h.wakes) | |||
| 1401 | + | }) | |||
| 1402 | + | ||||
| 1403 | + | t.Run("pane removal and session teardown cancel sync workers", func(t *testing.T) { | |||
| 1404 | + | for _, lifecycle := range []struct { | |||
| 1405 | + | name string | |||
| 1406 | + | end func(*renderCoordinator, *pane) | |||
| 1407 | + | }{ | |||
| 1408 | + | {"pane removal", func(rc *renderCoordinator, p *pane) { rc.noteSyncPaneRemoved(p) }}, | |||
| 1409 | + | {"session teardown", func(rc *renderCoordinator, _ *pane) { rc.noteSessionTeardown() }}, | |||
| 1410 | + | } { | |||
| 1411 | + | t.Run(lifecycle.name, func(t *testing.T) { | |||
| 1412 | + | clk := newInertCoordinatorMockClock(t, 2) | |||
| 1413 | + | rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock}) | |||
| 1414 | + | p := &pane{} | |||
| 1415 | + | rc.noteSyncBegin(p, 1) | |||
| 1416 | + | syncTimer := <-clk.timers | |||
| 1417 | + | rc.mu.Lock() | |||
| 1418 | + | syncDone := rc.syncBatches[p].done | |||
| 1419 | + | rc.mu.Unlock() | |||
| 1420 | + | lifecycle.end(rc, p) | |||
| 1421 | + | syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | |||
| 1422 | + | requireWorkerExit(t, syncDone) | |||
| 1423 | + | }) | |||
| 1424 | + | } | |||
| 1425 | + | }) | |||
| 1358 | } | 1426 | } | ||
| 1359 | 1427 | ||||
| 1360 | func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | 1428 | func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | ||