Repository navigation
test(runtime-core): give test fixtures fresh, uniquely named temp roots - #461
Merged
Merged
Conversation
Fixtures named temp roots from the process ID and the wall clock. macOS reads SystemTime in whole microseconds (node::now counts milliseconds), so two tests that cargo test starts together can compute the same root. With a non-exclusive create (state::private_directory is recursive) the second fixture reuses the first one's root: it finds the other test's provider lock held, and the first fixture's Drop removes the root under it (hostname_authority::managed, CI signature provider_busy at :111 and NotFound at :146). Add a test-only crate::unique_test_root / private_test_root helper: the name is <prefix>-<pid>-<process-wide sequence>, made with one exclusive, non-recursive mkdir; a name left by an earlier process with the same pid is skipped, never reused. Switch the lib fixtures that named roots from pid alone or pid plus clock to it. Fixtures that #399 also changes take #399's identical edit (crate::next_test_scratch appended), so the two branches merge cleanly in either order. Refs HACK-1393 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed helper The host relay control fixture named its /tmp root from the process ID alone, so a second root in the same process (the live test's other owner root) or a root left by an earlier process with the same ID failed the exclusive create. Take both roots from crate::private_test_root, which keeps the 0700 exclusive create, and record the identity as before. Refs HACK-1393 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
marked this pull request as ready for review
October 24, 2026 05:15
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
…GKILL reaps them (#473) Refs HACK-1420 ## Root cause The dead-owner publication tests killed the fixture child and then called `Child::wait`. `Child::wait` closes the child's stdin before it reaps. In the fixture, stdin EOF is the signal to exit cleanly: `publication_child` returns, and `ControlListener::drop` unlinks the socket and then the receipt. Under load, the child can wake on that EOF and run part or all of that drop before SIGKILL takes effect. The test then sees a publication that has changed. Recovery correctly refuses it, and the failure shows up as: - `Os NotFound` at `tests.rs:299` (`fs::remove_file(pin.paths.socket)`), or `Dead relay publication refused: receipt_unchanged=true socket_unchanged=false process_absent=true child_signal=Some(9)` (the child was SIGKILLed, but only after it had removed its socket). - `relay_owner_refused` from `dead::Witness::acquire` at `:384` and `:432`. The product code was correct. Failing closed on a changed publication is the intended behavior. The race was only in the test fixture. ## Fix (test-only) A new `kill_and_reap(child, pin)` helper replaces `kill()` + `wait()` in all three tests: 1. It takes the stdin write end and holds it, so SIGKILL is the child's only way out. 2. It reaps with `try_wait` under the named `REAP_BUDGET` (10 s). Running past the budget fails with `E_FIXTURE_REAP_TIMEOUT` and does not continue. 3. It asserts that the exit signal was SIGKILL (`E_FIXTURE_OWNER_EXIT`). 4. It asserts that the receipt and the socket are still the exact pinned pair (`E_FIXTURE_OWNER_CLEANED`) before any dead-owner assertion runs. None of the dead-owner assertions were removed or relaxed. ## Evidence (macOS, cargo +1.97.1, private HOME/HACK_HOME/DOCKER_CONFIG, `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`) The load was 8 concurrent loops of the filtered lib test binary plus 18 busy-loop CPU workers on an 18-core host. A trap stopped every load process. | Binary | Filter | Runs | Failures | | --- | --- | --- | --- | | base (next before fix) | `relay_owner::publication` | 200 | 42 (all three tests; NotFound :299, refused :321, relay_owner_refused :384/:432) | | base | the three tests | 400 | 39 | | fix | `relay_owner::publication` | 200 | 0 | | fix, rebased on 0ca0e45 | the three tests | 8 x 50 | 0 (each loop 50 consecutive passes) | | fix, rebased on 0ca0e45 | `relay_owner::publication` | 200 | 0 | A serial loop with load (60 runs of the three tests) passed on both base and fix, so it does not reproduce the race. The parallel loops above are the discriminating check. ### Mutation proofs - **M1:** release stdin right after `kill()`, which is what `Child::wait` does. Result: 23/200 failures under load, all `E_FIXTURE_OWNER_CLEANED`. This shows that holding stdin is the fix, and that the post-reap publication check catches a cleanup race. - **M2:** set `REAP_BUDGET = Duration::ZERO`. Result: 3/3 runs failed in every test with `E_FIXTURE_REAP_TIMEOUT`. This shows the budget check is enforced. - **M3:** expect SIGTERM instead of SIGKILL. Result: every test failed with `E_FIXTURE_OWNER_EXIT`. This shows the exit-signal check is enforced. ### Gates - `bun run check:local` (fmt + clippy, all targets, `-D warnings`): pass at the rebased head. - `bun run test:local`: pass on the pre-rebase head (lib 1047 passed, 60 ignored; all other targets ok). The rebase onto #461 touches no `relay_owner` files. Hosted CI is the full-suite evidence at the exact head. - Root `bun run check` (includes privacy:check): pass. - `bun run --cwd packages/cli typecheck`: 0 errors. - gitleaks 8.30.1 over `origin/next..HEAD`: no leaks. - The `relay_owner` module is compiled only on macOS and has no Cargo feature gate, so an all- features clippy run adds nothing here. ## Gaps - Hosted CI is green at the exact head 3fc82f0: Candidate core (macos-latest and ubuntu- latest), `test`, all docker-e2e shards, and every other check. Ubuntu does not compile this module, so the macOS job is the one that runs these tests. - The hosted macOS run is one pass, not a run under load. The repeated-run proof under load is local only. - Not run on the M3; this unit uses hosted CI for full-suite evidence. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.
Root cause
runtime-core test fixtures named their temporary roots
<prefix>-<pid>-<clock>(or<prefix>-<pid>alone). On macOSSystemTimehas microsecond resolution andcrate::node::now()counts milliseconds, so two tests thatcargo teststarts together on parallel threads can compute the same name. Where the fixture then creates the root non-exclusively (state::private_directoryis recursive), the second test silently shares the first one's root. Inprovider/hostname_authority/managed.rsthis gives the hostedCandidate core (macos-latest)failure (run 39968507108): one test finds the other's provider lock held (provider_busyat:111), and the first fixture'sDrop(remove_dir_all) deletes the root under the second (NotFoundat:146, or the deadline assertion at:142). Where the create is exclusive, the same collision fails withFile existsinstead.Fix (test-only; no product code path changes)
#[cfg(test)]modulepackages/runtime-core/src/test_root.rs, re-exported ascrate::unique_test_root(parent, prefix)(mode 0777 less umask, the same asfs::create_dir) andcrate::private_test_root(parent, prefix)(mode 0700). Each root is named<prefix>-<pid>-<sequence>. The sequence is a process-wideAtomicU64. The root is made with one exclusive, non-recursivemkdir. A name left by an earlier process with the same pid is skipped and never reused. Failures panic with a single value-free line (E_TEST_ROOT_CREATE/E_TEST_ROOT_EXHAUSTED).private_directorycalls and cleanup:node/journal.rs(2),provider/bridge.rs,provider/config_audit.rs,provider/graph/normalized.rs,provider/graph/probes.rs(2),provider/graph/relay.rs,provider/image_load.rs(2),provider/network_tools.rs,provider/publication/recovery.rs,provider/source_sync.rs(2),provider/graph/host_relay/live_tests.rs(the control root, through a smallControlRoot::claim).crate::next_test_scratch()to the name and addssrc/test_scratch.rs. For those fixtures this PR makes the byte-identical edit (same hunks, sametest_scratch.rs, samelib.rslines) instead of moving them to the new helper:hostname_authority/managed.rs(the failing test),project/snapshot.rs,config_audit.rs(recorded fixture),dependency_socket.rs,disk_audit.rs,disk_template.rs,engine.rs,guest_tool.rs,lifecycle.rs(5),lifecycle/interrupted.rs,lifecycle/prepared_boot/tests.rs,lifecycle/pressure_tests.rs,persistent_data/enrollment/witnessed/tests.rs,relay_loop/admission_tests.rsandsource_transfer.rs.git merge-treeof this head with test(runtime): isolate fork-exposed host checks and make scratch names unique #399's head (cb6661fd) is clean, so the two PRs merge in either order.graph_cli/restore_tests.rs,project/adapter.rs,project/live_source.rs,project/registry.rs,project/snapshot/generated.rsandproject/snapshot/mountpoints.rs.Tests
test_root::tests::parallel_fixtures_receive_distinct_freshly_created_roots: 32 threads behind one barrier each create 8 roots (mixed private and plain). The test asserts that all 256 are distinct, each is empty when returned, each holds only its own marker afterwards, and the private ones have no group or other bits.test_root::tests::a_root_left_by_an_earlier_process_is_skipped_and_never_reused: when the first three sequence names already exist, the fourth is returned, it is empty, and the stale roots are left untouched.E_TEST_ROOT_EXHAUSTED.AlreadyExistsreturn the existing path (reuse) fails the stale test: it getsfixture-0wherefixture-3was expected.Evidence
--test-threads=2 hostname_authority::managed, 200 runs each):origin/nextlib test binary: 30/200 and 41/200 failing runs, with the CI signatures (provider_busyatmanaged.rs:111;:142/:146).cargo +1.97.1:bun run check:local(fmt and clippy, all targets,-D warnings): pass.--all-targets --all-features -D warnings): pass.bun run test:local: lib 1050 passed / 60 ignored; bin 43 passed; all integration test binaries pass.bun run check(includes privacy-check) andbun run --cwd packages/cli typecheck: pass.origin/next..HEAD: no leaks.a87aee14: every check passes, includingCandidate core (macos-latest),Candidate core (ubuntu-latest),test, all fourdocker-e2eshards and all fourConfig compilerjobs.Gaps
packages/runtime-core/tests/are separate crates and cannot use the#[cfg(test)]lib helper. Their remaining pid-only or pid-plus-clock names (stream_relay_contract.rs,relay_fence_contract.rs,quiescent_dependency_recovery_cli_contract.rs,source_device_rebind_cli_contract.rs,absent_recovery_cli_contract.rs,loopback_descriptor_churn.rs,cache_quarantine_cli_contract.rs) are each used by exactly one test, so they cannot collide inside one process. The only remaining risk is a stale name from an earlier process, which fails loudly.src/main.rsunit tests (source_capture_tests.rs) are also outside the lib crate. They use two distinct prefixes and an exclusive create.#[ignore]VM tests that build owner tokens from pid plus clock (source_transfer.rs,graph_probe_test.rs,source_failure_test.rs,gateway_probe_test.rs) are unchanged.provider::relay_owner::publication::tests::dead_publication_recovery_requires_unchanged_receipt_and_socketfails intermittently withNotFoundatpublication/tests.rs:299(2/30 runs onorigin/next, 1/30 on this branch). Under full-suite load its twodead_witness_*siblings once failed withrelay_owner_refusedat:384/:432. Its roots already use a counter, so this PR does not address it; it is reported separately.Refs HACK-1393
🤖 Generated with Claude Code