FN-8954: preserve CLI liveness during startup

Ensure CLI startup operations settle before process exit on supported Node runtimes.

- Keep awaited QMD probes and ephemeral port selection ref'd until completion.
- Add CLI process regressions for init persistence and exit code 13.
- Declare the Node 22.4 runtime floor and extend boot smoke coverage.

Files changed:
 .changeset/fn-8954-cli-exit-13.md                  |  7 ++
 docs/testing.md                                    |  4 +-
 package.json                                       |  3 +
 packages/cli/agent-browser.mjs                     |  6 ++
 packages/cli/bin.mjs                               |  7 ++
 packages/cli/package.json                          |  3 +
 packages/cli/src/__tests__/ci-workflow.test.ts     |  9 +++
 packages/cli/src/__tests__/cli-exit-code.test.ts   | 82 ++++++++++++++++++++++
 packages/cli/src/__tests__/package-config.test.ts  | 12 ++++
 packages/cli/src/bin.ts                            |  6 ++
 .../__tests__/postgres/embedded-free-port.test.ts  | 37 ++++++++++
 packages/core/src/memory/memory-backend.ts         | 17 +++--
 packages/core/src/postgres/embedded-lifecycle.ts   | 16 +++--
 scripts/boot-smoke.mjs                             | 62 +++++++++++-----
 14 files changed, 244 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-8954

Fusion-Task-Lineage: 05303d07-2662-48d5-a442-6d43fa0a4493

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 04:20:33 -07:00
parent f038d04dc5
commit 25e292d0e6
14 changed files with 244 additions and 27 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix CLI commands aborting mid-command on Node 22.4+ so fn init completes.
category: fix
dev: Keeps awaited startup handles ref'd and declares Node >=22.4.0 support.

View File

