detached 9c054e2

test(daemon): cover reentrant coordinator timers

commit
9c054e2e9934ac05133b209226e7e51383473051
author
brice · · 2026-07-11 19:51 UTC
committer
brice · · 2026-07-11 19:51 UTC
parent
de2ad79
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +31 -0
M internal/usecase/daemon/render_coordinator_test.go +31 -0
@@ -73,6 +73,37 @@ func newInertCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockCl
7373 return clk
7474 }
7575
76+// TestRenderCoordinatorTimerCallbacksReenterCoordinator is deliberately a
77+// deadlock regression: every external timer method synchronously reads
78+// coordinator metadata. Before two-phase timer ownership this blocked while
79+// c.mu was held during normal arming, urgent replacement, sync arming, and
80+// detach cancellation.
81+func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {
82+ clock := portsmocks.NewMockClock(t)
83+ var rc *renderCoordinator
84+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
85+ timer := portsmocks.NewMockTimer(t)
86+ ch := make(chan time.Time)
87+ timer.EXPECT().C().Run(func() {
88+ _ = rc.resizeSnapshot()
89+ _ = rc.burstMetricsSnapshot()
90+ }).Return((<-chan time.Time)(ch)).Once()
91+ timer.EXPECT().Stop().Run(func() {
92+ _ = rc.resizeSnapshot()
93+ _ = rc.burstMetricsSnapshot()
94+ }).Return(true).Once()
95+ return timer
96+ }).Times(3)
97+ rc = newRenderCoordinator(renderCoordinatorOptions{clock: clock})
98+ ac := &attachedClient{}
99+ rc.attach(ac)
100+
101+ rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm
102+ rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion
103+ rc.noteSyncBegin(nil, 1) // sync arm
104+ rc.noteDetach(ac) // cancel and stop
105+}
106+
76107 // coordinatorHarness wires one coordinator to generated clock/timer mocks and
77108 // recording hooks. Every assertion is channel- or counter-based; nothing sleeps.
78109 type coordinatorHarness struct {
@@ -73,6 +73,37 @@ func newInertCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockCl
73 return clk73 return clk
74 }74 }
75 75
76+// TestRenderCoordinatorTimerCallbacksReenterCoordinator is deliberately a
77+// deadlock regression: every external timer method synchronously reads
78+// coordinator metadata. Before two-phase timer ownership this blocked while
79+// c.mu was held during normal arming, urgent replacement, sync arming, and
80+// detach cancellation.
81+func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {
82+ clock := portsmocks.NewMockClock(t)
83+ var rc *renderCoordinator
84+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
85+ timer := portsmocks.NewMockTimer(t)
86+ ch := make(chan time.Time)
87+ timer.EXPECT().C().Run(func() {
88+ _ = rc.resizeSnapshot()
89+ _ = rc.burstMetricsSnapshot()
90+ }).Return((<-chan time.Time)(ch)).Once()
91+ timer.EXPECT().Stop().Run(func() {
92+ _ = rc.resizeSnapshot()
93+ _ = rc.burstMetricsSnapshot()
94+ }).Return(true).Once()
95+ return timer
96+ }).Times(3)
97+ rc = newRenderCoordinator(renderCoordinatorOptions{clock: clock})
98+ ac := &attachedClient{}
99+ rc.attach(ac)
100+
101+ rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm
102+ rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion
103+ rc.noteSyncBegin(nil, 1) // sync arm
104+ rc.noteDetach(ac) // cancel and stop
105+}
106+
76 // coordinatorHarness wires one coordinator to generated clock/timer mocks and107 // coordinatorHarness wires one coordinator to generated clock/timer mocks and
77 // recording hooks. Every assertion is channel- or counter-based; nothing sleeps.108 // recording hooks. Every assertion is channel- or counter-based; nothing sleeps.
78 type coordinatorHarness struct {109 type coordinatorHarness struct {

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