diff --git a/tests/native-compose-adoption-binding.test.ts b/tests/native-compose-adoption-binding.test.ts index b94072e4..052ece40 100644 --- a/tests/native-compose-adoption-binding.test.ts +++ b/tests/native-compose-adoption-binding.test.ts @@ -36,20 +36,78 @@ import { restoreEnv } from "./helpers/env.ts"; /** * The product's default deadline for one aggregate Docker probe - * (`createNativeComposeProbe`). Each acquisition and each freshness check here - * owns one such probe unless a test names its own `timeoutMs`. + * (`createNativeComposeProbe`). */ const PROBE_DEADLINE_MS = 15_000; /** - * Every fake Docker query here is a cold Bun process, and one acquisition runs - * a dozen or more. Bun's 5 s default then budgets host process start-up rather - * than the product, and an expired test keeps running against the next test's - * shared root (HACK-1432, as HACK-1367 found for the ownership tests). The - * heaviest test here owns about eight default-deadline probes, so a slow host - * ends in the product's own closed refusal before this budget expires. + * The probe deadline this file's acquisitions use unless a test names its own + * `timeoutMs` (see {@link acquireFixtureBinding}). Each acquisition and each + * freshness check owns one probe with this deadline. Every fake Docker query + * is a cold Bun process, and one probe runs a dozen or more, so under heavy + * host load the product default refused with `E_LEGACY_COMPOSE_BINDING_PROBE` + * in tests that do not test deadlines (HACK-1452). */ -const FAKE_DOCKER_TEST_TIMEOUT_MS = 8 * PROBE_DEADLINE_MS; +const FIXTURE_PROBE_DEADLINE_MS = 3 * PROBE_DEADLINE_MS; +/** + * Bun's 5 s default would budget host process start-up rather than the + * product, and an expired test keeps running against the next test's shared + * root (HACK-1432, as HACK-1367 found for the ownership tests). Apart from the + * moving job snapshot case, which names {@link MOVING_JOB_TEST_TIMEOUT_MS}, + * each test here creates at most seven probes (counted per test, HACK-1452), + * each bounded by at most a fixture probe deadline, so a slow host ends in the + * product's own closed refusal before this budget expires. + */ +const FAKE_DOCKER_TEST_TIMEOUT_MS = 8 * FIXTURE_PROBE_DEADLINE_MS; setDefaultTimeout(FAKE_DOCKER_TEST_TIMEOUT_MS); +/** + * How long a test waits for the hanging fake Docker child to publish its PID. + * It covers acquisition work before the first probe and a cold Bun start under + * load, and stays below {@link FIXTURE_PROBE_DEADLINE_MS}, so the child is + * still owned by its probe when the test cancels it. + */ +const CHILD_START_WAIT_MS = PROBE_DEADLINE_MS; +const CHILD_START_POLL_MS = 10; +/** + * How many freshness checks the moving job snapshot case's ordered start makes. + * Each is one `assertFresh`, one fixture-deadline probe. The V7 job executor + * checks once on entry; for `db`, once before its start and twice in its + * started wait (one poll, one closing check); for the `seed` job, twice in its + * dependency wait, once for its attempt, once before its start and three times + * in its completion wait (its first poll still sees the job running); for + * `web`, twice in its dependency wait, once before its start and twice in its + * started wait; then twice in the final wait, once for the final read and once + * on exit. The case asserts that it made no more than this. + */ +const ORDERED_START_FRESHNESS_CHECKS = 20; +/** + * The aggregate deadline of the moving job snapshot case's ordered start: one + * fixture probe deadline for each freshness check, so the start refuses for + * its own deadline only after a probe could have refused for its own. The + * maintained job fixture's 20 s bound, and then a 180 s bound sized from four + * checks, refused with "did not become ready" under heavy load; each check + * took 7-17 s at a load average of about 115 (HACK-1452). + */ +const ORDERED_OPERATION_DEADLINE_MS = + ORDERED_START_FRESHNESS_CHECKS * FIXTURE_PROBE_DEADLINE_MS; +/** + * The moving job snapshot case's own test budget: its ordered start, plus the + * 5 probes it makes outside the start (3 before it: the acquisition creates 2 + * and the first resolve 1; 2 after it: the closing freshness check and the + * closing resolve), each one fixture-deadline probe, plus one more probe + * deadline for the fixture's file reads and the readiness polls' sleeps. A + * slow host therefore ends in the closed refusal before Bun's timeout. + */ +const MOVING_JOB_TEST_TIMEOUT_MS = + ORDERED_OPERATION_DEADLINE_MS + 6 * FIXTURE_PROBE_DEADLINE_MS; +/** + * The probe deadline of the tests that prove a moving snapshot exhausts it. + * Each repeated scan is several cold fake Docker processes, so a 3 s deadline + * under heavy load ended before the first bridge read (HACK-1452). The test + * still requires repeated scans and a refusal soon after this deadline. + */ +const EXHAUSTED_PROBE_DEADLINE_MS = PROBE_DEADLINE_MS; +/** How late after its deadline an exhausted probe may refuse under load. */ +const EXHAUSTED_REFUSAL_SLACK_MS = 5000; /** * A placeholder acquisition repeats bridge scans under one probe deadline * (two extra scans with two owned bridges). The default refused one under @@ -154,7 +212,7 @@ beforeEach(async () => { await writeFile( join(root, "docker"), `#!${process.execPath} -import {appendFileSync, existsSync, readFileSync, writeFileSync} from "node:fs"; +import {appendFileSync, existsSync, readFileSync, renameSync, writeFileSync} from "node:fs"; const root = ${JSON.stringify(root)}; const args = process.argv.slice(2); appendFileSync(root + "/commands", JSON.stringify(args) + "\\n"); @@ -163,7 +221,8 @@ const [kind, action] = args; if (!(kind === "info" && action === "--format") && (!['container','volume','network'].includes(kind) || !['ls','inspect'].includes(action) || !args.includes('--format'))) {writeFileSync(root + "/mutation", "unauthorized command");process.exit(99);} if (fixture.mode === "fail") {console.error(${JSON.stringify(CANARY)});process.exit(29);} if (fixture.mode === "malformed") {console.log(${JSON.stringify(CANARY)});process.exit(0);} -if (fixture.mode === "hang") {writeFileSync(root + "/started", String(process.pid));await Bun.sleep(60_000);} +// Publish the PID by rename: a child killed mid-write must never leave an empty "started" (HACK-1452). +if (fixture.mode === "hang") {writeFileSync(root + "/started.tmp", String(process.pid));renameSync(root + "/started.tmp", root + "/started");await Bun.sleep(60_000);} if (fixture.mode === "overflow") {await Bun.write(Bun.stdout, "x".repeat(9 * 1024 * 1024));process.exit(0);} if (fixture.mode === "stderr-overflow") {await Bun.write(Bun.stderr, "x".repeat(17 * 1024));process.exit(0);} let networkOrdinal = 0; @@ -365,6 +424,89 @@ async function commands(): Promise { .split("\n") .map((line) => JSON.parse(line)); } +/** Acquires with {@link FIXTURE_PROBE_DEADLINE_MS} unless the test names a deadline. */ +function acquireFixtureBinding(opts: { + readonly projectRoot: string; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +}) { + return acquireLegacyComposeAdoptionBinding({ + ...opts, + timeoutMs: opts.timeoutMs ?? FIXTURE_PROBE_DEADLINE_MS, + }); +} +/** + * Waits, at most {@link CHILD_START_WAIT_MS}, for the hanging fake Docker child + * to publish its PID, and returns it. The fake publishes by rename, and a PID + * must be a positive integer above 1: `Number("")` is 0, and + * `process.kill(0, 0)` signals this test's own process group and succeeds. + */ +async function startedChild(): Promise { + const started = join(root, "started"); + const deadline = Date.now() + CHILD_START_WAIT_MS; + while (!(await Bun.file(started).exists())) { + if (Date.now() >= deadline) { + throw new Error("Fake Docker child did not start within its budget."); + } + await Bun.sleep(CHILD_START_POLL_MS); + } + const pid = Number(await readFile(started, "utf8")); + expect(Number.isSafeInteger(pid) && pid > 1).toBe(true); + return pid; +} +type ObservedChild = { + readonly child: ReturnType; + exited: boolean; +}; +/** + * Records each fake Docker child the product spawns, and when it exits, without + * changing spawn or the product's timers. The exit callback is registered at + * spawn, before the probe awaits the same exit, so a refusal that waited for + * the reaped child always observes `exited` as true. `release` restores + * `Bun.spawn` and kills any child a broken reaping path left alive. + */ +function observeDockerChildren() { + const binary = join(root, "docker"); + const children: ObservedChild[] = []; + const actualSpawn = Bun.spawn; + const spawn = spyOn(Bun, "spawn").mockImplementation((...args) => { + const child: ReturnType = Reflect.apply( + actualSpawn, + Bun, + args + ); + const argv = args[0]; + if (Array.isArray(argv) && argv[0] === binary) { + const observed: ObservedChild = { child, exited: false }; + children.push(observed); + void child.exited.then(() => { + observed.exited = true; + }); + } + return child; + }); + return { + children, + release: async () => { + spawn.mockRestore(); + for (const { child } of children) { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await child.exited; + } + } + }, + }; +} +/** The refusal resolved only after every observed child was reaped. */ +function expectReaped(children: readonly ObservedChild[], pid: number) { + expect(children.map(({ child }) => child.pid)).toContain(pid); + for (const { child, exited } of children) { + expect(exited).toBe(true); + expect(child.signalCode).toBe("SIGKILL"); + expect(() => process.kill(child.pid, 0)).toThrow(); + } +} test.each([ "unchanged", @@ -408,6 +550,7 @@ test.each([ const observed = inspectLegacyComposeSourceBindResources({ root: projectRoot, intent: plan.intent, + timeoutMs: FIXTURE_PROBE_DEADLINE_MS, }); if (change === "unchanged") { expect(await observed).toMatchObject({ @@ -434,7 +577,7 @@ test.each([ }); test("stopped originals retain configured network identity without active endpoints", async () => { - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); const original = await acquired.resolveBinding({ projectRoot }); container().running = false; network().containers = []; @@ -444,210 +587,225 @@ test("stopped originals retain configured network identity without active endpoi expect(JSON.stringify(original)).not.toContain("running"); }); -test("moving job snapshot counterexample retains identity and admits a fresh completed exit", async () => { - snapshotCaseActive = true; - try { - const job = "e".repeat(64); - const web = "c".repeat(64); - const composeText = JSON.stringify({ - name: "fixture", - services: { - db: { - image: CANARY, - volumes: ["data:/var/lib/database"], - healthcheck: { - test: ["CMD", "probe"], - interval: "1s", - timeout: "1s", - retries: 2, +test( + "moving job snapshot counterexample retains identity and admits a fresh completed exit", + async () => { + snapshotCaseActive = true; + try { + const job = "e".repeat(64); + const web = "c".repeat(64); + const composeText = JSON.stringify({ + name: "fixture", + services: { + db: { + image: CANARY, + volumes: ["data:/var/lib/database"], + healthcheck: { + test: ["CMD", "probe"], + interval: "1s", + timeout: "1s", + retries: 2, + }, + }, + seed: { + image: CANARY, + command: ["seed-once"], + labels: { "hack.service.one-shot": "true" }, + depends_on: { db: { condition: "service_healthy" } }, + }, + web: { + image: CANARY, + depends_on: { + seed: { condition: "service_completed_successfully" }, + }, }, }, - seed: { - image: CANARY, - command: ["seed-once"], - labels: { "hack.service.one-shot": "true" }, - depends_on: { db: { condition: "service_healthy" } }, - }, - web: { - image: CANARY, - depends_on: { seed: { condition: "service_completed_successfully" } }, + volumes: { data: { name: VOLUME } }, + }); + await writeFile( + join(projectRoot, ".hack/docker-compose.yml"), + composeText + ); + fixture.container.push( + { + ...container(), + id: job, + name: "/fixture-seed-1", + service: "seed", + mounts: [], + running: false, }, - }, - volumes: { data: { name: VOLUME } }, - }); - await writeFile(join(projectRoot, ".hack/docker-compose.yml"), composeText); - fixture.container.push( - { - ...container(), - id: job, - name: "/fixture-seed-1", - service: "seed", - mounts: [], - running: false, - }, - { - ...container(), - id: web, - name: "/fixture-web-1", - service: "web", - mounts: [], - running: false, + { + ...container(), + id: web, + name: "/fixture-web-1", + service: "web", + mounts: [], + running: false, + } + ); + network().containers = [ID]; + await save(); + const mapped = mapLegacyNativeStorageAdoption({ + configText: '{"name":"fixture"}', + composeText, + }); + if (!mapped.candidate) { + throw new Error("Missing completed-job candidate"); } - ); - network().containers = [ID]; - await save(); - const mapped = mapLegacyNativeStorageAdoption({ - configText: '{"name":"fixture"}', - composeText, - }); - if (!mapped.candidate) { - throw new Error("Missing completed-job candidate"); - } - const plan = legacyComposeRetainedPlan(mapped.candidate); - expect(plan.requiresV7).toBe(true); - expect(plan.ordered.find((item) => item.service === "seed")?.kind).toBe( - "job" - ); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); - const original = await acquired.resolveBinding({ projectRoot }); - const effects: string[] = []; - let jobStarted = false; - let jobArmCommandCursor = 0; - let bracketTrace: string[][] = []; - const priorStartedAt = "2026-10-08T00:00:01Z"; - async function observed(): Promise { - const current: Fixture = JSON.parse( - await readFile(join(root, "fixture.json"), "utf8") + const plan = legacyComposeRetainedPlan(mapped.candidate); + expect(plan.requiresV7).toBe(true); + expect(plan.ordered.find((item) => item.service === "seed")?.kind).toBe( + "job" ); - return current.container.map((row) => ({ - id: String(row.id), - running: row.running === true, - paused: false, - status: row.running ? "running" : "exited", - health: row.id === ID ? "healthy" : "", - exitCode: 0, - startedAt: - row.id === job && jobStarted - ? "2026-10-08T00:00:02Z" - : priorStartedAt, - finishedAt: row.running - ? "0001-01-01T00:00:00Z" - : "2026-10-08T00:00:03Z", - restartPolicy: "no", - maximumRetryCount: 0, - })); - } - // The maintained job fixture uses the same 20s operation bound. The case - // bound is the file's fake-Docker budget, so a slow host ends in the - // operation's own refusal rather than an unconfirmed snapshot lifetime. - const deadline = Date.now() + 20_000; - await executeLegacyComposeRetainedPlan({ - plan, - binding: original, - operation: "start", - deadline, - assertFresh: async () => { - await acquired.assertFresh({ projectRoot }); - }, - observe: observed, - effect: async (action, id) => { - effects.push(`${action}:${id}`); - if (action !== "start" || ![ID, job, web].includes(id)) { - throw new Error("Unexpected synthetic lifecycle call"); - } - if (id === job) { - jobStarted = true; - const selected = fixture.container.find((row) => row.id === job); - if (!selected) { - throw new Error("Missing seed fixture"); + const acquired = await acquireFixtureBinding({ projectRoot }); + const original = await acquired.resolveBinding({ projectRoot }); + const effects: string[] = []; + let jobStarted = false; + let jobArmCommandCursor = 0; + let bracketTrace: string[][] = []; + const priorStartedAt = "2026-10-08T00:00:01Z"; + async function observed(): Promise { + const current: Fixture = JSON.parse( + await readFile(join(root, "fixture.json"), "utf8") + ); + return current.container.map((row) => ({ + id: String(row.id), + running: row.running === true, + paused: false, + status: row.running ? "running" : "exited", + health: row.id === ID ? "healthy" : "", + exitCode: 0, + startedAt: + row.id === job && jobStarted + ? "2026-10-08T00:00:02Z" + : priorStartedAt, + finishedAt: row.running + ? "0001-01-01T00:00:00Z" + : "2026-10-08T00:00:03Z", + restartPolicy: "no", + maximumRetryCount: 0, + })); + } + // The case bound is sized from the counted freshness checks and stays + // inside this case's own test budget, so a slow host ends in the + // operation's own refusal rather than an unconfirmed snapshot lifetime. + let freshnessChecks = 0; + const deadline = Date.now() + ORDERED_OPERATION_DEADLINE_MS; + await executeLegacyComposeRetainedPlan({ + plan, + binding: original, + operation: "start", + deadline, + assertFresh: async () => { + freshnessChecks += 1; + await acquired.assertFresh({ projectRoot }); + }, + observe: observed, + effect: async (action, id) => { + effects.push(`${action}:${id}`); + if (action !== "start" || ![ID, job, web].includes(id)) { + throw new Error("Unexpected synthetic lifecycle call"); } - selected.running = true; - network().containers = [ID, job]; - fixture.mode = "snapshot-job-exit"; - await writeFile(join(root, "snapshot-network-ordinal"), "0"); - jobArmCommandCursor = (await commands()).length; - await save(); - } - if (id === web) { - bracketTrace = (await commands()).slice(jobArmCommandCursor); - // Read the stand-in's persisted terminal seed state before changing - // only the newly admitted dependent's own running endpoint. - fixture = JSON.parse( - await readFile(join(root, "fixture.json"), "utf8") - ); - const selected = fixture.container.find((row) => row.id === web); - if (!selected) { - throw new Error("Missing web fixture"); + if (id === job) { + jobStarted = true; + const selected = fixture.container.find((row) => row.id === job); + if (!selected) { + throw new Error("Missing seed fixture"); + } + selected.running = true; + network().containers = [ID, job]; + fixture.mode = "snapshot-job-exit"; + await writeFile(join(root, "snapshot-network-ordinal"), "0"); + jobArmCommandCursor = (await commands()).length; + await save(); } - selected.running = true; - network().containers = [ID, web]; - await save(); - } - return 0; - }, - }); - expect(Date.now()).toBeLessThan(deadline); - expect(effects).toEqual([`start:${ID}`, `start:${job}`, `start:${web}`]); - const networkOffsets = bracketTrace.flatMap((args, index) => - args[0] === "network" && args[1] === "inspect" ? [index] : [] - ); - expect(networkOffsets.length).toBeGreaterThanOrEqual(3); - for (const offset of [0, 1]) { - expect( - bracketTrace - .slice(networkOffsets[offset]! + 1, networkOffsets[offset + 1]!) - .map((args) => args.slice(0, 2)) - ).toEqual(Array.from({ length: 3 }, () => ["container", "inspect"])); - } - const transition: { before: Fixture; after: Fixture; ordinal: number } = - JSON.parse( - await readFile(join(root, "snapshot-transition.json"), "utf8") + if (id === web) { + bracketTrace = (await commands()).slice(jobArmCommandCursor); + // Read the stand-in's persisted terminal seed state before changing + // only the newly admitted dependent's own running endpoint. + fixture = JSON.parse( + await readFile(join(root, "fixture.json"), "utf8") + ); + const selected = fixture.container.find((row) => row.id === web); + if (!selected) { + throw new Error("Missing web fixture"); + } + selected.running = true; + network().containers = [ID, web]; + await save(); + } + return 0; + }, + }); + expect(Date.now()).toBeLessThan(deadline); + // The deadline's sizing holds only while the start makes no more checks. + expect(freshnessChecks).toBeLessThanOrEqual( + ORDERED_START_FRESHNESS_CHECKS ); - expect(transition.ordinal).toBe(2); - const expectedAfter = structuredClone(transition.before); - const selected = expectedAfter.container.find((row) => row.id === job); - if (!selected) { - throw new Error("Missing original seed transition"); - } - selected.running = false; - expectedAfter.network[0]!.containers = [ID]; - const { mode: _mode, ...expectedWithoutMode } = expectedAfter; - expect(transition.after).toEqual(expectedWithoutMode); - const completed = (await observed()).find((row) => row.id === job); - if (!completed) { - throw new Error("Missing completed seed observation"); + expect(effects).toEqual([`start:${ID}`, `start:${job}`, `start:${web}`]); + const networkOffsets = bracketTrace.flatMap((args, index) => + args[0] === "network" && args[1] === "inspect" ? [index] : [] + ); + expect(networkOffsets.length).toBeGreaterThanOrEqual(3); + for (const offset of [0, 1]) { + expect( + bracketTrace + .slice(networkOffsets[offset]! + 1, networkOffsets[offset + 1]!) + .map((args) => args.slice(0, 2)) + ).toEqual(Array.from({ length: 3 }, () => ["container", "inspect"])); + } + const transition: { before: Fixture; after: Fixture; ordinal: number } = + JSON.parse( + await readFile(join(root, "snapshot-transition.json"), "utf8") + ); + expect(transition.ordinal).toBe(2); + const expectedAfter = structuredClone(transition.before); + const selected = expectedAfter.container.find((row) => row.id === job); + if (!selected) { + throw new Error("Missing original seed transition"); + } + selected.running = false; + expectedAfter.network[0]!.containers = [ID]; + const { mode: _mode, ...expectedWithoutMode } = expectedAfter; + expect(transition.after).toEqual(expectedWithoutMode); + const completed = (await observed()).find((row) => row.id === job); + if (!completed) { + throw new Error("Missing completed seed observation"); + } + expect( + legacyComposeFreshJobResult({ + attempt: { id: job, priorStartedAt }, + observed: completed, + }) + ).toBe("ready"); + expect( + legacyComposeFreshJobResult({ + attempt: { id: job, priorStartedAt: completed.startedAt }, + observed: completed, + }) + ).toBe("waiting"); + expect( + legacyComposeFreshJobResult({ + attempt: { id: "f".repeat(64), priorStartedAt }, + observed: completed, + }) + ).toBe("refused"); + expect( + legacyComposeFreshJobResult({ + attempt: { id: job, priorStartedAt }, + observed: { ...completed, finishedAt: "0001-01-01T00:00:00Z" }, + }) + ).toBe("refused"); + await acquired.assertFresh({ projectRoot }); + expect(await acquired.resolveBinding({ projectRoot })).toEqual(original); + expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); + } finally { + snapshotCaseActive = false; } - expect( - legacyComposeFreshJobResult({ - attempt: { id: job, priorStartedAt }, - observed: completed, - }) - ).toBe("ready"); - expect( - legacyComposeFreshJobResult({ - attempt: { id: job, priorStartedAt: completed.startedAt }, - observed: completed, - }) - ).toBe("waiting"); - expect( - legacyComposeFreshJobResult({ - attempt: { id: "f".repeat(64), priorStartedAt }, - observed: completed, - }) - ).toBe("refused"); - expect( - legacyComposeFreshJobResult({ - attempt: { id: job, priorStartedAt }, - observed: { ...completed, finishedAt: "0001-01-01T00:00:00Z" }, - }) - ).toBe("refused"); - await acquired.assertFresh({ projectRoot }); - expect(await acquired.resolveBinding({ projectRoot })).toEqual(original); - expect(await Bun.file(join(root, "mutation")).exists()).toBe(false); - } finally { - snapshotCaseActive = false; - } -}); + }, + MOVING_JOB_TEST_TIMEOUT_MS +); test.each([ "foreign", @@ -658,7 +816,7 @@ test.each([ fixture.mode = `snapshot-guard-${kind}`; await save(); await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot }), + acquireFixtureBinding({ projectRoot }), kind === "container" ? "E_LEGACY_COMPOSE_BINDING_PROBE" : "E_LEGACY_COMPOSE_BINDING_IDENTITY" @@ -671,10 +829,15 @@ test("moving snapshot guard exhausts the original shared deadline", async () => await save(); const started = Date.now(); await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot, timeoutMs: 3000 }), + acquireFixtureBinding({ + projectRoot, + timeoutMs: EXHAUSTED_PROBE_DEADLINE_MS, + }), "E_LEGACY_COMPOSE_BINDING_PROBE" ); - expect(Date.now() - started).toBeLessThan(5000); + expect(Date.now() - started).toBeLessThan( + EXHAUSTED_PROBE_DEADLINE_MS + EXHAUSTED_REFUSAL_SLACK_MS + ); const commands = (await readFile(join(root, "commands"), "utf8")) .trim() .split("\n") @@ -701,7 +864,7 @@ test.each([ ] as const)( "a sandbox-less endpoint placeholder on bridge read %s is re-observed, never admitted or refused (HACK-1421)", async (ordinal, extra) => { - const clean = await acquireLegacyComposeAdoptionBinding({ + const clean = await acquireFixtureBinding({ projectRoot, timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS, }); @@ -711,7 +874,7 @@ test.each([ fixture.mode = `snapshot-placeholder-${ordinal}`; await writeFile(join(root, "snapshot-network-ordinal"), "0"); await save(); - const acquired = await acquireLegacyComposeAdoptionBinding({ + const acquired = await acquireFixtureBinding({ projectRoot, timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS, }); @@ -727,7 +890,7 @@ test( "a placeholder on one of two owned bridges is re-observed with both bridges (HACK-1421)", async () => { await twoBridges(); - const clean = await acquireLegacyComposeAdoptionBinding({ + const clean = await acquireFixtureBinding({ projectRoot, timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS, }); @@ -738,7 +901,7 @@ test( fixture.mode = "snapshot-placeholder-3"; await writeFile(join(root, "snapshot-network-ordinal"), "0"); await save(); - const acquired = await acquireLegacyComposeAdoptionBinding({ + const acquired = await acquireFixtureBinding({ projectRoot, timeoutMs: PLACEHOLDER_RESCAN_PROBE_TIMEOUT_MS, }); @@ -754,10 +917,15 @@ test("a persistent endpoint placeholder exhausts the original shared deadline", await save(); const started = Date.now(); await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot, timeoutMs: 6000 }), + acquireFixtureBinding({ + projectRoot, + timeoutMs: EXHAUSTED_PROBE_DEADLINE_MS, + }), "E_LEGACY_COMPOSE_BINDING_PROBE" ); - expect(Date.now() - started).toBeLessThan(9000); + expect(Date.now() - started).toBeLessThan( + EXHAUSTED_PROBE_DEADLINE_MS + EXHAUSTED_REFUSAL_SLACK_MS + ); // Identity read plus at least two discarded loop reads before the deadline. expect(networkInspections(await commands())).toBeGreaterThan(2); }); @@ -766,7 +934,7 @@ test("a malformed placeholder-shaped bridge member still refuses identity withou fixture.mode = "snapshot-placeholder-always-malformed"; await save(); await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot }), + acquireFixtureBinding({ projectRoot }), "E_LEGACY_COMPOSE_BINDING_IDENTITY" ); expect(networkInspections(await commands())).toBe(1); @@ -784,7 +952,7 @@ test.each([ network().containers = members(); await save(); await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot }), + acquireFixtureBinding({ projectRoot }), "E_LEGACY_COMPOSE_BINDING_IDENTITY" ); expect(networkInspections(await commands())).toBe(1); @@ -796,7 +964,7 @@ test("moving snapshot guard rejects a selected ID on its undeclared bridge befor await writeFile(join(root, "snapshot-network-ordinal"), "0"); await save(); await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot }), + acquireFixtureBinding({ projectRoot }), "E_LEGACY_COMPOSE_BINDING_IDENTITY" ); const observed = await commands(); @@ -815,7 +983,7 @@ test("moving snapshot guard rejects a selected ID on its undeclared bridge befor test("one authored bridge binds original physical ID, internal policy and exact live aliases", async () => { await customBridge(); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); expect(acquired.report.binding_version).toBe(3); const original = await acquired.resolveBinding({ projectRoot }); if (original.binding_version !== 3) { @@ -848,7 +1016,7 @@ test("one authored bridge binds original physical ID, internal policy and exact test("two owned bridges bind both original IDs, policies, aliases and exact members", async () => { await twoBridges(); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); expect(acquired.report.binding_version).toBe(5); const original = await acquired.resolveBinding({ projectRoot }); expect(original.binding_version).toBe(5); @@ -926,7 +1094,7 @@ test.each([ ], ])("plural binding refuses %s before effect", async (_, change) => { await twoBridges(); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); change(); await save(); await refusal(acquired.assertFresh({ projectRoot })); @@ -991,7 +1159,7 @@ test.each([ ], ])("owned bridge refuses %s before adoption effects", async (_, change) => { await customBridge(); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); change(); await save(); await refusal(acquired.assertFresh({ projectRoot })); @@ -1001,7 +1169,7 @@ test("running original cannot hide absent aliases behind stopped endpoint allowa await customBridge(); (container().networks as Record[])[0]!.aliases = null; await save(); - await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); + await refusal(acquireFixtureBinding({ projectRoot })); }); test("mixed stopped and running originals require exactly the running endpoint IDs", async () => { @@ -1021,7 +1189,7 @@ test("mixed stopped and running originals require exactly the running endpoint I running: false, }); await save(); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); expect( (await acquired.resolveBinding({ projectRoot })).containers ).toHaveLength(2); @@ -1040,7 +1208,7 @@ test("stopped configured NetworkID drift and malformed running state refuse", as container().running = false; network().containers = []; await save(); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); container().networks = [{ name: "fixture_default", id: "d".repeat(64) }]; await save(); await refusal(acquired.assertFresh({ projectRoot })); @@ -1051,7 +1219,7 @@ test("stopped configured NetworkID drift and malformed running state refuse", as }); test("exact existing identities are private, frozen and repeatedly verified without writes", async () => { const before = await readFile(join(projectRoot, ".hack/docker-compose.yml")); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); expect(acquired.report).toEqual({ binding_version: 1, status: "verified", @@ -1115,7 +1283,7 @@ test("missing existing database volume refuses instead of allocating an empty re fixture.volume = []; await save(); await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot }), + acquireFixtureBinding({ projectRoot }), "E_LEGACY_COMPOSE_BINDING_IDENTITY" ); }); @@ -1147,7 +1315,7 @@ test.each([ } row[String(key)] = value; await save(); - await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); + await refusal(acquireFixtureBinding({ projectRoot })); }); test.each([ [ @@ -1190,7 +1358,7 @@ test.each([ ])("mounted identity must exactly match authored path and original data: %j", async (...mounts) => { container().mounts = mounts; await save(); - await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); + await refusal(acquireFixtureBinding({ projectRoot })); }); test.each([ undefined, @@ -1202,7 +1370,7 @@ test.each([ } mount[0].name = name; await save(); - await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); + await refusal(acquireFixtureBinding({ projectRoot })); }); test.each([ "container", @@ -1214,7 +1382,7 @@ test.each([ fixture.network = []; } await save(); - await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); + await refusal(acquireFixtureBinding({ projectRoot })); }); test("the authored default volume name must exist exactly, with no generated native replacement", async () => { const path = join(projectRoot, ".hack/docker-compose.yml"); @@ -1222,7 +1390,7 @@ test("the authored default volume name must exist exactly, with no generated nat path, (await readFile(path, "utf8")).replace(` name: ${VOLUME}`, " {}") ); - await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); + await refusal(acquireFixtureBinding({ projectRoot })); }); test.each([ "replace-volume", @@ -1231,7 +1399,7 @@ test.each([ ])("changes during acquisition cannot return a stale capability: %s", async (mode) => { fixture.mode = mode; await save(); - await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); + await refusal(acquireFixtureBinding({ projectRoot })); }); test.each([ "volume", @@ -1239,7 +1407,7 @@ test.each([ "network", "engine", ])("captured binding detects later %s replacement", async (kind) => { - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); if (kind === "volume") { volume().createdAt = "2026-02-02T01:02:03Z"; } @@ -1259,7 +1427,7 @@ test.each([ ".hack/docker-compose.yml", ".hack/hack.config.json", ])("raw whitespace-only source change invalidates private binding: %s", async (path) => { - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); const file = join(projectRoot, path); await writeFile(file, `${await readFile(file, "utf8")}\n`); await refusal(acquired.resolveBinding({ projectRoot })); @@ -1270,13 +1438,13 @@ test.each([ ".hack/hack.project.json", ".git", ])("adding a relevant marker after resolve invalidates binding: %s", async (path) => { - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); await acquired.resolveBinding({ projectRoot }); await writeFile(join(projectRoot, path), CANARY); await refusal(acquired.assertFresh({ projectRoot })); }); test("a different checkout target and changed Docker route refuse before a new probe", async () => { - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); const count = (await commands()).length; await refusal( acquired.assertFresh({ projectRoot: join(root, "sibling") }), @@ -1292,7 +1460,7 @@ test("a different checkout target and changed Docker route refuse before a new p test("caller option mutation cannot redirect acquisition", async () => { const opts = { projectRoot, - timeoutMs: 15_000, + timeoutMs: FIXTURE_PROBE_DEADLINE_MS, signal: new AbortController().signal, }; const pending = acquireLegacyComposeAdoptionBinding(opts); @@ -1312,65 +1480,43 @@ test.each([ ])("bounded real probe failures stay redacted: %s", async (mode) => { fixture.mode = mode; await save(); - await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot, timeoutMs: 1000 }) - ); + await refusal(acquireFixtureBinding({ projectRoot, timeoutMs: 1000 })); }); test("cancelled real child is reaped and abort reason stays private", async () => { fixture.mode = "hang"; await save(); + const observed = observeDockerChildren(); const controller = new AbortController(); - const pending = acquireLegacyComposeAdoptionBinding({ + const pending = acquireFixtureBinding({ projectRoot, signal: controller.signal, }); - const started = join(root, "started"); - for ( - let count = 0; - count < 100 && !(await Bun.file(started).exists()); - count++ - ) { - await Bun.sleep(5); + try { + const pid = await startedChild(); + controller.abort(CANARY); + await refusal(pending, "E_LEGACY_COMPOSE_BINDING_CANCELLED"); + expectReaped(observed.children, pid); + } finally { + // A failed start-up wait must not leave this acquisition to reject into a + // later test, or leave its child alive (HACK-1452). + controller.abort(CANARY); + await pending.catch(() => undefined); + await observed.release(); } - expect(await Bun.file(started).exists()).toBe(true); - const pid = Number(await readFile(started, "utf8")); - controller.abort(CANARY); - await refusal(pending, "E_LEGACY_COMPOSE_BINDING_CANCELLED"); - expect(() => process.kill(pid, 0)).toThrow(); }); test("timeout reaps real child without authorizing any mutation", async () => { const binary = join(root, "docker"); // A slow startup may be killed before its script can publish a PID. Force that // case; observe the actual returned child without changing spawn or the timer. await writeFile(binary, `#!${process.execPath}\nawait Bun.sleep(60_000);\n`); - const children: { - child: ReturnType; - exited: boolean; - }[] = []; - const actualSpawn = Bun.spawn; - const spawn = spyOn(Bun, "spawn").mockImplementation((...args) => { - const child: ReturnType = Reflect.apply( - actualSpawn, - Bun, - args - ); - const argv = args[0]; - if (Array.isArray(argv) && argv[0] === binary) { - const observed = { child, exited: false }; - children.push(observed); - void child.exited.then(() => { - observed.exited = true; - }); - } - return child; - }); + const observed = observeDockerChildren(); try { await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot, timeoutMs: 150 }), + acquireFixtureBinding({ projectRoot, timeoutMs: 150 }), "E_LEGACY_COMPOSE_BINDING_PROBE" ); - expect(children.length).toBeGreaterThan(0); - for (const { child, exited } of children) { + expect(observed.children.length).toBeGreaterThan(0); + for (const { child, exited } of observed.children) { expect(exited).toBe(true); expect(child.signalCode).toBe("SIGKILL"); expect(await child.exited).not.toBe(0); @@ -1378,20 +1524,14 @@ test("timeout reaps real child without authorizing any mutation", async () => { } expect(await Bun.file(join(root, "started")).exists()).toBe(false); } finally { - spawn.mockRestore(); // Preserve the assertion failure while preventing a broken reaping path from // leaving this exact synthetic child alive in the rest of the test suite. - for (const { child } of children) { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - await child.exited; - } - } + await observed.release(); } }); test("abort before acquisition starts no Docker process", async () => { await refusal( - acquireLegacyComposeAdoptionBinding({ + acquireFixtureBinding({ projectRoot, signal: AbortSignal.abort(CANARY), }), @@ -1403,13 +1543,13 @@ test("symlinked source and linked Git marker refuse before any engine probe", as const file = join(projectRoot, ".hack/hack.config.json"); await rm(file); await symlink(join(projectRoot, ".hack/docker-compose.yml"), file); - await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); + await refusal(acquireFixtureBinding({ projectRoot })); expect(await Bun.file(join(root, "commands")).exists()).toBe(false); await rm(file); await writeFile(file, '{"name":"fixture"}'); await writeFile(join(projectRoot, ".git"), "gitdir: ../other"); await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot }), + acquireFixtureBinding({ projectRoot }), "E_LEGACY_COMPOSE_BINDING_UNSUPPORTED" ); }); @@ -1439,7 +1579,7 @@ test("unknown inactive-profile behavior refuses before Docker inspection", async ) ); await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot }), + acquireFixtureBinding({ projectRoot }), "E_LEGACY_COMPOSE_BINDING_UNSUPPORTED" ); expect(await Bun.file(join(root, "commands")).exists()).toBe(false); @@ -1453,7 +1593,7 @@ test("different checkout containers in one project cannot be mistaken for the se }); await save(); await refusal( - acquireLegacyComposeAdoptionBinding({ projectRoot }), + acquireFixtureBinding({ projectRoot }), "E_LEGACY_COMPOSE_BINDING_IDENTITY" ); expect(await Bun.file(join(root, "commands")).exists()).toBe(true); @@ -1505,8 +1645,8 @@ test("two independently named checkouts retain separate existing data bindings", containers: [siblingId], }); await save(); - const selected = await acquireLegacyComposeAdoptionBinding({ projectRoot }); - const other = await acquireLegacyComposeAdoptionBinding({ + const selected = await acquireFixtureBinding({ projectRoot }); + const other = await acquireFixtureBinding({ projectRoot: sibling, }); expect( @@ -1524,29 +1664,31 @@ test("two independently named checkouts retain separate existing data bindings", }); test("captured cancellation cannot be replaced by a later fresh signal", async () => { const controller = new AbortController(); - const acquired = await acquireLegacyComposeAdoptionBinding({ + const acquired = await acquireFixtureBinding({ projectRoot, signal: controller.signal, }); fixture.mode = "hang"; await save(); + const observed = observeDockerChildren(); const pending = acquired.assertFresh({ projectRoot, signal: new AbortController().signal, }); - const started = join(root, "started"); - for ( - let count = 0; - count < 100 && !(await Bun.file(started).exists()); - count++ - ) { - await Bun.sleep(5); + try { + // Read the PID before the abort. This test once read it afterwards, from a + // "started" file a child killed between create and write left empty: + // Number("") is 0, and process.kill(0, 0) succeeded (HACK-1452). + const pid = await startedChild(); + controller.abort(CANARY); + await refusal(pending, "E_LEGACY_COMPOSE_BINDING_CANCELLED"); + // The probe joins its child before it refuses, so this holds at once. + expectReaped(observed.children, pid); + } finally { + controller.abort(CANARY); + await pending.catch(() => undefined); + await observed.release(); } - expect(await Bun.file(started).exists()).toBe(true); - controller.abort(CANARY); - await refusal(pending, "E_LEGACY_COMPOSE_BINDING_CANCELLED"); - const pid = Number(await readFile(started, "utf8")); - expect(() => process.kill(pid, 0)).toThrow(); }); const malformedArguments: readonly { readonly label: string; @@ -1586,7 +1728,7 @@ for (const { label, value } of malformedArguments) { expect(await Bun.file(join(root, "commands")).exists()).toBe(false); }); test(`malformed runtime freshness argument remains redacted: ${label}`, async () => { - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); const count = (await commands()).length; await refusal( Reflect.apply(acquired.assertFresh, undefined, [value]), diff --git a/tests/native-compose-adoption-command-messages.test.ts b/tests/native-compose-adoption-command-messages.test.ts new file mode 100644 index 00000000..f44343ba --- /dev/null +++ b/tests/native-compose-adoption-command-messages.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + errorResultFromUnknown, + HackCliError, + renderCliResult, +} from "../src/lib/cli-result.ts"; +import { tryLegacyComposeAdoptedCommand } from "../src/lib/native-compose-adoption-command.ts"; +import * as generation from "../src/lib/native-compose-adoption-generation.ts"; +import * as marker from "../src/lib/native-compose-adoption-marker.ts"; +import { createCompletedJobFixtureStartDiagnostics } from "./e2e/scenarios/native-compose-adoption-job-diagnostics.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +/** + * Drives the adopted command's own refusal and cancellation throw sites and + * maps what they throw to the diagnostics' closed token. Diagnostics match the + * command's fixed messages exactly (HACK-1432), so a throw site that stops + * using `LEGACY_COMPOSE_ADOPTED_COMMAND_MESSAGES` would turn its token into + * `other` (HACK-1452). The store is a stand-in: these sites sit after the + * store opens, and only the command's own handling is under test here. + */ +const CANARY = "synthetic-private-adopted-command-canary"; +let root: string; +let priorBackend: string | undefined; +const restores: (() => void)[] = []; +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "adopted-command-"))); + priorBackend = process.env.HACK_RUNTIME_BACKEND; + process.env.HACK_RUNTIME_BACKEND = "compose"; + const selection = spyOn( + marker, + "inspectLegacyComposeAdoptionSelection" + ).mockImplementation(async () => "active"); + restores.push(() => selection.mockRestore()); +}); +afterEach(async () => { + for (const restore of restores.splice(0).reverse()) { + restore(); + } + restoreEnv("HACK_RUNTIME_BACKEND", priorBackend); + await rm(root, { recursive: true, force: true }); +}); + +type Store = generation.LegacyComposeAdoptedGenerationStore; + +/** Opens `store` in place of the real generation store; never touches Docker. */ +function useStore(store: Partial) { + const closed = { count: 0 }; + const opened = spyOn( + generation, + "openLegacyComposeAdoptedGenerationStore" + ).mockImplementation( + async () => + ({ + close: async () => { + closed.count += 1; + }, + ...store, + }) as Store + ); + restores.push(() => opened.mockRestore()); + return closed; +} + +/** + * The command's own SIGTERM listener, called directly as the OS would. The + * listener is found by difference, so no signal is sent to this process and + * no other listener runs. + */ +function cancelFrom(before: readonly unknown[]) { + const added = process + .listeners("SIGTERM") + .filter((listener) => !before.includes(listener)); + expect(added).toHaveLength(1); + added[0]?.("SIGTERM"); +} + +async function rejection(pending: Promise): Promise { + let caught: unknown; + try { + await pending; + } catch (error: unknown) { + caught = error; + } + expect(caught).toBeInstanceOf(HackCliError); + if (!(caught instanceof HackCliError)) { + throw new Error("missing adopted command refusal"); + } + expect( + JSON.stringify(errorResultFromUnknown({ error: caught })) + ).not.toContain(CANARY); + return caught; +} + +/** The closed token the completed-job diagnostics name for this refusal. */ +function token(error: HackCliError): string { + const messages: string[] = []; + createCompletedJobFixtureStartDiagnostics({ + operation: "up", + scope: "alpha", + log: (message) => messages.push(message), + }).cliOutcome({ + exitCode: 1, + timedOut: false, + stdout: renderCliResult({ result: errorResultFromUnknown({ error }) }), + }); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain("code=E_CONFIG_INVALID"); + return messages[0]?.match(/ refusal=(\S+)$/)?.[1] ?? "missing"; +} + +test("an unexpected refusal after the store opens ends in the adopted-refused token", async () => { + const closed = useStore({ + loadActive: async () => { + throw new Error(CANARY); + }, + }); + const error = await rejection( + tryLegacyComposeAdoptedCommand({ cwd: root, operation: "ps" }) + ); + expect(error.code).toBe("E_CONFIG_INVALID"); + expect(token(error)).toBe("adopted-refused"); + expect(closed.count).toBe(1); +}); + +test("cancellation before an observed lease runs ends in the adopted-cancelled token", async () => { + const before = process.listeners("SIGTERM"); + let ran = false; + const closed = useStore({ + loadActive: async () => Object.freeze({}) as never, + withLease: async ({ run }) => { + cancelFrom(before); + ran = true; + // The command checks its signal before reading any lease input. + return await run({} as never); + }, + }); + const error = await rejection( + tryLegacyComposeAdoptedCommand({ cwd: root, operation: "ps" }) + ); + expect(ran).toBe(true); + expect(error.code).toBe("E_CONFIG_INVALID"); + expect(token(error)).toBe("adopted-cancelled"); + expect(closed.count).toBe(1); + expect(process.listeners("SIGTERM")).toEqual(before); +}); + +test("cancellation before a journaled mutation runs ends in the adopted-cancelled token", async () => { + const before = process.listeners("SIGTERM"); + let ran = false; + const closed = useStore({ + loadActive: async () => Object.freeze({}) as never, + withMutation: async ({ run }) => { + cancelFrom(before); + ran = true; + // The command checks its signal before it reads input or spawns Docker. + await run({} as never); + return 0; + }, + }); + const error = await rejection( + tryLegacyComposeAdoptedCommand({ + cwd: root, + operation: "up", + detach: true, + }) + ); + expect(ran).toBe(true); + expect(error.code).toBe("E_CONFIG_INVALID"); + expect(token(error)).toBe("adopted-cancelled"); + expect(closed.count).toBe(1); + expect(process.listeners("SIGTERM")).toEqual(before); +});