Fix activator double-unlock that crashed miren under activation contention#888
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThis change updates Changes
Sequence Diagram(s)sequenceDiagram
participant Goroutine
participant requestPoolCapacity
participant Mutex
participant EntityStore
Goroutine->>requestPoolCapacity: call requestPoolCapacity
requestPoolCapacity->>Mutex: lock
requestPoolCapacity->>EntityStore: lookup/create pool
alt pool already exists
requestPoolCapacity->>Mutex: unlock
requestPoolCapacity->>requestPoolCapacity: continue poolLoop
else pool missing
requestPoolCapacity->>Mutex: unlock
requestPoolCapacity->>requestPoolCapacity: retry after backoff
end
requestPoolCapacity-->>Goroutine: return
Related Issues: MIR-1306 Related PRs: None mentioned Suggested labels: bug, concurrency, regression-test Suggested reviewers: None specified Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@components/activator/activator_test.go`:
- Around line 3691-3697: The example command in
TestRequestPoolCapacityRetryRaceNoDoubleUnlock is misleading because it
recommends running with -race even though the known increment-path race will
trip the detector and fail the test. Update the test comment near the retry-race
scenario to either remove -race from the documented ./hack/dev-exec go test
invocation or explicitly note that the race check is intentionally deferred, and
if needed add a guard in the test itself to skip or avoid the race-detector path
when running under -race.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a19ff5e2-c30b-41f7-addc-f72b72d9b0c6
📒 Files selected for processing (2)
components/activator/activator.gocomponents/activator/activator_test.go
requestPoolCapacity's pool-creation path runs an inner retry loop whose invariant is that a.mu is held at the top of every iteration: the attempt>0 branch releases the lock before its backoff sleep and re-acquires it afterward. The "another goroutine already created the pool" branch broke that invariant. It unlocked and then did a bare `continue`, which re-enters the inner loop rather than the outer loop the comment intended. On the next iteration the backoff's a.mu.Unlock() ran against an already-unlocked RWMutex, a fatal, unrecoverable throw that took down the whole process (embedded etcd and every app on the node). It only fires when a concurrent goroutine wins the pool-creation race on a retry attempt, i.e. under activation contention. #883's pool-eviction churn drove more traffic through this path and surfaced it on garden, though the racy structure itself dates back to October. Label the outer loop and make that branch `continue poolLoop` so it breaks all the way out and picks up the now-cached pool through the normal wait/increment path. The sibling branch just above gets the same explicit label (already correct, just implicit), and the inner loop's lock invariant is now spelled out in a comment. Adds a stress test that drives the exact race: a pool that becomes visible in the store while concurrent callers are mid-backoff. Closes MIR-1306
e2cfcbf to
6c2972a
Compare
Garden hard-crashed with
fatal error: sync: Unlock of unlocked RWMutex, which on a single-node miren means the whole process goes down: embedded etcd, every app, everything. Asystemctl restartbrought it back, but it's the kind of bug that bites again the moment traffic spikes.The culprit lives in the activator's
requestPoolCapacity. When no pool is cached yet, it runs a small retry loop that polls the entity store for a pool the DeploymentLauncher might be creating concurrently. That inner loop has an invariant:a.muis held at the top of every iteration, because the backoff between attempts unlocks the mutex during the sleep and re-locks before the next store query. One branch broke it. When a goroutine noticed another goroutine had already populated the cache, it unlocked and did a barecontinue, meaning to loop back to the outer wait/increment logic. Butcontinueonly targets the nearest enclosing loop, so it re-entered the inner loop with the lock released. The next iteration's backoff-unlock then fired on an already-unlocked mutex, and Go turns that into a fatal throw you can't recover from.It's a narrow race: it only triggers when a concurrent goroutine wins the pool-creation race specifically on a retry attempt, so you need real activation contention (lots of apps waking at once) to hit it. That's why it stayed hidden since October and only showed up now. #883's pool-eviction work increased pool churn and pushed enough traffic through this path to finally surface it.
The fix labels the outer loop and makes that branch
continue poolLoop, so it breaks all the way out and picks the pool up through the normal cached path with the lock in the state the outer loop expects (released). While in here I audited the rest of the retry loop's lock discipline; this was the only actual defect, but I gave the sibling branch just above it the same explicit label (it was already correct by accident of nesting) and wrote the invariant down in a comment so the next person doesn't have to re-derive it. There's a new stress test that reconstructs the race by making a pool appear in the store while a crowd of callers is mid-backoff.One thing worth flagging separately: running that stress test under
-racealso turns up a pre-existing data race on the increment path (concurrent access to the shared cached pool state), unrelated to this crash and untouched by this change. The normal suite doesn't run-race, so it's not blocking, but it's real and I'll file a follow-up.Closes MIR-1306