FIX-007 «Event-Based gateway concurrent-fire races (double-win + gate self-execution)»¶
Type: FIX (one-shot bug-fix; not rewritten after landing).
Status: Accepted v.1 (2026-06-21, branch fix/event-gate-concurrent-fire, implemented).
Date: 2026-06-21.
Author: Ruslan Gabitov.
Branch: fix/event-gate-concurrent-fire (the defect is a concurrent-fire race in the Event-Based gateway's deferred choice).
Tracking issue: #164 (bug).
Paired doc: none (local to internal/instance track event delivery).
Upstream: SRD-024 v.1 (the gate this fixes), SRD-026 v.1 (signals — concurrent broadcast delivery makes the race reachable), ADR-001 v.5 (the single-writer execution model the follow-up ADR in §7 would extend).
1. Symptoms¶
The Event-Based gateway's deferred-choice invariant — exactly one arm wins — can be violated under concurrent event delivery: two arms both win (a double-win). It surfaced as a CI flake.
--- FAIL: TestEventGatewayConcurrentFires (0.01s)
event_gateway_internal_test.go:238:
Error: Should be true
Test: TestEventGatewayConcurrentFires
Messages: exactly one arm wins
Rare: the test passes ~60×/60× locally under -race; CI hit the failure once. The test (internal/instance/event_gateway_internal_test.go:209) builds a gate with two signal arms (GO_A, GO_B), fires both concurrently via two goroutines each calling eh.PropagateEvent, waits for the instance to complete, then asserts (:238):
require.True(t, ranA != ranB, "exactly one arm wins")
ranA != ranB is an XOR — exactly one arm's path (arm + end) ran. A failure means both ran (the double-win) — or, far less likely, neither.
2. Root cause analysis¶
The flake is two distinct concurrent-fire races on the gate track, not one — the second was found during implementation (eventMu fixed the double-win but the canary still failed). Both stem from the gate track being mutated by a waiter goroutine (ProcessEvent) racing another goroutine: a second waiter (race 1), then the track's own run() (race 2).
2.1 Race 1 — double-win (waiter-vs-waiter): a TOCTOU in track.ProcessEvent¶
An Event-Based gateway subscribes the hub on behalf of all its arms through a single gate track (SRD-024 v.1 §4.1) — the gate track is the one EventProcessor for every arm's definition. So two concurrent eh.PropagateEvent calls (GO_A, GO_B) become two waiter goroutines both invoking ProcessEvent on the same track. The method guards on the track state, then transitions it much later, with no lock across (internal/instance/track.go):
func (t *track) ProcessEvent(...) error {
if !t.inState(TrackWaitForEvent) { ... } // :838 — guard (check)
... ep.ProcessEvent(...) ... // bind + (gate) resolve the arm
t.advanceToArm(n, er, eDef) // :889 — append the winning arm's step
t.updateState(TrackReady) // :892 — state leaves WaitForEvent (act)
}
goroutine A (GO_A) goroutine B (GO_B)
:838 inState(WaitForEvent)=true
:838 inState(WaitForEvent)=true ← both pass
:889 advanceToArm(armA)
:889 advanceToArm(armB) ← both arms appended
Both append an arm step → both arms run → ranA && ranB. The narrow window (state changes only at :892) is why it is rare. Signals make it reachable in real use: a broadcast (broadcastSignal, SRD-026) fans out by name and can deliver to two arms near-simultaneously.
2.2 Race 2 — gate self-execution (waiter-vs-run())¶
Serializing ProcessEvent (race 1) was necessary but not sufficient — the stress canary still failed, now with the instance Completed but the winning arm short of its end (arm=true, end=false). A different goroutine pair: the waiter (ProcessEvent) vs the track's own run() loop, which captured the step before the wait-guard:
for {
step := t.currentStep() // captured here — the GATE step
... if t.inState(TrackWaitForEvent) { continue } ... // :443 — wait-guard
... executeNode(ctx, step) ... // ran the stale captured step
}
When ProcessEvent (waiter goroutine) flipped the state out of WaitForEvent and appended the arm step mid-iteration, run() fell through the :443 guard but executed the stale GATE step — the gate node's Exec fails loudly ("the gate must not be executed", SRD-024) → TrackFailed, and the instance then silently completed with the arm short of its end. (That silent completion of a TrackFailed track is a separate latent bug — see §7 / FIX-008 / #165.)
2.3 Why the test didn't catch it earlier¶
TestEventGatewayConcurrentFires fired the two events exactly once per run — it exercised the races but only non-deterministically triggered them, so it passed nearly always and did not reliably guard the invariant. §4.1 adds a looped stress canary that reproduces both.
3. Solution¶
A two-part serialization fix plus a benign-drop tidy:
eventMu(race 1): a dedicated per-track mutex held acrossProcessEvent's critical section, so the first fire completes (transitioning the state) before any second fire is evaluated; the loser then fails the guard.- Re-fetch-after-guard (race 2):
run()reads the current step after the wait-guard, not before — once the track is no longerWaitForEvent,advanceToArmhas appended the arm step, sorun()runs the arm, never the stale gate step.eventMualone did not fix this (a different goroutine pair); the re-fetch is the surgical fix for the waiter-vs-run()race. - Benign loser-drop: the guard returns
eventproc.ErrRejected(not an InvalidState error), and the signal waiter treatsErrRejectedas a Debug no-op (mirroring the message waiter), so the normal deferred-choice drop no longer logs a misleading "delivery failed" WARN.
The correlation-reject path (validateAndAssociate → ErrRejected) needs no special handling: it doesn't transition the state, so a wrong-conversation message naturally stays waiting.
3.1 Alternatives considered¶
| Alternative | Pros | Cons | Decision |
|---|---|---|---|
A. eventMu + re-fetch-after-guard |
Surgical; serializes waiter-vs-waiter (eventMu) and fixes waiter-vs-run() (re-fetch); the reject path is correct for free |
a losing fire blocks briefly (µs) | ✅ chosen |
B. Claimed-flag (bool guard + claim/release) |
loser fails fast (no block) | needs an explicit un-claim on the reject path or the waiter wedges — extra state, error-prone | ❌ rejected |
C. Async event-consumer / single-writer event delivery (route ProcessEvent through the instance loop) |
eliminates the whole waiter-goroutine race class (both races + future ones) | changes PropagateEvent's synchronous contract (inline error / no-catcher no-op / ErrRejected) — a design change touching the waiter→track→loop flow |
❌ for now → ADR-017 (§7) |
eventMu alone (a per-site lock for race 1) was insufficient — race 2 (waiter-vs-run()) needed the re-fetch. Per-site fixes patch each site; the race class is what ADR-017 would close.
Critical detail: eventMu MUST be a new mutex, not t.m. t.m (a sync.RWMutex) is taken inside ProcessEvent (the steps read; advanceToArm) — Go mutexes aren't reentrant, so holding t.m across the body would deadlock.
3.2 Changes by file¶
§3.2.1 internal/instance/track.go¶
eventMu sync.Mutexfield ontrack(besidem sync.RWMutex), held as the first statement ofProcessEvent(:835). The guard's not-WaitForEventcase (:838) now returnseventproc.ErrRejected(benign drop) instead of an InvalidState error.run()re-fetch (:454):step := t.currentStep()moved to after the wait-guard (:443), so it observes the advanced arm step rather than the stale gate step.
§3.2.2 internal/eventproc/eventhub/waiters/signal.go¶
The delivery loop treats errors.Is(err, eventproc.ErrRejected) (:212) as a Debug no-op ("signal delivery skipped: catcher not waiting", :216) and continues; other errors keep the existing Warn (:223). Mirrors the message waiter.
§3.2.3 internal/eventproc/eventhub/waiters/signal_test.go¶
TestSignalWaiterEdgeCases extended with an ErrRejected-returning catcher (covers the benign branch).
(The timer waiter was not changed — see §6.)
4. Verification¶
TestEventGatewayConcurrentFires (event_gateway_internal_test.go:210) exercised the races non-deterministically — not a reliable canary.
4.1 Regression tests¶
TestEventGatewayConcurrentFiresStress (event_gateway_internal_test.go:247): the two-arm gate, both arms fired concurrently, looped 500× (a fresh instance per iteration) under -race, asserting exactly one arm wins every iteration. It reproduces both races pre-fix (the double-win, then the gate-self-execution at iter ~354) and is green post-fix.
TestSignalWaiterEdgeCases (waiters/signal_test.go:95) is extended with an ErrRejected-returning catcher — covers the benign-drop branch.
Post-fix: go test ./internal/instance/ -run ConcurrentFires -race -count=8 green; make ci green.
4.5 Observability¶
None needed — the deferred-choice loser-drop is now a Debug no-op (signal delivery skipped: catcher not waiting), not a WARN.
5. Prevention¶
- Doc-comment on
ProcessEventstating the serialization invariant: one event is processed to completion per track, guarded byeventMu; concurrent fires at an Event-Based gateway are serialized so exactly one arm wins. - Doc-comment on the
run()re-fetch: the step is read after the wait-guard so a gate advance landing mid-iteration is observed (never the stale gate step). - The §4.1 stress test is the named canary — if it falls, either the serialization or the re-fetch regressed.
6. Regressions / side-effects¶
6.1 What may rely on the old behaviour¶
ProcessEvent is the delivery path for every waited event — message/timer/signal intermediate catch, Receive Task, and the Event-Based gateway. Audit before landing:
- Deadlock: confirm
eventMunever nests witht.mor the instance loop in a cycle.eventMuis held across the body;t.mis taken inside (steps /advanceToArm) — strictly nested (eventMu ⊃ t.m), never the reverse, so no lock-order cycle. The loop never takeseventMu. - Normal single-event paths (catch, receive, gate first-fire) still work —
eventMuis uncontended there. - Correlation reject (
validateAndAssociate→ErrRejected) still leaves the track waiting (state untouched under the held lock). - The loser-drop now returns
eventproc.ErrRejected(was an InvalidState error); the signal + message waiters treat it as a benign no-op, so no failure surfaces to callers. - Timer-waiter note (follow-up, not in this FIX): the timer waiter still treats any delivery error as a waiter failure (
WSFailed), so a timer-arm loser-drop would mark the waiter failed. Pre-existing and rare — timer arms don't fire concurrently the way signals broadcast — so it's left unchanged to keep FIX-007 focused; aligning the timer waiter with the signal/message benign-ErrRejectedhandling is a small follow-up. - Busy-wait yield (race-2 side-effect, fixed here): the race-2 re-fetch moved
step := t.currentStep()out of theTrackWaitForEventbusy-wait, removing the per-spint.m.RLockthat implicitly yielded — tightening the hot spin enough to starve the per-instance loop under-race, which amplified a pre-existing ~1/100TestComplexAbortInstanceflake (master flakes too, same rate) to ~7/100. Aruntime.Gosched()in the spin'scontinuepath restores master parity (~1/100) with no added latency. The residual ~1/100 is the poll-basedTrackWaitForEventwait anti-pattern itself — its proper cure is single-writer event delivery (ADR-017), not this FIX. The yield is correct regardless (a hot busy-wait should not starve the scheduler).
6.2 Rollback path¶
Single-commit revert (the eventMu field, the run() re-fetch + runtime.Gosched() yield, the guard→ErrRejected, and the test).
7. Related¶
- Follow-up ADR — ADR-017 (single-writer event delivery): route
ProcessEventthrough the instance loop / a per-instance event queue so a track is mutated only by its owner — eliminates the whole waiter-goroutine race class (both races here + future ones) and aligns with ADR-001 v.5's single-writer principle, at the cost of redesigningPropagateEvent's synchronous contract (inline error / no-catcher no-op /ErrRejected). Drafted as a deferred conception after this fix lands. - Sibling bug — FIX-008 / #165: a
TrackFailedtrack silently completes the instance (evEnded→active--→Completed,lastErrlost) — found during this diagnosis (it masked race 2's failure as a silent partial completion). Latent in general; an independent surgical fix. - SRD-024 v.1 — the Event-Based gateway whose deferred-choice invariant this protects.
- SRD-026 v.1 — signals; concurrent broadcast delivery is what makes the race reachable in real use.
- ADR-001 v.5 — the single-writer execution model the §7 follow-up ADR would extend.
8. Implementation summary (stage-by-stage actual landings + deltas)¶
8.1 Stages by commit (branch fix/event-gate-concurrent-fire)¶
| Stage | Commit | Scope | Tests |
|---|---|---|---|
| Doc | 8e44513 |
FIX-007 (this doc, Draft) | — |
| Fix | 97db6c4 |
track.go (eventMu + run() re-fetch + guard→ErrRejected); signal.go (ErrRejected-benign delivery); signal_test.go (ErrRejected catcher); the stress canary |
TestEventGatewayConcurrentFiresStress, TestSignalWaiterEdgeCases |
The fix commit was amended once: an initial version also touched the timer waiter and had a per-package coverage gap; the timer change was reverted and the signal ErrRejected test added before landing.
8.2 Empirical findings — where reality diverged from the §3 draft¶
- The RCA was incomplete: one race → two. The draft (and #164) saw only the double-win (waiter-vs-waiter).
eventMufixed that, but the stress canary still failed — a second race (gate self-execution, waiter-vs-run()) surfaced, fixed by therun()re-fetch-after-guard. §2 now documents both. eventMunecessary-but-not-sufficient. A per-site lock closes only its site; race 2 was a different goroutine pair — the evidence motivating ADR-017 (the race class).- The feared "needs single-writer" turned out surgical. Diagnosis suggested race 2 might only be cleanly fixable via the async event-consumer (ADR-017); the re-fetch fixed it surgically. ADR-017 remains architectural prevention, not a prerequisite.
- Timer-waiter consistency reverted. An interim version also made the timer waiter treat
ErrRejectedbenignly; it dragged in the timer waiter's untestedProcesspath and wasn't the reported symptom, so it was reverted (the timer-arm benign-drop is a §6 follow-up). - A sibling latent bug found. The
TrackFailed-silently-completes hole (§7, FIX-008 / #165) was discovered while diagnosing race 2 — it had masked race 2's failure as a silent partial completion.
8.3 Verification (V-results)¶
make ciPASS: build, lint,-race, diff-coverage 100% of 30 changed lines (track.go15/15,signal.go15/15), govulncheck.TestEventGatewayConcurrentFiresStressgreen under-race -count=8(4000 concurrent-fire iterations); reproduced both races pre-fix.
9. Open questions¶
- None.