Skip to content

fix: make the bash global lock actually exclude path mutations - #9

Open
beardthelion wants to merge 4 commits into
context-labs:mainfrom
beardthelion:fix/tool-lock-and-mcp-close
Open

beardthelion wants to merge 4 commits into
context-labs:mainfrom
beardthelion:fix/tool-lock-and-mcp-close

Conversation

@beardthelion

@beardthelion beardthelion commented Aug 26, 2026

Copy link
Copy Markdown

Fixes #8.

Rebased onto main. The MCP half of this PR is gone: 03434cd fixed Manager.Close upstream 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). acquirePath sent on a per-path channel and acquireGlobal sent 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 one sync.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.

ed07d91 covers 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.

1ffa940 adds a test for your Close fix. TestConnectDuringCloseDiscardsSession sets m.closed by hand and never calls Close, so deleting those three lines from Close leaves the whole internal/mcp suite green. The new test calls the real Close, asserts the flag, and polls two seconds for a restored session, and it is red against that same revert.

c2728e0 updates docs/concurrency.md, which still described bash taking the global channel this replaces.

What was measured:

check before after
bash + write concurrency 2 1
bash + bash concurrency (control) 1 1
different-path writes 3 3
121 calls with a bash mid-batch n/a finishes, bash runs
internal/mcp suite with the flag set removed from Close green red

Swapping acquirePath's RLock for Lock drops 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 tidy and the four cross-compile targets are clean, and the portable test set passes under -race -shuffle=on. TestAuthInferenceNetBYOKNoKey hangs 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.

@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot 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.

@atbe

atbe commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

@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.
@beardthelion
beardthelion force-pushed the fix/tool-lock-and-mcp-close branch from a90ccaf to 1ffa940 Compare August 27, 2026 02:52
@beardthelion beardthelion changed the title fix: make the bash global lock exclude file writes, and Manager.Close actually close fix: make the bash global lock actually exclude path mutations Aug 27, 2026
@beardthelion

Copy link
Copy Markdown
Author

Back-merged main and rebased. This is three commits on a3097f7 now and shows mergeable.

The MCP half is dropped: 03434cd fixed Manager.Close the same way while this was open. The only thing left from it is a test, since deleting those three lines from Close leaves the whole internal/mcp suite green and the new test is red against that revert.

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 -race -shuffle=on.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Two lock/shutdown guards don't hold: bash doesn't exclude file writes, and Manager.Close reconnects

2 participants