diff --git a/.ai/skills/hack-repo-verify/SKILL.md b/.ai/skills/hack-repo-verify/SKILL.md index 7c8b2a2d..d3fa9a6d 100644 --- a/.ai/skills/hack-repo-verify/SKILL.md +++ b/.ai/skills/hack-repo-verify/SKILL.md @@ -71,10 +71,13 @@ exit code, a guarded global function or a process listener changed fails with a environment, cwd, umask and exit code so later files run clean; it keeps replaced functions, which may still protect unsettled work. It keeps a test's leftover listeners until its file ends, then puts the file's listeners back, so -a later `process.emit` is not blamed for them. Fix the named file's restore +a later `process.emit` is not blamed for them. Removing a leftover `SIGINT` or +`SIGTERM` handler also drops whatever cleanup it would have run, so stop what +it owns in the file's own teardown. Fix the named file's restore (`try`/`finally` or `afterEach`), not the later failures. To emit a real signal in a test, use `withOwnedProcessListeners` from -`tests/helpers/owned-process-listeners.ts`. +`tests/helpers/owned-process-listeners.ts`; it puts the listeners it took off +back even when the test times out, with `once` listeners kept `once`. Restore environment keys with `restoreEnv` from `tests/helpers/env.ts`: Bun keeps a deleted `HTTP_PROXY`-style key for `fetch` and `Bun.spawn` without `env`, and the guard reports that as `native env changed`. diff --git a/docs/reference/native-compose-commands.md b/docs/reference/native-compose-commands.md index f5fa69f0..ca60638f 100644 --- a/docs/reference/native-compose-commands.md +++ b/docs/reference/native-compose-commands.md @@ -348,8 +348,9 @@ seed, verification, closing root proof, completion write and publication. Carrie transport additionally distinguishes target, create, policy, start, response and cleanup; policy reasons identify shape, command, labels, host policy, mount cardinality, program/storage mounts and policy stability. A re-inspected carrier -whose creation time or policy changed reports `helper-policy-stability`, and a -saved material binding that changed during a carrier step reports +whose creation time, policy or stopped state changed reports +`helper-policy-stability`, and a saved material binding that changed during a +carrier step, or before a verification's deferred evidence retirement, reports `private-state`. A policy or state refusal keeps its reason even when it is caught after the carrier's deadline. A timeout, budget or otherwise unclassified refusal that finds its carrier's storage budget diff --git a/docs/reference/native-compose-storage-xattr-carrier.md b/docs/reference/native-compose-storage-xattr-carrier.md index 7a6cd947..a28a14c3 100644 --- a/docs/reference/native-compose-storage-xattr-carrier.md +++ b/docs/reference/native-compose-storage-xattr-carrier.md @@ -433,8 +433,9 @@ that end. A refusal with its own observed reason keeps that reason even when it is caught after the deadline, including when a recorded command or its command record replaces it with a fixed refusal. Such reasons include a helper policy refusal, `helper-policy-stability` for a re-inspected carrier whose creation -time or policy changed, and `private-state` for a saved material binding that -changed during a carrier step. Cancellation +time, policy or stopped state changed, and `private-state` for a saved material +binding that changed during a carrier step or before a verification's deferred +evidence retirement. Cancellation keeps `probe-cancel`. The reason changes no check, retention or recovery rule. Only the original in-memory writer can advance its exact sequence. Publication diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index 440cf321..4028bef1 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -566,6 +566,48 @@ fn hack_ownership_label_namespaces_block_enrollment() { assert!(!fixture.candidate.state_root.exists()); } } + // The dependency cache declaration form: its own `hack.dependencies.*` + // labels are recognized, so without an ownership key nothing else reports + // an owner label. With one, the declaration does not exempt it. + let dependency_cache = |extra: &str| { + Fixture::new(&format!( + "services:\n deps:\n image: alpine:3.21\n volumes: ['node_modules:/app/node_modules']\n labels:\n hack.dependencies.cache-volume: node_modules\n hack.dependencies.lockfiles: bun.lock\n hack.dependencies.bootstrap: 'true'\n{extra}volumes:\n node_modules: {{}}\n" + )) + }; + let declared = dependency_cache(""); + assert!( + declared.plan().plan.services["deps"] + .dependency_cache + .is_some() + ); + assert!(!owner_label(&declared)); + for key in [ + "io.hack-local.owner", + "io.hack.native-config.owner", + "io.hack.native-file.token", + "io.hack.storage-witness.carrier", + ] { + let fixture = dependency_cache(&format!(" '{key}': owner-canary\n")); + let plan = fixture.plan(); + assert!( + plan.plan.services["deps"].dependency_cache.is_some(), + "{key}" + ); + assert!(owner_label(&fixture), "{key}"); + assert!(!plan.plan.enrollment_compatible, "{key}"); + assert!( + !serde_json::to_string(&plan) + .unwrap() + .contains("owner-canary") + ); + assert_eq!( + project::enroll(&fixture.candidate, fixture.options(), &plan.plan_id) + .unwrap_err() + .code, + "incompatible_compose" + ); + assert!(!fixture.candidate.state_root.exists()); + } // Near misses outside every ownership namespace stay reviewable. Closer // misses (a longer first segment, or another segment under the same // reverse-DNS root) are pinned by the `ownership_labels` unit tests; they diff --git a/src/lib/native-compose-storage-witness-docker.ts b/src/lib/native-compose-storage-witness-docker.ts index 4c83a0d9..53f8efd3 100644 --- a/src/lib/native-compose-storage-witness-docker.ts +++ b/src/lib/native-compose-storage-witness-docker.ts @@ -194,6 +194,21 @@ export function assertNativeComposeStorageCarrierStable( refuseObserved("storage-helper-policy", "helper-policy-stability"); } } +/** + * Refuse unless a stopped helper carrier, inspected again, still shows the + * exact stopped state first observed. A change is the same instability as a + * changed creation time or policy, so it reports `helper-policy-stability` at + * `storage-helper-policy`; untagged, such a refusal caught after the carrier + * deadline would read `storage-budget`. + */ +export function assertNativeComposeStorageCarrierStateUnchanged( + observed: NativeComposeStorageDockerCarrier, + stopped: NativeComposeStorageDockerCarrier +): void { + if (!same(observed.state, stopped.state)) { + refuseObserved("storage-helper-policy", "helper-policy-stability"); + } +} function lifetime(context: Context): void { if (context.signal.aborted) { refuse(); @@ -2013,9 +2028,7 @@ export async function createNativeComposeDockerStorageXattrCarrier( } const repeated = await inspectCarrier(true); assertNativeComposeStorageCarrierStable(repeated, known); - if (!same(repeated.state, stopped.state)) { - return refuse(); - } + assertNativeComposeStorageCarrierStateUnchanged(repeated, stopped); stage = "storage-helper-cleanup"; await checkFiles(files); tool(); @@ -2042,9 +2055,10 @@ export async function createNativeComposeDockerStorageXattrCarrier( assertAdmitted: async () => { const finalCarrier = await inspectCarrier(true); assertNativeComposeStorageCarrierStable(finalCarrier, owned); - if (!same(finalCarrier.state, stopped.state)) { - refuse(); - } + assertNativeComposeStorageCarrierStateUnchanged( + finalCarrier, + stopped + ); await engine(probe(true), context); tool(); assertNativeComposeStorageBindingUnchanged({ @@ -2117,14 +2131,16 @@ export async function createNativeComposeDockerStorageXattrCarrier( const check = async () => { await checkCompletion(); tool(); - if ( - !( - nativeComposeStorageCommandOwnerConfirmed(owner) && - same(before, await current(context, input.target)) - ) - ) { + if (!nativeComposeStorageCommandOwnerConfirmed(owner)) { return refuse(); } + // Deferred retirement is the carrier's own cleanup: a binding + // changed since the invocation began reports `private-state`. + assertNativeComposeStorageBindingUnchanged({ + stage: "storage-helper-cleanup", + before, + after: await current(context, input.target), + }); }; try { await check(); diff --git a/tests/global-state-guard.test.ts b/tests/global-state-guard.test.ts index fbe24937..a4b479bb 100644 --- a/tests/global-state-guard.test.ts +++ b/tests/global-state-guard.test.ts @@ -135,39 +135,58 @@ test("a replaced function or listener is reported but left for its owner", () => expect(diffGlobalState(before)).toEqual([]); }); -test("the file-boundary listener restore removes leftovers, adds back missing listeners and keeps a kept once listener", async () => { +test("the file-boundary listener restore removes leftovers, adds back missing listeners with their kind and keeps a kept once listener", async () => { await withOwnedProcessListeners("SIGUSR2", () => { let keptOnceCalls = 0; + let removedOnceCalls = 0; const keptOnce = () => { keptOnceCalls += 1; }; const removed = () => undefined; + const removedOnce = () => { + removedOnceCalls += 1; + }; const leftover = () => undefined; const secondLeftover = () => undefined; process.once("SIGUSR2", keptOnce); process.on("SIGUSR2", removed); + process.once("SIGUSR2", removedOnce); try { const before = captureGlobalState(); process.removeListener("SIGUSR2", removed); + process.removeListener("SIGUSR2", removedOnce); process.on("SIGUSR2", leftover); process.once("SIGUSR2", secondLeftover); expect(diffGlobalState(before)).toContain( - "process 'SIGUSR2' listeners changed (2 -> 3)" + "process 'SIGUSR2' listeners changed (3 -> 3)" ); expect(restoreProcessListeners(before)).toEqual(["SIGUSR2"]); - expect(sameItems(process.listeners("SIGUSR2"), [keptOnce, removed])).toBe( - true - ); + expect( + sameItems(process.listeners("SIGUSR2"), [ + keptOnce, + removed, + removedOnce, + ]) + ).toBe(true); expect(diffGlobalState(before)).toEqual([]); expect(restoreProcessListeners(before)).toEqual([]); - // The kept listener was never re-added, so it is still a `once`. + // The kept listener was never re-added, so it is still a `once`; the + // re-added `once` listener is a `once` again and the persistent one + // stays. process.emit("SIGUSR2"); expect(keptOnceCalls).toBe(1); + expect(removedOnceCalls).toBe(1); expect(sameItems(process.listeners("SIGUSR2"), [removed])).toBe(true); } finally { - for (const listener of [keptOnce, removed, leftover, secondLeftover]) { + for (const listener of [ + keptOnce, + removed, + removedOnce, + leftover, + secondLeftover, + ]) { process.removeListener("SIGUSR2", listener); } } diff --git a/tests/helpers/global-state-guard-preload.ts b/tests/helpers/global-state-guard-preload.ts index b7b88791..0e237687 100644 --- a/tests/helpers/global-state-guard-preload.ts +++ b/tests/helpers/global-state-guard-preload.ts @@ -12,6 +12,8 @@ * file's own hooks and preload `afterEach` hooks after them, so a file's * `beforeEach`/`afterEach` pair that restores correctly passes. A mismatch * fails that test, restores the pre-test environment and cwd, and names the file. + * Before comparing, it puts back the listeners that an unsettled + * `withOwnedProcessListeners` callback took off (a test that timed out in it). * - Per file: when Bun loads the next test file, the state must match the * state before the previous file loaded. This catches module-scope and * `beforeAll`/`afterAll` leaks, including an `afterAll` that never ran its @@ -43,6 +45,12 @@ import { restoreGlobalState, restoreProcessListeners, } from "./global-state-guard.ts"; +import { restoreUnsettledOwnedProcessListeners } from "./owned-process-listeners.ts"; +import { installProcessOnceListeners } from "./process-once-listeners.ts"; + +// Before any test file registers a listener, so a `once` listener that a +// helper takes off and puts back stays a `once` listener. +installProcessOnceListeners(); /** * Environment keys a test file may change without a restore. Every entry @@ -133,6 +141,9 @@ beforeEach(() => { }); afterEach(() => { + // A test that timed out inside `withOwnedProcessListeners` hands the + // listeners it took off back first, so it is not blamed for removing them. + restoreUnsettledOwnedProcessListeners(); const before = testBaseline; testBaseline = undefined; if (before === undefined) { diff --git a/tests/helpers/global-state-guard.ts b/tests/helpers/global-state-guard.ts index e7a4eda2..963ba440 100644 --- a/tests/helpers/global-state-guard.ts +++ b/tests/helpers/global-state-guard.ts @@ -15,6 +15,11 @@ * because fixtures may hold secret-shaped values. */ +import { + addProcessListener, + isProcessOnceListener, +} from "./process-once-listeners.ts"; + /** * Process events whose listener lists are compared. A change is reported per * test and per file, and restored only at the file boundary (see @@ -316,15 +321,23 @@ function countListeners(listeners: readonly Listener[]): Map { * (HACK-1416). * * Only the difference is edited: a listener added since the snapshot is - * removed and a snapshot listener that went missing is added back with - * `process.on`. Listeners present in both stay untouched, so a `once` - * listener there keeps its `once` behavior; Bun does not expose `once`, so a - * missing listener always comes back as a persistent one, at the end of the - * list. + * removed and a snapshot listener that went missing is added back. Listeners + * present in both stay untouched. A missing listener comes back with the + * registration kind it last had: a `once` listener as `once`, otherwise + * persistent (see `tests/helpers/process-once-listeners.ts`), at the end of + * the list. * - * Unlike replaced functions, removing a leftover listener cannot let unsettled - * work reach the host: it only stops that work from reacting to a signal or - * exit event of the shared test process. + * Removing a leftover listener is not free of effects. A leftover `SIGINT` or + * `SIGTERM` handler can be legitimate cleanup, for example one that kills a + * child process the file spawned when the run is interrupted. Once it is + * removed, an interrupted test run no longer runs that cleanup, and whatever + * the handler owned can outlive the run. The guard still removes it rather + * than calling it or waiting for what it owns: it cannot know what the handler + * owns or whether running it now is safe, and calling another file's handler + * at an arbitrary point (or leaving it for a later file's `process.emit`) is + * the cross-file interference this restore prevents. The leak is reported + * with the file's name and fails the run, so the fix belongs in that file: + * remove its handler, and stop what it owns, in its own teardown. */ export function restoreProcessListeners( snapshot: GlobalStateSnapshot @@ -354,7 +367,11 @@ export function restoreProcessListeners( if (left > 0) { kept.set(listener, left - 1); } else { - process.on(event, listener); + addProcessListener( + event, + listener, + isProcessOnceListener(event, listener) + ); } } } diff --git a/tests/helpers/native-compose-storage-docker.ts b/tests/helpers/native-compose-storage-docker.ts index 742d8fb7..9b3089c1 100644 --- a/tests/helpers/native-compose-storage-docker.ts +++ b/tests/helpers/native-compose-storage-docker.ts @@ -121,11 +121,18 @@ if(storageArgs[0]==="container"&&storageArgs[1]==="inspect"){ if(format.includes(".HostConfig")){ // Fault: the Nth helper policy inspection observes a replaced carrier (a new creation time). const replaced=storageRoot+"/fault-carrier-replaced",seen=storageRoot+"/carrier-policy-inspects"; + // Fault: the Nth helper policy inspection observes a changed state (another finish time) under + // the same creation time and policy. + const restated=storageRoot+"/fault-carrier-state-changed",restatedSeen=storageRoot+"/carrier-state-inspects"; for(const id of selected){const row=carriers.find(value=>value.id===id);if(!row)process.exit(1); if(await Bun.file(replaced).exists()){ const count=Number(await Bun.file(seen).exists()?await Bun.file(seen).text():0)+1;await Bun.write(seen,String(count)); if(count===Number(await Bun.file(replaced).text())){emit({...row,createdAt:"2026-01-01T00:00:00.000Z"});continue;} } + if(await Bun.file(restated).exists()){ + const count=Number(await Bun.file(restatedSeen).exists()?await Bun.file(restatedSeen).text():0)+1;await Bun.write(restatedSeen,String(count)); + if(count===Number(await Bun.file(restated).text())){emit({...row,state:{...row.state,FinishedAt:"2026-01-01T00:00:00.000Z"}});continue;} + } emit(row);}process.exit(0); } if(format.includes(".Mounts")){ diff --git a/tests/helpers/owned-process-listeners.ts b/tests/helpers/owned-process-listeners.ts index 5b310766..17be1793 100644 --- a/tests/helpers/owned-process-listeners.ts +++ b/tests/helpers/owned-process-listeners.ts @@ -1,3 +1,9 @@ +import { onTestFinished } from "bun:test"; +import { + addProcessListener, + isProcessOnceListener, +} from "./process-once-listeners.ts"; + /** * Lets a test emit a real process event without reaching listeners it does not * own. @@ -8,25 +14,77 @@ * are present, runs the callback, and adds them back afterwards, so the * callback sees only the listeners it installs itself. * + * The saved listeners come back however the test ends: when the callback + * settles, and also when the test times out while the callback is still + * pending. A timed-out callback never settles its own `finally`, so the + * restore is also registered with the test (`onTestFinished`) and in a + * process-wide set that the global-state-guard preload empties before it + * compares a test's state ({@link restoreUnsettledOwnedProcessListeners}). + * Without that, another file's listeners would stay removed and the guard + * would blame the timed-out test for removing them (HACK-1455). Restoring is + * idempotent: a callback that settles after its test timed out adds nothing a + * second time. Listeners that the callback is still about to install are its + * own; it keeps running with the saved listeners back in place. + * + * A saved `once` listener comes back as a `once` listener, and a persistent + * one as persistent (see `tests/helpers/process-once-listeners.ts`). * Listeners the callback leaves behind are kept, so the test's own assertions - * and the global-state guard still see them. Bun does not expose `once`, so a - * saved `once` listener comes back as a persistent one. Between tests the - * guarded events normally have no listeners (the guard fails a file that - * leaves one), so the saved list is normally empty. + * and the global-state guard still see them. Between tests the guarded events + * normally have no listeners (the guard fails a file that leaves one), so the + * saved list is normally empty. */ export async function withOwnedProcessListeners( event: NodeJS.Signals | "exit", run: () => Promise | T ): Promise { - const saved = process.listeners(event); - for (const listener of saved) { + const saved = (process.listeners(event) as Listener[]).map((listener) => ({ + listener, + once: isProcessOnceListener(event, listener), + })); + for (const { listener } of saved) { process.removeListener(event, listener); } + let restored = false; + const restore = () => { + if (restored) { + return; + } + restored = true; + unsettled.delete(restore); + for (const { listener, once } of saved) { + addProcessListener(event, listener, once); + } + }; + unsettled.add(restore); + try { + onTestFinished(restore); + } catch { + // Outside a running test there is no test to finish; the callback's own + // settlement below still restores. + } try { return await run(); } finally { - for (const listener of saved) { - process.on(event, listener); - } + restore(); + } +} + +type Listener = (...args: unknown[]) => void; + +/** Restores of callbacks that have not settled yet. */ +const unsettled = new Set<() => void>(); + +/** + * Puts back the saved listeners of every {@link withOwnedProcessListeners} + * callback that has not settled, and returns how many it restored. The + * global-state-guard preload calls this before it compares a test's state, + * so a test that timed out inside the helper hands other code's listeners + * back before the comparison. + */ +export function restoreUnsettledOwnedProcessListeners(): number { + const pending = [...unsettled]; + for (const restore of pending) { + restore(); } + return pending.length; } diff --git a/tests/helpers/process-once-listeners.ts b/tests/helpers/process-once-listeners.ts new file mode 100644 index 00000000..50d53404 --- /dev/null +++ b/tests/helpers/process-once-listeners.ts @@ -0,0 +1,93 @@ +/** + * Remembers which process listeners were added with `once`, so test helpers + * that take listeners off `process` and put them back can keep a `once` + * listener a `once` listener. + * + * Bun does not expose this: `process.listeners()` and `process.rawListeners()` + * both return the original function, with no `once` wrapper. The registry + * wraps the registration methods of `process` instead. `once` and + * `prependOnceListener` mark the function for that event; `on`, + * `addListener` and `prependListener` clear the mark, so the most recent + * registration kind wins. A function registered for one event both ways at + * once is read as the kind it was registered with last. + * + * `tests/helpers/global-state-guard-preload.ts` installs the registry before + * any test file loads. Without that preload (for example `packages/db`), it + * installs when a helper first needs it and does not know about `once` + * listeners added before then; those come back as persistent listeners. + */ + +type ProcessEvent = string | symbol; +type Listener = (...args: unknown[]) => void; +type Registration = (event: ProcessEvent, listener: Listener) => unknown; + +const INSTALLED = Symbol.for("hack.tests.processOnceListeners"); +const marks = new Map>(); + +function mark(event: ProcessEvent, listener: Listener): void { + let listeners = marks.get(event); + if (!listeners) { + listeners = new WeakSet(); + marks.set(event, listeners); + } + listeners.add(listener); +} + +function wrap(name: string, once: boolean): void { + const target = process as unknown as Record; + const original = target[name]; + if (typeof original !== "function") { + return; + } + target[name] = function register( + this: unknown, + event: ProcessEvent, + listener: Listener + ) { + if (!once) { + marks.get(event)?.delete(listener); + } + const result = Reflect.apply(original, this, [event, listener]); + if (once) { + mark(event, listener); + } + return result; + }; +} + +/** Wraps the registration methods of `process` once per process. */ +export function installProcessOnceListeners(): void { + const host = process as unknown as Record; + if (host[INSTALLED]) { + return; + } + host[INSTALLED] = true; + wrap("once", true); + wrap("prependOnceListener", true); + wrap("on", false); + wrap("addListener", false); + wrap("prependListener", false); +} + +/** Whether `listener` was last registered for `event` with `once`. */ +export function isProcessOnceListener( + event: ProcessEvent, + listener: Listener +): boolean { + return marks.get(event)?.has(listener) ?? false; +} + +/** Adds `listener` back for `event` with the registration kind it had. */ +export function addProcessListener( + event: ProcessEvent, + listener: Listener, + once: boolean +): void { + if (once) { + process.once(event as NodeJS.Signals, listener); + } else { + process.on(event as NodeJS.Signals, listener); + } +} + +installProcessOnceListeners(); diff --git a/tests/native-compose-adoption-routing-generation.test.ts b/tests/native-compose-adoption-routing-generation.test.ts index 47977224..7788e31a 100644 --- a/tests/native-compose-adoption-routing-generation.test.ts +++ b/tests/native-compose-adoption-routing-generation.test.ts @@ -907,6 +907,12 @@ boundedTest( gatedTest( "a refused teardown still restores HACK_HOME so later files never inherit the fixture home", async (restored) => { + // This case installs the fixture's global doubles, so it must hold the + // file's fixture gate: as a plain test it could run while an earlier + // fixture's lifetime is unknown, and a later case would not see this one + // still open (HACK-1455). + expect(fixtureActive).toBe(true); + expect(fixtureUncertain).toBe(false); const before = process.env.HACK_HOME; // Stands in for another fixture's spy that is kept on purpose while its // settlement is unknown: this case must restore only its own spies. diff --git a/tests/native-compose-storage-binding-call-sites.test.ts b/tests/native-compose-storage-binding-call-sites.test.ts new file mode 100644 index 00000000..e320da94 --- /dev/null +++ b/tests/native-compose-storage-binding-call-sites.test.ts @@ -0,0 +1,473 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { chmod, mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { nativeComposeEffectRefusal } from "../src/lib/native-compose-effect-diagnostics.ts"; +import * as generationOwner from "../src/lib/native-compose-generation.ts"; +import { holdDirectory } from "../src/lib/native-compose-private-state.ts"; +import { + beginNativeComposeStorageCarrierIntent, + initializeNativeComposeStorageCarrierJournal, +} from "../src/lib/native-compose-storage-carrier-journal.ts"; +import { + createNativeComposeDockerStorageXattrCarrier, + nativeComposeStorageCarrierBudgetReason, +} from "../src/lib/native-compose-storage-witness-docker.ts"; +import { + type NativeComposeStorageXattrInvocation, + type NativeComposeStorageXattrTarget, + nativeComposeStorageXattrCarrierArtifact, + nativeComposeStorageXattrCarrierPorts, +} from "../src/lib/native-compose-storage-witness-xattr-carrier.ts"; +import { createNativeComposeStorageXattrMarker } from "../src/lib/native-compose-storage-witness-xattr-codec.ts"; +import { isolatedDockerClientEnv } from "./helpers/docker-client.ts"; +import { restoreEnv } from "./helpers/env.ts"; +import { nativeComposeStorageDockerFixtureScript } from "./helpers/native-compose-storage-docker.ts"; + +/** + * Pins each saved-binding check of the real Docker storage carrier + * (`createNativeComposeDockerStorageXattrCarrier`) and its carrier state + * checks (HACK-1455). The carrier runs in process against the synthetic daemon + * of `tests/helpers/native-compose-storage-docker.ts`; only the saved material + * binding is a stand-in. Every binding read answers one fixed binding, except + * the chosen read, which answers it with another receipt hash, as a receipt + * publication between two reads would. + * + * A carrier step compares its reads with `assertNativeComposeStorageBindingUnchanged`, + * which refuses as `private-state` at the step's stage. A call site that went + * back to an untagged `if (!same(before, x)) return refuse();` reports + * `unclassified` instead, and `storage-budget` once caught after the carrier + * deadline, which sends an operator to raise the wrong budget. + * + * Binding reads are numbered in the order the carrier makes them. The tables + * name the call site each chosen read reaches; the baseline case pins the total + * count, so a change to the read order fails there first and the tables must be + * reviewed with it. + */ + +/** + * Bun budget of one case: one carrier run spawns about forty synthetic + * `docker` children (a Bun process each) plus host `ps` reads. A hosted run + * takes a few seconds; a host loaded far beyond its CPU count took 30-40 s, so + * this is a hang guard, not a speed assertion. + */ +const CASE_BUDGET_MS = 180_000; +/** Carrier deadline: past every case budget, so a slow host fails a case as a + * clean Bun timeout and never by exhausting the carrier's storage budget. */ +const CARRIER_LIFETIME_MS = 2 * CASE_BUDGET_MS; +/** Binding reads of one complete original readonly verify (inspect, invoke and + * its deferred finish), and of one cold provision. */ +const VERIFY_READS = 53; +const PROVISION_READS = 4; +const ENGINE_ID = "fixture-engine"; +const SELECTION = { name: "fixture_data", storage: "data" } as const; +const roots: string[] = []; +// Live carriers of the current case. A timed-out case's carrier is cancelled +// here, so its leftover work refuses before another synthetic or host command. +const lifetimes = new Set(); +afterEach(async () => { + for (const lifetime of lifetimes) { + lifetime.abort(); + } + lifetimes.clear(); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +type Fixture = Awaited>; +type Fault = "fault-carrier-state-changed"; + +async function fixture(opts: { readonly volume: boolean }) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-storage-binding-")) + ); + roots.push(root); + const checkoutRoot = join(root, "checkout"); + const instanceId = randomBytes(16).toString("hex"); + await mkdir( + join(checkoutRoot, ".hack/.internal/native-compose", instanceId), + { + recursive: true, + mode: 0o700, + } + ); + const bin = join(root, "bin"); + await mkdir(bin, { mode: 0o700 }); + const docker = join(bin, "docker"); + await Bun.write( + docker, + `#!${process.execPath} +import {appendFile} from "node:fs/promises"; +${nativeComposeStorageDockerFixtureScript(root)} +process.exit(99); +` + ); + await chmod(docker, 0o700); + const identity: generationOwner.NativeComposeIdentity = { + checkoutRoot, + repositoryRoot: checkoutRoot, + instance: null, + instanceId, + composeProject: "fixture", + ownerToken: randomBytes(16).toString("hex"), + }; + if (opts.volume) { + await Bun.write( + join(root, "volumes"), + JSON.stringify([ + { + id: SELECTION.name, + name: SELECTION.name, + project: identity.composeProject, + version: "1", + instance: identity.composeProject, + owner: identity.ownerToken, + storage: SELECTION.storage, + createdAt: "2026-10-08T12:00:00.000Z", + attributes: {}, + root: { device: "1", inode: "42", uid: 0, gid: 0 }, + }, + ]) + ); + } + const generationId = randomBytes(16).toString("hex"); + const binding = Object.freeze({ + identity, + generationId, + checkout: { dev: 1, ino: 2 }, + receipt: { dev: 1, ino: 3, hash: "1".repeat(64) }, + lease: { + token: randomBytes(16).toString("hex"), + directory: { dev: 1, ino: 4 }, + owner: { dev: 1, ino: 5 }, + }, + generation: null, + documentHash: null, + currentGenerationId: generationId, + pendingGenerationId: null, + pendingToken: null, + }) as unknown as generationOwner.NativeComposeMaterialBinding; + // Only what the carrier's binding read consults: the selected generation + // and its document's declaration of the selected volume. + const store = { + identity, + loadCurrent: async () => ({ + generation: { identity, generationId }, + storageWitnesses: null, + }), + loadPending: async () => null, + readGenerationDocument: async () => ({ + volumes: { [SELECTION.storage]: { name: SELECTION.name } }, + }), + } as unknown as generationOwner.NativeComposeGenerationStore; + return { root, bin, identity, binding, store }; +} + +/** + * Runs `work` with the synthetic `docker` first on PATH, an isolated Docker + * client config (the engine-ID admission then keeps the synthetic CLI and + * never dials a real socket) and the binding stand-in. Read `perturb` + * (1-based; 0 for none) answers a changed receipt hash. + */ +async function withCarrierEnvironment( + f: Fixture, + perturb: number, + work: (reads: () => number) => Promise +): Promise { + const keys = [ + "PATH", + "DOCKER_CONFIG", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + ] as const; + const saved = new Map(keys.map((key) => [key, process.env[key]] as const)); + let count = 0; + const authority = spyOn( + generationOwner, + "assertNativeComposeMaterialAuthority" + ).mockImplementation(async () => { + count += 1; + if (count === perturb) { + return { + ...f.binding, + receipt: { ...f.binding.receipt, hash: "2".repeat(64) }, + }; + } + return f.binding; + }); + try { + for (const key of keys) { + restoreEnv(key, undefined); + } + process.env.PATH = `${f.bin}:/usr/bin:/bin`; + process.env.DOCKER_CONFIG = isolatedDockerClientEnv(f.root).DOCKER_CONFIG; + return await work(() => count); + } finally { + authority.mockRestore(); + for (const [key, value] of saved) { + restoreEnv(key, value); + } + } +} + +async function carrier(f: Fixture) { + const lifetime = new AbortController(); + lifetimes.add(lifetime); + const deadline = Date.now() + CARRIER_LIFETIME_MS; + return { + deadline, + handle: await createNativeComposeDockerStorageXattrCarrier({ + // The binding read is the stand-in above; nothing else reads authority. + authority: {} as generationOwner.NativeComposeMaterialAuthority, + store: f.store, + engineId: ENGINE_ID, + signal: lifetime.signal, + deadline, + originalCommandRecords: true, + }), + }; +} + +function outcomeOf(error: unknown, deadline: number) { + if (error === undefined) { + return null; + } + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("values omitted"); + return { + refusal: nativeComposeEffectRefusal(error), + // The reason the same refusal reports when caught after its deadline. + late: nativeComposeStorageCarrierBudgetReason({ + error, + signal: new AbortController().signal, + deadline, + now: () => deadline + 1, + }), + }; +} + +/** + * One original readonly verify through the real carrier: inspect, a journaled + * invoke and its deferred finish (evidence retirement). + */ +async function verifyOnce(opts: { + readonly perturb: number; + readonly fault?: { readonly name: Fault; readonly inspection: number }; +}) { + const f = await fixture({ volume: true }); + if (opts.fault) { + await Bun.write( + join(f.root, opts.fault.name), + String(opts.fault.inspection) + ); + } + return await withCarrierEnvironment(f, opts.perturb, async (reads) => { + const { deadline, handle } = await carrier(f); + const ports = nativeComposeStorageXattrCarrierPorts(handle); + const journalPath = join(f.root, "journal"); + await mkdir(journalPath, { mode: 0o700 }); + const directory = await holdDirectory(journalPath, true); + let caught: unknown; + try { + const target = (await ports.inspect( + SELECTION + )) as NativeComposeStorageXattrTarget; + const token = randomBytes(16).toString("hex"); + const check = async () => {}; + await initializeNativeComposeStorageCarrierJournal({ + directory, + token, + check, + }); + const captured = Object.freeze({ + invocationId: randomBytes(16).toString("hex"), + artifact: nativeComposeStorageXattrCarrierArtifact(handle), + target, + readonly: true, + uid: 0, + gid: 0, + request: Object.freeze({ + ...createNativeComposeStorageXattrMarker(), + operation: "verify" as const, + root: Object.freeze({ device: "1", inode: "42", uid: 0, gid: 0 }), + }), + scope: Object.freeze({ + generationId: f.binding.generationId, + currentGenerationId: f.binding.currentGenerationId, + pendingGenerationId: null, + pendingToken: null, + }), + }); + const journal = await beginNativeComposeStorageCarrierIntent({ + directory, + token, + check, + input: captured, + }); + const input: NativeComposeStorageXattrInvocation = Object.freeze({ + ...captured, + recordCreated: journal.recordCreated, + }); + const result = (await ports.invoke(input)) as { + readonly carrierId: string; + readonly carrierCreatedAt: string; + }; + const completion = await journal.complete({ + id: result.carrierId, + createdAt: result.carrierCreatedAt, + }); + const finish = ports.finish; + if (!finish) { + throw new Error("Expected an original deferred finish"); + } + await finish({ input, completion }); + } catch (error: unknown) { + caught = error; + } finally { + await directory.file.close(); + } + return { + root: f.root, + reads: reads(), + outcome: outcomeOf(caught, deadline), + }; + }); +} + +/** One original cold provision through the real carrier. */ +async function provisionOnce(perturb: number) { + const f = await fixture({ volume: false }); + return await withCarrierEnvironment(f, perturb, async (reads) => { + const { deadline, handle } = await carrier(f); + const provision = nativeComposeStorageXattrCarrierPorts(handle).provision; + if (!provision) { + throw new Error("Expected an original provision port"); + } + let caught: unknown; + try { + await provision({ + ...SELECTION, + engineId: ENGINE_ID, + runtimeIdentity: f.identity.composeProject, + ownerToken: f.identity.ownerToken, + }); + } catch (error: unknown) { + caught = error; + } + return { + root: f.root, + reads: reads(), + outcome: outcomeOf(caught, deadline), + }; + }); +} + +async function helperOperations(root: string): Promise { + const file = Bun.file(join(root, "storage-operations")); + return (await file.exists()) + ? (await file.text()).split("\n").filter(Boolean) + : []; +} + +test( + "an unchanged saved binding verifies, retires its evidence and provisions through the real carrier", + async () => { + const verified = await verifyOnce({ perturb: 0 }); + expect(verified.outcome).toBeNull(); + expect(verified.reads).toBe(VERIFY_READS); + expect(await helperOperations(verified.root)).toEqual(["verify"]); + const provisioned = await provisionOnce(0); + expect(provisioned.outcome).toBeNull(); + expect(provisioned.reads).toBe(PROVISION_READS); + }, + CASE_BUDGET_MS +); + +// Each row: the changed read, the call site whose comparison sees it, and that +// step's stage. Reads 6 and 12 reach the original command owner's check (at +// owner creation and after the create arm is published); the record keeps the +// check's boundary on its fixed refusal (`refuseFrom`). +test.each([ + [2, "the target observation", "storage-target"], + [6, "the command owner's creation check", "storage-target"], + [8, "the read admitting the create command", "storage-target"], + [9, "the create command's admission", "storage-helper-create"], + [ + 12, + "the command owner's check of the published create arm", + "storage-helper-create", + ], + [21, "the read admitting the start command", "storage-helper-policy"], + [22, "the start command's admission", "storage-helper-start"], + [34, "the cleanup authority read", "storage-helper-cleanup"], + [35, "the remove command's admission", "storage-helper-cleanup"], + [49, "the deferred evidence retirement", "storage-helper-cleanup"], +] as const)( + "a verify binding changed at read %i (%s) refuses as private-state at %s, even after its deadline", + async (perturb, _, stage) => { + const verified = await verifyOnce({ perturb }); + expect(verified.reads).toBe(perturb); + expect(verified.outcome).toEqual({ + refusal: { stage, reason: "private-state" }, + late: "private-state", + }); + }, + CASE_BUDGET_MS +); + +test.each([ + [2, "the read admitting the volume create"], + [3, "the volume create's admission"], + [4, "the read after the volume was created"], +] as const)( + "a provision binding changed at read %i (%s) refuses as private-state, even after its deadline", + async (perturb) => { + const provisioned = await provisionOnce(perturb); + // The changed read is the last one: its own step refuses. + expect(provisioned.reads).toBe(perturb); + expect(provisioned.outcome).toEqual({ + refusal: { stage: "storage-provision", reason: "private-state" }, + late: "private-state", + }); + }, + CASE_BUDGET_MS +); + +// An original verify carrier is policy-inspected after creation (1), before +// start (2), at each of its start command's three admissions (3-5: before the +// arm, after it and once the stopped child is spawned), after it stopped (6), +// once more (7) and at each of its remove command's three admissions (8-10). +// The chosen inspection shows another stopped state under the same creation +// time and policy. +test.each([ + ["on its repeated stopped inspection", 7], + ["at its remove command's first admission", 8], + ["once its remove child is spawned", 10], +] as const)( + "a stopped helper carrier whose state changed %s refuses with its policy stability reason, even after its deadline", + async (_, inspection) => { + const verified = await verifyOnce({ + perturb: 0, + fault: { name: "fault-carrier-state-changed", inspection }, + }); + expect( + Number( + await Bun.file(join(verified.root, "carrier-state-inspects")).text() + ) + ).toBe(inspection); + expect(verified.outcome).toEqual({ + refusal: { + stage: "storage-helper-policy", + reason: "helper-policy-stability", + }, + late: "helper-policy-stability", + }); + // The helper ran once; nothing proved or retired the witness afterwards. + expect(await helperOperations(verified.root)).toEqual(["verify"]); + }, + CASE_BUDGET_MS +); diff --git a/tests/native-compose-storage-command-record.test.ts b/tests/native-compose-storage-command-record.test.ts index 04565921..d6c436b6 100644 --- a/tests/native-compose-storage-command-record.test.ts +++ b/tests/native-compose-storage-command-record.test.ts @@ -692,6 +692,63 @@ test( retained.add(f.root); }) ); +// Owner creation checks the saved binding before writing the record (1) and +// after reading it back (2). Either refusal is the same fixed refusal and keeps +// only the check's issued boundary (`refuseFrom`), as `publish()` does. +test.each([ + ["before it writes its record", 1, "storage-target"], + ["after it reads its record back", 2, "storage-helper-start"], +] as const)("a command owner whose creation check refuses %s keeps the check's issued boundary", (_, failingCheck, stage) => + ownedCase(async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-command-record-")) + ); + roots.push(root); + const directory = await holdDirectory(root, true); + held.push(directory); + let checks = 0; + const caught = await createNativeComposeStorageCommandOwner({ + directory, + binding: { + invocationId: "b".repeat(32), + engineId: "synthetic-engine", + materialHash: hash, + helperHash: hash, + requestHash: hash, + invocationHash: hash, + sourceHash: hash, + fixedInvocationHash: hash, + directory: { dev: directory.info.dev, ino: directory.info.ino }, + }, + check: async () => { + checks += 1; + if (checks === failingCheck) { + throw issuedRefusal({ stage, reason: "private-state" }); + } + }, + published: () => { + throw new Error("Unexpected owner publication"); + }, + }).catch((error: unknown) => error); + expect(checks).toBe(failingCheck); + expect((caught as Error).message).toBe( + "Native storage xattr proof refused; values omitted." + ); + expect(nativeComposeEffectRefusal(caught)).toEqual({ + stage, + reason: "private-state", + }); + expect( + nativeComposeStorageCarrierBudgetReason({ + error: caught, + signal: new AbortController().signal, + deadline: 5_000_000, + now: () => 5_000_001, + }) + ).toBe("private-state"); + // A record written before the refusal is never promoted to an owner. + expect(existsSync(join(root, "commands.json"))).toBe(failingCheck === 2); + })()); test( "failed arm directory sync prevents the target spawn rather than proving no-spawn from a record", ownedCase(async () => { diff --git a/tests/native-compose-storage-witness-docker.test.ts b/tests/native-compose-storage-witness-docker.test.ts index 5314931e..957d1683 100644 --- a/tests/native-compose-storage-witness-docker.test.ts +++ b/tests/native-compose-storage-witness-docker.test.ts @@ -18,6 +18,7 @@ import { NativeComposeOwnershipError } from "../src/lib/native-compose-ownership import { assertNativeComposeStorageBindingUnchanged, assertNativeComposeStorageCarrierStable, + assertNativeComposeStorageCarrierStateUnchanged, assertNativeComposeStorageDockerImage, nativeComposeStorageCarrierBudgetReason, } from "../src/lib/native-compose-storage-witness-docker.ts"; @@ -1264,7 +1265,56 @@ test("a re-inspected carrier whose creation time or policy changed keeps a polic } }); -test("a saved binding that changed across a carrier step keeps a private-state reason after its deadline", () => { +test("a stopped carrier whose state changed on re-inspection keeps a policy stability reason after its deadline", () => { + const deadline = 5_000_000; + const live = new AbortController(); + const selected = input(); + const inspect = (state: Record) => { + const fake = carrier(selected); + fake.value.state = state; + return checkNativeComposeStorageDockerCarrier({ + value: fake.value, + input: selected, + program: fake.program, + imageIds: [selected.artifact.imageId], + }); + }; + const exited = { Status: "exited", Running: false, Pid: 0, ExitCode: 0 }; + const stopped = inspect(exited); + expect(() => + assertNativeComposeStorageCarrierStateUnchanged( + inspect({ ...exited }), + stopped + ) + ).not.toThrow(); + const caught = refusalOf(() => + assertNativeComposeStorageCarrierStateUnchanged( + inspect({ ...exited, FinishedAt: "2026-10-08T12:00:01Z" }), + stopped + ) + ); + expect((caught as Error).message).toBe( + "Native storage xattr proof refused; values omitted." + ); + expect(nativeComposeEffectRefusal(caught)).toEqual({ + stage: "storage-helper-policy", + reason: "helper-policy-stability", + }); + expect( + nativeComposeStorageCarrierBudgetReason({ + error: caught, + signal: live.signal, + deadline, + now: () => deadline + 30_000, + }) + ).toBe("helper-policy-stability"); +}); + +// Two stages: the helper reports the stage its caller passes, never a fixed one. +test.each([ + "storage-helper-start", + "storage-provision", +] as const)("a saved binding that changed across a carrier step keeps a private-state reason at %s after its deadline", (stage) => { const deadline = 5_000_000; const live = new AbortController(); const before = { @@ -1273,14 +1323,14 @@ test("a saved binding that changed across a carrier step keeps a private-state r } as unknown as NativeComposeMaterialBinding; expect(() => assertNativeComposeStorageBindingUnchanged({ - stage: "storage-helper-start", + stage, before, after: structuredClone(before), }) ).not.toThrow(); const caught = refusalOf(() => assertNativeComposeStorageBindingUnchanged({ - stage: "storage-helper-start", + stage, before, after: { ...before, @@ -1292,7 +1342,7 @@ test("a saved binding that changed across a carrier step keeps a private-state r "Native storage xattr proof refused; values omitted." ); expect(nativeComposeEffectRefusal(caught)).toEqual({ - stage: "storage-helper-start", + stage, reason: "private-state", }); expect( diff --git a/tests/native-compose-storage-witness-xattr-owner.test.ts b/tests/native-compose-storage-witness-xattr-owner.test.ts index 125e9db6..d76371bd 100644 --- a/tests/native-compose-storage-witness-xattr-owner.test.ts +++ b/tests/native-compose-storage-witness-xattr-owner.test.ts @@ -1914,83 +1914,87 @@ test("runEffect captures the original fresh carrier factory across a long phase } }); -test("a guard storage proof extends the start deadline by its carrier proof only, never by its freshness checks", async () => { - const { store, transport, generation } = await rememberedActive(); - const command = commandPorts(transport); - // Fake monotonic clock: each freshness check takes 7 s, each carrier 3 s. - let monotonic = 0; - let excluding = 0; - let armed = false; - const steps: string[] = []; - const createCarrier = command.ports.carrier; - command.ports.carrier = async (opts) => { - if (armed) { - steps.push(excluding > 0 ? "carrier-excluded" : "carrier-counted"); - monotonic += 3000; - } - return await createCarrier(opts); - }; - await store.withMutation(async (mutation) => { - const storage = await prepareNativeComposeCommandStorage({ - store, - mutation, - operation: "up", - selected: [selection], - signal: transport.controller.signal, - ports: command.ports, - assertFresh: async () => { - if (armed) { - steps.push(excluding > 0 ? "fresh-excluded" : "fresh-counted"); - monotonic += 7000; - } - }, - }); - if (!storage) { - throw new Error("Expected retained storage"); - } - const start = createNativeComposeStartDeadline({ - timeoutMs: 10_000, - storageTimeoutMs: 90_000, - wallNow: () => 1_000_000, - monotonicNow: () => monotonic, - }); - const excludeStorage = (work: () => Promise): Promise => - start.excludeStorage(async () => { - excluding += 1; - try { - return await work(); - } finally { - excluding -= 1; - } +test( + "a guard storage proof extends the start deadline by its carrier proof only, never by its freshness checks", + async () => { + const { store, transport, generation } = await rememberedActive(); + const command = commandPorts(transport); + // Fake monotonic clock: each freshness check takes 7 s, each carrier 3 s. + let monotonic = 0; + let excluding = 0; + let armed = false; + const steps: string[] = []; + const createCarrier = command.ports.carrier; + command.ports.carrier = async (opts) => { + if (armed) { + steps.push(excluding > 0 ? "carrier-excluded" : "carrier-counted"); + monotonic += 3000; + } + return await createCarrier(opts); + }; + await store.withMutation(async (mutation) => { + const storage = await prepareNativeComposeCommandStorage({ + store, + mutation, + operation: "up", + selected: [selection], + signal: transport.controller.signal, + ports: command.ports, + assertFresh: async () => { + if (armed) { + steps.push(excluding > 0 ? "fresh-excluded" : "fresh-counted"); + monotonic += 7000; + } + }, }); - let adjacentAnswers = 0; - armed = true; - await nativeComposeGuardStorageProof({ - verify: storage.verify, - generation, - excludeStorage, - takeAdjacentProof: () => { - adjacentAnswers += 1; - return false; - }, - })(); - armed = false; - expect(adjacentAnswers).toBe(1); - // The enrolled volume's carrier proof is the only excluded span. - expect(steps).toEqual([ - "fresh-counted", - "carrier-excluded", - "fresh-counted", - ]); - // Only that carrier's 3 s moved the deadline; the guard's 14 s of - // freshness checks consumed the start budget. - expect(start.deadline()).toBe(1_000_000 + 10_000 + 3000); - }); - // Admission's proof plus the guard's: each verified the retained witness. - expect( - helperOperations(transport).filter((call) => call === "verify") - ).toHaveLength(2); -}); + if (!storage) { + throw new Error("Expected retained storage"); + } + const start = createNativeComposeStartDeadline({ + timeoutMs: 10_000, + storageTimeoutMs: 90_000, + wallNow: () => 1_000_000, + monotonicNow: () => monotonic, + }); + const excludeStorage = (work: () => Promise): Promise => + start.excludeStorage(async () => { + excluding += 1; + try { + return await work(); + } finally { + excluding -= 1; + } + }); + let adjacentAnswers = 0; + armed = true; + await nativeComposeGuardStorageProof({ + verify: storage.verify, + generation, + excludeStorage, + takeAdjacentProof: () => { + adjacentAnswers += 1; + return false; + }, + })(); + armed = false; + expect(adjacentAnswers).toBe(1); + // The enrolled volume's carrier proof is the only excluded span. + expect(steps).toEqual([ + "fresh-counted", + "carrier-excluded", + "fresh-counted", + ]); + // Only that carrier's 3 s moved the deadline; the guard's 14 s of + // freshness checks consumed the start budget. + expect(start.deadline()).toBe(1_000_000 + 10_000 + 3000); + }); + // Admission's proof plus the guard's: each verified the retained witness. + expect( + helperOperations(transport).filter((call) => call === "verify") + ).toHaveLength(2); + }, + STORE_CASE_BUDGET_MS +); test("durable tagged Expected precedes one cold provision/seed and fresh proof before workload", async () => { const { store, transport, reference } = await active(); diff --git a/tests/owned-process-listeners.test.ts b/tests/owned-process-listeners.test.ts new file mode 100644 index 00000000..623b43e4 --- /dev/null +++ b/tests/owned-process-listeners.test.ts @@ -0,0 +1,157 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + restoreUnsettledOwnedProcessListeners, + withOwnedProcessListeners, +} from "./helpers/owned-process-listeners.ts"; +import { isProcessOnceListener } from "./helpers/process-once-listeners.ts"; + +const PRELOAD = resolve( + import.meta.dir, + "helpers/global-state-guard-preload.ts" +); +const HELPER = resolve(import.meta.dir, "helpers/owned-process-listeners.ts"); +const REGISTRY = resolve(import.meta.dir, "helpers/process-once-listeners.ts"); +const owned: string[] = []; +afterEach(async () => { + await Promise.all( + owned.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ); +}); + +function sameItems(left: readonly unknown[], right: readonly unknown[]) { + return ( + left.length === right.length && + left.every((item, index) => item === right[index]) + ); +} + +test("saved listeners come back with their kind: a once listener as once, a persistent one as persistent", async () => { + // The outer helper owns every SIGUSR2 listener of the shared process, so the + // emits below reach only this test's listeners. + await withOwnedProcessListeners("SIGUSR2", async () => { + let onceCalls = 0; + let persistentCalls = 0; + const once = () => { + onceCalls += 1; + }; + const persistent = () => { + persistentCalls += 1; + }; + process.once("SIGUSR2", once); + process.on("SIGUSR2", persistent); + try { + await withOwnedProcessListeners("SIGUSR2", () => { + expect(process.listenerCount("SIGUSR2")).toBe(0); + }); + expect(sameItems(process.listeners("SIGUSR2"), [once, persistent])).toBe( + true + ); + process.emit("SIGUSR2"); + process.emit("SIGUSR2"); + expect(onceCalls).toBe(1); + expect(persistentCalls).toBe(2); + expect(sameItems(process.listeners("SIGUSR2"), [persistent])).toBe(true); + } finally { + process.removeListener("SIGUSR2", once); + process.removeListener("SIGUSR2", persistent); + } + }); +}); + +test("an unsettled callback's saved listeners come back before it settles, and its late settlement adds none twice", async () => { + const before = process.listeners("SIGUSR2"); + const saved = () => undefined; + process.once("SIGUSR2", saved); + const gate = Promise.withResolvers(); + const pending = withOwnedProcessListeners("SIGUSR2", () => gate.promise); + try { + expect(process.listenerCount("SIGUSR2")).toBe(0); + // What the guard preload does after a test timed out in the helper. + expect(restoreUnsettledOwnedProcessListeners()).toBe(1); + expect(sameItems(process.listeners("SIGUSR2"), [...before, saved])).toBe( + true + ); + expect(isProcessOnceListener("SIGUSR2", saved)).toBe(true); + expect(restoreUnsettledOwnedProcessListeners()).toBe(0); + gate.resolve(); + await pending; + expect(sameItems(process.listeners("SIGUSR2"), [...before, saved])).toBe( + true + ); + } finally { + gate.resolve(); + process.removeListener("SIGUSR2", saved); + } +}); + +// A file whose first test times out inside the helper while a `once` listener +// it does not own (registered by the file's hooks) is saved. The second test +// needs that listener back, still a `once` listener. +const TIMED_OUT = `import { afterAll, beforeAll, expect, test } from "bun:test"; +import { withOwnedProcessListeners } from ${JSON.stringify(HELPER)}; +import { isProcessOnceListener } from ${JSON.stringify(REGISTRY)}; +const foreign = () => undefined; +beforeAll(() => { + process.once("SIGUSR2", foreign); +}); +afterAll(() => { + process.removeListener("SIGUSR2", foreign); +}); +test("times out while it owns SIGUSR2", async () => { + await withOwnedProcessListeners("SIGUSR2", () => new Promise(() => {})); +}, 200); +test("finds the saved once listener back", () => { + expect(process.listeners("SIGUSR2")).toEqual([foreign]); + expect(isProcessOnceListener("SIGUSR2", foreign)).toBe(true); + console.log("SAVED-LISTENER-BACK"); +}); +`; + +async function runTimedOutSuite(preload: boolean) { + const dir = await realpath( + await mkdtemp(join(tmpdir(), "hack-owned-listeners-")) + ); + owned.push(dir); + const suite = join(dir, "suite"); + const home = join(dir, "home"); + await mkdir(suite); + await mkdir(home); + await writeFile( + join(suite, "bunfig.toml"), + preload ? `[test]\npreload = [${JSON.stringify(PRELOAD)}]\n` : "" + ); + await writeFile(join(suite, "timed-out.test.ts"), TIMED_OUT); + const child = Bun.spawn([process.execPath, "test", "./timed-out.test.ts"], { + cwd: suite, + env: { PATH: "/usr/bin:/bin", HOME: home, TMPDIR: dir, NO_COLOR: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { code, output: `${stdout}\n${stderr}` }; +} + +test.each([ + ["with the guard preload", true], + ["without it", false], +] as const)( + "a test that times out inside the helper hands the saved listeners back %s", + async (_, preload) => { + const run = await runTimedOutSuite(preload); + expect(run.code).not.toBe(0); + expect(run.output).toContain("(fail) times out while it owns SIGUSR2"); + expect(run.output).toContain("SAVED-LISTENER-BACK"); + expect(run.output).toContain("(pass) finds the saved once listener back"); + // The guard compares after the restore, so the timed-out test is charged + // only with its timeout, not with removing another file's listener. + expect(run.output).not.toContain("a test in timed-out.test.ts left"); + }, + 60_000 +);