fix: make the bash global lock actually exclude path mutations - #9
beardthelion wants to merge 4 commits into
Conversation
Bugbot is paused — on-demand spend limit reachedBugbot uses usage-based billing for this team and has hit its on-demand spend limit. A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue. |
|
@beardthelion thanks for the contribution! could you back-merge main and make sure all CI passes? |
filelocks.go said bash "takes the global lock because a command can touch anything" and that the global lock "serializes bash with mutations". It did neither: acquirePath sent on a per-path channel and acquireGlobal sent on a separate one, so the only thing global excluded was a second bash. A bash command and a write to a file ran concurrently, which is the case the lock exists for. Replaced with one RWMutex: a path mutation holds RLock for as long as it holds its path channel, bash holds Lock. Mutations still run in parallel with each other because readers do. The gate is taken before the path channel and released after it, but that order is a readability convention, not a deadlock guard: bash only ever takes the gate and never holds a path channel, so no wait cycle exists either way. Measured, since a lock change is only worth what was run. The new exclusion test observes max concurrency 2 before the fix and 1 after, and goes RED against a revert of filelocks.go to the old two-channel version. The load test drives 121 calls with a bash in the middle and proves the batch finishes and bash is not starved; it does not constrain acquisition order, and still passes with that order inverted, as does a same-path variant. Nothing paired bash with a path mutation before, which is why the suite stayed green over a documented invariant that was false.
The fix's own risk is not the bug it fixes. Every path mutation now read-locks the gate bash write-locks, so an exclusive lock on both sides would pass the bash-versus-write test while serializing every write in a batch, and Go parks new readers once a writer waits, which is a starvation shape nothing looked at. Both are load-bearing rather than decorative: swapping acquirePath's RLock for Lock drops different-path concurrency from 3 to 1 and turns the first test red. The second drives 121 calls with a bash in the middle and fails on a batch that does not finish or a bash that never runs; it completes in 17ms.
03434cd fixed Close to set the closed flag, but nothing in the package proves it. TestConnectDuringCloseDiscardsSession sets m.closed by hand and never calls Close, so removing the fix leaves the whole internal/mcp suite green — verified by reverting those three lines and running the package. This test calls the real Close, asserts the flag, and polls two seconds for a restored session. It is RED against that same mutant and GREEN with the fix in place, so the guard cannot go inert again unnoticed.
a90ccaf to
1ffa940
Compare
|
Back-merged main and rebased. This is three commits on The MCP half is dropped: CI has not run on this PR on any head yet. A fork PR needs a maintainer to approve the workflow run, so that one is on your side. Everything I can run locally is clean: gofmt, vet, golangci-lint, go mod tidy, govulncheck, the four cross-compile targets, and the portable test set under |
The per-path section still described the design this branch replaces: bash holding a single global channel that excluded nothing but a second bash. Say what the code now does instead.
Fixes #8.
Rebased onto main. The MCP half of this PR is gone:
03434cdfixedManager.Closeupstream while this was open, with the same remedy, so all that survives of it here is a test.The bash global lock didn't exclude path mutations (
f507fec).acquirePathsent on a per-path channel andacquireGlobalsent on a separate one, so the only thing the global lock excluded was a second bash. A bash command and a write to a file ran concurrently, which is the case the lock exists for. Replaced the two channels with onesync.RWMutex: a path mutation holdsRLockfor as long as it holds its path channel, bash holdsLock. Mutations still run in parallel with each other because readers do.ed07d91covers what the lock change itself could break rather than what it fixes: an exclusive lock on both sides would pass the bash-versus-write test while serializing every write in a batch, and Go parks new readers once a writer waits, which is a starvation shape nothing looked at.1ffa940adds a test for yourClosefix.TestConnectDuringCloseDiscardsSessionsetsm.closedby hand and never callsClose, so deleting those three lines fromCloseleaves the wholeinternal/mcpsuite green. The new test calls the realClose, asserts the flag, and polls two seconds for a restored session, and it is red against that same revert.c2728e0updatesdocs/concurrency.md, which still describedbashtaking the global channel this replaces.What was measured:
internal/mcpsuite with the flag set removed fromCloseSwapping
acquirePath'sRLockforLockdrops different-path concurrency from 3 to 1 and turns that test red, so it is not passing by construction.One correction to the earlier version of this PR. I had claimed the acquisition order, gate before path channel, prevents a deadlock. It does not: bash only ever takes the gate and never holds a path channel, so no wait cycle exists either way, and the load test still passes with the order inverted. The code comment and commit message now say that instead.
Locally
gofmt -s -l,go vet,golangci-lint,go mod tidyand the four cross-compile targets are clean, and the portable test set passes under-race -shuffle=on.TestAuthInferenceNetBYOKNoKeyhangs on this machine for lack of network and does the same on an unmodified main, so I skipped it to measure; coverage with it skipped is 89.4% here against 89.3% for main.