FN-9020: parallelize boot smoke preflights

Reduce durable boot-smoke latency while preserving its CLI, initialization, health, and shutdown assertions.

- Run independent help and init preflights concurrently with bounded async child processes.
- Add phase timing diagnostics and deterministic init failure classification.
- Cover phase scheduling and isolated environment behavior, and document the diagnostics flag.

Files changed:
 docs/testing.md                       |   4 +-
 scripts/__tests__/boot-smoke.test.mjs |  65 ++++++++++-
 scripts/boot-smoke.mjs                | 201 +++++++++++++++++++++++++---------
 3 files changed, 214 insertions(+), 56 deletions(-)

Fusion-Task-Id: FN-9020

Fusion-Task-Lineage: 1f5d3d7f-dc0c-4d16-a4ab-fd5e3952dbdf

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-12 19:45:40 -07:00
parent f8f828357f
commit 6d2c1bf0c9
3 changed files with 214 additions and 56 deletions

View File

@@ -6,7 +6,9 @@ This guide consolidates the detailed testing guidance moved from `AGENTS.md`.
## The merge gate
CI blocks PRs on exactly four checks (`.github/workflows/pr-checks.yml`): **Lint, Typecheck, Build, Gate**. The Gate job runs the boot smoke (`scripts/boot-smoke.mjs`: CLI `--help`, real `fn init` with a durable `.fusion/project.json` marker, then a real `fn serve` answering `GET /api/health`, all against one isolated home) and `pnpm test:gate`: 11 static policy validators, 22 curated `engine-core` files, two PostgreSQL canaries, four core unit files, then the CI-shape test. Everything else — the 4-way shards, the engine slow tier, the dashboard inventory guard — runs NON-BLOCKING in `.github/workflows/full-suite.yml` on push to main.
CI blocks PRs on exactly four checks (`.github/workflows/pr-checks.yml`): **Lint, Typecheck, Build, Gate**. The Gate job runs the boot smoke (`scripts/boot-smoke.mjs`: independent CLI `--help` and real `fn init` preflights run concurrently, the latter proving a durable `.fusion/project.json` marker, then a real `fn serve` answers `GET /api/health`, all against one isolated home) and `pnpm test:gate`: 11 static policy validators, 22 curated `engine-core` files, two PostgreSQL canaries, four core unit files, then the CI-shape test.
Set `BOOT_SMOKE_TIMINGS=1` when invoking `pnpm smoke:boot` to print per-attempt help, init, health, and SIGTERM phase timings for diagnosis; the flag is off by default so normal gate output stays concise. Everything else — the 4-way shards, the engine slow tier, the dashboard inventory guard — runs NON-BLOCKING in `.github/workflows/full-suite.yml` on push to main.
Gate membership is the explicit allow-list in `packages/engine/vitest.config.ts` (`engine-core` project). Admission requires evidence of value (the test catches real regressions); tests never graduate in by default. A flaky gate test is evicted by deleting its allow-list line — the eviction PR does not need the flaky test to pass. The whole `engine-core` project must stay under ~60s wall-clock.

View File

