detached 6ad62fa

test(daemon): cover concurrent synchronized panes

commit
6ad62fa0e72daf4397922b06698fad94d1f99074
author
brice · · 2026-07-11 18:41 UTC
parent
0a4727b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
338338
339339 // --- synchronized output ---------------------------------------------------------
340340
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+
341392 func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
342393 t.Run("completion flushes pending state in one wake", func(t *testing.T) {
343394 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)

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