detached 8d4da10

test(daemon): cover sync batches across attachment lifecycle

commit
8d4da102a21eb78a45162def2d067cc228804521
author
brice · · 2026-07-11 21:50 UTC
parent
5d071d6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
101101 rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm
102102 rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion
103103 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
105106 }
106107
107108 // coordinatorHarness wires one coordinator to generated clock/timer mocks and
@@ -1330,31 +1331,98 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T
13301331 rc.mu.Unlock()
13311332 }
13321333
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+ })
13581426 }
13591427
13601428 func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
@@ -101,7 +101,8 @@ func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {
101 rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm101 rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm
102 rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion102 rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion
103 rc.noteSyncBegin(nil, 1) // sync arm103 rc.noteSyncBegin(nil, 1) // sync arm
104- rc.noteDetach(ac) // cancel and stop104+ 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 and108 // 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 string1336+ 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.timers1350+ transition.run(h.rc, target)
1350- rc.mu.Lock()1351+
1351- syncDone := rc.syncBatches[nil].done1352+ // 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close