remove jonboulle/clockwork for testing/synctest - #14185
Conversation
| // The returned channel is closed when the debouncer is stopped via context cancellation or by closing the input channel. | ||
| func BatchDebounceEvents(ctx context.Context, clock clockwork.Clock, input <-chan FileEvent) <-chan []FileEvent { | ||
| func BatchDebounceEvents(ctx context.Context, input <-chan FileEvent) <-chan []FileEvent { |
There was a problem hiding this comment.
Not sure if this function is used outside of compose itself, because it's a signature change; if that's a problem, we can perhaps replace it with _ any or something
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
This PR cleanly removes the jonboulle/clockwork dependency by replacing it with Go 1.26's testing/synctest — a good improvement that eliminates the clock abstraction from production code. The debounce.go implementation correctly uses t.Reset(QuietPeriod) after each event, preserving the sliding-window debounce semantics.
One test logic issue was found in watch_test.go: the second event batch sends both a rebuild event and a sync event, but handleWatchBatch processes both independently — meaning syncer.Sync() IS called. The subsequent select { default: } assertion (asserting no sync occurred) misses this, leaving a goroutine permanently blocked in the synctest bubble, which will cause a panic rather than a clean test pass.
| watcher.Events() <- watch.NewFileEvent("/rebuild") | ||
| watcher.Events() <- watch.NewFileEvent("/sync/changed") | ||
| time.Sleep(watch.QuietPeriod) | ||
| synctest.Wait() |
There was a problem hiding this comment.
[medium] Second test batch asserts no sync occurred, but handleWatchBatch WILL call syncer.Sync() — leaving a goroutine permanently blocked
The second event batch sends both /rebuild and /sync/changed:
/rebuildmatches theWatchActionRebuildrule → triggerss.rebuild(...)/sync/changedmatches theWatchActionSyncrule → triggerssyncer.Sync(...)which blocks on the unbufferedsyncer.synced <- paths
handleWatchBatch processes both actions (rebuild first, then sync via syncfiles iteration — they are independent maps). So syncer.Sync() is called, and the goroutine durably blocks on f.synced <- paths (a bubble-internal channel send).
After synctest.Wait() returns (because all other goroutines are durably blocked), the select { default: } branch is taken — the pending send on syncer.synced is never read. The blocked goroutine leaks inside the bubble. When synctest.Test exits and waits for all goroutines to finish, the test panics with a deadlock/goroutine-leak message rather than a clear failure.
The original clockwork-based test used time.After(100ms) as the "no sync" timeout, which would also time out incorrectly in this scenario — but the old test's assertion was already wrong before this PR (the test comment says // TODO: there's not a great way to assert that the rebuild attempt happened). The migration to synctest surfaces this latent bug as a hard panic.
Fix options:
- Read (and drain)
syncer.syncedafter the rebuild batch, then assert the contents were not pure-sync (or just drain it):// Drain any pending sync caused by the mixed rebuild+sync batch select { case <-syncer.synced: default: }
- Adjust the watch rules so the second batch contains only a rebuild event (no
/sync/changed), making the "no sync" assertion valid. - Explicitly assert that a sync DID happen for the
/sync/changedevent (since it matches a sync rule even in a rebuild batch), updating the test intention.
| Confidence | Score |
|---|---|
| 🟡 moderate | 60/100 |
There was a problem hiding this comment.
@docker-agent I think this is a false positive. handleWatchBatch only reaches the sync actions if the rebuild succeeds:
if len(rebuild) > 0 {
if err := s.rebuild(...); err != nil {
return err
}
}In this test the rebuild intentionally fails (invalid service "test". Must specify either image or build), so syncer.Sync() is never called for the /sync/changed event in that batch.
That also explains the existing assertion and TODO: the mixed batch is useful to verify that sync processing does not continue after the rebuild failure, while the test doesn't have a clean way to directly assert the rebuild attempt itself.
With synctest.Wait() followed by the non-blocking select, the original assertion remains valid and deterministic.
There was a problem hiding this comment.
You're absolutely right — I misread the control flow. The early return on rebuild failure means syncer.Sync() is never reached for the /sync/changed event in the mixed batch, so the "no sync" assertion is correct.
I've stored this pattern (early-return-on-rebuild-failure preventing downstream sync processing) to avoid flagging it incorrectly in future reviews. Thanks for the clarification and for explaining the test's intention — the mixed batch now makes much more sense as a deliberate verification that sync doesn't proceed after rebuild failures.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The fake clock is only used by tests to control the debounce timer, but requires carrying a clock abstraction through production code. Use the standard time package for the debounce ticker and use testing/synctest to control time in tests instead. This removes the clock from composeService and BatchDebounceEvents, and eliminates the clockwork dependency from the watch implementation. Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
This PR cleanly removes the jonboulle/clockwork abstraction and replaces fake-clock test control with Go's standard testing/synctest package. The implementation is correct.
Key correctness checks performed:
-
synctest.Testbubble scoping: All goroutines started within thesynctest.Testcallback — including thego func()runningwatchEventsand the inner goroutine spawned byBatchDebounceEvents— participate in the fake-time bubble.time.NewTicker,time.Sleep, andTicker.Resetall interact correctly with the bubble's fake clock. -
time.Sleep(QuietPeriod)→synctest.Wait()→<-syncer.syncedordering: Fake time only advances when all goroutines in the bubble are durably blocked. By the timetime.Sleep(QuietPeriod)returns, the full pipeline (ticker fire →flushEvents→out <- events→watchEventsreceive →handleWatchBatch→syncer.Sync→f.synced <- paths) has already completed to the point wherewatchEventsis blocked on the unbufferedf.synced <- pathssend.synctest.Wait()then confirms this stable state, and<-syncer.syncedsafely receives. No race. -
Rebuild scenario
default:branch: After rebuild events are processed,s.rebuildfails (no build context in the inlinecomposeServicestruct literal), sohandleWatchBatchreturns early without callingsyncer.Sync. Aftersynctest.Wait()all goroutines are durably blocked in their select loops andsyncer.syncedis empty — thedefault:case is correctly non-racy in the synctest context. -
Ticker.Resetwithin synctest:time.Ticker.Resetworks correctly inside a synctest bubble; Go 1.26.3's synctest implementation handles all standard time operations. -
clockworkfully removed: Confirmed absent fromgo.mod,go.sum,pkg/compose/compose.go(clockfield andclockwork.NewRealClock()initialisation),pkg/watch/debounce.go(signature andt.Chan()→t.C), and both test files. -
Debounce semantics preserved: The sliding-window behaviour (
t.Reset(QuietPeriod)on each incoming event) is unchanged; only the clock source changes from clockwork to the standard library.
The fake clock is only used by tests to control the debounce timer,
but requires carrying a clock abstraction through production code.
Use the standard time package for the debounce ticker and use
testing/synctest to control time in tests instead.
This removes the clock from composeService and BatchDebounceEvents,
and eliminates the clockwork dependency from the watch implementation.
What I did
Related issue
(not mandatory) A picture of a cute animal, if possible in relation to what you did