@@ -4,7 +4,13 @@
// persistently-throwing remover) so a post-PASS cleanup can never fail the gate.
import test from "node:test";
import assert from "node:assert/strict";
import { removeTempDir } from "../boot-smoke.mjs";
import {
classifyInitFailure,
createBootSmokePhasePlan,
createChildEnv,
runPreflightPhasePlan,
removeTempDir,
} from "../boot-smoke.mjs";
function enotempty() {
const err = new Error("ENOTEMPTY: directory not empty");
@@ -68,6 +74,63 @@ test("removeTempDir uses the provided maxRetries/retryDelayMs overrides", () =>
assert.equal(seenOpts.retryDelay, 250);
});
test("boot plan overlaps independent preflight checks while retaining all smoke assertions", () => {
for (const dataDir of ["cold", "warm"]) {
const plan = createBootSmokePhasePlan({ attempt: 2, dataDir });
assert.deepEqual(plan.map((phase) => phase.name), ["help", "init", "serve", "shutdown"]);
assert.deepEqual(plan.map((phase) => phase.assertion), [
"help-exits-0-and-mentions-serve",
"init-settles-and-writes-project-marker",
"health-200",
"sigterm-delivered-and-clean-exit",
]);
assert.equal(plan.filter((phase) => phase.concurrency === "preflight").length, 2);
assert.equal(plan.find((phase) => phase.name === "serve").attempt, 2);
assert.deepEqual(plan.find((phase) => phase.name === "serve").dependsOn, ["init"]);
assert.equal(plan.find((phase) => phase.name === "init").dataDir, dataDir);
}
});
test("production preflight scheduler launches independent phases before either settles", async () => {
const plan = createBootSmokePhasePlan();
const started = [];
const results = await runPreflightPhasePlan(plan, async (phase) => {
started.push(phase.name);
await new Promise((resolve, reject) => {
Promise.resolve().then(() => {
if (started.length !== 2) {
reject(new Error("preflight phase was awaited before every independent phase launched"));
return;
}
resolve();
});
});
return `${phase.name}-result`;
});
assert.deepEqual(started, ["help", "init"]);
assert.deepEqual(results, { help: "help-result", init: "init-result" });
});
test("init liveness classification preserves every failing state", () => {
const base = { error: null, status: 0, stdout: "", stderr: "" };
assert.equal(classifyInitFailure(base, true), null);
assert.equal(classifyInitFailure({ ...base, status: 13 }, true), "non-zero-exit");
assert.equal(classifyInitFailure({ ...base, stderr: "Detected unsettled top-level await" }, true), "unsettled-top-level-await");
assert.equal(classifyInitFailure(base, false), "missing-project-marker");
assert.equal(classifyInitFailure({ ...base, error: new Error("spawn failed") }, true), "process-error");
});
test("isolated child env strips inherited database and port controls", () => {
const env = createChildEnv({ DATABASE_URL: "postgres://ambient", FUSION_NO_EMBEDDED_PG: "1", PORT: "4040", KEEP: "yes" }, "/tmp/fusion-home");
assert.equal(env.HOME, "/tmp/fusion-home");
assert.equal(env.FUSION_SKIP_ONBOARDING, "1");
assert.equal(env.DATABASE_URL, undefined);
assert.equal(env.FUSION_NO_EMBEDDED_PG, undefined);
assert.equal(env.PORT, undefined);
assert.equal(env.KEEP, "yes");
});
test("importing boot-smoke.mjs does not boot a server (main() guard holds)", async () => {
// The module was already imported at the top of this file for `removeTempDir`.
// If the main()/bootAndVerify() top-level invocation ran unguarded, this test

View File

@@ -28,10 +28,11 @@
* captured child stderr on stdout for CI logs.
*/
import { spawn, spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { performance } from "node:perf_hooks";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -57,6 +58,61 @@ const SHUTDOWN_TIMEOUT_MS = 15_000;
// Ephemeral-port TOCTOU: retry the whole boot with a fresh port when the
// child loses the bind race (EADDRINUSE).
const BOOT_ATTEMPTS = 3;
const TIMINGS_ENABLED = process.env.BOOT_SMOKE_TIMINGS === "1";
/*
* FNXC:BootSmoke 2026-08-13-02:23:
* FN-9020 measured the post-W32 boot-smoke regression phase by phase. `fn init`
* and `fn --help` are independent CLI processes, so serializing them charged both
* imports to every gate run without adding proof. Start them together, wait for
* both original assertions, then boot serve only after init has written its marker.
* The optional timings flag keeps normal merge-gate output unchanged while leaving
* future regressions attributable without a wall-clock test threshold.
*/
export function createBootSmokePhasePlan({ attempt = 1, dataDir = "cold" } = {}) {
return [
{ name: "help", assertion: "help-exits-0-and-mentions-serve", concurrency: "preflight" },
{ name: "init", assertion: "init-settles-and-writes-project-marker", concurrency: "preflight", dataDir },
{ name: "serve", assertion: "health-200", dependsOn: ["init"], attempt },
{ name: "shutdown", assertion: "sigterm-delivered-and-clean-exit", dependsOn: ["serve"] },
];
}
/*
* FNXC:BootSmoke 2026-08-13-02:32:
* FN-9020 requires the phase plan to control production scheduling, not merely
* describe it for tests. Dispatch every independent preflight before awaiting
* either result; a serialized await would restore the avoidable CLI-import cost
* while retaining all four smoke assertions.
*/
export async function runPreflightPhasePlan(plan, runPhase) {
const preflight = plan.filter((phase) => phase.concurrency === "preflight");
return Object.fromEntries(await Promise.all(
preflight.map(async (phase) => [phase.name, await runPhase(phase)]),
));
}
/** Construct the isolated child environment without retaining ambient database or port state. */
export function createChildEnv(baseEnv, isolatedHome) {
return {
...baseEnv,
HOME: isolatedHome,
FUSION_SKIP_ONBOARDING: "1",
DATABASE_URL: undefined,
FUSION_NO_EMBEDDED_PG: undefined,
PORT: undefined,
};
}
/** Return the deterministic init assertion failure, or null when its liveness proof holds. */
export function classifyInitFailure(init, projectMarkerExists) {
const output = `${init.stdout ?? ""}${init.stderr ?? ""}`;
if (init.error) return "process-error";
if (init.status !== 0) return "non-zero-exit";
if (/Detected unsettled top-level await/.test(output)) return "unsettled-top-level-await";
if (!projectMarkerExists) return "missing-project-marker";
return null;
}
function parsePortList(raw) {
return String(raw ?? "")
@@ -110,6 +166,23 @@ async function getEphemeralPort() {
throw new Error("could not obtain a non-reserved ephemeral port");
}
function createPhaseTimer() {
const timings = {};
return {
async measure(name, run) {
const startedAt = performance.now();
try {
return await run();
} finally {
timings[name] = Math.round(performance.now() - startedAt);
}
},
report(attempt) {
if (TIMINGS_ENABLED) console.log(`boot-smoke: timings attempt ${attempt} ${JSON.stringify(timings)}`);
},
};
}
function fail(message, stderr = "") {
console.error(`boot-smoke: FAIL — ${message}`);
if (stderr.trim()) {
@@ -140,20 +213,7 @@ async function pollHealth(port, deadline) {
}
async function main() {
// 1. CLI answers --help.
const help = spawnSync(process.execPath, [cliBin, "--help"], {
encoding: "utf8",
timeout: 30_000,
});
if (help.status !== 0) {
fail(`\`fn --help\` exited ${help.status ?? `signal ${help.signal}`}`, help.stderr ?? "");
}
if (!/serve/i.test(help.stdout ?? "")) {
fail("`fn --help` output does not mention the serve command", help.stderr ?? "");
}
console.log("boot-smoke: `fn --help` OK");
// 2. Real server boot on an ephemeral port with isolated HOME/project state.
// Real server boot on an ephemeral port with isolated HOME/project state.
// The ephemeral-port probe is inherently TOCTOU (probe closes before the
// server binds), so an EADDRINUSE loss on a busy machine retries with a
// fresh port instead of failing the gate.
@@ -183,53 +243,84 @@ async function main() {
* Returns "retry-port" when the child lost the ephemeral-port race
* (EADDRINUSE); calls fail() (which exits) on any real failure.
*/
/** Spawn a bounded CLI preflight command while collecting the output its assertion needs. */
async function runCli(command, args, options) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [cliBin, ...args], {
...options,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timeout;
let settled = false;
const settle = (result) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(result);
};
child.stdout.on("data", (data) => (stdout += data));
child.stderr.on("data", (data) => (stderr += data));
child.once("error", (error) => settle({ error, status: null, signal: null, stdout, stderr }));
child.once("exit", (status, signal) => settle({ error: null, status, signal, stdout, stderr }));
timeout = setTimeout(() => {
child.kill("SIGKILL");
settle({ error: new Error(`${command} timed out`), status: null, signal: "SIGKILL", stdout, stderr });
}, options.timeout);
});
}
async function bootAndVerify(attempt, registerCleanup) {
const timer = createPhaseTimer();
const port = await getEphemeralPort();
const isolatedHome = mkdtempSync(path.join(tmpdir(), "fusion-boot-smoke-home-"));
const isolatedProject = mkdtempSync(path.join(tmpdir(), "fusion-boot-smoke-project-"));
let stderrBuf = "";
const childEnv = {
...process.env,
HOME: isolatedHome,
FUSION_SKIP_ONBOARDING: "1",
// FNXC:BackendFlip 2026-06-26-14:55:
// Force the smoke to exercise the embedded PostgreSQL backend. Unset
// DATABASE_URL so a developer's external DB connection never leaks in
// (the smoke must prove the zero-config embedded path boots). Unset
// FUSION_NO_EMBEDDED_PG so the smoke cannot be opted out by an
// inherited env var — the embedded default is what the gate must prove.
DATABASE_URL: undefined,
FUSION_NO_EMBEDDED_PG: undefined,
// Make sure nothing inherits a PORT that fights the explicit flag.
PORT: undefined,
};
// FNXC:BackendFlip 2026-06-26-14:55:
// Force the smoke to exercise the embedded PostgreSQL backend. Unset
// DATABASE_URL so a developer's external DB connection never leaks in
// (the smoke must prove the zero-config embedded path boots). Unset
// FUSION_NO_EMBEDDED_PG so the smoke cannot be opted out by an
// inherited env var — the embedded default is what the gate must prove.
const childEnv = createChildEnv(process.env, isolatedHome);
registerCleanup(() => {
removeTempDir(isolatedHome);
removeTempDir(isolatedProject);
});
/*
* FNXC:CliAwaitLiveness 2026-08-11-09:30:
* CI already uses Node 24; it missed FN-8954 because help-only smoke never
* ran `fn init`. Share this attempt's isolated HOME with serve so cold initdb
* is paid once while the check detects exit 13 before project registration.
*/
const init = spawnSync(process.execPath, [cliBin, "init", "--name", "boot-smoke", "--path", isolatedProject], {
cwd: isolatedProject,
env: childEnv,
encoding: "utf8",
timeout: HEALTH_TIMEOUT_MS,
});
const initOutput = `${init.stdout ?? ""}${init.stderr ?? ""}`;
if (init.error || init.status !== 0 || /Detected unsettled top-level await/.test(initOutput)) {
fail(
`\`fn init\` exited ${init.status ?? `signal ${init.signal ?? "timeout"}`}`,
`${init.error?.message ? `${init.error.message}\n` : ""}${initOutput}`,
);
// Both preflight commands must settle, but neither consumes the other's state.
// Keep init's isolated HOME for serve; only their process startup overlaps.
const preflightRuns = await runPreflightPhasePlan(
createBootSmokePhasePlan({ attempt }),
(phase) => {
if (phase.name === "help") {
return timer.measure(phase.name, () => runCli("fn --help", ["--help"], { timeout: 30_000 }));
}
return timer.measure(phase.name, () => runCli("fn init", ["init", "--name", "boot-smoke", "--path", isolatedProject], {
cwd: isolatedProject,
env: childEnv,
timeout: HEALTH_TIMEOUT_MS,
}));
},
);
const { help, init } = preflightRuns;
if (help.error || help.status !== 0) {
fail(`\`fn --help\` exited ${help.status ?? `signal ${help.signal ?? "timeout"}`}`, `${help.error?.message ? `${help.error.message}\n` : ""}${help.stderr ?? ""}`);
}
if (!existsSync(path.join(isolatedProject, ".fusion", "project.json"))) {
fail("`fn init` did not write .fusion/project.json", initOutput);
if (!/serve/i.test(help.stdout ?? "")) {
fail("`fn --help` output does not mention the serve command", help.stderr ?? "");
}
console.log("boot-smoke: `fn --help` OK");
const initOutput = `${init.stdout ?? ""}${init.stderr ?? ""}`;
const initFailure = classifyInitFailure(init, existsSync(path.join(isolatedProject, ".fusion", "project.json")));
if (initFailure) {
const message = initFailure === "missing-project-marker"
? "`fn init` did not write .fusion/project.json"
: `\`fn init\` exited ${init.status ?? `signal ${init.signal ?? "timeout"}`}`;
fail(message, `${init.error?.message ? `${init.error.message}\n` : ""}${initOutput}`);
}
console.log("boot-smoke: `fn init` OK");
@@ -272,18 +363,19 @@ async function bootAndVerify(attempt, registerCleanup) {
});
try {
await Promise.race([
await timer.measure("serve-to-health-200", () => Promise.race([
pollHealth(port, Date.now() + HEALTH_TIMEOUT_MS),
exitedEarly.then(({ code, signal }) => {
throw new Error(`server exited before becoming healthy (${code ?? `signal ${signal}`})`);
}),
]);
]));
} catch (err) {
if (/EADDRINUSE/.test(stderrBuf) && attempt < BOOT_ATTEMPTS) {
console.log(`boot-smoke: port :${port} lost to another process (EADDRINUSE), retrying with a fresh port (attempt ${attempt}/${BOOT_ATTEMPTS})`);
await exitedEarly; // child is already dead or dying; wait so cleanup is race-free
removeTempDir(isolatedHome);
removeTempDir(isolatedProject);
timer.report(attempt);
return "retry-port";
}
fail(err.message, stderrBuf);
@@ -300,12 +392,12 @@ async function bootAndVerify(attempt, registerCleanup) {
} catch {
// ESRCH: server already exited — sigtermSent stays false and fails below.
}
const { code, signal } = await Promise.race([
const { code, signal } = await timer.measure("sigterm-to-exit", () => Promise.race([
exitedEarly,
new Promise((resolve) =>
setTimeout(() => resolve({ code: null, signal: "timeout" }), SHUTDOWN_TIMEOUT_MS),
),
]);
]));
if (!sigtermSent) {
fail(`server exited on its own after the health check (${code ?? `signal ${signal}`}) — SIGTERM shutdown could not be verified`, stderrBuf);
}
@@ -317,6 +409,7 @@ async function bootAndVerify(attempt, registerCleanup) {
fail(`server exited uncleanly on SIGTERM (${code ?? `signal ${signal}`})`, stderrBuf);
}
console.log(`boot-smoke: clean shutdown (${code ?? signal})`);
timer.report(attempt);
return "ok";
}