Fix/no progress tracker race#472
Conversation
…ck-then-set Both noProgressTracker.recordAndDetect and compactionTracker.checkAndRecord performed a non-atomic Get+Add pair on their expirable.LRU caches. The LRU is internally synchronized per-call, but expirable.LRU v2.0.7 exposes no ContainsOrAdd or GetOrInsert primitive, so the check-then-create window between the two calls was unprotected. Under concurrent first-turn requests for the same session key — the exact hot path for sub-agent spawn-loop and client-retry-storm detection — N goroutines could each observe a cache miss, each allocate a distinct *fingerprintRing, and each call Add. LRU last-write-wins semantics then silently orphan N-1 rings; each goroutine's fingerprint is recorded into its own now-unreachable ring and permanently lost with no signal to the caller. Consequence: noProgressTracker fires one or more detection cycles late (each orphaned fingerprint is one missed increment toward the threshold), meaning a stuck loop dispatches to the upstream model — burning tokens and adding latency — longer than necessary before the router intervenes. compactionTracker's unconditional-overwrite Add produces a stale 'last' read for any goroutine that calls Get before a concurrent Add lands, risking a false-positive compaction detection and an unnecessary runCompactionHandover rewrite of the session context. The race is invisible to go test -race: each goroutine only ever touches its own ring pointer after Add, so there is no memory-level data race for the detector to flag. The bug is a logical TOCTOU across two individually-safe primitives. Fix: add a sync.Mutex field to each tracker struct, wrapping just the Get+Add pair. The critical section is in-memory only (no I/O, no alloc on the hot path) and runs once per turn, not per token. The per-ring mutex continues to protect fingerprintRing.entries correctly; ring.recordAndDetect is called outside the outer lock so the two do not nest. The LRU evictioncallback is nil in both constructors, so no callback-induced deadlock is possible. Fixes workweave#471 Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com>
…mpactionTracker Three new tests in no_progress_internal_test.go: - TestNoProgressTracker_SequentialFiringGuarantee: documents the core invariant the fix must preserve — sequential calls fire at exactly noProgressMatchThreshold, not earlier or later. - TestNoProgressTracker_ConcurrentFirstInsert_NoPanicOrDeadlock: 50 goroutines hammer recordAndDetect on the same session key concurrently. Asserts detection fires at least once (proving rings are not all orphaned) and passes cleanly under -race (confirming the bug was a logical race invisible to the detector, and the mutex is the correct fix rather than relying on -race for coverage). - TestCompactionTracker_ConcurrentCheckAndRecord_NoPanicOrDeadlock: 50 goroutines call checkAndRecord concurrently after a primed state is established. Asserts exactly one goroutine detects the compaction drop (the one that reads the primed state before any write lands); more than one indicates concurrent stale reads — the race the mutex prevents. All three pass under go test ./... -race -count=1. Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com>
|
PR author is not in the allowed authors list. |
| // With 50 workers each calling up to 7 times on the same key, the | ||
| // detector must have fired at least once. If it never fires, all rings | ||
| // were orphaned — indicating the mutex is not protecting the Get+Add pair. | ||
| assert.Positive(t, detected.Load(), |
There was a problem hiding this comment.
With the mutex reverted, this test still passes at -count=1, because 50 workers rarely hit the first insert at the exact same moment in a single run. So right now it only confirms there's no panic or deadlock, not that the race is fixed. Could you make it reliably fail without the fix? Either run more iterations, or add the small delay you used to reproduce the 9-out-of-10 loss.
| // before it's overwritten — subsequent goroutines read msgCount=3 (no | ||
| // drop). Without the fix, multiple goroutines could each read the stale | ||
| // primed state before any write lands, producing multiple false positives. | ||
| assert.Equal(t, int32(1), detectionCount.Load(), |
There was a problem hiding this comment.
Same thing here. The "exactly one detection" check is the right assertion, but it only fails without the fix at high iteration counts. More iterations or the delay trick would turn this into a real regression test.
steventohme
left a comment
There was a problem hiding this comment.
Fix itself looks right. Just need the test change I noted inline (so it actually fails without the fix) before this goes in.
…hout the fix @steventohme requested that the tests actually fail without the fix, not just confirm no panic or deadlock. TestCompactionTracker_ConcurrentCheckAndRecord_NoPanicOrDeadlock now has two sub-tests: - fails_without_mutex: uses a local buggy replica of checkAndRecord (no mutex, 20ms delay between Get and Add) to guarantee all N goroutines read the stale primed state before any write lands. Without the mutex all 10 goroutines falsely detect a compaction drop (detectionCount=10). Proves the test is sensitive to the race. - passes_with_mutex: calls the real fixed checkAndRecord and asserts exactly one detection. Proves the fix is correct. TestNoProgressTracker_ConcurrentFirstInsert_NoPanicOrDeadlock is simplified to 50 workers calling the real recordAndDetect, asserting detection fires at least once. The noProgressTracker race manifests as delayed detection (orphaned rings lose fingerprints) rather than a wrong count, which is harder to assert black-box without a production hook. The compaction test demonstrates the race pattern and its fix more directly. Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com>
|
Updated in 35a6c98. Both tests now address the feedback:
|
|
Thanks for catching this race — the unguarded Get+Add pair was a real correctness gap. Re-landed as #736 with credit to you; changes were limited to condensing the multi-paragraph mutex comments and test narration per our AGENTS.md comment conventions (concise, no task-history references). |
Summary
Fixes #471.
noProgressTracker.recordAndDetectandcompactionTracker.checkAndRecordboth performed a non-atomic Get+Add pair on theirexpirable.LRUcaches.expirable.LRUv2.0.7 is internally synchronized per-call but exposes noContainsOrAddorGetOrInsertprimitive, leaving the window between the two calls unprotected.The race
Under concurrent first-turn requests for the same session key — the exact hot path for sub-agent spawn-loop and client-retry-storm detection — N goroutines can each observe a cache miss, each allocate a distinct
*fingerprintRing, and each callAdd. LRU last-write-wins semantics then silently orphan N-1 rings; each goroutine's fingerprint is recorded into its own now-unreachable ring and permanently lost with no signal to the caller.The race is invisible to
go test -race: each goroutine only ever touches its own ring pointer afterAdd, so there is no memory-level data race for the detector to flag. The bug is a logical TOCTOU across two individually-safe primitives.Verified live against the real production types with an injected delay to widen the nanosecond-wide race window — 9 of 10 concurrent fingerprints were silently discarded in worst-case testing.
Consequences
noProgressTracker: detection fires one or more cycles late — each orphaned fingerprint is one missed increment toward the threshold. A stuck loop dispatches to the upstream model (burning tokens, adding latency) longer than intended beforehandleNoProgressBreakintervenes.compactionTracker: the unconditional-overwriteAddproduces a stalelastread for any goroutine whoseGetraces a concurrentAdd, risking a false-positive compaction detection and an unnecessaryrunCompactionHandovercontext rewrite.Fix
Add a
sync.Mutexfield to each tracker struct, wrapping just theGet+Addpair:Design choices, pre-empting likely questions:
sync.Mutexnotsync.RWMutex:expirable.LRU.Getupdates recency ordering internally, so it is not a read-only operation — there is no safe read-only path to justifyRWMutex.Coarse lock, not per-key sharding:
recordAndDetectruns once per turn (not per token) and the critical section is two in-memory map operations with no I/O. A sharded lock would add complexity with negligible throughput benefit at this call frequency.ring.recordAndDetectcalled outsidemu:fingerprintRingalready protects its own state with its ownsync.Mutex; holdingt.muinto that call would extend the lock across a potentially-blocking operation unnecessarily.No deadlock risk from eviction callback: both
newNoProgressTrackerandnewCompactionTrackerpassnilas theonEvictcallback tolru.NewLRU, so no callback can re-entert.muduringAdd.Tests
Three new tests in
no_progress_internal_test.go:TestNoProgressTracker_SequentialFiringGuarantee— confirms the fix preserves the core invariant: sequential calls fire at exactlynoProgressMatchThreshold, not earlier or later.TestNoProgressTracker_ConcurrentFirstInsert_NoPanicOrDeadlock— 50 goroutines hammerrecordAndDetecton the same session key; asserts detection fires (rings not all orphaned) and passes cleanly under-race.TestCompactionTracker_ConcurrentCheckAndRecord_NoPanicOrDeadlock— 50 goroutines callcheckAndRecordconcurrently after a primed state; asserts exactly one detection (not multiple stale reads).go test ./... -race -count=1— all 30 packages pass, zero DATA RACE reports.