components.Report.Gate (components/component.go:503) lazily creates and writes r.Gates, an
unlocked map. extract_llm calls it from inside its per-candidate goroutines:
components/offload/extract_llm.go:1343 — rep.Gate("deduped_inflight_extraction")
components/offload/extract_llm.go:1371 — rep.Gate("reply_truncated")
Both sit inside the region bounded by sem := make(chan struct{}, llmConcurrency) and a
sync.WaitGroup, so up to llmConcurrency (4) goroutines can reach them on one request.
Why this is worse than a data race
Concurrent map writes are a runtime fatal error, not a panic. pipeline.runOne's per-component
recover() does not catch it — nothing can. So the outcome is not "the component reverts and the
request proceeds"; it is the proxy process dies, taking every in-flight request with it.
That distinction matters for triage. Most failure modes in this component are designed to fail open
and cost a compaction. This one costs the process.
Why it has not been seen
Both branches are rare. deduped_inflight_extraction requires two goroutines to collide on the same
content within one request, and reply_truncated requires a reply to stop exactly at the output cap.
singleflight serialises part of the dedup path. So the window is narrow — but it widens with
candidate count, and a request with many large tool outputs is exactly when llmConcurrency is
saturated and truncation is most likely.
Found by
Building extract_llm_sweep (#118). The first version of that component called rep.Gate from its
own per-call goroutines and the cap test died with fatal error: concurrent map writes; under
-race it reported WARNING: DATA RACE … components.(*Report).Gate(). That component now
accumulates gate names per slot and raises them in the serial phase. extract_llm was left
untouched, so it still has the defect.
The fix, and the choice in it
Report is copied by value across this codebase, so it cannot carry a sync.Mutex — that is the
constraint that makes the obvious fix wrong. Two workable options:
- Accumulate per-slot, raise serially — what
extract_llm_sweep does. Each goroutine records
its gate names in its own slot of a pre-sized slice; the serial phase after wg.Wait() replays
them into the report. No locking, no API change, and it keeps Report copyable. Downside:
every concurrent component has to remember to do it.
- Give
Gate a package-level lock or make Gates a sync.Map — fixes every present and future
caller at once, but adds contention on a hot path and changes Report's shape, which
/stats, the Prometheus component counters and the dashboard's components table all read.
Option 1 is the local, proven fix; option 2 is the one that stops this recurring. Worth deciding
deliberately, because the same trap is available to any component that adds concurrency later — and
the failure mode it produces is a process crash, which is not the kind of thing to leave to a
convention nobody is reminded of.
Verification
A reproduction should run extract_llm with several large candidates under -race and assert no
race is reported — and be confirmed to FAIL (report the race) before the fix.
components.Report.Gate(components/component.go:503) lazily creates and writesr.Gates, anunlocked map.
extract_llmcalls it from inside its per-candidate goroutines:components/offload/extract_llm.go:1343—rep.Gate("deduped_inflight_extraction")components/offload/extract_llm.go:1371—rep.Gate("reply_truncated")Both sit inside the region bounded by
sem := make(chan struct{}, llmConcurrency)and async.WaitGroup, so up tollmConcurrency(4) goroutines can reach them on one request.Why this is worse than a data race
Concurrent map writes are a runtime fatal error, not a panic.
pipeline.runOne's per-componentrecover()does not catch it — nothing can. So the outcome is not "the component reverts and therequest proceeds"; it is the proxy process dies, taking every in-flight request with it.
That distinction matters for triage. Most failure modes in this component are designed to fail open
and cost a compaction. This one costs the process.
Why it has not been seen
Both branches are rare.
deduped_inflight_extractionrequires two goroutines to collide on the samecontent within one request, and
reply_truncatedrequires a reply to stop exactly at the output cap.singleflightserialises part of the dedup path. So the window is narrow — but it widens withcandidate count, and a request with many large tool outputs is exactly when
llmConcurrencyissaturated and truncation is most likely.
Found by
Building
extract_llm_sweep(#118). The first version of that component calledrep.Gatefrom itsown per-call goroutines and the cap test died with
fatal error: concurrent map writes; under-raceit reportedWARNING: DATA RACE … components.(*Report).Gate(). That component nowaccumulates gate names per slot and raises them in the serial phase.
extract_llmwas leftuntouched, so it still has the defect.
The fix, and the choice in it
Reportis copied by value across this codebase, so it cannot carry async.Mutex— that is theconstraint that makes the obvious fix wrong. Two workable options:
extract_llm_sweepdoes. Each goroutine recordsits gate names in its own slot of a pre-sized slice; the serial phase after
wg.Wait()replaysthem into the report. No locking, no API change, and it keeps
Reportcopyable. Downside:every concurrent component has to remember to do it.
Gatea package-level lock or makeGatesasync.Map— fixes every present and futurecaller at once, but adds contention on a hot path and changes
Report's shape, which/stats, the Prometheus component counters and the dashboard's components table all read.Option 1 is the local, proven fix; option 2 is the one that stops this recurring. Worth deciding
deliberately, because the same trap is available to any component that adds concurrency later — and
the failure mode it produces is a process crash, which is not the kind of thing to leave to a
convention nobody is reminded of.
Verification
A reproduction should run
extract_llmwith several large candidates under-raceand assert norace is reported — and be confirmed to FAIL (report the race) before the fix.