detached c842af7

fix(render): preserve watchdog flush semantics

commit
c842af759411bf98cd0fbc0d1576e8da58286012
author
brice · · 2026-07-14 04:33 UTC
parent
a7dcf8b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +30 -9
M internal/usecase/daemon/output_state_sync_test.go +4 -2
@@ -370,6 +370,7 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
370370 case <-time.After(time.Second):
371371 t.Fatal("first resize did not schedule a bounded paint")
372372 }
373+ firstDone := captureResizeCallbackDone(t, sess.renderCoordinator())
373374 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
374375 var latest *signalTimer
375376 select {
@@ -378,11 +379,12 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
378379 t.Fatal("latest resize did not replace the bounded paint")
379380 }
380381
381- done := captureResizeCallbackDone(t, sess.renderCoordinator())
382+ latestDone := captureResizeCallbackDone(t, sess.renderCoordinator())
382383 first.ch <- time.Now()
384+ awaitTestCompletion(t, firstDone, "obsolete resize callback did not complete")
383385 requireNoOutputFrame(t, sends)
384386 latest.ch <- time.Now()
385- awaitTestCompletion(t, done, "latest resize callback did not complete")
387+ awaitTestCompletion(t, latestDone, "latest resize callback did not complete")
386388 awaitFrame(t, sends, ports.MsgOutput)
387389 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
388390 require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)
@@ -370,6 +370,7 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
370 case <-time.After(time.Second):370 case <-time.After(time.Second):
371 t.Fatal("first resize did not schedule a bounded paint")371 t.Fatal("first resize did not schedule a bounded paint")
372 }372 }
373+ firstDone := captureResizeCallbackDone(t, sess.renderCoordinator())
373 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})374 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
374 var latest *signalTimer375 var latest *signalTimer
375 select {376 select {
@@ -378,11 +379,12 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
378 t.Fatal("latest resize did not replace the bounded paint")379 t.Fatal("latest resize did not replace the bounded paint")
379 }380 }
380 381
381- done := captureResizeCallbackDone(t, sess.renderCoordinator())382+ latestDone := captureResizeCallbackDone(t, sess.renderCoordinator())
382 first.ch <- time.Now()383 first.ch <- time.Now()
384+ awaitTestCompletion(t, firstDone, "obsolete resize callback did not complete")
383 requireNoOutputFrame(t, sends)385 requireNoOutputFrame(t, sends)
384 latest.ch <- time.Now()386 latest.ch <- time.Now()
385- awaitTestCompletion(t, done, "latest resize callback did not complete")387+ awaitTestCompletion(t, latestDone, "latest resize callback did not complete")
386 awaitFrame(t, sends, ports.MsgOutput)388 awaitFrame(t, sends, ports.MsgOutput)
387 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)389 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
388 require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)390 require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)
M internal/usecase/daemon/render_coordinator.go +3 -3
@@ -215,14 +215,14 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
215215 }
216216 c.mu.Unlock()
217217 if !valid {
218- stopDetachedTimer(&timerToken{timer: timer})
218+ timer.Stop()
219219 if onInvalidate != nil {
220220 onInvalidate(inv)
221221 }
222222 return true
223223 }
224224 if timerC == nil {
225- stopDetachedTimer(&timerToken{timer: timer})
225+ timer.Stop()
226226 c.fire(gen, false, true)
227227 if onInvalidate != nil {
228228 onInvalidate(inv)
@@ -707,7 +707,7 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
707707
708708 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
709709 c.mu.Lock()
710- if c.normalLane.generation != gen {
710+ if !watchdog && c.normalLane.generation != gen {
711711 c.mu.Unlock()
712712 return
713713 }
@@ -215,14 +215,14 @@ func (c *renderCoordinator) invalidateForLeaseAtResizeEpoch(source *attachedClie
215 }215 }
216 c.mu.Unlock()216 c.mu.Unlock()
217 if !valid {217 if !valid {
218- stopDetachedTimer(&timerToken{timer: timer})218+ timer.Stop()
219 if onInvalidate != nil {219 if onInvalidate != nil {
220 onInvalidate(inv)220 onInvalidate(inv)
221 }221 }
222 return true222 return true
223 }223 }
224 if timerC == nil {224 if timerC == nil {
225- stopDetachedTimer(&timerToken{timer: timer})225+ timer.Stop()
226 c.fire(gen, false, true)226 c.fire(gen, false, true)
227 if onInvalidate != nil {227 if onInvalidate != nil {
228 onInvalidate(inv)228 onInvalidate(inv)
@@ -707,7 +707,7 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
707 707
708 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {708 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
709 c.mu.Lock()709 c.mu.Lock()
710- if c.normalLane.generation != gen {710+ if !watchdog && c.normalLane.generation != gen {
711 c.mu.Unlock()711 c.mu.Unlock()
712 return712 return
713 }713 }
M internal/usecase/daemon/render_coordinator_test.go +19 -0
@@ -712,6 +712,25 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
712712 requireNoWake(t, h.wakes)
713713 })
714714
715+ t.Run("watchdog flushes after the snapshotted generation is replaced", func(t *testing.T) {
716+ h := newCoordinatorHarness(t)
717+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
718+ h.rc.mu.Lock()
719+ staleGeneration := h.rc.normalLane.generation
720+ h.rc.mu.Unlock()
721+
722+ // Simulate a new urgent publication after fireCurrent(true) snapshots its
723+ // generation but before fire validates it.
724+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
725+ h.rc.fire(staleGeneration, true, true)
726+
727+ w := awaitWake(t, h.wakes)
728+ require.True(t, w.watchdog)
729+ require.True(t, w.urgent)
730+ require.Equal(t, 2, w.coalesced)
731+ requireNoWake(t, h.wakes)
732+ })
733+
715734 t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
716735 h := newCoordinatorHarness(t)
717736 h.syncActive.Store(true)
@@ -712,6 +712,25 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
712 requireNoWake(t, h.wakes)712 requireNoWake(t, h.wakes)
713 })713 })
714 714
715+ t.Run("watchdog flushes after the snapshotted generation is replaced", func(t *testing.T) {
716+ h := newCoordinatorHarness(t)
717+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
718+ h.rc.mu.Lock()
719+ staleGeneration := h.rc.normalLane.generation
720+ h.rc.mu.Unlock()
721+
722+ // Simulate a new urgent publication after fireCurrent(true) snapshots its
723+ // generation but before fire validates it.
724+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
725+ h.rc.fire(staleGeneration, true, true)
726+
727+ w := awaitWake(t, h.wakes)
728+ require.True(t, w.watchdog)
729+ require.True(t, w.urgent)
730+ require.Equal(t, 2, w.coalesced)
731+ requireNoWake(t, h.wakes)
732+ })
733+
715 t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {734 t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
716 h := newCoordinatorHarness(t)735 h := newCoordinatorHarness(t)
717 h.syncActive.Store(true)736 h.syncActive.Store(true)
M internal/usecase/daemon/render_resize_deadline.go +4 -4
@@ -42,7 +42,7 @@ func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *att
4242 timer := clock.NewTimer(minOutputRenderDeadline)
4343 timerC := timer.C()
4444 if timerC == nil {
45- stopDetachedTimer(&timerToken{timer: timer})
45+ timer.Stop()
4646 if c.resizeCurrentForLease(epoch, source, lease, false) {
4747 run(epoch)
4848 }
@@ -70,7 +70,7 @@ func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *att
7070 }
7171 c.mu.Unlock()
7272 if !valid {
73- stopDetachedTimer(&timerToken{timer: timer})
73+ timer.Stop()
7474 }
7575 return epoch
7676 }
@@ -106,7 +106,7 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
106106 timer := clock.NewTimer(minOutputRenderDeadline)
107107 timerC := timer.C()
108108 if timerC == nil {
109- stopDetachedTimer(&timerToken{timer: timer})
109+ timer.Stop()
110110 return
111111 }
112112 c.mu.Lock()
@@ -130,7 +130,7 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
130130 }
131131 c.mu.Unlock()
132132 if !valid {
133- stopDetachedTimer(&timerToken{timer: timer})
133+ timer.Stop()
134134 }
135135 }
136136
@@ -42,7 +42,7 @@ func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *att
42 timer := clock.NewTimer(minOutputRenderDeadline)42 timer := clock.NewTimer(minOutputRenderDeadline)
43 timerC := timer.C()43 timerC := timer.C()
44 if timerC == nil {44 if timerC == nil {
45- stopDetachedTimer(&timerToken{timer: timer})45+ timer.Stop()
46 if c.resizeCurrentForLease(epoch, source, lease, false) {46 if c.resizeCurrentForLease(epoch, source, lease, false) {
47 run(epoch)47 run(epoch)
48 }48 }
@@ -70,7 +70,7 @@ func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *att
70 }70 }
71 c.mu.Unlock()71 c.mu.Unlock()
72 if !valid {72 if !valid {
73- stopDetachedTimer(&timerToken{timer: timer})73+ timer.Stop()
74 }74 }
75 return epoch75 return epoch
76 }76 }
@@ -106,7 +106,7 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
106 timer := clock.NewTimer(minOutputRenderDeadline)106 timer := clock.NewTimer(minOutputRenderDeadline)
107 timerC := timer.C()107 timerC := timer.C()
108 if timerC == nil {108 if timerC == nil {
109- stopDetachedTimer(&timerToken{timer: timer})109+ timer.Stop()
110 return110 return
111 }111 }
112 c.mu.Lock()112 c.mu.Lock()
@@ -130,7 +130,7 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
130 }130 }
131 c.mu.Unlock()131 c.mu.Unlock()
132 if !valid {132 if !valid {
133- stopDetachedTimer(&timerToken{timer: timer})133+ timer.Stop()
134 }134 }
135 }135 }
136 136

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