Skip to content

extract_llm: rep.Gate from per-candidate goroutines can crash the proxy (concurrent map writes) #119

Description

@amiddavid

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:1343rep.Gate("deduped_inflight_extraction")
  • components/offload/extract_llm.go:1371rep.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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions