Skip to content

perf(storage): reuse unchanged storage witness proofs across warm commands - #313

Merged
roodboi merged 6 commits into
nextfrom
wf/witness-reuse-b8
Oct 12, 2026
Merged

roodboi merged 6 commits into
nextfrom
wf/witness-reuse-b8

Conversation

@roodboi

@roodboi roodboi commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

HACK-1123: fewer storage witness proofs per warm command. Draft. Hosted docker-e2e timings on this PR compared with next are 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 up or restart that is image-only, unrouted and has no hooks now runs 4 proofs instead of 6.

proof before after
P1 opening (command admission) full full
P2 prepublication full full
P3 effect-entry (after pending is saved) full full
P4 enrollment tail full reuses P3 when no new volume (step A)
pre-spawn guard reuses P4 reuses P4 (now the P3 record)
P5 first finalization (after the workload) full full
P6 second finalization full removed when there is no route, after hook, or new or saved file material (step C)

Step A (src/lib/native-compose-command-storage.ts): enroll() calls verify(generation, { adjacentProof: true }) when newVolumes.length === 0. It uses the existing per-lease takeLeaseProof record. 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): retireFiles is passed only when files !== null or a saved generation carries x-hack-native-files (new helper nativeComposeSavedFilesPresent). Without either, retirement only read documents. Leaving it out lets preparedEffectOwnership leave beforeComplete undefined when there are no routes and no after hooks. finalizeEffect then takes its existing single-pass path, the same one routed run already uses. With files === null, assertFiles and assertFilesAfterCompletion behave the same, so the single pass loses no check.

Observability: each performed verification proof now emits a passive storage.proof phase-trace span (HACK_NATIVE_COMPOSE_PHASE_TRACE=1). A reused proof emits none.

Not in this PR (each needs an explicit decision, see "Remaining"):

  • step B: prepublication reusing the opening proof;
  • dropping the closing proof after saved exec.

Safety argument

  • Reuse stays inside one command and one mutation lease. No receipt is persisted and there is no time window. Every command, including the first one after an engine restart, opens with a full 3-helper proof. A persisted cross-command receipt was rejected for four reasons:
    • the host cannot see the volume root without a helper;
    • root identity (device, inode, uid, gid) can repeat after the volume is recreated;
    • any workload container can remove the user.* xattr;
    • Docker exposes no boot ID, and the engine ID survives restarts.
  • Callers claim adjacency from code structure, never from timing.
    • Step A: newVolumes.length === 0. Between P3 and P4, the warm path runs only the pure selection compare, the no-op assertColdAbsent and the enroll loop over zero volumes. On up/restart, prepareEffectBoundary does nothing after the effect-entry check, because downHooks exists only on down.
    • Step C: beforeComplete is absent only when it would have been a no-op read.
  • Receipt writes force a full proof. Pending, births, completion and before hooks all change the receipt hash/inode, so the binding check catches them.
  • No reuse across pending publication or a workload. P2 and P3 stay separate, and P5 stays fresh.
  • Refusal classes are unchanged. P3/P4 and P5/P6 already sit in the UNCERTAIN class, so the refusal type, the stage and the retained pending state are the same.
  • Failure clears reuse. takeLeaseProof deletes the record before any check.

Invalidation table

input change between recorded proof and reuse result
receipt write (pending, storage birth, completion, hooks) binding mismatch → full proof
different generation or inputRevision different key → full proof
new or cold volume step A not claimed → seed plus full proof
changed witness reference (marker, completion, artifact) different key → full proof
changed engine ID different key → full proof, then refused by matchesBinding
next command / new lease / engine restart new authority object → no record → full proof
refused or failed verification record already deleted → next verification is full
route, after hook, or new/saved file material beforeComplete kept → second finalization proof kept

Tests (all with injected stubs or the synthetic docker; no real socket or HOME state)

  • tests/native-compose-storage-witness-xattr-owner.test.ts:
    • The warm no-op up/restart test now expects the enrollment tail to reuse the effect-entry proof (7 → 6 verify helpers).
    • New: "warm enrollment proves in full when the material binding moved since the effect-entry proof". This stands in for a storage birth between P3 and P4.
    • New: "an adjacent verification never reuses a proof across a receipt publication or a later lease". It covers:
      • a positive control;
      • the completion receipt publication;
      • a new lease, as after an engine restart;
      • a different engine ID, which refuses.
    • Mutation check: when the reuse condition ignored the binding (recorded !== undefined), both new tests failed. They pass again with the condition restored.
    • The existing xattr drift tests at prepublication and effect-entry still refuse before the workload.
  • tests/native-compose-storage-activation-command.test.ts (source CLI, synthetic engine):
    • cold up: root, seed, 3 proofs, workload, 1 proof (was 2 after the workload);
    • two consecutive warm ups: 3 proofs, workload, 1 proof each (was 4 / 2);
    • exec: unchanged, 1 / 1;
    • restart: 3 / 1 (was 4 / 2).
    • New negative control: a warm up with 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 against next sources.
  • tests/native-compose-storage-fence-cost.test.ts: up 6× → 4× PROOF_FENCES and PROOF_OBSERVATIONS. A reused proof costs no engine fence. exec is unchanged.
  • tests/native-compose-phase-trace.test.ts: a one-off run with no file material now has a single finalization pass.
  • Hosted: 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 4 storage.proof spans and 1 finalize.witnesses span each, and logs the per-up proof time and total time.

