fix(worktree): validate wt.schema under the repo lock - #109
Merged
Merged
Conversation
`ensure_schema_supported` ran before the advisory repo lock on every mutating path, so a concurrent `wt.schema` bump was not excluded from the window the gate exists to protect. The gap was widest in `create_in`, where enumeration, the branch-exists probe, base resolution and the reuse decision all ran between the check and the acquisition. Moving the existing calls below the lock would not have fixed it: `gix` snapshots the git config when a repository is opened, so a handle obtained before the lock cannot see a bump that landed during the wait. The read has to come from a repository opened after the lock is held. Fold the gate into `acquire_repo_lock` instead, reading through a freshly discovered repository once the lock is held. Every mutating path already takes the lock, so this makes "no mutation runs against an unsupported schema" hold by construction — including `prune`, `branch remove` and the `pr` checkout paths, which had no schema check at all and relied on the one at session discovery. A refusal drops the lock on the way out. Nothing about hook re-entrancy moves: no lock acquisition or hook call changes position, so `post_create` still runs after the lock is released and `pre_remove` still runs before it is taken. The pre-lock checks in `create_in` and `remove_in` stay as fast paths — `remove_in`'s is load-bearing, guarding the `wt.*` read, the guards and the hook that all precede the lock. The ones in `write_meta` and `clear_meta` are gone: they were check-then-lock with nothing in between, which the lock now expresses on its own. Closes #106 Claude-Session: https://claude.ai/code/session_01VBq1c3eXjLBYXHMfexqeAN
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
ensure_schema_supportedran before the advisory repo lock on every mutating path, so a concurrentwt.schemabump was not excluded from the window the gate exists to protect. This folds the gate intoacquire_repo_lock, so the schema is validated with the lock held and read fresh from disk at that moment.Why moving the calls would not have worked
ensure_schema_supportedtakes agix::Repository, andgixsnapshots the git config when a repository is opened. Relocating the existing calls below the lock would still have read a handle opened before the wait — it would have looked fixed and changed nothing. The read has to come from a repository opened after the lock is held, which is whatensure_schema_supported_nowdoes.Approach
The gate lives in
acquire_repo_lock, immediately after the marker is taken. Every mutating path already goes through that function, so "no mutation runs against an unsupported schema" now holds by construction rather than by each call site remembering to ask.Two things fell out of that which the issue did not list:
prune.rs(×2),remove.rs,pr.rs(×2) andpr_open.rsrelied entirely on the check at session discovery. They are gated now.Hook re-entrancy from #99 is preserved trivially: no lock acquisition and no hook call changes position.
post_createstill runs after the lock is released;pre_removestill runs before it is taken. The tests that pin both orderings are untouched and still pass.Which pre-lock checks stayed, and why
Half-fixing this would leave the paths inconsistent, so the rule is stated once and applied consistently:
ensure_schema_supportedguardswt.*reads that happen outside the lock; the lock guards the mutation.remove_inwt.*metadata read, the guards, and thepre_removehook all run before the lock (they must, for re-entrancy). Readingwt.*under an unsupported schema could misinterpret it.create_inissue.rs::link_issuewrite_meta,clear_metafresh_repo()calls went with them.Tests
Three new tests in
worktree::service:a_schema_bump_landing_during_the_lock_wait_is_refused— the acceptance test. A writer discovers aWorkspace, signals over a channel, then blocks on the lock; the test thread bumpswt.schemato 2 while holding the lock, then releases it. Ordering is forced, not slept on, so it is deterministic.a_schema_refusal_does_not_strand_the_lock— a refused acquisition leaves no lock file, and the next acquisition succeeds.the_lock_gate_reads_a_bare_repository_too— for a bare primary the lock root is the git directory, so the re-open must not depend on a workdir.Verified the first two fail on the pre-fix ordering: the blocked write returned
Ok(()), which is the defect in #106 exactly.Validation
All run in this worktree, all passing:
mise run testmise run check-corelock_repoiscfg(feature = "cli"))mise run lint-D warningsmise run format-checkmise run coverageworktree/service.rs95.44% (threshold 80%)Acceptance criteria
Follow-up, not fixed here
issue.rs::link_issueopensRepo::discoverbefore the lock and then callsread_meta(repo.gix(), …)after it, so its read-check-write reads a stale config snapshot.pr.rsandpr_open.rslook similar. That is a distinct staleness defect from #106's gate ordering and deserves its own change rather than widening this one.Cost
One extra
gix::discoverper lock acquisition — once or twice per command (pruneholds a single lock across its whole loop), against the git subprocess work these paths already do.Closes #106