Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/reference/native-compose-phase-trace.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,18 @@ environment values, subprocess output or exception text.

The canonical call sites cover `oneoff.post-remove-owned`,
`oneoff.post-remove-fresh`, `oneoff.post-remove-guard`, `storage.enroll`,
`guard.fresh-before`, `guard.ownership`, `guard.storage`, `guard.fresh-after`,
`storage.proof`, `guard.fresh-before`, `guard.ownership`, `guard.storage`, `guard.fresh-after`,
`finish.pending`, `finish.witness-work`, and the finalization
phases `finalize.fresh`, `finalize.generation`, `finalize.projection`,
`finalize.owned`, `finalize.remember-storage`, `finalize.witnesses`,
`finalize.pending`, `finalize.pending-check`, `finalize.before-complete` and `finalize.save`. A finalization
recheck repeats the phase with a new span. Conditional phases appear only when the
existing command takes that path. `guard.storage` measures the ordinary storage
verification await inside the workload ownership guard; `storage.enroll` measures
its preceding enrollment phase. Nested spans describe nested awaits, so their
its preceding enrollment phase. `storage.proof` measures one performed
directory-xattr verification kernel proof (three helper carriers) and nests inside
whichever phase requested it; a verification that reuses an adjacent proof under
the same lease emits no `storage.proof` span. Nested spans describe nested awaits, so their
durations must not be added as independent CPU time.

Output stops after 256 records or 64 KiB, or if the clock or sink fails. Diagnostic
Expand Down
35 changes: 32 additions & 3 deletions docs/reference/native-compose-storage-witness.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,44 @@ projection, recovery or newly declared volume. A speculative ownership read can
preview that merge but cannot publish it. A birth addition repeats the original
fully fenced path. Each qualifying phase keeps a fresh closing kernel proof and
exact source, generation and receipt-incarnation checks. No proof is reused across
pending publication or effects. Opening preparation, enrollment, pre-spawn and
both final receipt proofs remain separate. The one-volume offline counter is
pending publication or effects. The opening preparation proof and the
post-workload receipt proof remain separate. The one-volume offline counter is
three full proofs before effect entry rather than five; it establishes call
ordering and count, not CPU savings or atomic observation across the daemon and
private filesystem.

Within one command and one mutation lease, a verification whose code since the
previous proof performed only reads may reuse that completed kernel proof. It
still runs every host check (anchors, expectation, completion and binding). It
skips the three helper carriers only when the engine, generation and reference
are the same and its opening material binding (receipt identity and hash, lease
token, checkout anchor and generation) is byte-identical to the binding that
opened and closed the recorded proof. A receipt publication in between, such as
pending intent, a storage birth or completion, changes that binding and forces a
full proof. A refused or failed verification clears the record. Nothing is
persisted: every later command or lease, including the first after an engine
restart, opens with a full proof. Callers claim adjacency from code structure,
never from timing:

- the warm `up`/`restart` enrollment tail (no newly declared volume) reuses the
effect-entry proof of the same generation; a new volume seeds and proves in
full;
- the first pre-spawn guard of an `up`/`restart` without build, routing or file
material reuses the enrollment-tail record;
- saved `exec` reuses its opening proof before the process. Its closing proof
after the process stays fresh.

Final receipt verification proves once after the workload when no route, after
hook, or new or saved file material needs a completion step. Otherwise it proves
again after that step. A warm image-only, unrouted `up` without hooks therefore
proves four times (opening, prepublication, effect-entry and post-workload)
rather than six. With `HACK_NATIVE_COMPOSE_PHASE_TRACE=1`, each performed
verification proof emits one `storage.proof` span; a reused proof emits none.
These counts establish call ordering and helper invocations, not wall time.