@@ -6,7 +6,7 @@ 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` + a real `fn serve` answering `GET /api/health`) 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`: 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.
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.
@@ -37,7 +37,7 @@ Use the narrowest command that exercises the behavior you changed, then broaden
```bash
pnpm test # gate suite + changed-only affected tests (bounded; never full-suite)
pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test
pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health
pnpm smoke:boot # boot smoke: CLI --help + init marker + real serve /api/health
pnpm verify:fast # TEST-FREE: static check:* gates + bootstrap + scoped typecheck/build + CLI build + boot smoke
pnpm test:full # full workspace suite — explicit opt-in only
pnpm lint # lint all packages

View File

@@ -130,5 +130,8 @@
"typescript": "^5.7.0",
"typescript-eslint": "^8.66.0",
"yaml": "^2.8.3"
},
"engines": {
"node": ">=22.4.0"
}
}

View File

@@ -5,4 +5,10 @@ FNXC:AgentBrowserPackaging 2026-07-22-12:19:
Publish this top-level bin shim with Fusion so npm can expose agent-browser and
delegate to the pinned dependency's platform-aware launcher.
*/
/*
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
* This delegation intentionally retains top-level await. Replacing it with a
* forced successful exit could hide an unsettled delegated command and truncate
* its long-running process; the FN-8954 fix belongs in the stranded operation.
*/
await import("agent-browser/bin/agent-browser.js");

View File

@@ -17,4 +17,11 @@ try {
globalThis.process.exit(1);
}
/*
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
* Retain top-level await so an unsettled CLI completion remains Node's loud,
* non-zero diagnostic rather than a silent partial success. FN-8954 fixes the
* awaited QMD child liveness at its source; this shim must not force exit 0,
* which would truncate `serve`, `dashboard`, or `daemon` as well.
*/
await import(pathToFileURL(distEntry).href);

View File

@@ -121,5 +121,8 @@
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion"
},
"engines": {
"node": ">=22.4.0"
}
}

View File

@@ -257,6 +257,15 @@ describe("Merge gate (.github/workflows/pr-checks.yml)", () => {
expect(engineVitestConfigContent).toContain('name: "engine-core"');
});
/*
* FNXC:CliRuntimeContract 2026-08-11-09:30:
* The modern Node 24 default must remain above the CLI's 22.4 engine floor;
* FN-8954's gap was missing init coverage, not an outdated CI runtime.
*/
it("pins the composite action to the modern Node 24 default", () => {
expect(compositeAction.inputs?.["node-version"]?.default).toBe("24");
});
it("pins dependency bootstrap to frozen lockfile in every job", () => {
for (const jobName of ["lint", "typecheck", "build", "gate"]) {
expect(findCompositeSetupStep(workflow.jobs?.[jobName]?.steps ?? [])).toBeDefined();

View File

@@ -0,0 +1,82 @@
import { afterAll, describe, expect, it } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
const workspaceRoot = join(import.meta.dirname, "..", "..", "..", "..");
const cliBin = join(workspaceRoot, "packages", "cli", "bin.mjs");
const builtEntry = join(workspaceRoot, "packages", "cli", "dist", "bin.js");
const tempDirs: string[] = [];
const isolatedHome = mkdtempSync(join(tmpdir(), "fn-cli-exit-home-"));
tempDirs.push(isolatedHome);
function runCli(args: string[]) {
const {
DATABASE_URL: _databaseUrl,
FUSION_NO_EMBEDDED_PG: _noEmbeddedPg,
NODE_ENV: _nodeEnv,
VITEST: _vitest,
...env
} = process.env;
const result = spawnSync(process.execPath, [cliBin, ...args], {
cwd: workspaceRoot,
env: {
...env,
HOME: isolatedHome,
/*
* FNXC:CliAwaitLiveness 2026-08-11-09:30:
* The subprocess must be production-shaped: inherited Vitest markers reject
* implicit global config directories before CentralCore can initialize.
*/
NODE_ENV: "production",
VITEST: undefined,
},
encoding: "utf8",
timeout: 180_000,
});
expect(result.error, `CLI timed out or could not start: ${result.stderr}`).toBeUndefined();
expect(result.stderr).not.toMatch(/Detected unsettled top-level await/);
return result;
}
function expectSuccessfulExit(result: ReturnType<typeof runCli>): void {
expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0);
}
afterAll(() => {
for (const path of tempDirs) rmSync(path, { recursive: true, force: true });
});
/*
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
* This uses the built launcher in a real isolated process because Node's exit-13
* unsettled-top-level-await path cannot be reproduced by mocked in-process init.
* One HOME shares the cold embedded-PostgreSQL initialization across every surface.
*/
describe.skipIf(!existsSync(builtEntry))("built CLI completion", () => {
const projectDir = mkdtempSync(join(tmpdir(), "fn-cli-exit-project-"));
tempDirs.push(projectDir);
it("keeps the help fast path successful", () => {
expectSuccessfulExit(runCli(["--help"]));
}, 180_000);
it("completes init, persists the project marker, and does not exit 13", () => {
const result = runCli(["init", "--name", "exit-code-repro", "--path", projectDir]);
expectSuccessfulExit(result);
expect(result.status).not.toBe(13);
const identityPath = join(projectDir, ".fusion", "project.json");
expect(existsSync(identityPath)).toBe(true);
expect(JSON.parse(readFileSync(identityPath, "utf8"))).toMatchObject({ id: expect.any(String) });
}, 180_000);
it("lets project list and idempotent re-init complete with the marker preserved", () => {
expectSuccessfulExit(runCli(["project", "list"]));
expectSuccessfulExit(runCli(["init", "--name", "exit-code-repro", "--path", projectDir]));
expect(JSON.parse(readFileSync(join(projectDir, ".fusion", "project.json"), "utf8"))).toMatchObject({
id: expect.any(String),
});
}, 180_000);
});

View File

@@ -93,6 +93,18 @@ describe("CLI package.json publishing config", () => {
const pkg = loadPackageJson("cli");
const prepackScript = loadCliPrepackScript();
/*
* FNXC:CliRuntimeContract 2026-08-11-09:30:
* Node 22.4 is the supported floor because the CLI uses import attributes and
* `node:fs/promises` glob; declaring it prevents unsupported runtimes from
* silently reaching the Node 22.4+ exit-13 liveness path FN-8954 repaired.
*/
it("declares the supported Node runtime in both manifests", () => {
const rootPkg = loadRootPackageJson();
expect(pkg.engines?.node).toBe(">=22.4.0");
expect(rootPkg.engines?.node).toBe(pkg.engines.node);
});
it('has "bin" field with fn/fusion pointing to committed launcher', () => {
expect(pkg.bin).toBeDefined();
expect(pkg.bin.fn).toBe("./bin.mjs");

View File

@@ -2362,6 +2362,12 @@ async function main() {
}
}
/*
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
* Preserve this await and the skip-main build/test guard. A forced success exit
* would mask a non-settling command promise and could terminate long-running
* CLI modes before their intended shutdown path completes.
*/
if (process.env.FUSION_CLI_SKIP_MAIN !== "1") {
await main();
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { Server } from "node:net";
import { findFreePort } from "../../postgres/embedded-lifecycle.js";
describe("findFreePort", () => {
it("resolves to a positive ephemeral TCP port", async () => {
await expect(findFreePort()).resolves.toSatisfy(
(port: unknown) => typeof port === "number" && Number.isInteger(port) && port > 0,
);
});
it("keeps the listener ref'd until the listen/close promise settles", async () => {
let unrefCalls = 0;
let closeCalls = 0;
const fakeServer = {
on: () => fakeServer,
listen: (_port: number, _host: string, onListening: () => void) => {
onListening();
return fakeServer;
},
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 43123 }),
close: (callback?: () => void) => {
closeCalls += 1;
callback?.();
return fakeServer;
},
unref: () => {
unrefCalls += 1;
return fakeServer;
},
};
await expect(findFreePort(() => fakeServer as unknown as Server)).resolves.toBe(43123);
expect(closeCalls).toBe(1);
expect(unrefCalls).toBe(0);
});
});

View File

@@ -30,7 +30,7 @@ const QMD_COLLECTION_PREFIX = "fusion-memory";
type ExecFileAsync = (
file: string,
args: readonly string[],
options?: { cwd?: string; timeout?: number; maxBuffer?: number },
options?: { cwd?: string; timeout?: number; maxBuffer?: number; keepAlive?: boolean },
) => Promise<{ stdout: string; stderr: string }>;
const qmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
@@ -1120,10 +1120,16 @@ async function getDefaultExecFileAsync(): Promise<ExecFileAsync> {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
// FNXC:ProjectMemory 2026-07-08-00:00: unref synchronously right after spawn —
// see the doc comment above this function for why this must NOT go through
// promisify(execFile).
unrefQmdChildProcess(child);
/*
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
* Background QMD refreshes must unref their child, but an awaited availability
* probe must retain its child and stdio until close/error settles. Otherwise a
* missing `qmd` leaves `fn init`'s top-level await pending with an empty loop,
* so Node exits 13 before durable project registration.
*/
if (!options?.keepAlive) {
unrefQmdChildProcess(child);
}
let stdout = "";
let stderr = "";
@@ -1305,6 +1311,7 @@ export async function isQmdAvailable(): Promise<boolean> {
await execFileAsync("qmd", ["--help"], {
timeout: 3000,
maxBuffer: 128 * 1024,
keepAlive: true,
});
return true;
} catch {

View File

@@ -1230,11 +1230,19 @@ async function isAlreadyRunning(
* is tiny. For the zero-config default this is acceptable; callers needing a
* fixed port can pass `options.port`.
*/
function findFreePort(): Promise<number> {
/*
* FNXC:CliAwaitLiveness 2026-08-11-09:17:
* Keep this temporary listener ref'd until its listen/close promise settles.
* An unref'd sole handle lets Node drain the event loop beneath a pending top-level
* await, terminating `fn init` with exit 13 before project registration. Exporting
* the helper provides the direct test seam for this listener-liveness contract.
*/
export function findFreePort(createServerFn: () => Server = createServer): Promise<number> {
return new Promise((resolve, reject) => {
const srv: Server = createServer();
srv.unref();
srv.on("error", reject);
const srv = createServerFn();
srv.on("error", (error) => {
srv.close(() => reject(error));
});
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (addr && typeof addr === "object") {

View File

@@ -29,7 +29,7 @@
*/
import { spawn, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -188,6 +188,50 @@ async function bootAndVerify(attempt, registerCleanup) {
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,
};
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}`,
);
}
if (!existsSync(path.join(isolatedProject, ".fusion", "project.json"))) {
fail("`fn init` did not write .fusion/project.json", initOutput);
}
console.log("boot-smoke: `fn init` OK");
const child = spawn(
process.execPath,
@@ -203,21 +247,7 @@ async function bootAndVerify(attempt, registerCleanup) {
],
{
cwd: isolatedProject,
env: {
...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,
},
env: childEnv,
stdio: ["ignore", "pipe", "pipe"],
},
);