detached c6654e6

fix(daemon): evaluate ack readiness outside coordinator lock

commit
c6654e67df5ff5f6b9e59f6a4b2c8371bf29edd0
author
brice · · 2026-07-11 19:02 UTC
parent
b906d90
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +38 -12
M internal/usecase/daemon/render_coordinator.go +29 -7
@@ -203,9 +203,7 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
203203 c.mu.Unlock()
204204 return
205205 }
206- if c.opts.onInvalidate != nil {
207- c.opts.onInvalidate(inv)
208- }
206+ onInvalidate := c.opts.onInvalidate
209207 c.metrics.invalidations.Add(1)
210208 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
211209 if !wasPending {
@@ -235,6 +233,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
235233 c.armed = c.armed || arm
236234 if !arm || clock == nil {
237235 c.mu.Unlock()
236+ if onInvalidate != nil {
237+ onInvalidate(inv)
238+ }
238239 return
239240 }
240241 timer := clock.NewTimer(delay)
@@ -243,6 +244,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
243244 // synchronously before allocating a worker or its coordination channels.
244245 if timerC == nil {
245246 c.mu.Unlock()
247+ if onInvalidate != nil {
248+ onInvalidate(inv)
249+ }
246250 timer.Stop()
247251 c.fire(gen, false, true)
248252 return
@@ -251,6 +255,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
251255 done := make(chan struct{})
252256 c.normalTimer, c.normalCancel, c.normalWorkerDone = timer, cancel, done
253257 c.mu.Unlock()
258+ if onInvalidate != nil {
259+ onInvalidate(inv)
260+ }
254261 go func() {
255262 defer close(done)
256263 select {
@@ -536,25 +543,36 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
536543 }
537544
538545 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
546+ // Readiness probes enter the attachment send path, which may in turn read
547+ // coordinator metadata. Never hold c.mu across that external callback.
539548 c.mu.Lock()
540- if c.torndown || !c.pending || (!watchdog && gen != c.generation) {
549+ if !c.fireValidLocked(gen, watchdog) {
541550 c.mu.Unlock()
542551 return
543552 }
544- if len(c.syncBatches) != 0 {
553+ ackReady := c.opts.ackReady
554+ c.mu.Unlock()
555+
556+ ready := ackReady == nil || ackReady()
557+
558+ // The callback can detach, replace, start a synchronized batch, or publish
559+ // newer work. Revalidate every consumption precondition after it returns.
560+ c.mu.Lock()
561+ if !c.fireValidLocked(gen, watchdog) {
545562 c.mu.Unlock()
546563 return
547564 }
548565 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}
549- preview, previews := c.takePendingPreviewsLocked()
550- if c.opts.ackReady != nil && !c.opts.ackReady() {
566+ if !ready {
551567 if deadline {
552568 c.ackDeferred = true
553569 }
570+ preview, previews := c.takePendingPreviewsLocked()
554571 c.mu.Unlock()
555572 c.notifyPreviews(w, preview, previews)
556573 return
557574 }
575+ preview, previews := c.takePendingPreviewsLocked()
558576 c.metrics.wakes.Add(1)
559577 c.metrics.coalesced.Add(uint64(w.coalesced))
560578 if !w.urgent {
@@ -577,6 +595,10 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
577595 c.notifyPreviews(w, preview, previews)
578596 }
579597
598+func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool {
599+ return !c.torndown && c.pending && (watchdog || gen == c.generation) && len(c.syncBatches) == 0
600+}
601+
580602 // takePendingPreviewsLocked snapshots and consumes the preview delivery for
581603 // this target generation. Unlike the target primary, this is never ACK-gated.
582604 func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
@@ -203,9 +203,7 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
203 c.mu.Unlock()203 c.mu.Unlock()
204 return204 return
205 }205 }
206- if c.opts.onInvalidate != nil {206+ onInvalidate := c.opts.onInvalidate
207- c.opts.onInvalidate(inv)
208- }
209 c.metrics.invalidations.Add(1)207 c.metrics.invalidations.Add(1)
210 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview208 wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
211 if !wasPending {209 if !wasPending {
@@ -235,6 +233,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
235 c.armed = c.armed || arm233 c.armed = c.armed || arm
236 if !arm || clock == nil {234 if !arm || clock == nil {
237 c.mu.Unlock()235 c.mu.Unlock()
236+ if onInvalidate != nil {
237+ onInvalidate(inv)
238+ }
238 return239 return
239 }240 }
240 timer := clock.NewTimer(delay)241 timer := clock.NewTimer(delay)
@@ -243,6 +244,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
243 // synchronously before allocating a worker or its coordination channels.244 // synchronously before allocating a worker or its coordination channels.
244 if timerC == nil {245 if timerC == nil {
245 c.mu.Unlock()246 c.mu.Unlock()
247+ if onInvalidate != nil {
248+ onInvalidate(inv)
249+ }
246 timer.Stop()250 timer.Stop()
247 c.fire(gen, false, true)251 c.fire(gen, false, true)
248 return252 return
@@ -251,6 +255,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
251 done := make(chan struct{})255 done := make(chan struct{})
252 c.normalTimer, c.normalCancel, c.normalWorkerDone = timer, cancel, done256 c.normalTimer, c.normalCancel, c.normalWorkerDone = timer, cancel, done
253 c.mu.Unlock()257 c.mu.Unlock()
258+ if onInvalidate != nil {
259+ onInvalidate(inv)
260+ }
254 go func() {261 go func() {
255 defer close(done)262 defer close(done)
256 select {263 select {
@@ -536,25 +543,36 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo
536 }543 }
537 544
538 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {545 func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
546+ // Readiness probes enter the attachment send path, which may in turn read
547+ // coordinator metadata. Never hold c.mu across that external callback.
539 c.mu.Lock()548 c.mu.Lock()
540- if c.torndown || !c.pending || (!watchdog && gen != c.generation) {549+ if !c.fireValidLocked(gen, watchdog) {
541 c.mu.Unlock()550 c.mu.Unlock()
542 return551 return
543 }552 }
544- if len(c.syncBatches) != 0 {553+ ackReady := c.opts.ackReady
554+ c.mu.Unlock()
555+
556+ ready := ackReady == nil || ackReady()
557+
558+ // The callback can detach, replace, start a synchronized batch, or publish
559+ // newer work. Revalidate every consumption precondition after it returns.
560+ c.mu.Lock()
561+ if !c.fireValidLocked(gen, watchdog) {
545 c.mu.Unlock()562 c.mu.Unlock()
546 return563 return
547 }564 }
548 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}565 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}
549- preview, previews := c.takePendingPreviewsLocked()566+ if !ready {
550- if c.opts.ackReady != nil && !c.opts.ackReady() {
551 if deadline {567 if deadline {
552 c.ackDeferred = true568 c.ackDeferred = true
553 }569 }
570+ preview, previews := c.takePendingPreviewsLocked()
554 c.mu.Unlock()571 c.mu.Unlock()
555 c.notifyPreviews(w, preview, previews)572 c.notifyPreviews(w, preview, previews)
556 return573 return
557 }574 }
575+ preview, previews := c.takePendingPreviewsLocked()
558 c.metrics.wakes.Add(1)576 c.metrics.wakes.Add(1)
559 c.metrics.coalesced.Add(uint64(w.coalesced))577 c.metrics.coalesced.Add(uint64(w.coalesced))
560 if !w.urgent {578 if !w.urgent {
@@ -577,6 +595,10 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
577 c.notifyPreviews(w, preview, previews)595 c.notifyPreviews(w, preview, previews)
578 }596 }
579 597
598+func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool {
599+ return !c.torndown && c.pending && (watchdog || gen == c.generation) && len(c.syncBatches) == 0
600+}
601+
580 // takePendingPreviewsLocked snapshots and consumes the preview delivery for602 // takePendingPreviewsLocked snapshots and consumes the preview delivery for
581 // this target generation. Unlike the target primary, this is never ACK-gated.603 // this target generation. Unlike the target primary, this is never ACK-gated.
582 func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {604 func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
M internal/usecase/daemon/render_coordinator_test.go +9 -5
@@ -354,12 +354,16 @@ resize:
354354 select {
355355 case epoch := <-resizeDone:
356356 require.Equal(t, uint64(1), epoch)
357- select {
358- case <-fireDone:
359- case <-wakes:
360- t.Fatal("fire did not return after wake")
357+ require.Equal(t, renderWake{coalesced: 1}, awaitWake(t, wakes))
358+ for range 4096 {
359+ select {
360+ case <-fireDone:
361+ return
362+ default:
363+ runtime.Gosched()
364+ }
361365 }
362- return
366+ t.Fatal("fire did not return after wake")
363367 default:
364368 runtime.Gosched()
365369 }
@@ -354,12 +354,16 @@ resize:
354 select {354 select {
355 case epoch := <-resizeDone:355 case epoch := <-resizeDone:
356 require.Equal(t, uint64(1), epoch)356 require.Equal(t, uint64(1), epoch)
357- select {357+ require.Equal(t, renderWake{coalesced: 1}, awaitWake(t, wakes))
358- case <-fireDone:358+ for range 4096 {
359- case <-wakes:359+ select {
360- t.Fatal("fire did not return after wake")360+ case <-fireDone:
361+ return
362+ default:
363+ runtime.Gosched()
364+ }
361 }365 }
362- return366+ t.Fatal("fire did not return after wake")
363 default:367 default:
364 runtime.Gosched()368 runtime.Gosched()
365 }369 }

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