fix(daemon): evaluate ack readiness outside coordinator lock
- commit
- c6654e67df5ff5f6b9e59f6a4b2c8371bf29edd0
- author
- brice · · 2026-07-11 19:02 UTC
- parent
- b906d90
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 203 | 203 | c.mu.Unlock() | |
| 204 | 204 | return | |
| 205 | 205 | } | |
| 206 | - | if c.opts.onInvalidate != nil { | |
| 207 | - | c.opts.onInvalidate(inv) | |
| 208 | - | } | |
| 206 | + | onInvalidate := c.opts.onInvalidate | |
| 209 | 207 | c.metrics.invalidations.Add(1) | |
| 210 | 208 | wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview | |
| 211 | 209 | if !wasPending { | |
| @@ -235,6 +233,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) { | |||
| 235 | 233 | c.armed = c.armed || arm | |
| 236 | 234 | if !arm || clock == nil { | |
| 237 | 235 | c.mu.Unlock() | |
| 236 | + | if onInvalidate != nil { | |
| 237 | + | onInvalidate(inv) | |
| 238 | + | } | |
| 238 | 239 | return | |
| 239 | 240 | } | |
| 240 | 241 | timer := clock.NewTimer(delay) | |
| @@ -243,6 +244,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) { | |||
| 243 | 244 | // synchronously before allocating a worker or its coordination channels. | |
| 244 | 245 | if timerC == nil { | |
| 245 | 246 | c.mu.Unlock() | |
| 247 | + | if onInvalidate != nil { | |
| 248 | + | onInvalidate(inv) | |
| 249 | + | } | |
| 246 | 250 | timer.Stop() | |
| 247 | 251 | c.fire(gen, false, true) | |
| 248 | 252 | return | |
| @@ -251,6 +255,9 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) { | |||
| 251 | 255 | done := make(chan struct{}) | |
| 252 | 256 | c.normalTimer, c.normalCancel, c.normalWorkerDone = timer, cancel, done | |
| 253 | 257 | c.mu.Unlock() | |
| 258 | + | if onInvalidate != nil { | |
| 259 | + | onInvalidate(inv) | |
| 260 | + | } | |
| 254 | 261 | go func() { | |
| 255 | 262 | defer close(done) | |
| 256 | 263 | select { | |
| @@ -536,25 +543,36 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo | |||
| 536 | 543 | } | |
| 537 | 544 | ||
| 538 | 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 | 548 | c.mu.Lock() | |
| 540 | - | if c.torndown || !c.pending || (!watchdog && gen != c.generation) { | |
| 549 | + | if !c.fireValidLocked(gen, watchdog) { | |
| 541 | 550 | c.mu.Unlock() | |
| 542 | 551 | 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 | 562 | c.mu.Unlock() | |
| 546 | 563 | return | |
| 547 | 564 | } | |
| 548 | 565 | 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 { | |
| 551 | 567 | if deadline { | |
| 552 | 568 | c.ackDeferred = true | |
| 553 | 569 | } | |
| 570 | + | preview, previews := c.takePendingPreviewsLocked() | |
| 554 | 571 | c.mu.Unlock() | |
| 555 | 572 | c.notifyPreviews(w, preview, previews) | |
| 556 | 573 | return | |
| 557 | 574 | } | |
| 575 | + | preview, previews := c.takePendingPreviewsLocked() | |
| 558 | 576 | c.metrics.wakes.Add(1) | |
| 559 | 577 | c.metrics.coalesced.Add(uint64(w.coalesced)) | |
| 560 | 578 | if !w.urgent { | |
| @@ -577,6 +595,10 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) { | |||
| 577 | 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 | 602 | // takePendingPreviewsLocked snapshots and consumes the preview delivery for | |
| 581 | 603 | // this target generation. Unlike the target primary, this is never ACK-gated. | |
| 582 | 604 | 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 | return | 204 | 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.pendingPreview | 208 | 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 || arm | 233 | 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 | return | 239 | 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 | return | 252 | 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, done | 256 | 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 | return | 551 | 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 | return | 563 | 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 = true | 568 | 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 | return | 573 | 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 for | 602 | // 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: | |||
| 354 | 354 | select { | |
| 355 | 355 | case epoch := <-resizeDone: | |
| 356 | 356 | 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 | + | } | |
| 361 | 365 | } | |
| 362 | - | return | |
| 366 | + | t.Fatal("fire did not return after wake") | |
| 363 | 367 | default: | |
| 364 | 368 | runtime.Gosched() | |
| 365 | 369 | } | |
| @@ -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 | - | return | 366 | + | t.Fatal("fire did not return after wake") |
| 363 | default: | 367 | default: | ||
| 364 | runtime.Gosched() | 368 | runtime.Gosched() | ||
| 365 | } | 369 | } | ||