Skip to content

fix(worktree): validate wt.schema under the repo lock - #109

Merged
justin13888 merged 1 commit into
masterfrom
fix/106-schema-under-lock
Aug 31, 2026
Merged

justin13888 merged 1 commit into
masterfrom
fix/106-schema-under-lock

Conversation

@justin13888

Copy link
Copy Markdown
Contributor

What

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. This folds the gate into acquire_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_supported takes a gix::Repository, and gix snapshots 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 what ensure_schema_supported_now does.

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:

  • Six CLI paths had no schema check at all — prune.rs (×2), remove.rs, pr.rs (×2) and pr_open.rs relied entirely on the check at session discovery. They are gated now.
  • A refusal drops the lock on the way out, so one stamped repository cannot wedge later mutations for the full 10s timeout.

Hook re-entrancy from #99 is preserved trivially: no lock acquisition and no hook call changes position. post_create still runs after the lock is released; pre_remove still 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_supported guards wt.* reads that happen outside the lock; the lock guards the mutation.

Site Kept? Reason
remove_in Kept Load-bearing. The wt.* metadata read, the guards, and the pre_remove hook all run before the lock (they must, for re-entrancy). Reading wt.* under an unsupported schema could misinterpret it.
create_in Kept Fast path only. Fails a stamped repository before enumeration and base resolution instead of after.
issue.rs::link_issue Kept Its handle is also used for the read it encloses; left alone (see follow-up below).
write_meta, clear_meta Removed Check-then-lock with nothing in between — the lock now expresses exactly this. Two purposeless fresh_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 a Workspace, signals over a channel, then blocks on the lock; the test thread bumps wt.schema to 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:

Command Result
mise run test 881 passed, 0 failed
mise run check-core 364 + 624 passed, 0 failed (matters here — lock_repo is cfg(feature = "cli"))
mise run lint clean under -D warnings
mise run format-check clean
mise run coverage 92.66% total, worktree/service.rs 95.44% (threshold 80%)

Acceptance criteria

  • Every mutating path validates the schema under the same lock that guards the mutation.
  • Hook re-entrancy from feat: wt.schema version and repo advisory lock #99 is preserved — hooks still run outside the lock.
  • A test covers a schema bump landing concurrently with a mutation.

Follow-up, not fixed here

issue.rs::link_issue opens Repo::discover before the lock and then calls read_meta(repo.gix(), …) after it, so its read-check-write reads a stale config snapshot. pr.rs and pr_open.rs look 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::discover per lock acquisition — once or twice per command (prune holds a single lock across its whole loop), against the git subprocess work these paths already do.

Closes #106

`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
@justin13888
justin13888 merged commit 7c0080d into master Aug 31, 2026
3 checks passed
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.

fix: validate wt.schema under the repo lock, not before it

1 participant