FIX-001 — Thresher / EventHub startup race¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-06-03 |
| Owner | Ruslan Gabitov |
| Surfaces from | The -race CI gate (chore/ci-audit, d731895) running over the multi-module / make ci scaffold (SAD-001 v.1 §9, ADR-003 v.1) — -race now gates CI; pre-existing race becomes visible |
| Related conception | ADR-001 v.2 Execution Model — race-freedom is a P0 verification gate per §7 |
1. Symptoms¶
TestThresher_EventQueueProcessing/event_queue_processes_registered_events (in pkg/thresher/thresher_events_test.go) is flaky under -race:
go test -race -count=5 -run TestThresher_EventQueueProcessing ./pkg/thresher/
# → 2/5 runs fail with:
# WARNING: DATA RACE
# testing.go:1617: race detected during execution of test
The race fires non-deterministically — sometimes the test passes, sometimes the race detector flags it. The test logic itself is reasonable (start the Thresher, then register an event); the race is in the engine's startup sequence and was masked by timing on faster runs and by the previous absence of -race in CI before commit d731895 (chore/ci-audit).
-race is now gating CI (added in chore/ci-audit d731895, wired into make ci's test-all by the multi-module scaffold — see ADR-003 v.1). Without addressing this race, master CI fails intermittently.
2. Root cause analysis¶
The race involves two unsynchronized scalar fields on the shared EventHub struct: started bool and ctx context.Context. The map field waiters is properly mutex-protected (see eh.m.Lock/RLock usage); the two scalars are NOT.
2.1 The racing writes (background goroutine)¶
internal/eventproc/eventhub/eventhub.go:48-60:
func (eh *EventHub) Run(ctx context.Context) error {
if eh.started { ... } // line 49 — read of started (no mutex)
eh.started = true // line 55 — WRITE of started (no mutex)
eh.ctx = ctx // line 56 — WRITE of ctx (no mutex)
<-ctx.Done()
return ctx.Err()
}
This Run runs in a goroutine launched by Thresher.Run:
pkg/thresher/thresher.go:184-190:
// Run eventhub in background
go func() {
_ = t.eventHub.Run(ctx) // line 186 — Run executes in this goroutine
}()
// Give eventhub a moment to initialize
time.Sleep(1 * time.Millisecond) // line 190 — TIMING HOPE, NOT A BARRIER
2.2 The racing reads (caller goroutine)¶
After the 1 ms sleep, the caller calls Thresher.RegisterEvent, which calls into EventHub.RegisterEvent:
internal/eventproc/eventhub/eventhub.go:70-113:
func (eh *EventHub) RegisterEvent(...) error {
if !eh.started { ... } // line 70 — READ of started (no mutex)
...
if err := w.Service(eh.ctx); err != nil { ... } // line 113 — READ of ctx (no mutex)
...
}
2.3 The race-detector report (definitive evidence)¶
WARNING: DATA RACE
Read at 0x...4f8 by goroutine 10:
EventHub.RegisterEvent() eventhub.go:70
Thresher.RegisterEvent() thresher.go:222
test func1() thresher_events_test.go:225
Previous write at 0x...4f8 by goroutine 11:
EventHub.Run() eventhub.go:55
Thresher.Run.func1() thresher.go:186
(second race report at offset ...4b0 — same shape but on eh.ctx,
reads at eventhub.go:113 vs write at eventhub.go:56)
Both racing fields are simple scalars touched across goroutines with no sync.Mutex, no sync/atomic, no channel handshake. The time.Sleep(1 * time.Millisecond) in the caller is a non-synchronization — it gives the background goroutine time to run but does not force it to, and even if it did, the Go memory model would not guarantee visibility of the writes without proper synchronization primitives.
2.4 Why it became visible now (and not earlier)¶
-racewas added to CI ind731895(chore/ci-audit, merged before this work).- The race is timing-dependent. CI runs since then likely got lucky on the timing.
- The multi-module scaffold landing added
make ciwith race-gatedtest-all, plus the depguard rules. Runningmake cilocally and on CI more reliably exercises the timing where the race fires.
The fragility was always there; the race-gated make ci just stopped letting it slip through.
3. Solution¶
Two viable approaches. Solution A is preferred (cleaner separation of concerns; matches ADR-001's intent); Solution B is the smaller-diff fallback.
3.1 Solution A (preferred) — split Start from Run¶
Introduce a new EventHub.Start(ctx) method that performs the synchronous initialization (started = true, ctx = ctx), and reduce Run(ctx) to just the blocking event-loop body. Thresher.Run calls Start synchronously before spawning the background goroutine, and removes the 1 ms sleep.
Sketch:
// internal/eventproc/eventhub/eventhub.go
func (eh *EventHub) Start(ctx context.Context) error {
if eh.started {
return errs.New(errs.M("eventHub is already started"), ...)
}
eh.started = true
eh.ctx = ctx
return nil
}
func (eh *EventHub) Run(ctx context.Context) error {
if !eh.started {
return errs.New(errs.M("eventHub not started"), ...)
}
<-ctx.Done()
return ctx.Err()
}
// pkg/thresher/thresher.go (excerpt of Run)
t.ctx = ctx
if err := t.eventHub.Start(ctx); err != nil { // SYNCHRONOUS
return err
}
go func() {
_ = t.eventHub.Run(ctx) // BACKGROUND, but started already
}()
// time.Sleep(1ms) DELETED — no longer needed.
return t.UpdateState(Started)
Why this is the preferred fix:
startedandctxare written synchronously in the parent goroutine, before the background goroutine is spawned. This makes the writes safe to read from both sides without a mutex:- Reads from the spawned goroutine (e.g.,
eh.Runcheckingeh.started) see the writes via the goroutine-creation happens-before edge in the Go memory model — every write in the parent beforego f()is visible tof. - Reads from the parent goroutine (and any goroutine sharing an EventHub reference that was published after
Startreturned — e.g., a Thresher whoseRunreturned successfully) see the writes via plain sequential consistency on the parent's own program order, then via the synchronization that published the EventHub reference. - The
waitersmap remains mutex-protected as today; this fix doesn't change its access pattern. - Removes the
time.Sleep(1 * time.Millisecond)— that was a code smell and is now demonstrably wrong. - Cleanly separates concerns:
Startis an initialization step (returns errors if misconfigured);Runis the blocking loop (returns when ctx is done). - Aligns with ADR-001 §4.3 channel-topology intent: the engine has explicit setup phases before background goroutines spawn.
- The
EventHubinterface ininternal/eventproc/eventproc.gois internal; adding a method to its default implementation does not break public API.
3.2 Solution B (fallback) — atomic protection¶
If splitting Start/Run is undesirable for any reason (e.g., the EventHub interface itself would need to add Start), the smaller-diff alternative protects the two scalars with sync/atomic:
type EventHub struct {
started atomic.Bool
ctxVal atomic.Value // stores context.Context
...
}
func (eh *EventHub) Run(ctx context.Context) error {
if !eh.started.CompareAndSwap(false, true) {
return errs.New(errs.M("already started"), ...)
}
eh.ctxVal.Store(ctx)
<-ctx.Done()
return ctx.Err()
}
func (eh *EventHub) RegisterEvent(...) error {
if !eh.started.Load() { ... }
...
ctx, _ := eh.ctxVal.Load().(context.Context)
if err := w.Service(ctx); err != nil { ... }
}
The 1 ms sleep in Thresher.Run still needs to go away — atomic protection makes the race detector happy but doesn't fix the fact that RegisterEvent may run before Run if the goroutine hasn't been scheduled. Replace the sleep with a small handshake (e.g., a buffered channel signal from Run once started.Store(true) completes; Thresher.Run reads-or-times-out).
This solution preserves the existing EventHub interface shape but introduces additional handshake complexity. Solution A is strictly better.
3.3 Decision¶
Adopt Solution A. The EventHub interface (in internal/eventproc/eventproc.go) currently exposes only RegisterEvent / UnregisterEvent / PropagateEvent / Run / RemoveWaiter. Adding Start to that interface (and to the default implementation) is a small, internal change. The thresher's Run becomes cleaner; the sleep disappears.
4. Verification¶
| What | How |
|---|---|
| Race is gone | go test -race -count=100 -run TestThresher_EventQueueProcessing ./pkg/thresher/ passes 100/100. (Pre-fix: ~40 failures expected on a 100-run loop.) |
| No regression in other Thresher tests | go test -race ./pkg/thresher/... clean. |
| Whole core is race-clean under stress | make test-all clean; additionally go test -race -count=10 ./... clean (covers the rest of core under repeated runs). |
| EventHub still rejects double-start | New unit test: eh.Start(ctx) twice → second call returns the "already started" error. |
| EventHub still rejects pre-Start RegisterEvent | Existing test path covering "eventHub isn't started" remains green. |
| No timing-dependent test logic remains | Code review confirms time.Sleep(1 * time.Millisecond) is removed from Thresher.Run. |
| CI passes after fix lands | make ci runs clean locally; GitHub Actions check passes on the merge commit. |
The acceptance gate for flipping this FIX to Accepted: the -count=100 race-stress test on the regression test is committed (or scripted in CI) and passes.
5. Prevention¶
Two project-wide habits that would have caught this earlier:
-raceongo testfrom day one of CI. Now in place per chore/ci-audit. This FIX validates that policy.- Convention: any field touched by a goroutine spawned in a method must be either:
- written before the goroutine spawn (visible via goroutine-creation happens-before), or
- protected by a
syncprimitive (Mutex, atomic, channel handshake).
No time.Sleep ever counts as synchronization. Anywhere time.Sleep appears in non-test code, code review should ask: "what is this hoping for, and what's the actual synchronization primitive?"
A linter would help enforce #2 but no off-the-shelf one catches this exact pattern. Consider a one-line internal check: grep for time.Sleep in non-test Go files and review each occurrence at PR time.
6. Regression analysis¶
The change is EventHub adding a Start method and Thresher.Run calling it synchronously before spawning the background goroutine. Risks:
- Other callers of
EventHub.Run— only one production caller (Thresher.Run). Search confirmed viagrep -rn 'eventHub.Run\|EventHub\.\|eh\.Run'(results: onlythresher.goand tests). Tests that mockEventHub(via mockery) need aStartexpectation added — small mechanical update. - Mockery-generated mocks —
EventHubinterface gainsStart(ctx) error. Re-runmockeryto regenerategenerated/mockeventproc/MockEventHub.go. Any test that previously asserted onlyRunexpectations now also needs aStartexpectation. The list of such tests is bounded (grepMockEventHubinpkg/thresher/*_test.goandinternal/eventproc/eventhub/*_test.go). - Other call paths through
eventHub.started/eventHub.ctx— the existingeh.startedcheck inRun(line 49) becomes redundant ifStartis the only entry point that sets it. Cleanup: inRun, replaceif eh.started { ... return already-started-error }withif !eh.started { ... return not-started-error }. The two error messages flip — make sure existing tests that match error text are updated. - Behavior under double-
Runcalls — previously double-Runwould error on the second call viaeh.startedcheck. With Solution A,Runno longer setsstarted; it only checks it. Double-Runbecomes a different error path. Confirm existing test for "already started" still passes (it should —Startnow does the check, andThresher.RuncallsStartonce).
7. Related¶
- ADR-001 v.2 Execution Model §7 — race-freedom is verification gate #1; this FIX is the first concrete payment on that gate.
- SAD-001 v.1 §9 Module Layout + the
chore/ci-audit-racegate (d731895) — the multi-modulemake ciscaffold and its-racegate are what surface this and similar bugs. - ADR-003 v.1 §4.6 step 3 — when
EventHubinterface moves frominternal/eventproc/topkg/messaging/(per the migration plan), theStartmethod moves with it. This FIX is upstream of that promotion; the change must happen here first to land the race-free design before the public-interface freeze. - (Potential follow-up) Audit other engine startup sequences for similar racy "Run-in-a-goroutine then sleep" patterns. Likely none elsewhere, but worth a one-time pass.
8. Implementation summary¶
Solution A (per §3.1) was implemented as a single change-set on branch
fix/eventhub-startup-race. No deviations from §3 — Start was added to the
internal EventHub interface and implemented on the default *EventHub;
Run was reduced to the blocking event-loop body; Thresher.Run calls
Start synchronously before spawning the background goroutine; the
time.Sleep(1 * time.Millisecond) is gone.
Files touched:
internal/eventproc/eventproc.go— addedStart(ctx context.Context) errorto theEventHubinterface with doc-comments referencing FIX-001 for the rationale.internal/eventproc/eventhub/eventhub.go— implementedStart;Runnow guards on!eh.started(was: guarded oneh.started); the started-flag and ctx writes moved fromRunintoStart. Error messages flipped accordingly (Runreturns"eventHub isn't started"when called pre-Start, consistent with the existingRegisterEvent/UnregisterEvent/PropagateEventwording).pkg/thresher/thresher.go— replaced the goroutine-spawn-then-sleep block with a synchronoust.eventHub.Start(ctx)call beforego func() { eh.Run(ctx) }(). Thetimeimport was dropped — no other usage in this file.internal/eventproc/eventhub/eventhub_base_test.go— split formerTestRunintoTestStart(successful start, double-start error) andTestRun(run before start error, run with timeout, run with cancellation). TheRegister/Unregister/PropagateEventbase-error tests stopped spawningRunin a goroutine +time.Sleepand callhub.Start(ctx)synchronously.internal/eventproc/eventhub/eventhub_timer_test.goandeventhub_message_test.go— same goroutine+sleep → synchronousStartswap.generated/mockeventproc/mock_EventHub.go— regenerated viamake gen_mock_filesto expose the newStartmock surface.
Verification evidence (run on the branch HEAD before commit):
| Command | Result |
|---|---|
go test -race -count=100 -run TestThresher_EventQueueProcessing ./pkg/thresher/... |
300/300 pass (3 subtests × 100 iterations) — pre-fix this loop produced ~40 race-detector failures |
go test -race ./pkg/thresher/... |
30 tests pass |
go test -race ./internal/eventproc/... |
34 tests pass across 3 packages |
make ci |
green (tidy-check-all, lint-all-modules, build-all, test-all, vuln) |
The -count=100 race-stress is documented above as the acceptance gate
rather than committed as a permanent test (running it on every CI invocation
would multiply core test time by ~100× for one specific regression). The
command is in this document and in the §4 verification table; reviewers
exercising the fix should run it once.
Branch and commit:
- Branch:
fix/eventhub-startup-race - Commits: this FIX doc (
c601683) + implementation commit70fa5f5(fix(eventhub): split Start/Run to remove Thresher startup race (FIX-001)).
Status flip plan:
- [x] Implementation committed on
fix/eventhub-startup-race. - [x] PR #108 merged into
master(merge commit28aa6b9). Thecheckworkflow on that merge was red on an unrelated pre-existing govulncheck finding (stdlib vulns), remediated immediately after by the go1.25.11 toolchain pin in PR #109;checkis green onmasteras of3705717. - [x] Flipped Draft → Accepted. Acceptance gate re-run on
masterHEAD3705717:-race -count=100clean. SHAs recorded in Document History.
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 | 2026-06-06 | Ruslan Gabitov | Accepted. Implementation merged to master via PR #108 (impl commit 70fa5f5, merge 28aa6b9). Acceptance gate re-verified on master 3705717: go test -race -count=100 -run TestThresher_EventQueueProcessing ./pkg/thresher/... clean (100/100); check workflow green on master at 3705717 (the PR #108 merge build was red on an unrelated stdlib govulncheck finding, remediated by the go1.25.11 toolchain pin in PR #109). Prior Draft iteration folded into this version without per-round rows. |