The one-off `run` path separately omits the enrollment-tail read that
would immediately repeat its mandatory pre-spawn witness read. `up` and `restart`
keep that tail read. Cold enrollment still seeds once and verifies before
keep that tail read; when warm it is the adjacent reuse above. Cold enrollment still seeds once and verifies before
publishing its enrolled reference. The one-volume cold-run control reaches the
actual pre-spawn boundary after eleven helper calls rather than fourteen; it
intercepts the workload there and does not qualify Docker execution or timing.
Expand Down
11 changes: 10 additions & 1 deletion src/lib/native-compose-command-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,16 @@ export async function prepareNativeComposeCommandStorage(opts: {
// guard. Up and restart can arm other effects before that guard.
if (operation !== "run") {
stage = "storage-verify";
await verify(generation);
// Warm enrollment (no new volume) performs only the read-only
// selection compare and cold-absence check above, after the
// effect-entry proof of this same generation under this lease. It
// may reuse that proof; a receipt write since then changes the
// material binding and forces a full proof. A new volume seeds and
// always proves in full.
await verify(
generation,
newVolumes.length === 0 ? { adjacentProof: true } : {}
);
}
} catch (error) {
retainNativeComposeEffectRefusal(error, {
Expand Down
38 changes: 24 additions & 14 deletions src/lib/native-compose-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
assertNativeComposeFileRunSupported,
assertNativeComposeSavedFileEngines,
type NativeComposeCommandFiles,
nativeComposeSavedFilesPresent,
prepareNativeComposeCommandFiles,
prepareNativeComposeSavedFileStop,
retireNativeComposeSavedFiles,
Expand Down Expand Up @@ -1653,21 +1654,30 @@ async function executePreparedGeneration(opts: {
signal,
});
},
retireFiles: async () => {
await files?.assertReady(selection, generation);
await retireNativeComposeSavedFiles({
mutation,
store,
saved: previous,
selection,
signal,
});
},
// Without new or saved file material the retirement only reads, so
// leaving it out lets finalization take its single proof pass when no
// route or after hook needs a completion step either.
...(files !== null || nativeComposeSavedFilesPresent(previous)
? {
retireFiles: async () => {
await files?.assertReady(selection, generation);
await retireNativeComposeSavedFiles({
mutation,
store,
saved: previous,
selection,
signal,
});
},
}
: {}),
});
// Up/restart enrollment ends with a storage proof. With no build, routing or
// file material, the first pre-spawn guard follows it with only read-only
// source and ownership checks (arming is a no-op), so that guard may reuse the
// adjacent proof. Every later guard and finalization proof stays fresh.
// Up/restart enrollment ends with a storage proof (a warm enrollment reuses
// the effect-entry proof; a new volume proves in full). With no build,
// routing or file material, the first pre-spawn guard follows it with only
// read-only source and ownership checks (arming is a no-op), so that guard
// may reuse the adjacent proof. Every later guard and the post-workload
// finalization proof stay fresh.
let adjacentGuardProof =
(operation === "up" || operation === "restart") &&
builds.length === 0 &&
Expand Down
11 changes: 11 additions & 0 deletions src/lib/native-compose-file-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,17 @@ export async function prepareNativeComposeCommandFiles(opts: {
}
}

/** Whether any saved generation carries file material that
* `retireNativeComposeSavedFiles` would retire. With none, that retirement
* performs only read-only document loads and no file or engine effect. */
export function nativeComposeSavedFilesPresent(
saved: readonly Saved[]
): boolean {
return saved.some((selected) =>
Object.hasOwn(selected.document, NATIVE_COMPOSE_FILES_EXTENSION)
);
}

/** Saved cleanup acquires neither authored inputs nor credentials and never infers child reaping. */
export async function retireNativeComposeSavedFiles(opts: {
readonly mutation: NativeComposeMutation;
Expand Down
1 change: 1 addition & 0 deletions src/lib/native-compose-phase-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const PHASES = [
"oneoff.post-remove-fresh",
"oneoff.post-remove-guard",
"storage.enroll",
"storage.proof",
"guard.fresh-before",
"guard.ownership",
"guard.storage",
Expand Down
6 changes: 4 additions & 2 deletions src/lib/native-compose-storage-witness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
publishNativeComposeStorageWitnessEnrollment,
runNativeComposeMaterialAction,
} from "./native-compose-generation.ts";
import { measureNativeComposePhase } from "./native-compose-phase-trace.ts";
import {
type HeldDirectory,
holdDirectory,
Expand Down Expand Up @@ -1274,10 +1275,11 @@ function takeLeaseProof(opts: {
const recorded = proofs.get(key);
proofs.delete(key);
return {
/** Run the kernel proof unless an adjacent caller opens with the recorded binding. */
/** Run the kernel proof unless an adjacent caller opens with the recorded
* binding. Only a performed proof emits the passive `storage.proof` span. */
async prove(opening: string, run: () => Promise<unknown>): Promise<void> {
if (!(opts.adjacent && recorded === opening)) {
await run();
await measureNativeComposePhase("storage.proof", run);
}
},
/** Record a completed proof only when its binding never moved. */
Expand Down
62 changes: 62 additions & 0 deletions tests/e2e/scenarios/native-config-compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,36 @@ function object(text: string): Record<string, unknown> {
return value;
}

/**
* Storage verification proof counts from one CLI's phase trace. Each performed
* kernel proof (three helper carriers) emits one `storage.proof` span; a proof
* reused within the command's lease emits none.
*/
function storageProofTrace(stderr: string): {
readonly proofs: number;
readonly unfinished: number;
readonly proofMs: number;
readonly finalizations: number;
} {
const rows = stderr
.split("\n")
.filter((line) => line.startsWith('{"diagnostic":"native-compose-phase",'))
.map((line) => object(line));
const spans = (phase: string, boundary: string) =>
rows.filter((row) => row.phase === phase && row.boundary === boundary);
const ended = spans("storage.proof", "end");
return {
proofs: spans("storage.proof", "begin").length,
unfinished: spans("storage.proof", "begin").length - ended.length,
proofMs: ended.reduce(
(total, row) =>
total + (typeof row.durationMs === "number" ? row.durationMs : 0),
0
),
finalizations: spans("finalize.witnesses", "begin").length,
};
}

function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
Expand Down Expand Up @@ -1280,6 +1310,38 @@ export const nativeConfigComposeScenario: Scenario = {
await checkSql(marker);
await Bun.write(projectPath, projectText);
await cli(["up", "--detach", "--json"]);
// HACK-1123: two consecutive unchanged warm ups each prove storage four
// times (opening, prepublication, effect-entry, post-workload); the
// enrollment tail and first guard reuse the effect-entry proof, and with
// no route, hook or file material finalization proves once.
const warmUps: (ReturnType<typeof storageProofTrace> & {
readonly totalMs: number;
})[] = [];
for (const attempt of [1, 2]) {
const warm = await raw(["up", "--detach", "--json"], {
HACK_NATIVE_COMPOSE_PHASE_TRACE: "1",
});
expectExit({
result: warm,
codes: [0],
message: `Traced unchanged warm up ${attempt} must succeed`,
});
warmUps.push({
...storageProofTrace(warm.stderr),
totalMs: Math.round(warm.durationMs),
});
}
ctx.log(`warm up storage proofs: ${JSON.stringify(warmUps)}`);
expect({
that: warmUps.every(
(warm) =>
warm.proofs === 4 &&
warm.unfinished === 0 &&
warm.finalizations === 1
),
message:
"Unchanged warm up must perform exactly four storage proofs and one finalization witness check",
});
const beforeRestart = await snapshot();
const beforeRestartBoot = await probe("exec", marker);
stage("owned workload removal/rename and retained SQL data verified");
Expand Down
4 changes: 3 additions & 1 deletion tests/helpers/native-compose-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export async function fixture(
cleanupGuards.set(root, opts.cleanupAllowed);
}
await mkdir(join(root, ".hack"));
// Private HOME for child CLIs; the contributor's home is never selected.
await mkdir(join(root, "user-home"), { mode: 0o700 });
await Bun.write(
join(root, ".hack/hack.project.json"),
JSON.stringify({
Expand Down Expand Up @@ -246,7 +248,7 @@ export async function invoke(
{
cwd: root,
env: {
HOME: process.env.HOME,
HOME: join(root, "user-home"),
LANG: "C",
PATH: `${root}:/usr/bin:/bin`,
HACK_HOME: join(root, "home"),
Expand Down
14 changes: 10 additions & 4 deletions tests/helpers/native-compose-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export async function fileCommandFixture(source: unknown = FILE_SOURCE) {
const root = join(parent, "checkout");
await mkdir(join(root, ".hack"), { recursive: true });
await mkdir(join(parent, "home"), { mode: 0o700 });
// Private HOME for child CLIs; the contributor's home is never selected.
await mkdir(join(parent, "user-home"), { mode: 0o700 });
await Bun.write(
join(root, ".hack/hack.project.json"),
JSON.stringify(source)
Expand Down Expand Up @@ -152,7 +154,9 @@ await fail();
}
export function spawnFiles(
fixture: { readonly parent: string; readonly root: string },
args = ["up", "--detach", "--json"]
args = ["up", "--detach", "--json"],
/** Diagnostics only, such as the passive phase trace. */
extraEnv: Readonly<Record<string, string>> = {}
) {
const child = Bun.spawn(
[
Expand All @@ -165,7 +169,7 @@ export function spawnFiles(
{
cwd: fixture.root,
env: {
HOME: process.env.HOME,
HOME: join(fixture.parent, "user-home"),
LANG: "C",
PATH: `${fixture.parent}:/usr/bin:/bin`,
HACK_HOME: join(fixture.parent, "home"),
Expand All @@ -176,6 +180,7 @@ export function spawnFiles(
HACK_COMPOSE_STARTUP_TIMEOUT_MS: "3000",
CI: "1",
HACK_EXECUTION_MODE: "non_interactive",
...extraEnv,
},
stdin: "ignore",
stdout: "pipe",
Expand All @@ -187,9 +192,10 @@ export function spawnFiles(
}
export async function invokeFiles(
fixture: { readonly parent: string; readonly root: string },
args = ["up", "--detach", "--json"]
args = ["up", "--detach", "--json"],
extraEnv: Readonly<Record<string, string>> = {}
) {
const child = spawnFiles(fixture, args);
const child = spawnFiles(fixture, args, extraEnv);
const timer = setTimeout(() => child.kill("SIGKILL"), 45_000);
try {
const [code, stdout, stderr] = await Promise.all([
Expand Down
53 changes: 53 additions & 0 deletions tests/native-compose-file-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,59 @@ test("source CLI delivers actual acquired binary, managed and empty files with e
expect(await fileJournal(ready.document)).toContain('"phase":"retired"');
await assertFileTransport(fixture);
}, 120_000);
// Finalization may take its single proof pass only when the file retirement
// step would merely read. A first up has new file material and no saved file
// material: its completion step must still run, so the post-completion
// ownership pass re-observes the exact file mounts (files.assertReady) and the
// second finalize witness pass follows it.
test("first file up without saved file material keeps the completion step, post-completion file check and second finalize pass", async () => {
const fixture = await fileCommandFixture();
const up = await invokeFiles(fixture, undefined, {
HACK_NATIVE_COMPOSE_PHASE_TRACE: "1",
});
expect(up.code).toBe(0);
const phases = up.stderr
.split("\n")
.filter((line) => line.startsWith('{"diagnostic":"native-compose-phase",'))
.map((line) => JSON.parse(line) as { phase: string; boundary: string })
.filter((row) => row.boundary === "begin")
.map((row) => row.phase);
const pass = [
"finalize.fresh",
"finalize.generation",
"finalize.owned",
"finalize.remember-storage",
"finalize.witnesses",
"finalize.pending",
"finalize.pending-check",
];
expect(phases.slice(phases.indexOf("finalize.fresh"))).toEqual([
...pass,
"finalize.before-complete",
...pass,
"finalize.save",
]);
// Exact file-mount observations after the engine start: workload readiness,
// the first finalize pass's unbound-root check, the completion step's
// readiness check and the post-completion ownership pass's readiness check.
// Without the completion step only the first two remain.
const requests = await assertFileTransport(fixture);
const started = requests.findIndex((args) => args[0] === "compose");
expect(started).toBeGreaterThanOrEqual(0);
expect(
requests
.slice(started)
.filter(
(args) =>
args[0] === "container" &&
args[1] === "inspect" &&
String(args[3]).includes('"mounts":{{json .Mounts}}')
)
).toHaveLength(4);
const ready = await fileCommandState(fixture.root);
expect(ready.pending).toBeNull();
expect(ready.current.stopped).toBe(false);
}, 120_000);
test("unchanged authored file up acquires new bytes and retires its prior snapshot instead of reusing a warm generation", async () => {
const fixture = await fileCommandFixture();
expect((await invokeFiles(fixture)).code).toBe(0);
Expand Down
Loading
Loading