From 391af280f6efd82875ddf9880de6b71e8f1cbe4e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 24 Oct 2026 15:16:07 -0400 Subject: [PATCH 1/4] test(native): read the probe child PID before cancelling and widen binding probe deadlines (HACK-1452) The cancellation refusal already joins its probe child: createNativeComposeProbe awaits child.exited on every path before it rethrows, and Bun resolves exited only after reaping. The leaked-child report came from the test: it read the PID after the abort, from a "started" file the fake Docker child created and was killed before writing. Number("") is 0, and process.kill(0, 0) signals the test's own process group and succeeds. - The fake Docker child publishes its PID by rename, and tests read it before the abort with a bounded, named start-up wait that rejects PIDs 0 and 1. - Both cancellation tests now observe the spawned child and assert at refusal time that it already exited, was SIGKILLed and is gone. - The cancellation test always aborts and awaits its acquisition in finally, so a failed start-up wait cannot reject into a later test. - The file's acquisitions use a named 45 s fixture probe deadline through acquireFixtureBinding, unless a test names its own deadline. - New tests drive the adopted command's refusal and cancel throw sites and assert the diagnostics' closed tokens. Co-Authored-By: Claude Opus 5.5 --- tests/native-compose-adoption-binding.test.ts | 296 +++++++++++------- ...-compose-adoption-command-messages.test.ts | 175 +++++++++++ 2 files changed, 360 insertions(+), 111 deletions(-) create mode 100644 tests/native-compose-adoption-command-messages.test.ts diff --git a/tests/native-compose-adoption-binding.test.ts b/tests/native-compose-adoption-binding.test.ts index b94072e4..b98522ae 100644 --- a/tests/native-compose-adoption-binding.test.ts +++ b/tests/native-compose-adoption-binding.test.ts @@ -36,20 +36,35 @@ 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). The heaviest + * test here owns about eight fixture-deadline probes, 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; /** * A placeholder acquisition repeats bridge scans under one probe deadline * (two extra scans with two owned bridges). The default refused one under @@ -154,7 +169,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 +178,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 +381,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 +507,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 +534,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 = []; @@ -508,7 +608,7 @@ test("moving job snapshot counterexample retains identity and admits a fresh com expect(plan.ordered.find((item) => item.service === "seed")?.kind).toBe( "job" ); - const acquired = await acquireLegacyComposeAdoptionBinding({ projectRoot }); + const acquired = await acquireFixtureBinding({ projectRoot }); const original = await acquired.resolveBinding({ projectRoot }); const effects: string[] = []; let jobStarted = false; @@ -658,7 +758,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,7 +771,7 @@ 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: 3000 }), "E_LEGACY_COMPOSE_BINDING_PROBE" ); expect(Date.now() - started).toBeLessThan(5000); @@ -701,7 +801,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 +811,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 +827,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 +838,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,7 +854,7 @@ 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: 6000 }), "E_LEGACY_COMPOSE_BINDING_PROBE" ); expect(Date.now() - started).toBeLessThan(9000); @@ -766,7 +866,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 +884,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 +896,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 +915,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 +948,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 +1026,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 +1091,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 +1101,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 +1121,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 +1140,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 +1151,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 +1215,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 +1247,7 @@ test.each([ } row[String(key)] = value; await save(); - await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); + await refusal(acquireFixtureBinding({ projectRoot })); }); test.each([ [ @@ -1190,7 +1290,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 +1302,7 @@ test.each([ } mount[0].name = name; await save(); - await refusal(acquireLegacyComposeAdoptionBinding({ projectRoot })); + await refusal(acquireFixtureBinding({ projectRoot })); }); test.each([ "container", @@ -1214,7 +1314,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 +1322,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 +1331,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 +1339,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 +1359,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 +1370,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 +1392,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 +1412,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 +1456,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 +1475,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 +1511,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 +1525,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 +1577,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 +1596,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 +1660,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); +}); From db1037dcc3c0f7f7ad84e2b69747aa27f2bbae2c Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 24 Oct 2026 15:39:19 -0400 Subject: [PATCH 2/4] test(native): size the binding file's deadline-bound cases for load (HACK-1452) Under heavy load the moving job snapshot case's 20 s ordered operation refused with "did not become ready", and the 3 s shared-deadline case ended before its first bridge read. Name an ordered operation deadline of four fixture probe deadlines, and give both exhausted-deadline cases a 15 s probe deadline with a named 5 s refusal slack. They still require repeated scans and a refusal soon after the deadline. Co-Authored-By: Claude Opus 5.5 --- tests/native-compose-adoption-binding.test.ts | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/tests/native-compose-adoption-binding.test.ts b/tests/native-compose-adoption-binding.test.ts index b98522ae..f28378b5 100644 --- a/tests/native-compose-adoption-binding.test.ts +++ b/tests/native-compose-adoption-binding.test.ts @@ -65,6 +65,22 @@ setDefaultTimeout(FAKE_DOCKER_TEST_TIMEOUT_MS); */ const CHILD_START_WAIT_MS = PROBE_DEADLINE_MS; const CHILD_START_POLL_MS = 10; +/** + * The aggregate deadline of the moving job snapshot case's ordered start. It + * covers about four freshness checks, each one fixture-deadline probe. The + * maintained job fixture's 20 s bound refused with "did not become ready" + * under heavy load (HACK-1452). + */ +const ORDERED_OPERATION_DEADLINE_MS = 4 * 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 @@ -637,10 +653,9 @@ test("moving job snapshot counterexample retains identity and admits a fresh com 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; + // 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() + ORDERED_OPERATION_DEADLINE_MS; await executeLegacyComposeRetainedPlan({ plan, binding: original, @@ -771,10 +786,15 @@ test("moving snapshot guard exhausts the original shared deadline", async () => await save(); const started = Date.now(); await refusal( - acquireFixtureBinding({ 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") @@ -854,10 +874,15 @@ test("a persistent endpoint placeholder exhausts the original shared deadline", await save(); const started = Date.now(); await refusal( - acquireFixtureBinding({ 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); }); From 07e1597f603d6db7056447fbf8d43342208e23c9 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 24 Oct 2026 18:39:11 -0400 Subject: [PATCH 3/4] test(native): size the moving job case's ordered start from its counted freshness checks (HACK-1452) The moving job snapshot case's ordered start makes 20 assertFresh calls, not about four, so its 180 s aggregate deadline refused with "did not become ready" at a load average of about 115 and above. Count the checks, size the deadline at one fixture probe deadline each, assert the start makes no more, and give the case its own test budget above that deadline. Correct the file budget's comment, which named this case's probes as the heaviest test's. Co-Authored-By: Claude Opus 5.5 --- tests/native-compose-adoption-binding.test.ts | 446 ++++++++++-------- 1 file changed, 244 insertions(+), 202 deletions(-) diff --git a/tests/native-compose-adoption-binding.test.ts b/tests/native-compose-adoption-binding.test.ts index f28378b5..464c092b 100644 --- a/tests/native-compose-adoption-binding.test.ts +++ b/tests/native-compose-adoption-binding.test.ts @@ -51,9 +51,11 @@ 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). The heaviest - * test here owns about eight fixture-deadline probes, so a slow host ends in - * the product's own closed refusal before this budget expires. + * 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); @@ -66,12 +68,36 @@ setDefaultTimeout(FAKE_DOCKER_TEST_TIMEOUT_MS); const CHILD_START_WAIT_MS = PROBE_DEADLINE_MS; const CHILD_START_POLL_MS = 10; /** - * The aggregate deadline of the moving job snapshot case's ordered start. It - * covers about four freshness checks, each one fixture-deadline probe. The - * maintained job fixture's 20 s bound refused with "did not become ready" - * under heavy load (HACK-1452). + * 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_OPERATION_DEADLINE_MS = 4 * FIXTURE_PROBE_DEADLINE_MS; +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 its + * acquisition, its first resolve, its closing freshness check and its 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 + 5 * 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 @@ -560,209 +586,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 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") + 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 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() + ORDERED_OPERATION_DEADLINE_MS; - 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", From bf3dab7cdf5910d9674711962cd75113196ddafd Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 24 Oct 2026 21:01:11 -0400 Subject: [PATCH 4/4] test(native): leave a spare probe deadline in the moving job case budget (HACK-1452) The case makes 3 probes before its ordered start and 2 after it, so the old 5-probe allowance left no room for file reads and poll sleeps. Co-Authored-By: Claude Opus 5.5 --- tests/native-compose-adoption-binding.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/native-compose-adoption-binding.test.ts b/tests/native-compose-adoption-binding.test.ts index 464c092b..052ece40 100644 --- a/tests/native-compose-adoption-binding.test.ts +++ b/tests/native-compose-adoption-binding.test.ts @@ -90,14 +90,15 @@ const ORDERED_START_FRESHNESS_CHECKS = 20; 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 its - * acquisition, its first resolve, its closing freshness check and its 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. + * 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 + 5 * FIXTURE_PROBE_DEADLINE_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