Skip to content

test(runtime-core): give test fixtures fresh, uniquely named temp roots - #461

Merged
roodboi merged 2 commits into
nextfrom
wf/g74-runtime-core-unique-test-roots
Oct 24, 2026
Merged

roodboi merged 2 commits into
nextfrom
wf/g74-runtime-core-unique-test-roots

Conversation

@roodboi

@roodboi roodboi commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Root cause

runtime-core test fixtures named their temporary roots <prefix>-<pid>-<clock> (or <prefix>-<pid> alone). On macOS SystemTime has microsecond resolution and crate::node::now() counts milliseconds, so two tests that cargo test starts together on parallel threads can compute the same name. Where the fixture then creates the root non-exclusively (state::private_directory is recursive), the second test silently shares the first one's root. In provider/hostname_authority/managed.rs this gives the hosted Candidate core (macos-latest) failure (run 39968507108): one test finds the other's provider lock held (provider_busy at :111), and the first fixture's Drop (remove_dir_all) deletes the root under the second (NotFound at :146, or the deadline assertion at :142). Where the create is exclusive, the same collision fails with File exists instead.

Fix (test-only; no product code path changes)

  • New #[cfg(test)] module packages/runtime-core/src/test_root.rs, re-exported as crate::unique_test_root(parent, prefix) (mode 0777 less umask, the same as fs::create_dir) and crate::private_test_root(parent, prefix) (mode 0700). Each root is named <prefix>-<pid>-<sequence>. The sequence is a process-wide AtomicU64. The root is made with one exclusive, non-recursive mkdir. 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).
  • Lib fixtures that named roots from the pid alone or from pid plus clock now use the helper. Each fixture keeps its own parent (canonicalized or not), permissions, follow-up private_directory calls 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 small ControlRoot::claim).
  • Overlap with test(runtime): isolate fork-exposed host checks and make scratch names unique #399 (G40, HACK-1347). test(runtime): isolate fork-exposed host checks and make scratch names unique #399 also changes some of these fixtures. It appends crate::next_test_scratch() to the name and adds src/test_scratch.rs. For those fixtures this PR makes the byte-identical edit (same hunks, same test_scratch.rs, same lib.rs lines) 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.rs and source_transfer.rs. git merge-tree of 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.
  • Fixtures the issue lists that already had a process-wide counter are unchanged: graph_cli/restore_tests.rs, project/adapter.rs, project/live_source.rs, project/registry.rs, project/snapshot/generated.rs and project/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.
  • Mutation proof:
    • Removing the counter (every name repeats) fails the parallel test: other threads exhaust their attempts on the one existing name with E_TEST_ROOT_EXHAUSTED.
    • Making AlreadyExists return the existing path (reuse) fails the stale test: it gets fixture-0 where fixture-3 was expected.

Evidence

  • Repro (macOS, M-series, --test-threads=2 hostname_authority::managed, 200 runs each):
    • origin/next lib test binary: 30/200 and 41/200 failing runs, with the CI signatures (provider_busy at managed.rs:111; :142 / :146).
    • This branch: 0/200.
  • Gates, all with cargo +1.97.1:
    • bun run check:local (fmt and clippy, all targets, -D warnings): pass.
    • All-feature clippy (--all-targets --all-features -D warnings): pass.
    • bun run test:local: lib 1050 passed / 60 ignored; bin 43 passed; all integration test binaries pass.
  • Root bun run check (includes privacy-check) and bun run --cwd packages/cli typecheck: pass.
  • gitleaks 8.30.1 over origin/next..HEAD: no leaks.
  • Hosted CI at head a87aee14: every check passes, including Candidate core (macos-latest), Candidate core (ubuntu-latest), test, all four docker-e2e shards and all four Config compiler jobs.

Gaps

  • The integration tests in 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.
  • The src/main.rs unit tests (source_capture_tests.rs) are also outside the lib crate. They use two distinct prefixes and an exclusive create.
  • Manual #[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.
  • Pre-existing, unrelated flake. Found while running the gates: provider::relay_owner::publication::tests::dead_publication_recovery_requires_unchanged_receipt_and_socket fails intermittently with NotFound at publication/tests.rs:299 (2/30 runs on origin/next, 1/30 on this branch). Under full-suite load its two dead_witness_* siblings once failed with relay_owner_refused at :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

roodboi and others added 2 commits October 23, 2026 22:53
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>
@linear-code

linear-code Bot commented Oct 24, 2026

Copy link
Copy Markdown

HACK-1393

@roodboi
roodboi marked this pull request as ready for review October 24, 2026 05:15
@roodboi
roodboi merged commit 0ca0e45 into next Oct 24, 2026
17 checks passed
@roodboi
roodboi deleted the wf/g74-runtime-core-unique-test-roots branch 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)
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.

1 participant