Repository navigation
test(runtime): isolate fork-exposed host checks and make scratch names unique - #399
Merged
Merged
Conversation
…s unique HACK-1347: in the shared parallel test process, an unrelated test's pre-exec fork (process::clean_command) holds a copy of every open descriptor until it execs. Endpoint discovery then sees the fork as a second holder of the test's listener (or finds a listener the test already dropped), and the lsof release checks see disk handles the fixture had just written, so they correctly refuse before the result under test. Run every such fixture alone in a child test process with the shared run_isolated helper: - host_endpoint: exclusive and executable-pinned discovery, plus the two close/rebind fixtures that still shared the process; the module's own runner now delegates to run_isolated (120 s hang guard, not 5 s). - admission_pool: every release-check test that expects a pass or a non-active refusal (7 more), behind one isolated() helper that keeps serial scans. - short_home, host_filesystem, interrupted: recovery, migration and stop tests that need the lsof proof to pass. HACK-1348: fixtures named scratch directories from the PID and the wall clock and created them exclusively. macOS reports the clock in whole microseconds, so parallel tests can read the same value and the second create fails with "File exists (os error 17)". Add crate::next_test_scratch() and append it to every such name in the lib tests, with a regression test. No product code changes. Refs HACK-1347 Refs HACK-1348 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HACK-1347 audit, second part: more runtime-core tests drop a listener (or a publication that owns one) in the shared parallel test process and then require a release check to pass. An unrelated test's pre-exec fork (process::clean_command) holds a copy of every open descriptor until it execs, so the dropped listener still accepts and its flock stays held; the check correctly refuses and the test fails. Under in-process fork pressure with a 20 ms pre-exec hold the full all-features lib suite failed 3/3 runs on 9 to 13 such tests. - Make provider::test_executable available on every Unix target (the quiescent-recovery tests also run on Linux) and isolate, with run_isolated: dependency_slots socket recovery (2), quiescent_recovery dropped-listener tests (2), foreground::transport abandoned-publisher tests that expect retirement to pass (7), acknowledged_publisher::missing_lock (5, all use the dead-publisher fixture), native foreground abandoned publication (1) and the https_recovery full commands (2, their fixture drops the provider flock that recovery must then acquire). - Fixtures that only need a stale socket inode now bind a socket that never listens (new provider::test_socket::bound_unlistened, shared with the quiescent-recovery fixture) instead of dropping a listener: https_recovery and dependency_slots::fixture_prior_boot. Regression test included. - relay_exec framing test: shut the peer down instead of only dropping it, so the truncated frame always reaches end of stream. No product code changes. Refs HACK-1347 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
provider::publisher::tests::Fixture picked a port by binding 127.0.0.1:0,
reading the port and dropping the probe, then started stream-relay-host on
that explicit port. In the shared parallel test process an unrelated test's
pre-exec fork (process::clean_command) can still hold the probe listener, or
another test can take the port, and the publisher exits 73 without
readiness ("publisher readiness missing", 2 of 5 full-suite runs under fork
pressure with a 20 ms pre-exec hold).
Start the publisher with --publish 0 (from #382): it binds a kernel-chosen
port and reports it as "ready <port>", which the fixture now requires.
Refs HACK-1347
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 22, 2026
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
…ts (#461) ## 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 #399 (G40, HACK-1347).** #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 #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::publ ication::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](https://claude.com/claude-code)
roodboi
marked this pull request as ready for review
October 24, 2026 05:47
roodboi
added a commit
that referenced
this pull request
Oct 25, 2026
…reap failed relay starts (#508) ## Summary Fix unit G112 for HACK-1342, items 1, 2 and 4. Test-harness only: one file changed (`packages/runtime-core/tests/stream_relay_contract.rs`). The relay (`guest/stream-relay.c`) is not changed, because the evidence shows it behaves correctly. **Item 3 (the 2 s deadlines in `provider/graph/native/runtime/tests.rs`) is left to #419 (HACK-1367).** This PR does not touch `runtime/tests.rs`, `exec_tests.rs`, `run_tests.rs` or `test_budget.rs`. | Item | Root cause | Change | |---|---|---| | 2. `Relay::connect` EINVAL (CI run 40119166731, `stream_relay_contract.rs:108:14`) | The relay refuses an excess connection by accepting it and calling `close()` (`stream-relay.c:489`). For an unavailable target it calls `release()` after `connect_target` fails. Both can happen before the client's next syscall. Once the peer has closed a stream socket, macOS (XNU `sosetopt`) rejects `SO_RCVTIMEO`/`SO_SNDTIMEO` with EINVAL, because both directions are shut. `Relay::connect` unwraps `set_read_timeout` (line 105) and `set_write_timeout` (line 108), so it panics when the refusal arrives first. The hypothesis is **confirmed**. The relay is correct. | The two refusal tests now call `Relay::connect_refused()`. It sets no socket timeouts. It waits up to `REFUSAL_DEADLINE` (10 s, as before) with `poll` and then still requires EOF or a reset (`assert_refused`). A peer that stays open fails the check (timeout), and so does a peer that sends data. `Relay::connect` still unwraps the timeout calls for streams the relay must keep, so EINVAL there is still a real failure. | | 4. Orphaned `stream-relay-host` on a readiness panic | `start_options` spawned the child and ran its readiness assertions before any `Relay` existed, so a panic dropped the `Child` without killing it. | The readiness checks moved into `await_bare_ready`. Until readiness passes, the existing `PublisherStartup` guard owns the child. Its `Drop` kills and reaps the child, and removes a fresh scratch root (never a root the caller passed in). | | 1. `provider/publisher.rs` probe-port race | Already fixed on `next` by #399 (674a0a4): the fixture now runs `--publish 0` and reads `ready <port>`. No probe listener is left. | No change. It was re-verified under load (below). | ## Evidence ### Root cause (macOS 26.5.1, Darwin 25.5.0) A standalone Rust repro (not committed): - socketpair, peer still open: `set_read_timeout` = `Ok(())` - socketpair, peer dropped: `set_read_timeout` and `set_write_timeout` = `Os { code: 22, InvalidInput }`; `read` = `Ok(0)` - Unix listener, server accepts and then closes, as the relay does for an excess connection: **200/200** `set_write_timeout` calls return EINVAL - peer shut down only its write side: `set_read_timeout` = `Ok(())`. The error needs both directions to be shut, which matches XNU's check. ### Parallel load on this macOS host (`cargo +1.97.1`, Zig 0.15.2 via mise, 8 concurrent processes × `--test-threads 16`, host load average ~105–116 on 18 cores) | Run | Before (origin/next) | After | |---|---|---| | Full `stream_relay_contract` binary | 104/104 pass | 104/104 pass | | Focused (`connection_limit`, `occupied_socket`) | **1994/2000 pass**: 6 failures, all `connection_limit_refuses_excess...` with `Os { code: 22 }` at the `set_read_timeout` unwrap (`:105:14`) | **2000/2000 pass** | | Item 1: `provider::publisher` lib tests (`--features native-stream-relay`) | n/a (already fixed on next) | 104/104 pass | The before failures appeared at line 105 locally and at line 108 on CI. Both are the same race; it depends on which `setsockopt` the relay's `close()` lands before. ### New tests, each proven by mutation - `refusal_check_accepts_a_peer_that_closed_before_the_check`: drops the peer before the check. Mutation: restore the old pattern (set timeouts, then read) in `assert_refused_within`. Result: it fails deterministically with EINVAL. - `refusal_check_rejects_a_live_peer` and `refusal_check_rejects_a_peer_that_sends_data` (`should_panic`). Mutation: make the check return early (accept anything). Result: both fail. The new check does not weaken the refusal proof. - `failed_relay_readiness_kills_and_reaps_the_child`: a live child prints the wrong readiness line. The test then requires `waitpid(pid, WNOHANG)` = ECHILD (already killed and reaped) and the fresh root removed. Mutation: take the child out of the guard before the checks (the old ownership). Result: it fails with `E_RELAY_STARTUP_ORPHAN`, and the test kills the child itself. ### Gates - `cargo +1.97.1 fmt --check`; clippy `--all-targets -D warnings` with default features (`check:local`) and with `--all-features`: clean - `test:local` (`cargo +1.97.1 test --jobs 2`): all pass (lib 1065 passed, 101 ignored) - `stream_relay_contract` with `native-stream-relay`: 29/29 - `bun run check` (includes `privacy:check`) and `bun run --cwd packages/cli typecheck`: 0 errors - gitleaks 8.30.1 over `origin/next..HEAD` (`gitleaks dir` on a `git log -p` export): no leaks - Hosted CI at head 8cfc2a0 (run 40145181695): all 20 checks green, including `test` shards 1-4 and `Candidate core (macos-latest)` (optional feature contracts). This is the full-suite evidence. ## Gaps - Load proof ran on this macOS host, not the M3. The M3 is reserved; hosted CI provides the full suite. - The EINVAL behavior comes from XNU's `sosetopt` check. Linux does not show it, and the new helper does not depend on it either way. - Item 3 is left to #419. ### Round 2 (5752a32): stale scratch roots The verifier hit one unrelated failure on `next` under 32-process load: `fs::create_dir` at line 52 returned EEXIST. Roots are named `/tmp/<prefix>-<pid>-<n>`, and a directory left by an earlier run whose PID is now reused collides. The new `fresh_root(prefix)` skips an existing name and panics on any other error. Every root site uses it, including the fixed-name `hkr- netns`, `hkr-reservation`, `hkp-ack` and `hkp-private` roots. - New test `fresh_root_skips_a_stale_directory_with_the_next_name`. A mutation that stops skipping makes it fail. - `stream_relay_contract`: 30/30 pass. 32 full-binary runs (4 rounds of 8 processes, `--test- threads 16`, load 92-154) all pass. fmt and `clippy --all-targets --all-features -D warnings` are clean. gitleaks found no leaks. - Not changed: the occupied-socket test still takes its "unavailable" port by binding and dropping a listener, so a parallel process could in theory take that port. I tried holding a bound socket that never listens. On macOS 26 a connect to such a port times out (ETIMEDOUT after about 7.8 s, with `net.inet.tcp.blackhole` 0) and is not refused, so that would turn the refusal into a timeout. The theoretical race is tracked separately. Refs HACK-1342 🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi
added a commit
that referenced
this pull request
Nov 2, 2026
…drain isolated test output (#482) ## Summary Two follow-ups from the #399 and #467 verifiers (HACK-1427). ### 1. `run_isolated` no longer hides a noisy child's failure behind the hang guard `packages/runtime-core/src/provider/test_executable.rs`: `run_isolated` (from #387, extended by #399) used to pipe the child's stdout and read it only after the child exited. A failing child that wrote more than a 64 KiB pipe buffer blocked on its write until the 120 s `ISOLATED_HANG_GUARD`. The guard then reported a watchdog panic instead of the real failure. - stdout and stderr are both piped and drained on reader threads while the parent waits. Each reader keeps a bounded tail (16 KiB) for the failure message and drains the rest, so the child never blocks on a full pipe. - The bound covers the child's exit and the readers reaching end of stream. A descendant that keeps a copy of a pipe open is reported, not waited on forever. - A failure message now includes both streams' tails. A passing child must still print at most 4096 bytes on stdout and must show the exact `test <name> ... ok` / `1 passed` lines, as before. - The second helper, `provider/graph/startup/runtime/rebind/tests.rs:219`, only delegates to this shared helper, so this change fixes it too. No other `run_isolated` exists. New test `isolated_child_failure_with_large_output_is_reported` launches the ignored child `noisy_failure_child`. That child writes 160 KiB to each stream and then panics. The test requires the parent to fail well within the guard with the child's own failure, not "watchdog". The message must hold the panic text and the stderr end marker, and must be bounded. ### 2. The busy refusal over an interrupted rollback names the command that works there `src/commands/config-adopt.ts`: once a switch's rollback is journaled (`publication.phase: rolling-back`), plain `hack config adopt --recover` refuses whatever the override holds, because `completePublication` accepts only `switching`. The generic `E_LEGACY_ADOPTION_BUSY` step still named it first whenever the override was not literal. The busy step for `rolling-back` is now `PENDING_ROLLBACK_BUSY_NEXT_STEP`, which names only `hack config adopt --recover --rollback`. The `switching` wording (HACK-1414) is unchanged. The text is fixed and value-free. `docs/reference/native-compose-adoption.md` is updated. `src/lib/native-compose-adoption-marker.ts`: the `classify()` comment now matches `pendingSelectionMatches`: a pending stop is also admitted over a `rolled-back` publication. `LegacyComposeAdoptionSelectionDetail` gains `switchPhase` so the CLI can tell the two pending-switch phases apart. ## Validation All at head `2adbd9dc`: - Rust (cargo 1.97.1): `bun run check:local` passes (fmt and clippy `-D warnings`). The focused `provider::test_executable` test passes. - `bun run test:local` was run twice on a heavily loaded local host (load average 50 to 130). Each run had 1051 passing tests and one different failure: `dependency_slots::concurrent_claim_writers_serialize_without_overlapping_slots` (its 3 s lock deadline) in the first run, and `relay_owner::publication::dead_publication_recovery_requires_unchanged_receipt_and_socket` in the second. Neither test touches `run_isolated` or any changed file, and both modules pass when rerun alone (`dependency_slots` 20/20, `relay_owner::publication` 11/11 three times). Hosted Candidate core on macOS and ubuntu is green. - Adoption: both HACK-1427 tests pass, run focused and serially with `--timeout 60000` (2 pass, 146 s), because these tests are load-sensitive (HACK-1367). - gitleaks 8.30.1 over `origin/next..HEAD`: no leaks. `bun run check` (includes `privacy:check`): exit 0. `bun run --cwd packages/cli typecheck`: 0 errors. - Hosted CI at `2adbd9dc`: all checks green, including Candidate core (macos-latest, ubuntu-latest), `test` shards 1-4/4, docker-e2e shards 1-4/4, Config compiler (4 targets), Runtime state models, linux-process-lifetime, runtime-images and secret-scan. Refs HACK-1427 ## Round 2 (rework after verifier: no PR existed) The first implementation round committed `ad916597` locally but never pushed it, so the verifier found no branch and no PR. This round reviews that commit, adds the report's exact repro, reruns every gate, pushes the branch and opens this PR. **Added test.** `CLI busy refusal over an interrupted rollback of a literal-bound switch does not name plain recovery once the override is moved aside (HACK-1427)` sets up the exact reported state. It starts from the HACK-1318 literal fixture, journals the switch with a first `--recover --stop`, rewrites the receipt phase to `rolling-back`, and moves the bound override aside. `--recover --stop --json`, plain `--json` and `--stop --json` must each return the store's BUSY message plus `PENDING_ROLLBACK_BUSY_NEXT_STEP`, and must never name plain `--recover`. Plain `--recover` must refuse and leave the receipt unchanged. The existing round-1 test, `...names only the recovery rollback that finishes it (HACK-1427)`, covers an active adoption with a reference-form override, rewritten to `rolling-back`. It shows that the named `--recover --rollback` actually completes the rollback and restores the originals. Plain `--recover` refuses there. **Mutation proof.** - TS: with the `rolling-back` branch of `pendingSwitchBusyNextStep` disabled, both HACK-1427 adoption tests fail (the busy message is the generic step). Restored, both pass. - Rust: with the reader threads started only after the child exits (the old ordering), `isolated_child_failure_with_large_output_is_reported` fails after 120 s with `exceeded its watchdog`. Restored, it passes. **Known limit (reported separately).** In the reported state, the bound override has been moved aside. `--recover --rollback` then refuses as `E_LEGACY_ADOPTION_STATE`, because the saved generated-input proof pins the file, including its ctime. Moving the file back does not satisfy the proof, because a rename changes ctime. The message now names the only command that can finish a `rolling-back` publication, and that command works whenever the bound inputs are intact. With the bound file moved away, though, no `hack config adopt` command finishes the rollback. That is outside this unit and is reported as a follow-up finding. (Resolved in round 3 below, HACK-1437.) ## Round 3 (HACK-1437: a supported way out of an interrupted rollback with a moved override) The round-2 verifier found that the known limit above was a real dead end. In the HACK-1318 literal fixture, journal the switch with `--recover --stop`, then move `.hack/.internal/compose.env.override.yml` aside. `--recover --rollback` journaled `rolling-back` and then refused with `E_CONFIG_INVALID`. Plain `--recover` refuses a rollback. The busy message named `--recover --rollback` again, and moving the file back did not help, because a rename changes ctime. This round fixes that path. It merges `origin/next` twice, first `1281184b` and then `7dac4a03` after next moved again. Both merged cleanly and neither touches the files this round changes. The fix commit is `0e17a563` and the head is `80c9b1fe`. **What the proof protects.** A saved read binds each generated override by its bytes and file identity, ctime included. This protects adopted execution and switch completion, which must run exactly the inputs that preparation qualified and whose Compose config hash the retained containers carry. The binding also keeps `hack doctor --fix` and the env scrubs from removing a pinned file. Rollback restores the held legacy originals, archives the candidate and verifies the original resources. It never reads, runs, rewrites or removes the env override, which is derived from the managed env layers and rewritten by the next `hack up`. No data depends on it. **Change.** - `src/lib/native-compose-adoption-projection.ts`: `readSavedLegacyComposeAdoptionProjection` takes `envOverride: "bound" | "released"`, with `bound` as the default. In `released` mode the env override is neither read nor compared. Every other generated proof stays exact: the `.hack/.internal` directory identity and the runtime override's bytes and identity. The saved `config_files` path list keeps the override's path, so the containers are still matched against the label Compose recorded. The new value-free `savedLegacyComposeEnvOverrideChanged` reports a moved or changed override when the other generated inputs still match. - `src/lib/native-compose-adoption-generation.ts`: rollback reads in `released` mode in `rollback()` (from `active`) and in `completeRollback` (`--recover --rollback` from `switching` or `rolling-back`). Execution, leases, publication and completion still use `bound`. The new read-only store method `pendingSwitchEnvOverrideChanged()` backs the CLI hint. - `src/commands/config-adopt.ts`: every pending-switch step names only a command that the state does not refuse for a known reason. The new `PENDING_CHANGED_SWITCH_BUSY_NEXT_STEP` covers a `switching` publication whose bound override was moved or changed, and names only `--recover --rollback`. A refused plain `--recover` now adds the step when its cause is known (`rolling-back`, or a moved or changed override). The text is fixed and value-free. - `docs/reference/native-compose-adoption.md`: a new "Rollback releases the generated env override (HACK-1437)" section gives the written rationale. It covers what the binding protects, why rollback can release it, what rollback still checks, and the step for each pending state. The version 3 paragraph notes the exception. **Tests** (`tests/native-compose-adoption-generation.test.ts`; long tests use the named budget `ROLLBACK_RECOVERY_BUDGET_MS`, HACK-1367): - State 2, reachable without hand edits: `CLI recovery rollback finishes a pending switch whose bound literal override was moved aside and back, without hand edits (HACK-1437)`. With the override moved aside, BUSY names only `--recover --rollback` (`PENDING_CHANGED_SWITCH_BUSY_NEXT_STEP`). Plain `--recover` refuses, journals nothing and names the same step. After the file is moved back (same inode, new ctime), BUSY names the literal step. The test then parses the message and runs the one command it names. That command must finish: the originals are restored, the selection is `rolled-back`, and the literal file is untouched and no longer pinned. - State 1, `rolling-back` with the override moved aside: the round-2 test (renamed `..., and that rollback finishes (HACK-1427, HACK-1437)`) now also checks that plain `--recover` names `PENDING_ROLLBACK_BUSY_NEXT_STEP`, then runs the named command to completion. The override stays absent and the moved file is untouched. - `CLI rollback of an active adoption releases a replaced generated env override while plain recovery stays refused (HACK-1437)`: `--rollback` finishes with a different file at the bound path and does not rewrite it. - Store level: `rollback releases a moved-aside generated env override that execution still refuses` checks that `loadActive` still refuses while rollback succeeds. `rollback still binds the generated runtime override when the env override is released` rewrites the runtime override with identical bytes (a new ctime) and checks that rollback still refuses before anything moves. **Mutation proof** (each reverted afterwards): - M1, rollback reads with the full binding (`released` disabled): store-level release, state 1, state 2 and active-rollback tests fail at the rollback step. The strictness test still passes. - M2, the moved/changed check removed from the busy step: state 2 fails at the BUSY message (it gets the generic step naming plain `--recover`). - M3, the hint on a refused plain `--recover` disabled: state 1 and state 2 fail at the completion message. - M4, the released path dropped from `config_files`: the store-level release, state 1 and active-rollback tests fail (rollback refuses on the binding). - M5, the released generated-proof comparison skipped: the runtime-override strictness test fails with "unexpected adoption success". **Gates** (fix commit `0e17a563`; rerun at head `80c9b1fe` after the second merge): - At both commits: gitleaks 8.30.1 (`gitleaks dir` over a `git log -p origin/next..HEAD` export): no leaks. `bun run check` (includes `privacy:check`): exit 0, 34 existing complexity warnings. `bun run --cwd packages/cli typecheck`: 0 errors. CLI surface and agent guidance are unchanged, so no regeneration was needed. - Focused (local host at load 20 to 170): all 5 HACK-1437 tests pass together at `0e17a563` (714 s) and again at `80c9b1fe` (555 s). The HACK-1318/1414/1427/1273 group passes 8/10. The two failures, `...keeps it loadable until rollback frees it (HACK-1273)` and `v3 value-free env override binds the engine config hash... (HACK-1273)`, time out at their fixed 20 s budget inside `store.prepare`, before any changed path. They fail the same way on the unchanged PR head `2adbd9dc` on this host, so they are load-sensitive (HACK-1367), not a regression. For local runs only, the named budget was raised so that the load could not end a test. The committed value is 240 s. - Hosted CI at `0e17a563` (run 40124835555): all 20 checks green, including `test` shards 1-4/4, docker-e2e shards 1-4/4, Candidate core (macos-latest, ubuntu-latest), Config compiler (4 targets), Runtime state models, linux-process-lifetime, runtime-images and secret-scan. - Hosted CI at `80c9b1fe`: all 20 checks green (run 40128748032), with the same set as above. After that run, `next` moved three more commits to `246f1b4e` (#484, #485 and #486: runtime-core and e2e tests, none in this change's files). `git merge-tree` merges them cleanly. They are not merged here, so GitHub reports BEHIND again. **Remaining limits.** Rollback still refuses when something it checks has changed: a running original, an edited held original or candidate, changed resources, changed managed env layers, the runtime override, or conflicting bytes at an original path. That refusal keeps the journal and evidence. Rollback keeps the managed-revision proof. Hack's own `hack env set` refuses while a switch is pending: a temporary probe, not committed, showed it fails with the adoption selection refusal. A managed env layer file edited outside Hack during a pending switch may still leave a similar dead end, because `--recover --rollback` journals `rolling-back` before it reads its inputs. That case is not reproduced here and is reported as a lower-severity follow-up finding. Refs HACK-1437 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
added a commit
that referenced
this pull request
Nov 3, 2026
> **Current state (627c21f, merges `next` at e5d907d).** HACK-1319 re-sized `tests/native-compose-adoption-generation.test.ts` on `next`, so `next`'s version of that file stands. This PR no longer changes that file, and the case-drain helper and its test are gone because nothing else used them (HACK-1800 tracks the gap). Budget evidence below for that file describes earlier heads, not this one. Focused checks at this head: 135 pass, 0 fail, across `test-budget`, the adoption generation file, `native-process-policy-initial-trace` and `native-fixture-cases`. > **Current state (9e11cf5, merges `next` at 8621d90).** Independent-review findings fixed: the next `beforeEach` now restores every saved file-level variable after the reservation wait and removes the kept root, with a new case for it. `tests/case-drain.test.ts` no longer asserts a 40 ms upper bound. The 250 s `beforeEach` timeout stays above the case ceiling on purpose; its comment says why. Adoption file locally, three runs at load about 10-15: 121/122, 122/122, 122/122. The one failure, `public retained up ... "no"`, did not recur and looks like the HACK-1458 15 s dispatch clock (not confirmed: the product omits the cause). Hosted CI had not finished on this head when this note was written. <!-- entire-trail-link-start --> https://entire.com/gh/hack-dance/-/trails/2/changes/hack/2 <!-- entire-trail-link-end --> Refs HACK-1367 (unit G52). Related: HACK-1458, which owns the product clocks this PR leaves alone. Test-only change: no product code, product default or product bound changes. The branch also merges `next` (latest: 191382d). > **Round 3 summary.** The table under "Tests and evidence" records round 1 at 7f76651. Rounds 2 and 3 replace parts of it; where they disagree, the later round wins. See "Round 3" at the end. ## Root cause Each case below passes alone but failed under host load (M5 at load 15-40, or higher; M3 while another suite runs), because a fixed harness budget was spent on fixture work whose wall time follows host load, or because the harness raced a kill: 1. **runtime-core native runtime tests** (`provider::graph::native::runtime::tests`, for example `two_owned_bridges_keep_per_network_members_and_aliases_through_cleanup` and `two_bridge_cleanup_retries_only_the_retained_second_bridge_after_first_delete`). Fake-backend `execution::run` calls had 1-5 s budgets. The fake is ready at once, but on a loaded host the journal writes took longer than 2 s, so the run failed with `graph_timeout`. 2. **`tests/native-compose-adoption-generation.test.ts`**. Every engine probe spawns the Bun stub engine, and one retained dispatch issues several hundred probes. On the loaded M5 one start took 18-24 s, which is past the 20 s caller deadlines and the 20-30 s case timeouts. Baseline at `origin/next` on the loaded M5: 8 failures (v3, v5 and v7 retained dispatch cases) before I stopped the run, all `E_LEGACY_ADOPTION_DEADLINE` or case timeouts. 3. **`tests/e2e/native-process-policy-initial-replay.ts`**. Each replay child cold-starts Bun, transpiles the ownership policy and spawns one stub engine per recorded reply. Its default was 5 s, so under load the child timed out and the harness threw `Initial process-policy replay unavailable`. 4. **`tests/native-authored-project-start.test.ts`**. - The named `stage runtime` failure, and the module-level `unconfirmed` latch that then failed every later case, were already fixed on `next` by #388 (HACK-1299). It added per-case guards (`tests/helpers/native-fixture-cases.ts`), so a failed case reports its own failure once, and it replaced the owner's fixed 30 ms ready window with a release handshake. - A loaded M5 run on this branch found a second harness race. In `cancel during the runtime status check refuses before the startup intent`, the stand-in status request appended its PID with `appendFile`, and the case cancelled as soon as the request appeared in the call log. The product's SIGKILL could land after the PID file was created but before the PID was written. Settlement reads an empty PID file as a write still in progress, so it reported `settlement unconfirmed` after 15 s even though the case's assertions passed. The retained root had an empty `status.pid`. The witnessed enrollment `Fixture::new` temp-dir collision (`persistent_data/enrollment/witnessed/tests.rs`) is fixed by open draft #399 (G40/HACK-1348, `next_test_scratch()`). This PR does not duplicate it. ## Fix - New `tests/helpers/test-budget.ts` holds one scale, `TEST_BUDGET_SCALE = 4`, and `testBudgetMs(unloadedMs)`. Each call site names the time its fixture work needs on an unloaded host, and the helper scales it. - Adoption generation: case timeouts, and the caller deadlines of cases that do not test the deadline (`OPERATION_DEADLINE_MS`), now come from `testBudgetMs`. The product bounds under test stay literal: the 5 s deadline-expiry cases, the `deadline + 1500` promptness check, and the 4 s cancellation reap. - Replay harness: the child budget (`REPLAY_CHILD_BUDGET_MS`), the summary budget, and the replay cases' timeouts in `tests/native-process-policy-initial-trace.test.ts` now come from `testBudgetMs`. The ownership policy's own probe bounds still run unchanged inside the child. - runtime-core: one `RUN_BUDGET` (120 s, under the 600 s product cap of `execution::run`) for every fake-backend run, selection, exec, run and log read in `runtime/{tests,exec_tests,run_tests}.rs`. Passing calls never wait on it. The log-expiry case keeps its own 1 s deadline, because the fake sleeps past it on purpose. - Authored start: - The stand-in status request stages its PID and renames it into place, so the record is either absent or complete. - The case now waits for the published PID (`awaitPidFile`) before it cancels. It no longer waits on the call log. - The file's settlement, startup and stop harness budgets now derive from `testBudgetMs` (20 s, 60 s and 60 s). The 50 ms deadline cases and their 1 s wall bound stay literal. ## Tests and evidence - Toolchain: Bun 1.4.2 (`mise x bun@1.4.2`). Rust used cargo +1.97.1 with its own `CARGO_TARGET_DIR` and debug info off. - **Full suite on the M3** at the pushed head 7f76651 (`bun run test`): 7817 pass, 126 skip, 1 fail. The one failure is `repository catalog > the checked-in .hack/tasks.json recipes are all reviewed`, which refuses because the M3 CI worktree is a detached checkout (`detached_checkout`). It is unrelated to this change: the same failure is in the G36, G41, G49 and G51 M3 logs, and the test passes locally on a branch checkout. See the finding in the unit report. - **Loaded repetitions on the M3** at 7f76651 (round 1, `TEST_BUDGET_SCALE = 4`). **Superseded for the authored start and the adoption retained dispatch.** Round 2 could not repeat these 10/10 results on the M5 under heavier concurrent load, and its "Not met" section stands. The runtime-core and replay rows still hold. Load came from 16 spin-and-spawn shell workers on the 16-core host, stopped in a shell trap. The 1-minute load average during the runs is shown. | Target | Command | Consecutive passes | 1-min load during runs | Per run | | --- | --- | --- | --- | --- | | runtime-core runtime tests | `cargo +1.97.1 test --features native-config-plan --lib -- provider::graph::native::runtime::tests` | 10/10 (51 pass, 1 ignored each) | 47-79 | 6-9 s | | Process-policy replay | `bun test tests/native-process-policy-initial-trace.test.ts` | 10/10 (10 pass each) | 67-92 | 19-21 s | | Authored start | `bun test tests/native-authored-project-start.test.ts` (whole file) | 10/10 (52 pass each) | 74-159 | 167-186 s | | Adoption generation, retained dispatch | `bun test tests/native-compose-adoption-generation.test.ts -t '^(v[0-9]+ \|rolled-back v\|public \|retained command dispatch)'` | 10/10 (55 pass each) | 107-181 | 1172-1268 s | The baseline (the `616407a5` versions of the Rust and replay tests, under the same churn) passed 3/3 for both targets on the M3. This churn did not reproduce those two failures on the M3, so the before/after contrast for them comes from the M5 runs below. - **Local runs (M5, already loaded, no added churn):** - Adoption generation: 115 pass, 0 fail (948 s); at `origin/next` the same host failed 8 cases. - Replay trace: 10 consecutive passes at load 64-176. - runtime-core runtime tests: 10 consecutive passes at load 76-153. - Authored start, before the status-PID fix: 2 of 4 runs failed at load 80-138. One was the status-PID race. The other is the product diagnostic finding below. - Authored start, after the fix: 52 pass, 0 fail. - **Mutations:** - With only `RUN_BUDGET` set to 1 ns, both named Rust tests fail with `graph_timeout` at their `execution::run` line. - Status-PID race, three runs on the authored case: - (A) Base harness, with the stand-in pausing 1.5 s between creating and writing its PID record: fails with `settlement unconfirmed ... stand-in hanging runtime status request`. This reproduces the loaded failure deterministically. - (B) Fixed harness, with the same 1.5 s pause between staging and publishing: passes. - (D) Fixed harness, with the published PID belonging to a process that survives the cancel: fails with `settlement unconfirmed`, so the case still detects a surviving request. - All mutations were restored afterwards. - **Gates:** root `bun run check` (includes `privacy:check`) and `bun run --cwd packages/cli typecheck` passed with 0 errors. Biome found nothing on the changed test files. `cargo +1.97.1 fmt --check` and clippy `-D warnings` passed for the Rust change. gitleaks 8.30.1 over `origin/next..HEAD` found no leaks. ## Gaps - ~~The 5 s deadline-expiry adoption cases still check product bounds against the wall clock.~~ Fixed in round 3: these cases now move `Date.now` instead of waiting. - ~~A static7 case that times out latches `publicStatic7Settlement`, and every later case then fails.~~ Fixed in round 3 by the per-case drain guard. The file still shares module-level fixture globals. - The authored stand-in owner still appends its PID with `appendFile`. A SIGTERM that lands before its handler is registered could leave an empty `owner.pid` in the same way. This was not seen in any run. - `cancel during the runtime status check` assumes the status request is still in flight when it cancels. If a Bun child takes longer than the product's 5 s status timeout to start, the case can still fail. The product has no seam for that timeout. - Product finding, not fixed here. On the M5 at load 120-150, `startup keeps the native failure when missing receipt inspection also refuses` failed once. The start reported `native_graph_receipt` from the follow-up inspection instead of the serve failure `graph_network_intent`. The serve child had written its code to stderr before exiting. The most likely cause, which I have not confirmed, is that `serveGraphProcess` stops reading stderr 250 ms after the child exits (`src/backends/native-project-process.ts`), so a reader that falls behind on a loaded host drops the code. The case passed 10/10 on the M3. - Loaded-run proof is from the M3 with added churn and from the loaded M5. CI runs neither. ## Round 2: verifier rework (M5 under concurrent load) The verifier found that items (2), (3) and (4) still failed on the M5 at head 7f76651. This round merges `next` at 6e4bcf2 and adds four commits: 1686c1d, f0352ce, fa5b36d and 073041c. It is still test-only. The `Fix` section above describes round 1. Where it gives `TEST_BUDGET_SCALE = 4` and fixed 5 s expiry deadlines, this section replaces it. ### What changed - **One scale, now 20.** `TEST_BUDGET_SCALE` went from 4 to 10, then to 20. These fixtures are spawn-bound: one retained adoption dispatch runs about 750 stub-engine processes. Their wall time therefore grows faster than the load average. - With a second suite plus churn workers (1-min load 28-91), a scale of 4 still expired the Source5 authored starts and the v7 dispatches. - At scale 10, one public retained `up` outlasted a 200 s deadline. - Every budget that `testBudgetMs` derives is a ceiling. A passing case does not wait on it. - **Authored start** (`tests/native-authored-project-start.test.ts`): - The startup deadline is `min(testBudgetMs(15 s), 300 s)`. 300 s is the largest value `serveNativeAuthoredProject` accepts. The stop deadline uses the same value. - The planner-refusal case used a literal 10 s deadline. It now uses `STARTUP_TIMEOUT_MS`. - `known failed down.after ...` runs two full cycles, so it gets two case budgets. The stand-in owner's cap was raised so it outlasts that case. - **Adoption generation** (`tests/native-compose-adoption-generation.test.ts`): - Public dispatch takes its caller deadline from the Compose startup budget, which defaults to 90 s. `beforeEach` now injects the harness operation deadline through `HACK_COMPOSE_STARTUP_TIMEOUT_MS`, and `afterEach` restores it. - Stores the cases open now use a probe-phase bound of 60 s (`timeoutMs`, the largest the product accepts) unless the case names one. One store probe carries a single 15 s clock across a whole resource inspection, and under load it expired as `E_NATIVE_COMPOSE_PROBE_TIMEOUT`, which surfaces as `E_LEGACY_ADOPTION_STATE`. - The two expiry cases used a 5 s caller deadline, which expired before the callback was reached at load 30-45. They now use 45 s. That is still under the 60 s phase bound, so the caller deadline, not a probe's own clock, ends the held probe. The 1.5 s promptness assertion is unchanged. - **Replay** (`tests/native-process-policy-initial-trace.test.ts`, `tests/e2e/native-process-policy-initial-replay.ts`): - Cases that name no time now get Bun's 5 s default scaled by `testBudgetMs`. Lines 258, 302 and 749 had kept an unscaled 5 s. - The multicall and UTF-8 cases used a literal 3 s for exec and probe. They now use the forwarded-exec bound. - The replayed ownership policy runs with its largest probe bound (60 s), unless the recorded selection names one. ### Mutations (each restored afterwards) - **Replay wrapper default.** The UTF-8 case first sleeps 6 s. With the new wrapper it passes. With the round-1 wrapper it fails with `timed out after 5000ms`. - **Dispatch deadline injection.** With `HACK_COMPOSE_STARTUP_TIMEOUT_MS` injected as 1000, `public retained up ... "no"` fails 5.5 s after `start`, with `E_CONFIG_INVALID`. This shows the injected value reaches the CLI's deadline. - **Store probe-phase bound.** With `PROBE_PHASE_BUDGET_MS = 1`, `v7 start preserves ...` fails in 1.2 s with `E_LEGACY_ADOPTION_STATE`. - **Replay probe bound.** With `REPLAY_PROBE_BUDGET_MS = 1`, `HACK-1279 case 1` replays `E_NATIVE_COMPOSE_PROBE_TIMEOUT` instead of `code: null`. ### Evidence (M5, Bun 1.4.2, private HOME, HACK_HOME and DOCKER_CONFIG, unreachable DOCKER_HOST, keychain fallback disabled) Load came from `bash` spin and `/bin/sh` spawn workers, stopped in an EXIT/INT/TERM/HUP trap. Other agents on the host added their own load, at 1-min load 20-140 even with none of mine running. Loop logs were kept outside the checkout. The checkout-write guard from #405 fails a whole file when any new file appears in the checkout during the run. | Target | Load setup | Result | | --- | --- | --- | | Replay trace file, at 073041c | 36 workers | 10/10 consecutive (10 pass each), 1-min load 11-164, 234-420 s per run | | Replay trace file, at fa5b36d (before the replay probe bound) | 36 workers, load 54-122 | 4 passes, then run 5 failed `HACK-1279 case 1` with `E_NATIVE_COMPOSE_PROBE_TIMEOUT` inside the replay child | | Authored file, scale 10 (1686c1d) | 8 workers plus the adoption subset running at the same time, load 44-91 | 52/52 in one full run (1463 s) | | Authored file, scale 20 (fa5b36d) | same, load 23-138 | run 1: 50/52; run 2: 48/52; run 3: at least 5 failures before I stopped it | | Adoption retained-dispatch subset, fa5b36d | same | run 1 had 6 failures before I stopped it | **Not met: 10 consecutive loaded passes for items (2) and (4).** None of the remaining failures is a harness budget expiring. They come from product bounds that a test cannot set, and from one hang: - **Authored:** - `E_COMPILER_TIMEOUT` at stage `runtime` or `hook-up-before`. Each compiler invocation has a fixed 10 s default (`DEFAULT_TIMEOUT_MS` in `src/lib/native-config-compiler.ts`). The authored start passes no compiler timeout, even when its own deadline has minutes left. - The product promptness bound under test: a 50 ms deadline must return within 1 s. It returned in 2.6 s. - In one run, `Source5 cancellation after readiness ...` hung for its whole 600 s budget. See the findings below. - **Adoption:** - Public dispatch opens its own store without `timeoutMs`, so its 15 s probe-phase clock stays fixed. It expires under load and is reported as `E_CONFIG_INVALID` ("generation state is invalid, unsafe or changed"). - After a public case fails, later cases' store `open`/`prepare` refuse with `E_LEGACY_ADOPTION_STATE` within 4-5 s. `translate()` hides the original error, so I did not find the root cause. ### Gates - Root `bun run check` (includes `privacy:check`): passed at every pushed head. - `bun run --cwd packages/cli typecheck`: 0 errors at every pushed head. - Biome: nothing found on the changed files. - gitleaks 8.30.1 `dir`, over a `git log -p origin/next..HEAD` export: no leaks. - The CLI surface and agent guidance did not change, so `docs:cli-reference` and plugin regeneration do not apply. ### Hosted CI At 073041c, run 40115797226 (`pull_request`, its headSha matches) passed every check. That includes `test` shards 1-4, `docker-e2e` shards 1-4, Candidate core (macOS and ubuntu) and secret-scan. This is the full-suite evidence. It ran against the PR merge ref with `next` as of that time. At fa5b36d, `test shard 3/4` failed in `tests/project-env-layer-lock.test.ts > concurrent legacy dotenv sets and removals of different keys all apply`, with a private-file "not a regular file" refusal. That test is not in this diff and not on the known-flake list, so I did not rerun it. It passed at the next head. ### Round 2 findings (not fixed here) 1. **Public adoption dispatch has a fixed 15 s inspection clock.** `tryLegacyComposeAdoptedCommand` opens its store without `timeoutMs`, so each inspection phase gets `ctx.timeoutMs ?? 15_000` across dozens of engine calls. `HACK_COMPOSE_STARTUP_TIMEOUT_MS` does not affect it. A slow engine therefore turns `hack up` on an adopted project into `E_CONFIG_INVALID` "state is invalid, unsafe or changed", not a timeout code. Medium. 2. **The authored start's compiler calls ignore the remaining startup deadline.** Each call is bounded only by the compiler's 10 s default, so a loaded host fails starts with `E_COMPILER_TIMEOUT` while `startupTimeoutMs` still has time left. Medium-low. 3. **`Source5 cancellation after readiness` can hang.** - What happened: 8 churn workers plus the adoption subset, load 23-60. After `onReady` aborted, the runtime issued `graph native control --action status` twice. It never terminated the `frontend-serve` owner and never ran the down hooks. The owner and the persistent host process group were still alive 13 minutes later. - Expected: cancellation settles the host processes and retires the hooks, or refuses within its budget. - Medium-high: possibly a product hang in cancellation after publish. 4. **Failed authored cases leave lifecycle process groups running.** The persistent host process and the mux `bun index.ts` stay up after the run. Settlement reports them as unconfirmed but does not end them. I ended the groups that my runs left, after confirming ownership from my temp roots. Medium. ## Round 3: verifier rework (items 1-4) This round merges `next` (7dac4a0, cd35034, then 191382d) and adds four commits: 7fbeb50, 5afe58a, 6e3ec5e and b18202c. It is still test-only. Where this section disagrees with rounds 1 and 2 above, this section wins. **Scope.** The product clocks stay out of scope here. They are tracked in **HACK-1458**: the fixed 15 s public dispatch inspection clock (`E_CONFIG_INVALID`), the authored compiler's fixed 10 s timeout, the Source5 cancellation hang, and lifecycle process groups left after failed authored cases. Because of them, HACK-1367's "10 consecutive loaded passes" for the adoption and authored files is still **not met**, and this PR does not claim it. ### What changed 1. **Witnessed enrollment deadlines** (item 1). - The runtime tests' 120 s budget moved to a crate-level `TEST_RUN_BUDGET` in `packages/runtime-core/src/test_budget.rs`. It is gated on `test` and `native-config-plan`, the only feature its users build under. - `persistent_data/enrollment/witnessed/tests.rs` (lines 196 and 206) now uses it in place of a literal 5 s. So do the sibling `enrollment/tests.rs` (literal 10 s) and `engine/tests.rs` (literal 5 s). - `runtime/tests.rs` keeps its local `RUN_BUDGET` name, now set to that constant. 2. **No more file latch in the adoption file** (item 2). - `publicStatic7Settlement`, `publicStatic7Commands` and the `publicStatic7Case` wrapper are gone. - Every case now runs under `createCaseDrain` (`tests/helpers/case-drain.ts`). Each case body has its own budget, and an overrun fails that case once, by name, with `E_TEST_CASE_BUDGET`. - The overrunning body then gets a drain of 85 s (the 80 s operation deadline plus 5 s) inside the same case's Bun timeout. If it settles, the next case starts normally. - If the body is still running, it keeps the file's roots and environment. The next case's `beforeEach` waits for it for up to one more case ceiling (240 s). This wait runs outside that case's own timeout, and the case starts normally once the body settles. - Only a body that outlasts the budget, the drain and that wait makes later cases refuse with `E_TEST_CASE_RESERVED`, which names the case still running. They refuse immediately, without waiting again, until it settles. - Each case restores the environment as it was when the file loaded, not a per-case "prior" value that a late body could have left behind. 3. **Budgets stay inside the hosted per-file limit** (item 3). - `testBudgetMs` now stops at `CASE_TIMEOUT_CEILING_MS`. That is a third of `scripts/test-shard.ts`'s `DEFAULT_FILE_TIMEOUT_SECONDS` (720 s), so 240 s. - `withinCaseCeilingMs` checks each file's composed case timeout when the file loads. - Adoption file: - Body budgets are at most 150 s, so 150 + 85 + 5 = 240 s. - `OPERATION_DEADLINE_MS` is 80 s, down from 400 s. - The 60/90/240 s "unloaded" literals all clamp to the 150 s body ceiling. - Authored file: - The case budget is 240 - 30 (settlement) - 5 = 205 s, down from 600 s. The two-cycle case uses the same budget, down from 1200 s. - Startup and stop deadlines are min(300 s, 102 s), down from 300 s. - The stand-in owner cap and the hanging stand-in status request are both 240 s, down from 1800 s and 1200 s. Neither outlives the 720 s file any more. - Replay/trace cases clamp to 240 s, down from 600 s and 1200 s. - The trade-off: above the ceiling, the scale no longer gives headroom, so a host loaded past it fails cases, by name. That is the point of item 3, and the evidence below shows it. 4. **The expiry cases use a controllable clock** (item 4). - The product has no clock seam. It reads the caller deadline as a `Date.now` timestamp and derives each probe's timeout from the time left. Bun's `setSystemTime` freezes `Date.now`, so the product's `Date.now() >= deadline` check after a killed probe would never pass. I tried it and did not use it. - Instead, `shiftProductClock` spies on `Date.now` for the rest of the case. It moves the clock forward and lets it keep advancing at the real rate. It is restored in `finally`. - Once the callback runs: - `v5 expired aggregate deadline after callback` moves the clock 5 ms past the deadline instead of sleeping until then. - The two `v5 remaining aggregate clock ...` cases move the clock to a lead before the deadline: 5 s for per-effect freshness and 20 s for the final observation. The held probe still ends by the timeout the product derives from the time left. The 1.5 s promptness assertion, the PID-killed check and pending retention are unchanged. - The leads come from measurements on the M5 at 1-min load 45, from the callback to the held stub recording its PID: 0.3 s for per-effect freshness and 5.7 s for the final observation (it first runs other final probes). - Each shift is `max(0, ...)`, so the clock never moves back. - The `v3 linked saved proof` case (49 s on hosted CI) waits on no clock. It is compiler and probe work, and it is unchanged. ### New tests and mutations (each restored afterwards) - `tests/case-drain.test.ts` (8 cases) and `tests/test-budget.test.ts` (4 cases). Mutations run against copies of the helper: - Never clearing the reservation fails 4 cases. - Dropping the refusal fails 1 case. - Skipping the drain fails 1 case. - Skipping the next-case wait fails 2 cases. - Waiting again after the wait has already run out fails 1 case. - One mutation passes: always reserving, even after a settled drain. The reservation then clears in the same microtask turn, so this mutation does not change behavior. - Rust: with `TEST_RUN_BUDGET` set to 1 ns, 16 of 44 `persistent_data` tests fail, including 6 of the 7 in `enrollment::witnessed::tests`. - **Injected overrun in the adoption file**, like the verifier's reproduction. A case with a 2 s budget was placed before the first real case, then the file ran with a filter for it and three fast cases. The host was at 1-min load 185-215, from other agents plus these three runs in parallel. | Injected body | Result | | --- | --- | | Ends after 5 s (inside the drain) | Injected case fails once (`E_TEST_CASE_BUDGET ... settled within the 85000 ms drain`). The two light real cases pass. `v3 raw managed drift` failed its own 150 s budget by name at that load. | | Ends after 97 s (past budget and drain) | Injected case fails once. The next case waits for it, then passes. `v3 raw managed drift` failed its own budget by name, as above. | | Never ends | Injected case fails once. The next case waits 240 s, then refuses with `E_TEST_CASE_RESERVED` naming it, and the remaining cases refuse at once. This is the one case in which later cases still fail. Their reason names the case that is still running, and they start again if that case settles. | At the original head, the verifier's single hanging case produced 0 pass and 116 fail. ### Evidence (M5, Bun 1.4.2, private HOME, HACK_HOME and DOCKER_CONFIG, unreachable DOCKER_HOST, keychain fallback disabled) Other agents kept the host at 1-min load 55-215 throughout, with their own churn workers. I added no churn of my own. - **Rust, `persistent_data` module**, cargo +1.97.1 `--all-features`: 10/10 consecutive runs, 44 pass each, at load 56-59. Also `fmt --check` and clippy `-D warnings`, both default and `--all-features`, and the `runtime` module (52 pass, 1 ignored). - **Adoption expiry cases** (`-t aggregate`, 5 cases): 5/5 pass at load 56-66, after the merge too. There is no 45 s wait any more. Hosted per-case times are in the CI section below. - **Adoption file, whole**, at load 100-120. This ran before b18202c added the next-case wait. - 15 cases failed their own budget, by name. 14 of their bodies settled within the drain. - One body (`rolled-back v5 can reprepare ... directory`) still had several owner operations to run. The 92 cases after it refused at once, so the reservation never got time to clear. - That run is what led to b18202c: the next case now waits for such a body. - I did not repeat the whole file at that load after the change. - **Authored file, plus the trace and test-shard files**, at load 110-120: 96 pass, 4 fail, each failure reported by name with no cascade. - `Source5 supervised ...` failed with `hook-up-before`. - `Source5 cancellation after readiness ...` failed with `hook-up-before (E_COMPILER_CANCELLED)`. Both of these are compiler-side failures (HACK-1458 items 2-3). - The two-cycle `known failed down.after ...` exceeded its 205 s budget. This is the ceiling trade-off. - The fourth failure was the checkout write guard, because I edited files during that run. ### Gates (at 0dd0d5a) - Root `bun run check` (includes `privacy:check`): exit 0. - `bun run --cwd packages/cli typecheck`: 0 errors. - Biome: nothing found on the changed files. - gitleaks 8.30.1 `dir`, over a `git log -p next..HEAD` export: no leaks. - The CLI surface and agent guidance did not change. ### Hosted CI At 0dd0d5a, run 40138406767 (`pull_request`, headSha 0dd0d5a) passed every check: `test` shards 1-4, `docker-e2e` shards 1-4, Candidate core (macOS and ubuntu), Config compiler (all four platforms), Runtime state models, linux-process-lifetime, runtime-images and secret-scan. This is the full-suite evidence. It ran on the PR merge ref with `next` at 191382d. Per-file times from the shard logs: | File | Round 2 (073041c, run 40115797226) | Round 3 (0dd0d5a) | | --- | --- | --- | | `native-compose-adoption-generation.test.ts` | 6m57s (417 s of 720 s) | 5m20s (320 s) | | `v5 expired aggregate deadline after callback ...` | 46.2 s | 1.7 s | | `v5 remaining aggregate clock ... per-effect freshness` | 46.2 s | 6.8 s | | `v5 remaining aggregate clock ... final observation` | 46.2 s | 21.8 s | | `v3 linked saved proof ...` (no clock wait) | 49.3 s | 53.3 s | | `native-authored-project-start.test.ts` | - | 31.6 s | | `native-process-policy-initial-trace.test.ts` | - | 2.6 s | | `case-drain.test.ts` / `test-budget.test.ts` | new | 1.6 s / 0.5 s | ### Round 3 findings (not fixed here) 1. **The ceiling and loaded-host headroom conflict.** - What happens: hosted CI runs each file under a 720 s limit, so a case cannot take more than about 240 s and still be named. At 1-min load 100+, adoption public dispatch and authored two-cycle cases need more than that. - Option: `scripts/test-shard.ts` could pass its effective `--file-timeout-seconds` to each file, so direct `bun test` runs keep the larger 20x budgets and only shard runs clamp. - Severity: low-medium. It decides how HACK-1367's loaded-run acceptance can be proven. 2. **A truly hung adoption body still blocks the rest of its file**, after one 240 s wait. - Why: the cases share module globals and `process.env`, so later cases cannot safely reuse them while that body may still resume. - Option: per-case state via `AsyncLocalStorage`, as `native-fixture-cases.ts` does. That is a rewrite of a 5,000-line file. - Severity: low, since it takes a body that never settles. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
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.
Summary
Fix unit G40: two runtime-core test reliability defects, following #387. Test-only; no product code changes.
Rework after verification: the verifier found that part of the HACK-1347 audit was still open. Several tests outside the first commit's modules still checked a dropped listener in the shared process, and the full lib suite failed 0/5 under the brief's fork-pressure condition. The rework isolates or de-exposes every such test found (see the audit), and merges
origin/next(b90b67e) for #382's--publish 0.process::clean_commandsets apre_execumask, so std uses fork+exec, notposix_spawn) holds a copy of every open descriptor until it execs.HostEndpoint::discover(host_endpoint.rs:202-247) scans every same-user process running the selected executable; the test binary is both the scanner and the selected executable, so the fork is a second exclusive holder of the test's listener (tests.rs:78, ambiguity refusal, the CI signature) or still holds a listener the test already dropped (tests.rs:84). Thelsofrelease checks (lock_absent_disks_within) see the fork's copy of a disk handle the fixture just wrote or read, and socket probes see a dropped listener still accepting, so they correctly refuse before the result under test.test_executable::run_isolatedhelper from #387 (new sharedISOLATED_HANG_GUARD, 120 s). See the audit below.<prefix>-<pid>-<nanos>and created them exclusively. macOS reports the wall clock in whole microseconds (nanos always end in000), so two parallel tests in one process can read the same value and the second create fails withFile exists (os error 17)(witnessed/tests.rs:22).crate::next_test_scratch()(process-unique sequence) appended to every pid+timestamp scratch name in the lib tests: the witnessed fixture and 18 sibling sites in 14 other files. The 4 integration-test files with the same pattern each have one test and one call, so they cannot collide in-process; they are unchanged. Regression testtest_scratch::tests::parallel_fixtures_reading_one_clock_tick_create_distinct_directories.HACK-1347 audit: what runs isolated now
Rule: a test that runs a release or liveness check in the shared process (endpoint discovery, the
lsofdisk-handle check, a dropped listener that must refuse connections, anflockthe fixture dropped and the check must acquire), and expects anything other than the fail-closed refusal, runs isolated, or its fixture stops depending on a dropped descriptor.First commit (1c14276):
host_endpoint:discovery_requires_the_exact_executable_and_live_exclusive_listener(the reported test),executable_pinned_discovery_follows_a_new_listener_process, and the two close/rebind fixtures that still shared the process (replacement_between_precheck_and_connect_receives_no_payload,backend_fin_does_not_bypass_listener_replacement). The module's own runner now delegates torun_isolated(its old 5 s watchdog for launching the whole binary becomes the 120 s hang guard; it now also requires the exact child's... okline).admission_pool: every release-check test that expects a pass or a non-active refusal (7 more, behind oneisolated()helper that keeps holdingserialso the module's whole-host scans stay one at a time). This closes the gap test(runtime): make four hosted-macOS candidate-core tests deterministic #387 left open.short_home(4),host_filesystem(7, every test whoseinspectmust pass first),interrupted(1): recovery, migration and stop tests that need thelsofproof to pass.Rework (f6bc8ed, cb6661f), the rest of the brief's audit. The verifier's run found these still exposed; a full all-features lib run under fork pressure (below) found the rest:
test_executableis now compiled on every Unix target (cfg(all(test, unix))), becausequiescent_recoveryalso runs on Linux.sleeping_executablestays macOS-only in use.run_isolated(each module has oneisolated()helper with the reason; each child is#[ignore]d with its parent's name):graph::dependency_slots:recovery_refuses_live_and_replaced_sockets_without_touching_siblings,recovery_preflights_entire_assignment_before_unlinking(a dropped listener must refuse).dependency_socket::quiescent_recovery:first_socket_refusal_records_closed_live_listener_facts_without_removal,live_listener_and_foreign_replacement_are_never_removed.graph::foreground::transport: the 7abandoned_publishertests that expect retirement or the dead-owner guard to pass (recovered_publisher_retirement...,cleanup_fence...,retired_recovery_refuses_pending...,retired_recovery_rechecks...,interrupted_socket_move...,selected_legacy_device_socket_move...,dead_owner_guard...). The fixture drops a live publication, so both its listener and its operation-lockflockcan survive in a fork.graph::acknowledged_publisher::missing_lock: all 5 tests (they all build on the dead-publisher fixture, which requires the dropped listener closed).graph::native::foreground:abandoned_publication_is_evidence_and_never_implicit_recovery_authority(needsnative-config-plan).https_recovery:full_command_preserves_large_public_mode_ca_and_exact_retry,full_legacy_command_archives_with_real_current_witness_and_exact_retry(the fixture drops the providerflockthat recovery must acquire).provider::test_socket::bound_unlistenedbinds a socket that never listens, so every copy refuses connections (shared with the existingquiescent_recoveryfixture, which already did this inline). Used by thehttps_recoveryfixture (owner.sock) anddependency_slots::fixture_prior_boot(slot sockets). Regression testtest_socket::tests::an_inherited_copy_of_an_unlistened_socket_still_refuses_connections.engine::relay_exec::framing_bounds_reject_truncation_invalid_stream_and_overflow: shuts the peer down instead of only dropping it, and now holds a cloned copy of the peer for the whole check, so it proves the truncation refusal with an inherited copy present, every run.provider::publisherstop fixture: startedstream-relay-hoston a probe port it had bound and dropped. A fork's copy of the probe (or another test) kept the port, and the publisher exited 73 without readiness. It now uses--publish 0from test(runtime-core): bind stream relay publisher test ports in the publisher #382 and requires theready <port>line. This needednext, so the branch mergesorigin/next(b90b67e).rebind(isolated in test(runtime): make four hosted-macOS candidate-core tests deterministic #387;nested_testskeeps its listener in a child that never forks),network_updateand the remainingshort_home/host_filesystem/admission_pool/transporttests (they assert only the fail-closed refusal, or refuse before any host-wide check),identity::executable_runningusers (a pre-exec fork runs the test binary, never the pool's provider path),https_recovery::rejects_a_live_inherited_unix_listener_at_the_pinned_inode(it already polls the dropped listener for 1 s), and other fixtures that drop anflock(interrupted_start_cleanup,live_owner_cleanup::relay,retained_startup,persistent_data::engine,same_boot_absent_relay): none failed in the pressure runs; see Gaps.Product question: can product forks cause false ambiguity refusals?
process::clean_command,node::supervisorandnode::transportall usepre_exec, so they fork+exec and have a pre-exec window in which the child runs hack-local's image and holds all of its descriptors. Discovery only counts processes running the user-selected dependency executable, and hack-local never selects itself, so a hack-local fork cannot be a second holder in the product. Only the test binary, which is both the scanner and the target, sees this.FD_CLOEXECwould not help: the descriptors are already close-on-exec, and the window ends at exec. No product change. A separate, real descriptor leak found during the audit is reported in Findings (non-atomic close-on-exec for sockets on macOS).Evidence
"Fork pressure" adds a scratch co-tenant test (never committed) to the same test process: 4 threads loop
Command+pre_exec(umask) on/usr/bin/true, the same fork+exec pathprocess::clean_commandtakes, and it starts first. "Hold N ms" makes those children sleep N ms before exec, to model a stalled pre-exec child on a loaded host. "CPU load" isnicebusy loops stopped by a shell trap.Rework (this Mac, Apple Silicon, 18 CPUs, already at load average 10-24 from other work; Rust 1.97.1)
Full all-features lib test binary (CI's
--all-featuresset, 1296-1297 tests),--test-threads=16, fork pressure for 90 s:flockrefusals:missing_lock(4 of its 5 tests,missing_lock.rs:832),dependency_slots(:719,:735),transport(3),native::foreground(1),https_recovery(3),quiescent_recovery(1)graph_timeout(Findings 7), not a descriptor refusalIntermediate builds showed the remaining exposures one at a time. After the first isolation round, every run still failed on the two
https_recoveryfull-command tests (flockdrop, 0/3). After those,provider::publisherfailed 2/5 withpublisher readiness missing(exit 73: the dropped probe port). The publisher fixture change fixed that.Mutations (each new test must fail without the property it guards):
relay_execframing test: remove the peershutdown(keep the cloned copy)relay_exec.rs:338(no end of stream, so no truncation refusal)test_socket::bound_unlistened:listen()afterbind()test_socket.rs:73)assertions did not runEvery isolated parent passes only if its child's exact
test ... okline and1 passedare in the child's output, so a missing or renamed child cannot pass silently.Linux:
cargo +1.97.1 clippy --all-targets -D warnings --target x86_64-unknown-linux-gnu(cross-checked with zig cc, this Mac): exit 0, which coverstest_executableandtest_socketnow compiling on Linux. Running the Linux tests is left to hosted CI.First commit (1c14276, M3, 16 CPUs)
next16f4547)host_endpoint+ fork pressure + CPU loadtests.rs:84or:78)host_endpoint,admission_pool,short_home,host_filesystem,interrupted+ fork pressure + CPU loadhost_endpointdrop tests whose own 1 s read timeout is shorter than the hold)test_scratchregression test, sequence mutated to 0File exists (os error 17), the CI signature)The verifier independently confirmed these at 1c14276 (
host_endpoint20/20 vs next 3/10; the 5 modules 8/8 vs 3/5; thetest_scratchmutation 0/10).Gates at cb6661f
cargo +1.97.1 fmt --check: exit 0.clippy --all-targets -D warnings, default and--all-features(with the pinned Zig): exit 0 (this Mac).cargo test(thetest:localcommand, private target dir): exit 0; lib 1049 passed, 101 ignored; 21 binaries, 1204 passed in total.--all-featurescargo test: the first run, concurrent with this unit's 4-CPU-loop stress, failed 3 native runtime tests withgraph_timeout(Findings 7). Two reruns without the stress: exit 0 both times, 1538 passed over 25 binaries.bun run check(Bun 1.4.2; includes the Bun pin check andprivacy:check: ok) andbun run --cwd packages/cli typecheck: exit 0.gitleaks diron thegit log -p origin/next..HEADexport): no leaks.No TypeScript, CLI surface, agent guidance or task pins changed.
Gaps
host_endpointdrop tests at 1 s;https_recovery::rejects_a_live_inherited_unix_listener...at 1 s).flockand later require it free (listed above) and did not fail in any of the 22 full-suite pressure runs on this Mac. A longer hold could still expose them.test_scratchmutation.Findings (not fixed here)
More fork-exposed release-check tests outside this unit: fixed in this rework (see the audit).SOCK_CLOEXEC, so std (socket,accept,socketpair) andhost_endpoint::pending::connect(pending.rs:16-25) set close-on-exec in a second call. A child spawned by another thread in between (fork+exec orposix_spawn, which std uses withoutPOSIX_SPAWN_CLOEXEC_DEFAULT) keeps the socket for its whole life. Repro (scratch probe, never committed): one thread creates sockets with the two-step pattern (gap widened to 500 us), another spawns the test binary to count its socket descriptors: 199/200 children inherited a socket with either spawn path; control without the creator: 0. Expected: a droppedPendingConnectionor listener closes. Actual: it stays open until an unrelated child exits. Proposed fix: spawn product helpers withPOSIX_SPAWN_CLOEXEC_DEFAULT(a smallposix_spawnwrapper), or serialize socket creation against spawns with a process-wide lock. Needs a product-flow repro before changing behavior.lsofbound still times out under load (HACK-1328 product part, known).short_home::vm_lock_releases...failed once in 20 runs atlock_absent_diskswithprovider_timeout_uncertainunder CPU load and concurrent scans. Unchanged here.network_update::stoppedreturns a plainFilelock (minor). UnlikeVmLock, it does notLOCK_UNon drop, so a concurrent fork's copy keeps the VM lock held briefly after the network update ends. Not reproduced.EBADF(low).host_endpoint::tests::accepted_stream_preserves_payload_and_half_closefailed once atread_to_stringwithBad file descriptoron the unmodified base under fork pressure (1 of 10 runs); not seen in 60+ later runs.git worktree add --detach <dir> <commit>, thenbun test tests/project-task-catalog.test.ts -t "recipes are all reviewed"in it. Expected: the pin review runs (it passes on an attached branch). Actual: it fails attests/project-task-catalog.test.ts:1415withE_USAGEdetached_checkout, because the test selects an instance implicitly fromREPO_ROOT. Reproduced at both 331ec18 (base) and 1c14276. Any detached CI or verification worktree reports a false failure. Proposed fix: pass an explicit branch selector, or useworktree.auto_branch=false, in that test.graph::native::runtime::tests::{two_owned_bridges_keep_per_network_members_and_aliases_through_cleanup, two_bridge_cleanup_retries_only_the_retained_second_bridge_after_first_delete}(tests.rs:143,:243) andauthored_run::{cleanup_reaps_a_recorded_one_off..., finite_one_off_refuses_changed_member_mounts...}(run_tests.rs:155) callexecution::run(..., Duration::from_secs(2))against the in-memoryFakebackend and fail withgraph_timeoutwhen the host is saturated. Repro: all-features lib binary,--test-threads=16, on a host at load average 17-24 plus 4nicebusy loops: 1 of 5 runs (and 3 tests in one CI-equivalentcargo test --all-featuresrun during the stress). Expected: a fake backend finishes well within its deadline. Actual: the 2 s wall-clock deadline expires on an unoptimized, CPU-starved run. Not a descriptor refusal; reruns without the extra load passed (2/2). The isolated tests add ~20 short child test processes per suite run, which slightly raises load. Proposed fix: give these fake-backend runs a hang-guard deadline (for example 30 s), as test(adoption): give v14 compound routing operations the product's caller deadline #383 did for v14 routing.next.Refs HACK-1347
Refs HACK-1348
🤖 Generated with Claude Code