test: two CI flakes that were both unlocked reads of shared state (one test had also stopped testing its regression) - #281
Merged
Conversation
…side" `applying_a_workflow_hides_a_base_that_lands_mid_call` waited for the new base's DIRECTORY to appear before starting the apply. `create_base` builds a base in `.creating-<id>-<uuid>` and publishes it with a single rename, so that directory appears at the very END of the transaction — after `git init`, the initial commit, the graph cache, the classification stamp and the registry row. The wait therefore did the opposite of what its comment claimed, twice over: * It un-staged the race. With the base already installed when the apply started, the pre-fix unlocked inventory would have seen it too, so the test could no longer fail on the regression it is named for. Measured: with that inversion reintroduced in `workflow::runtime::apply_knowledge_selection`, the old barrier passes 5/5 and the new one fails on the first attempt. * Its 5 s budget became a bet on a whole `create_base` (subprocesses included) returning in time. It lost that bet in `test (windows-latest)` on `main` (run 34679956636, head 9096d37: 608 passed; 1 failed) and failed a PR with no Rust changes the same day. `KnowledgeService::publication_in_progress` is the observable that means "a publication holds the root lock right now and the base is not installed yet". It costs one `read_dir` and never waits on a subprocess. The stage loop now ends on an event either way — the creation becomes observable (proceed) or the creator thread finishes (the window was missed: stage a fresh one rather than assert into an unstaged run) — so a starved runner makes the test slower, never red. The single remaining clock is a 600 s wedge detector whose only reachable cause is the root lock being held by something else, and it says so. `a_creation_is_visible_as_staged_long_before_its_base_directory_exists` pins both halves of that contract from inside the transaction, through the creation's own checkpoints, which is the only place the claim is observable.
…nment says
`pinned_path_root` (extension manager) and `pinned_store_root` (global memory)
both opened with
let current = std::env::var("BIOROUTER_PATH_ROOT").ok();
env_lock::lock_env([("BIOROUTER_PATH_ROOT", current.as_deref())])
whose stated intent — hold the lock, do not change the root — is right, and
which does the opposite exactly when it matters. The read happens BEFORE the
lock is acquired, and around thirty tests in this binary legitimately point
that variable at a `TempDir` of their own under `env_lock` (`logging`,
`managed`, `providers::utils`, `session::diagnostics`, `skills_extension`,
`agents::agent`, `execution::manager`, `knowledge::conversation_ingest`, …).
When one of them holds the lock at that instant, `current` is ITS root: the pin
blocks, the relocator finishes, its guard restores the sandbox root and its
`TempDir` is deleted — and the pin then installs that deleted directory as this
test's root for the whole test. Everything the test resolves afterwards
(`Config::global()`, `extensions_root()`, the global memory store) points
outside its own sandbox.
That is the shared state behind
`a_removal_prunes_the_extension_from_every_stored_session_roster`, which failed
a full-suite run inside `install_sideloaded_fixture`'s `create_dir_all` with
`Os { code: 22, InvalidInput }` while passing 3/3 in isolation. A writer's lock
cannot protect an unlocked reader; the fix is to have no unlocked read.
`test_sandbox` records the root that is in effect before `main` — the one
moment at which reading the variable answers the right question — and
`pin_sandbox_path_root()` pins that. `the_lib_test_binary_config_root_is_sandboxed`
was exposed the same way and now reads the recorded value too.
Three tests, each failing before this change and passing after:
* `the_pin_source_ignores_a_root_another_test_has_installed` stages a thread
that holds `env_lock` with a foreign root and is released by a channel rather
than a sleep, then asserts the pin source is still the sandbox. Against the
pre-fix source it fails naming both temp directories.
* `the_pin_installs_the_sandbox_root_for_its_lifetime` checks the guard really
moves `Paths::config_dir()` and `extensions_root()` into the sandbox.
* `only_the_resolver_and_the_sandbox_read_the_path_root_variable` walks this
crate's sources and allows the read only in the resolver and this module —
verified non-vacuous by re-adding the old idiom, which it names by file and
line. Setting the variable under `env_lock` stays fine; reading it to find
out where the sandbox is does not.
…he read #282 ("pin the process session store before any test can move it") is the same root cause one layer down — a reader capturing `BIOROUTER_PATH_ROOT` at a moment it does not control, against a variable ~30 test sites in this binary relocate. Neither fix makes the other redundant, and after this merge they read as one rule: * #282 freezes the *session store's* path in the `#[ctor]`, so no later relocation can move a singleton that was resolved once. * This branch removes the unlocked *read* from the two pin helpers, so a test that resolves paths itself (`extensions_root()`, the global memory store) is told which root is its own rather than asking a variable whose current value belongs to whichever sibling is relocating it. Conflicts, both in `crates/biorouter/src/test_sandbox.rs`: 1. The `#[ctor]`. Kept #282's shape — the `is_none()` branch plus the `shared_store_root()` freeze — and record the root that ends up in effect after it, in both branches rather than only the one that mints a `TempDir`. 2. The test module. Kept all four tests from this branch and #282's `the_lib_test_binary_session_store_is_sandboxed`, with one change to the latter: its expected root is now `sandbox_path_root()` rather than a live `std::env::var`. As written it was a fresh instance of the hazard the rest of the module closes — a sibling relocating the root while it runs would have it compare a frozen store path against that sibling's `TempDir` and fail, blaming the store for a harness race. `only_the_resolver_and_the_sandbox_read_the_path_root_variable` allows the read inside this file, so nothing would have caught it. Nothing of Family A is touched: `publication_in_progress`, the event-only stage loop, the wedge detector and `a_creation_is_visible_as_staged_long_before_its_base_directory_exists` all merged cleanly.
…t spin The stage loop's exits are unchanged — the creation becoming observable, or the creator thread finishing — so this is not a budget. It is the cost of asking: `yield_now` spun a core and issued thousands of `read_dir`s over the few milliseconds a `create_base` needs to reach its first write, inside a binary where ~660 other tests share one session store and some of them are sensitive to load. It is not, on measurement, the whole story: the `-p biorouter-server --lib --bins` failure rate on this tree was 2/12 after this change and 3/20 before it, with the same two rotating names (`declassify_tests::…` answering 500, and `session_reach::bypass_tests::the_sidebar_continuation_value_…` reading a different session id). That is the shared-session-store family, not this test — recorded here because the measurement is what says so.
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
…as vacuous My own fix was the failure mode #281 found in its Family A: an assertion that passes for the wrong reason. The first commit took `env_lock` **pinned to `sandbox_root()`**, then asserted that `Paths::data_dir()` — which reads the very variable just written — starts with `sandbox_root()`. True by construction. It serialised correctly against the relocator and, in doing so, stopped testing anything: the sibling guard test in this binary deliberately reads its expected root from the ENVIRONMENT rather than from `shared_store_root()` for exactly this reason, and I had just broken that property one test over. An empty variable set acquires the same mutex and changes nothing, so the assertion reads the value the `#[ctor]` installed. It is not circular either: `sandbox_root()` is `temp_dir()` + pid, never the environment. **Falsified, so it cannot pass vacuously:** BIOROUTER_PATH_ROOT=/tmp/notsandbox.epKWb6 cargo test -p biorouter-server \ --test session_store_survives_a_relocated_path_root the_session_database_is_not_the_developers ... FAILED expected the per-process sandbox, got /tmp/notsandbox.epKWb6/data (The sibling `the_session_store_is_pinned_inside_the_sandbox` fails there too, correctly — with an external root the store is pinned outside the sandbox.) Unset: 3/3 pass.
Textually clean — zero conflicts. The "ctor conflict" the previous pass was resolving had already been settled in this branch's own earlier merges of #282 (ccdd9b2) and #280 (055cb08); what was left was an unpushed branch, not an unfinished resolution. GitHub reported DIRTY because the remote tip (24a6d65) was 23 commits behind the local branch. The one thing worth writing down is that #286 and this branch are the SAME rule at two layers, not two patches, and the difference between them is not a matter of taste: * A subject that resolves `BIOROUTER_PATH_ROOT` **live** and needs it held still takes `env_lock` and installs the RECORDED sandbox root (`pin_sandbox_path_root`) — the two call sites this branch corrects. * A subject that resolves it live where the live value IS the assertion takes `env_lock` with an EMPTY set — #286's `the_session_database_is_not _the_developers`. Pinning there would assert the value it had just written, which #286 shipped once and corrected. * A subject that is already FROZEN takes no lock at all and compares against the recorded root. `Config::global()` is a `OnceCell<Config>` and `SHARED_STORE_ROOT` a `LazyLock<PathBuf>` forced by the ctor (#282), so neither re-reads the variable and there is nothing for a lock to serialise — pinning them would be #286's vacuity in this crate. All three say the same thing: after main starts, the live variable answers "whichever of ~30 relocating tests holds it right now", so the only stable answer is one recorded before any test ran. `only_the_resolver_and_the_sandbox_read_the_path_root_variable` enforces that mechanically for `crates/biorouter/src`. Nothing in this branch was made redundant by #280, #282 or #286, so nothing was deleted. #280's five removed collision defences stay removed: verified that `seeded_target` carries no band counter and `reserve_child_session_ids` survives only in the doc comment recording its deletion.
Clean — no overlap with this branch's five files. main moved twice during verification; the rule recorded in the previous merge commit is unaffected.
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.
Two CI flake families, measured, both independent of the SQLite
busy_timeoutcontention and the
subagent_handle::HANDLESdeadlock being fixed elsewhere.Neither turned out to be a production bug — both are test-isolation defects,
and Family A's test had also stopped testing the regression it is named
for, which is the more important finding of the two.
Family A —
applying_a_workflow_hides_a_base_that_lands_mid_callMeasured before this branch
mainitself: run 34679956636 (head9096d371), job103516636542,test (windows-latest)—test result: FAILED. 608 passed; 1 failed,panicked at crates\biorouter-server\src\routes\agent.rs:5697:13: the creator must begin writing gamma while the test is waiting.other PRs passed the same job against the same
main.What the race is
Nothing — the test stopped staging one on 2026-08-21.
create_basebuilds a base in.creating-<id>-<uuid>and publishes it with asingle
rename, so<root>/<id>appears at the very end of thetransaction, after
git init, the initial commit, the graph cache, theclassification stamp and the registry row. The barrier waited for exactly that
directory, which means:
create_basereturning, subprocessesincluded, on a Windows runner concurrently running 608 other tests. That is
the assertion that fired: the staging barrier, not the invariant.
installed, the pre-fix unlocked inventory would have seen it too — so the
test could no longer fail on its own regression.
Point 2, measured by reintroducing the pre-fix inversion in
workflow::runtime::apply_knowledge_selection(list unlocked → invert →set_selectionwith a hidden list):kb_root(root, "gamma").exists()publication_in_progress("gamma0")left: ["alpha", "gamma0"],right: ["alpha"]History:
76952512("stabilize Windows knowledge selection race test")replaced a 10 ms sleep — which staged the window correctly but bet on the
creator thread being scheduled inside it — with a wait for the opposite event.
One flake was traded for a false pass plus a slower flake.
Not a production bug
KnowledgeService::set_visible_kbstakes the root lock and inverts thecaller's visible set against the inventory read inside that lock; the
caller has no way to hand it a stale snapshot. The nondeterminism was entirely
in the harness.
Fix
KnowledgeService::publication_in_progress(id)— oneread_dir, no lock, nosubprocess — is the observable that means "a publication holds the root lock
right now and the base is not installed yet".
observable (proceed), or the creator thread finishes (the window was missed —
stage a fresh one rather than assert into an unstaged run). A starved runner
makes the test slower, never red.
create_baseanyone has measured, reachable only when a creation can neitherpublish nor return, i.e. the root lock is held by something else — and it says
that instead of hanging the job.
a_creation_is_visible_as_staged_long_before_its_base_directory_exists(
biorouter-mcp) pins both halves from inside the transaction via thecreation's own checkpoints, which is the only place the claim is observable.
Family B —
a_removal_prunes_the_extension_from_every_stored_session_rosterMeasured before this branch
Full
-p biorouter --libsuite on a merged integration tree:4008 passed; 1 failed,panicked at crates/biorouter/src/agents/extension_manager_extension.rs:4266:79: called Result::unwrap() on an Err value: Os { code: 22, kind: InvalidInput, message: "Invalid argument" }— i.e.install_sideloaded_fixture'screate_dir_all(install_dir.join("skills").join(&skill_slug)). The same testpasses 3/3 in isolation.
The shared state, and who writes it
extensions_root()→Paths::config_dir()→Paths::get_dirreadsBIOROUTER_PATH_ROOTon every call, uncached — deliberately, becauseproduction honours the relocation.
test_sandbox's#[ctor]sets it to aTempDirbeforemain, and 29 further sites in the same binary thenlegitimately relocate it to a
TempDirof their own underenv_lock:logging,managed(×3),providers::utils(×5),session::diagnostics(×7),agents::skills_extension(×6),agents::agent(×3),agents::reply_parts,execution::manager(×2),extension_install::claim,knowledge::conversation_ingest(×2).The bug: the pin read the variable before taking the lock
Both call sites (
agents::extension_manager_extension,security::global_memory) documented the intent as "hold the lock, do notchange the root", and both did the opposite exactly when it mattered:
env_lockwith its ownTempDir.TempDiris deleted.the rest of the test.
So the fixture — and
Config::global(),extensions_root(), the global memorystore — resolve outside the test's own sandbox, under a path whose owner has
just removed it.
the_lib_test_binary_config_root_is_sandboxedwas exposed thesame way (it compared a frozen
Config::global()path against a live read ofthe variable).
A writer's lock cannot protect an unlocked reader. The fix is to have no
unlocked read:
test_sandboxrecords the root in effect beforemain— theone moment at which reading the variable answers the right question — and
pin_sandbox_path_root()pins that.On the
EINVALThe isolation defect is proven and fixed; the specific errno is not
reproduced, and I would rather say so than invent a mechanism. Ruled out by
measurement on this machine (APFS):
EILSEQ(andstd::env::varcannot yield one)ENAMETOOLONGcreate_dir_allinside a tree beingrm -rf'd (3087 creates)/var/folders/<hash>EACCES/-rooted (blank-home fallback)EROFS.config/biorouter/...(blank-home fallback)What is certain is that the fixture was writing somewhere it does not own. If
it recurs after this change, the root is provably the sandbox, which narrows it
to something other than the environment.
Fix, with three tests that fail before and pass after
the_pin_source_ignores_a_root_another_test_has_installed— a thread holdsenv_lockwith a foreign root and is released by a channel, not a sleep(every step is an event, so a starved runner makes it slower, never red), and
the test asserts the pin source is still the sandbox. Against the pre-fix
source it fails naming both temp directories:
left: "…/.tmpejKell",right: "…/.tmpEPxyAn".the_pin_installs_the_sandbox_root_for_its_lifetime— the guard really movesPaths::config_dir()andextensions_root()into the sandbox.only_the_resolver_and_the_sandbox_read_the_path_root_variable— walkscrates/biorouter/srcand permits the read only in the resolver and intest_sandbox. Verified non-vacuous: re-adding the old idiom makes it failnaming
agents/extension_manager_extension.rs:4014. Setting the variableunder
env_lockstays fine; reading it to find out where the sandbox is doesnot.
No
#[serial], no retry of an assertion, no widened race budget, no narrowedassertion, nothing
#[ignore]d.Verification
Five consecutive runs of each affected suite, sequentially (concurrent cargo
runs in one worktree produce phantom failures), on this branch rebased onto
35a67843, withBIOROUTER_DISABLE_KEYRING=true. 15/15 exit 0, zero failures.-p biorouter --lib-p biorouter-server --lib --bins-p biorouter-mcp --lib* run 1's
biorouter-mcplog was deleted by a sibling process before thecounts could be read from it; the run itself exited 0, and runs 2-5 give the
count. Wall times include the build for the first run of each suite.
Plus, on the same tree: the rewritten race test 10x on its own (0.06-0.13 s
every time, so the window is staged on the first attempt and the restage path is
not being leaned on), and the four
test_sandboxtests plusa_removal_prunes_the_extension_from_every_stored_session_rostertogether.cargo fmt --all -- --check: clean../scripts/clippy-lint.sh: clean —-D warnings, thetoo_many_linesbaseline and the banned-TLS check all pass. (Worth recording: the same script
was RED on
9096d371, the commit this work started from, in files this branchdoes not touch —
clippy::result_large_errinroutes/reply.rs,clippy::string_sliceincommands/agent.rsand twotoo_many_linesbaseline violations. All four were fixed upstream in the 121 commits that
landed while this branch was being written. CI would not have caught them:
rust.ymlruns clippy without-D warnings, "informational until warningsare burned down".)
🤖 Generated with Claude Code