Repository navigation
perf(storage): reuse unchanged storage witness proofs across warm commands - #313
Merged
Merged
Conversation
…mands A warm, image-only, unrouted `up` or `restart` with no hooks now runs four directory-xattr storage proofs instead of six. Each proof is three fresh helper containers. - The warm enrollment tail (no new volume) reuses the effect-entry proof of the same generation under the same lease. It uses the existing adjacentProof record, which needs a byte-identical material binding, so any receipt write in between forces a full proof. - Finalization skips the second proof pass when there is no route, after hook, or new or saved file material. In that case the completion step only reads. Proof reuse stays within one command and one lease. Nothing is persisted, so every command still opens with a full proof. Each performed verification proof now emits a passive `storage.proof` phase-trace span. The hosted native-config-compose scenario uses it to assert four proofs on each of two consecutive warm ups. Refs: HACK-1123 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sed file The HACK-1211 experiment produced two iterations per Bun version: one focused pass and one full `bun test` that started on a 600 s estimate and overran the 30-minute budget, because the full suite takes 30-35 minutes on hosted macOS. - Never start an iteration whose estimate exceeds the remaining budget. The estimate is a conservative prior (focused 120 s, full 2400 s) until a run completes, then the longest completed run times 1.2. A command that does not fit is recorded once in summary.json `skippedForBudget` and leaves the rotation. Deadline stops remain a safety net (`overrun`). - Add `--mode=focused|full|alternate` (default focused: only tests/native-https-owner.test.ts) and `--role=baseline|candidate`. - The workflow gains a validated `mode` dispatch input; the label-triggered matched pair uses focused with baseline/candidate roles. Full mode needs a budget of at least 40 minutes. The job timeout stays budget + 15. - summary.json gains per-command counts (iterations, passes, crashes, other failures, overruns, skipped-for-budget) and a one-line verdict such as "baseline 0/97 focused crashes". - The shared classifier also treats exit 134 (SIGABRT) and 138 (SIGBUS) as crash exits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a source CLI regression for a first up with new file material and no saved file material: finalization keeps its completion step, the second finalize pass, and the post-completion file-mount readiness check. The test fails when the retirement condition keeps only the saved-files arm. Native-compose CLI harnesses now give the child a private HOME under the fixture root instead of the contributor's HOME. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicted files: none. The plain merge applied cleanly; no generated files needed hand resolution, and the generation and reference tests pass on the merged tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-parents onto next after the previous stacked PR squashed in; next's tree equals a8bf46d, which this branch already contains, so the branch tree is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
added a commit
that referenced
this pull request
Oct 13, 2026
**Draft.** Test-only change. No CLI surface, source or docs change.
## Problem
Many tests ran the source CLI (`process.execPath index.ts ...`) with `env: { ...process.env, ...
}`, or with no `env` at all. The child CLI then saw the contributor's real `HOME` and every
inherited variable: `HACK_*`, `DOCKER_HOST`, `DOCKER_CONTEXT`, `DOCKER_CONFIG`, `XDG_*`, `CI`
and so on. A few of these tests also had no `HACK_HOME`, so the child used the real `~/.hack`
(for example `native-ca-doctor-cli`, `native-project-down`, `native-project-exec` and `env-
backend-command`).
## What changes
- New `tests/helpers/private-cli-env.ts` with `privateCliEnv({ root, home?, env?,
isolateDockerClient? })`. It builds the whole child environment:
- `HOME` is `<root>/user-home` or a given path inside the fixture root. Any other path throws,
and `env.HOME` is refused. The directory is created if needed, and its mode is set to 0700
even when the test created it first.
- Only `PATH` and `TMPDIR` are inherited from the runner. Everything else must be passed by
the test.
- By default it adds the existing isolated Docker client config (`isolatedDockerClientEnv`),
so no real engine or context is selected. A `DOCKER_HOST`/`DOCKER_CONFIG` passed by the test
still wins.
- Migrated every source-CLI spawn that copied the runner environment or passed no `env`:
- The seven named native-compose harnesses: `native-compose-open`, `-routing-command`,
`-routed-run-command`, `-saved-command` (4 sites), `-before-command`, `-adoption-generation`,
plus `-build-command` and `-network-command`.
- Other spawns without a `HOME` override: `doctor-domain-migration`, `env-backend-command` (29
call sites, through `runHack`), `native-ca-doctor-cli`, `native-config-import-preview`,
`native-project-down` (3), `native-project-exec`, `native-routing-project`, `prerelease-plan`
(the `index.ts --version` spawn), `project-env-local-inheritance`.
- Spawns that already set `HOME` but still inherited everything else: `doctor-command`, `mcp-
lifetime`, `mcp-prompts`, `mcp`, `native-hack-local-route-cli`, `project-env-worktree-key`,
`e2e/run-dependency-cache`.
- `helpers/project-name-fixture.ts`: its `env` (`HOME` = fixture root) feeds the CLI spawns in
`project-name-command`, `local-network-hint` and `local-network-hint-call-sites`, and the git
spawns in `projects-registry-identity`. It now comes from `privateCliEnv`.
- Spawns with no `env`, which inherited the whole runner environment and real `HOME`: `doctor-
browser-network`, `log-pipe` (2), `mcp-entrypoint` (2), and `host-exec-tty` (the Python probe
that runs `bun index.ts` with the env it was given, and the probe of the compiled binary).
- `mcp.test.ts`: the env of the in-process MCP server, which spawns `HACK_MCP_COMMAND` with
it.
- `dispatch-e2e.test.ts` (opt-in, `HACK_DISPATCH_E2E=1`): its three CLI runs and its git runs
now use `privateCliEnv` with the test's temporary `HOME`.
- The three harnesses #313 already fixed (`helpers/native-compose-command.ts`,
`helpers/native-compose-files.ts`, `native-compose-storage-activation-command`) now use the
helper too. Their env is the same as before, except that `TMPDIR` is inherited and the files
helper now also gets the isolated Docker client config.
- Tests that set `HACK_HOME`, the compiler path or a secret key in-process now pass those
values explicitly.
- `native-project-down` puts the CLI's private HOME and Docker config in a separate temporary
directory, not inside the project checkout under test.
- New guard `tests/cli-spawn-env-isolation.test.ts`, with the scan in `tests/helpers/cli-spawn-
env-scan.ts`. The scan reads each file's TypeScript syntax tree (the `typescript` package that
`typecheck` already installs).
- Scope: files that name the source CLI (a string ending in `index.ts`, `CLI_ENTRYPOINT` or
`resolveCliSpawnArgs`), plus every test-local module they import, transitively. So an env that
a fixture helper builds, such as `project-name-fixture.ts`, is checked where it is built.
- `runner-env`: no copy of the whole runner environment. Any use of `process.env`, `Bun.env`,
`import.meta.env` or a `node:process` `env` import is a copy unless it reads, deletes or tests
one key, or replaces the object. This covers `...process.env` whatever `HOME` is set to, `env:
process.env`, `...Bun.env`, `Object.assign({}, process.env)`, `const saved = process.env` and
rest destructuring.
- `runner-home`: no `HOME` property whose value comes from `process.env.HOME`, `homedir()` in
any import form (`os.homedir()` included), `userInfo().homedir`, or a same-file binding of one
of these.
- `cli-without-env`: a `Bun.spawn`/`spawnSync` or `node:child_process` spawn whose arguments
reach the CLI must pass `env`. A wrapper whose argv names the CLI, such as the Python probe,
counts.
- `env-without-home`: when that `env` is an object literal, it must set `HOME` or spread
another object. Without `HOME` the child falls back to the account's real home.
- Documented exceptions, each with an exact finding count so a new finding still fails, and a
check that each exception still exists:
- `tests/e2e/harness.ts` (1): `runCommand` runs git/docker for the real-engine E2E suite;
its CLI calls use `buildCliEnv`.
- `tests/prerelease-plan.test.ts` (4): two env objects for `scripts/prerelease-plan.ts`, one
`tar` env, and one `bun build index.ts --compile`. None of them runs the CLI.
- `tests/e2e/scenarios/native-routing-fixture-ingress.ts` (1): the real-engine `docker` TLS
probe env, not the CLI.
- `tests/host-exec-tty.test.ts` (1): `bun build index.ts --compile`, which does not run the
CLI.
- Limits, documented in the scan module: it is a syntax check within one file, not data flow.
It trusts an env, `HOME` or argv value that comes from another module or a parameter (for
example `HOME: opts.home`, or a local `run(argv)` wrapper whose own spawn never names the
CLI). It does not see a CLI path built at runtime without naming `index.ts`, such as a
compiled binary.
- `doctor-command` lists the fixture root after the run. That list now includes the test-created
`docker-client` config directory.
Not changed: `tests/native-compose-renderer-config.test.ts` passes `HOME` to a real `docker
compose config` client, not the CLI, and does not name the CLI, so it is out of the scan's
scope.
## Validation
- Guard mutations on real files: each makes the guard fail, and it passes again after the revert
(16 of 16):
- In `native-compose-open`, replacing `privateCliEnv` with `env: process.env`; no `env`; `{
...Bun.env, HOME }`; `Object.assign({}, process.env)`; `{ ...process.env, HOME: os.homedir()
}`; `{ ...process.env, HOME: someVar }`; `{ PATH, HOME: homedir() }`; `{ PATH, HOME: someVar
}` with `const someVar = homedir()`; and `{ PATH }` with no `HOME`.
- Restoring the previous PR-head version of `project-name-fixture`, `dispatch-e2e`, `doctor-
browser-network`, `log-pipe`, `mcp-entrypoint`, `host-exec-tty` or `mcp.test`.
- Scan and helper mutations: each fails a guard-file test (14 of 14). They include disabling
each rule, the transitive-import scope, `child_process` import tracking, `node:process` `env`
aliases, `Bun.env`, the rest-destructuring check, the CLI-path taint pruning, and the helper's
`chmod` of an existing HOME.
- Focused runs (local macOS), with the runner's `HACK_HOME`, `DOCKER_CONFIG`,
`HACK_GLOBAL_CONFIG_PATH`, `DOCKER_HOST` and `DOCKER_CONTEXT` pointed at canary paths: one
`bun test` run of 44 files: all 31 changed test files, the project-name fixture users
(`project-name-command`, `local-network-hint`, `local-network-hint-call-sites`, `projects-
registry-identity`, `project-name-registry`), the #313 harness users except `file-command`
(`after`, `down`, `phase-trace`, `retained-storage`, `storage-fence-cost`, `storage-witness`,
`warm-up`), and `integration-ownership`. 595 pass, 4 skip (3 opt-in `dispatch-e2e`, 1 existing
in `adoption-generation`), 0 fail. The canary directories stayed empty.
- `bun run --cwd packages/cli typecheck`: exit 0. `bun run --cwd packages/cli check`: exit 0, 0
errors (34 existing warnings in `src`). `ultracite check` on every changed test file: clean.
## Gaps
- Local macOS runs only. Hosted CI on this PR is the cross-platform check.
- The local canary run did not also point `HOME` at a canary. The guard and the helper keep the
runner's `HOME` out of the child.
- `dispatch-e2e.test.ts` is opt-in and was only typechecked, scanned and run in its default
skipped mode. Its in-process setup can reach the node-token keychain, so it was not run with
`HACK_DISPATCH_E2E=1`.
- `native-compose-file-command` needs `dist/hack-config-compiler`, which this worktree cannot
build without the pinned Rust toolchain. It does not call `privateCliEnv` directly; it uses
the #313 harness, whose env is unchanged apart from the HOME `chmod`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
HACK-1123: fewer storage witness proofs per warm command. Draft. Hosted docker-e2e timings on this PR compared with
nextare the measurement. This PR does not update Linear.What changes
One directory-xattr storage proof is three fresh helper containers: root, verify and root-after. A warm
uporrestartthat is image-only, unrouted and has no hooks now runs 4 proofs instead of 6.Step A (
src/lib/native-compose-command-storage.ts):enroll()callsverify(generation, { adjacentProof: true })whennewVolumes.length === 0. It uses the existing per-leasetakeLeaseProofrecord. That record is keyed by the lease's authority object, the engine ID, the generationId plus inputRevision, and the exact witness reference. It is reused only when the opening material binding is byte-identical to the binding that opened and closed the recorded proof. The material binding covers the receipt dev/ino/hash, the lease token, the checkout anchor and the generation. Every host check still runs (anchors, expectation, completion and binding).Step C (
src/lib/native-compose-command.ts):retireFilesis passed only whenfiles !== nullor a saved generation carriesx-hack-native-files(new helpernativeComposeSavedFilesPresent). Without either, retirement only read documents. Leaving it out letspreparedEffectOwnershipleavebeforeCompleteundefined when there are no routes and no after hooks.finalizeEffectthen takes its existing single-pass path, the same one routedrunalready uses. Withfiles === null,assertFilesandassertFilesAfterCompletionbehave the same, so the single pass loses no check.Observability: each performed verification proof now emits a passive
storage.proofphase-trace span (HACK_NATIVE_COMPOSE_PHASE_TRACE=1). A reused proof emits none.Not in this PR (each needs an explicit decision, see "Remaining"):
exec.Safety argument
user.*xattr;newVolumes.length === 0. Between P3 and P4, the warm path runs only the pure selection compare, the no-opassertColdAbsentand the enroll loop over zero volumes. Onup/restart,prepareEffectBoundarydoes nothing after the effect-entry check, becausedownHooksexists only ondown.beforeCompleteis absent only when it would have been a no-op read.takeLeaseProofdeletes the record before any check.Invalidation table
matchesBindingbeforeCompletekept → second finalization proof keptTests (all with injected stubs or the synthetic
docker; no real socket or HOME state)tests/native-compose-storage-witness-xattr-owner.test.ts:up/restarttest now expects the enrollment tail to reuse the effect-entry proof (7 → 6 verify helpers).recorded !== undefined), both new tests failed. They pass again with the condition restored.tests/native-compose-storage-activation-command.test.ts(source CLI, synthetic engine):up:root, seed, 3 proofs, workload, 1 proof(was 2 after the workload);ups:3 proofs, workload, 1 proofeach (was 4 / 2);exec: unchanged, 1 / 1;restart:3 / 1(was 4 / 2).upwith an after hook keeps all 8 post-workload proofs. Only the enrollment tail drops (6 → 5 before the workload). The baseline numbers were measured by running the same test againstnextsources.tests/native-compose-storage-fence-cost.test.ts:up6× → 4×PROOF_FENCESandPROOF_OBSERVATIONS. A reused proof costs no engine fence.execis unchanged.tests/native-compose-phase-trace.test.ts: a one-offrunwith no file material now has a single finalization pass.tests/e2e/scenarios/native-config-compose.ts(its existing docker-e2e shard registration) runs two consecutive unchanged warm ups with the phase trace. It asserts exactly 4storage.proofspans and 1finalize.witnessesspan each, and logs the per-up proof time and total time.Docs:
docs/reference/native-compose-storage-witness.md(reuse rules and counts) anddocs/reference/native-compose-phase-trace.md(storage.proof).Validation (local, macOS, source CLI; nothing ran against a real engine)
bun run --cwd packages/cli typecheck: clean.bun run --cwd packages/cli checkandbun run check: 0 errors and 34 warnings, the same count asnext. The complexity warning innative-compose-command.tsexists onnexttoo.tests/native-compose-storage-witness-xattr-owner.test.ts: 106 pass.tests/native-compose-storage-activation-command.test.ts: the proof-count case and the new after-hook case pass.tests/native-compose-storage-fence-cost.test.ts: 4 pass.tests/native-compose-phase-trace.test.ts: 12 pass.bun test tests/native-compose- tests/native-authored tests/integration-ownership.test.ts, 131 files.nextsources and on this branch.mise exec -- bun run build:config-compiler, those 11 files pass: 293 pass, 1 skip, 0 fail. They include the saved and new file-material retirement paths that still keep the second finalization pass.dockerwith an isolatedDOCKER_CONFIGthroughout. No hack command ran against a real engine.bun-*.ipscrash reports. The only ones present date from 04:10, before this work.Review follow-up (polish commit)
tests/native-compose-file-command.test.ts: a firstupwith new file material and no saved file material. It checks that finalization still runs the completion step and then a second pass (finalize.ownedandfinalize.witnesses, read from the phase trace). It also checks that the exact file mounts are observed 4 times after the engine starts; the post-completionfiles.assertReadyprovides the last of these.nativeComposeSavedFilesPresent(previous)(withoutfiles !== null), the new test fails. The completion step and the whole second finalize pass disappear, and the mount observations drop from 4 to 2. It passes with the PR code. This fixture has no storage, so the secondfinalize.witnessespass is checked as a phase, not as a carrier proof.HOMEto the child CLI.HOMEis now a privateuser-homeinside the fixture root, in the storage activation test and in thenative-compose-commandandnative-compose-fileshelpers.HACK_HOMEwas already inside the fixture.tests/native-compose-renderer-config.test.tsstill passesHOMEto a realdocker compose configclient invocation. That is a different harness and is not changed here.storage-activation-command, one instorage-witness-xattr-owner) were time-sensitive cases. Both files passed alone: 8/8 and 106/106.after-command,down-command,phase-traceandwarm-up-command): 46/46 pass.packages/clitypecheck and check both pass. Check reports existing warnings only.bun-*.ipscrash reports.Expected saving (estimate, not measured here)
Basis: hosted traced run 38225752202 (#298). A warm
uptook about 22.0 s, with 13.0 s of witness time over 6 proofs, so about 2.2 s per proof. Proof counts are exact. Wall times vary about ±20% with the engine.upwithout hooks: −2 proofs ≈ −4.3 s (about 22.0 → 17.7 s).up: step A only, ≈ −2.2 s.restartin the fixture shape: −2 proofs.native-config-composescenario: about 11 warm ups × 4.3 s ≈ 47 s of 535.7 s traced. The two traced warm ups this PR adds cost about 35 s, so the net scenario wall time stays about the same. The per-up numbers in the log are the comparison.Remaining (needs decisions; not in this PR)
up. Trade-off: a volume replaced between P1 and P2 would be caught at P3 as UNCERTAIN (after pending) instead of as a clean prepublication refusal.exec: saves about 2.65 s of 5.7 s per storage exec, about 58 s per compose scenario. A replacement during the exec would then show up one command later, not in this exec's result.🤖 Generated with Claude Code