detached dfcd8eb

test(daemon): cover ack deadline isolation

commit
dfcd8eb258ac6ec5dea9eda5221d12492666d124
author
brice · · 2026-07-11 18:55 UTC
parent
0d938b0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +58 -0
M internal/usecase/daemon/render_coordinator_test.go +58 -0
@@ -313,6 +313,64 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
313313
314314 // --- ACK gating ----------------------------------------------------------------
315315
316+func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) {
317+ h := newCoordinatorHarness(t)
318+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
319+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
320+ require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
321+
322+ // An unrelated ACK must not turn a normal output deadline into an
323+ // immediate wake or cancel its timer.
324+ h.rc.notifyAck()
325+ requireNoWake(t, h.wakes)
326+ timer.mock.AssertNotCalled(t, "Stop")
327+
328+ timer.ch <- time.Time{}
329+ awaitWake(t, h.wakes)
330+ requireNoWake(t, h.wakes)
331+}
332+
333+func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
334+ t.Run("expired deadline flushes exactly once after readiness", func(t *testing.T) {
335+ h := newCoordinatorHarness(t)
336+ h.ackReady.Store(false)
337+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "render.go"})
338+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
339+ timer.ch <- time.Time{}
340+ requireNoWake(t, h.wakes)
341+
342+ // Repeated ACKs while capacity remains unavailable are no-ops.
343+ h.rc.notifyAck()
344+ requireNoWake(t, h.wakes)
345+ h.ackReady.Store(true)
346+ h.rc.notifyAck()
347+ w := awaitWake(t, h.wakes)
348+ require.True(t, w.reset)
349+ requireNoWake(t, h.wakes)
350+ h.rc.notifyAck()
351+ requireNoWake(t, h.wakes)
352+ })
353+
354+ t.Run("lifecycle clears deferred work and urgent explicit fires stay immediate", func(t *testing.T) {
355+ h := newCoordinatorHarness(t)
356+ h.ackReady.Store(false)
357+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
358+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
359+ timer.ch <- time.Time{}
360+ requireNoWake(t, h.wakes)
361+ h.rc.noteSessionTeardown()
362+ h.ackReady.Store(true)
363+ h.rc.notifyAck()
364+ requireNoWake(t, h.wakes)
365+
366+ immediate := newCoordinatorHarness(t)
367+ immediate.rc.invalidate(renderInvalidation{class: invalidateUrgent})
368+ immediate.rc.fireCurrent(false)
369+ w := awaitWake(t, immediate.wakes)
370+ require.True(t, w.urgent)
371+ })
372+}
373+
316374 func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {
317375 h := newCoordinatorHarness(t)
318376 h.ackReady.Store(false)
@@ -313,6 +313,64 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
313 313
314 // --- ACK gating ----------------------------------------------------------------314 // --- ACK gating ----------------------------------------------------------------
315 315
316+func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) {
317+ h := newCoordinatorHarness(t)
318+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
319+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
320+ require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
321+
322+ // An unrelated ACK must not turn a normal output deadline into an
323+ // immediate wake or cancel its timer.
324+ h.rc.notifyAck()
325+ requireNoWake(t, h.wakes)
326+ timer.mock.AssertNotCalled(t, "Stop")
327+
328+ timer.ch <- time.Time{}
329+ awaitWake(t, h.wakes)
330+ requireNoWake(t, h.wakes)
331+}
332+
333+func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
334+ t.Run("expired deadline flushes exactly once after readiness", func(t *testing.T) {
335+ h := newCoordinatorHarness(t)
336+ h.ackReady.Store(false)
337+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "render.go"})
338+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
339+ timer.ch <- time.Time{}
340+ requireNoWake(t, h.wakes)
341+
342+ // Repeated ACKs while capacity remains unavailable are no-ops.
343+ h.rc.notifyAck()
344+ requireNoWake(t, h.wakes)
345+ h.ackReady.Store(true)
346+ h.rc.notifyAck()
347+ w := awaitWake(t, h.wakes)
348+ require.True(t, w.reset)
349+ requireNoWake(t, h.wakes)
350+ h.rc.notifyAck()
351+ requireNoWake(t, h.wakes)
352+ })
353+
354+ t.Run("lifecycle clears deferred work and urgent explicit fires stay immediate", func(t *testing.T) {
355+ h := newCoordinatorHarness(t)
356+ h.ackReady.Store(false)
357+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
358+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
359+ timer.ch <- time.Time{}
360+ requireNoWake(t, h.wakes)
361+ h.rc.noteSessionTeardown()
362+ h.ackReady.Store(true)
363+ h.rc.notifyAck()
364+ requireNoWake(t, h.wakes)
365+
366+ immediate := newCoordinatorHarness(t)
367+ immediate.rc.invalidate(renderInvalidation{class: invalidateUrgent})
368+ immediate.rc.fireCurrent(false)
369+ w := awaitWake(t, immediate.wakes)
370+ require.True(t, w.urgent)
371+ })
372+}
373+
316 func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {374 func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {
317 h := newCoordinatorHarness(t)375 h := newCoordinatorHarness(t)
318 h.ackReady.Store(false)376 h.ackReady.Store(false)

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