Docs: docs/reference/native-compose-storage-witness.md (reuse rules and counts) and docs/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 check and bun run check: 0 errors and 34 warnings, the same count as next. The complexity warning in native-compose-command.ts exists on next too.
  • Focused tests:
    • 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.
  • Broad run: bun test tests/native-compose- tests/native-authored tests/integration-ownership.test.ts, 131 files.
    • First pass: 2601 pass, 40 skip, 59 fail. All 59 failures were in 11 file-material, VM-file and adoption files. Those files need the native config compiler, which this fresh worktree had not built. Before the compiler was built, the same 10 files gave the identical failure set (51) on next sources and on this branch.
    • After 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.
  • Side effects:
    • Synthetic docker with an isolated DOCKER_CONFIG throughout. No hack command ran against a real engine.
    • No new bun-*.ips crash reports. The only ones present date from 04:10, before this work.
  • Hosted docker-e2e has not been observed yet. It is required runtime evidence.

Review follow-up (polish commit)

  • New regression test in tests/native-compose-file-command.test.ts: a first up with new file material and no saved file material. It checks that finalization still runs the completion step and then a second pass (finalize.owned and finalize.witnesses, read from the phase trace). It also checks that the exact file mounts are observed 4 times after the engine starts; the post-completion files.assertReady provides the last of these.
  • Mutation check: if the step C condition is only nativeComposeSavedFilesPresent(previous) (without files !== 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 second finalize.witnesses pass is checked as a phase, not as a carrier proof.
  • Test harnesses no longer pass the real HOME to the child CLI. HOME is now a private user-home inside the fixture root, in the storage activation test and in the native-compose-command and native-compose-files helpers. HACK_HOME was already inside the fixture. tests/native-compose-renderer-config.test.ts still passes HOME to a real docker compose config client invocation. That is a different harness and is not changed here.
  • Checks:
    • The 14 native-compose file and VM-file test files: 164 pass, 15 env-gated skips, 0 fail.
    • The 16 native-compose storage test files: 453 of 455 passed in one combined run. The load average was about 20 because other jobs were running on the machine. The 2 failures (one in storage-activation-command, one in storage-witness-xattr-owner) were time-sensitive cases. Both files passed alone: 8/8 and 106/106.
    • The other users of the helpers (after-command, down-command, phase-trace and warm-up-command): 46/46 pass.
    • packages/cli typecheck and check both pass. Check reports existing warnings only.
    • No new bun-*.ips crash reports.

Expected saving (estimate, not measured here)

Basis: hosted traced run 38225752202 (#298). A warm up took 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.

  • Warm unrouted up without hooks: −2 proofs ≈ −4.3 s (about 22.0 → 17.7 s).
  • Routed or hooked up: step A only, ≈ −2.2 s.
  • restart in the fixture shape: −2 proofs.
  • native-config-compose scenario: 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)

  • Step B: prepublication reusing the opening proof saves about 2.2 s more per warm 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.
  • Dropping the closing proof after saved 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.
  • Follow-ups: merge the root and verify helpers into one run (needs a new helper image and catalog release); build the carrier factory lazily for reused proofs (about 2 CLI probes each).
  • Hosted docker-e2e on this PR is the runtime evidence. It is not yet observed.

🤖 Generated with Claude Code

roodboi and others added 5 commits October 12, 2026 13:52
…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>
@roodboi
roodboi changed the base branch from next to wf/bun-loop-b9 October 12, 2026 20:39
@roodboi
roodboi marked this pull request as ready for review October 12, 2026 21:10
Base automatically changed from wf/bun-loop-b9 to next October 12, 2026 21:49
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
roodboi merged commit 0a4b5b8 into next Oct 12, 2026
29 of 30 checks passed
@roodboi
roodboi deleted the wf/witness-reuse-b8 branch October 12, 2026 21:50
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant