From 25e292d0e6d3b6b730372da612b4208eedbd4c8c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 11 Aug 2026 04:20:33 -0700 Subject: [PATCH] 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) --- .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 + .../cli/src/__tests__/ci-workflow.test.ts | 9 ++ .../cli/src/__tests__/cli-exit-code.test.ts | 82 +++++++++++++++++++ .../cli/src/__tests__/package-config.test.ts | 12 +++ packages/cli/src/bin.ts | 6 ++ .../postgres/embedded-free-port.test.ts | 37 +++++++++ packages/core/src/memory/memory-backend.ts | 17 ++-- .../core/src/postgres/embedded-lifecycle.ts | 16 +++- scripts/boot-smoke.mjs | 62 ++++++++++---- 14 files changed, 244 insertions(+), 27 deletions(-) create mode 100644 .changeset/fn-8954-cli-exit-13.md create mode 100644 packages/cli/src/__tests__/cli-exit-code.test.ts create mode 100644 packages/core/src/__tests__/postgres/embedded-free-port.test.ts diff --git a/.changeset/fn-8954-cli-exit-13.md b/.changeset/fn-8954-cli-exit-13.md new file mode 100644 index 0000000000..b93d5498b4 --- /dev/null +++ b/.changeset/fn-8954-cli-exit-13.md @@ -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. diff --git a/docs/testing.md b/docs/testing.md index 66650798d8..4e240c7390 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/package.json b/package.json index ec8cc89ed3..0463f9ae60 100644 --- a/package.json +++ b/package.json @@ -130,5 +130,8 @@ "typescript": "^5.7.0", "typescript-eslint": "^8.66.0", "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22.4.0" } } diff --git a/packages/cli/agent-browser.mjs b/packages/cli/agent-browser.mjs index 09bccb9c84..e7e8e4c548 100755 --- a/packages/cli/agent-browser.mjs +++ b/packages/cli/agent-browser.mjs @@ -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"); diff --git a/packages/cli/bin.mjs b/packages/cli/bin.mjs index 54bbb24d6d..b31ac404c3 100755 --- a/packages/cli/bin.mjs +++ b/packages/cli/bin.mjs @@ -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); diff --git a/packages/cli/package.json b/packages/cli/package.json index 7992bfe2d5..3f1b244afc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -121,5 +121,8 @@ "repository": { "type": "git", "url": "https://github.com/Runfusion/Fusion" + }, + "engines": { + "node": ">=22.4.0" } } diff --git a/packages/cli/src/__tests__/ci-workflow.test.ts b/packages/cli/src/__tests__/ci-workflow.test.ts index 105de8c8a1..ba7edbac1f 100644 --- a/packages/cli/src/__tests__/ci-workflow.test.ts +++ b/packages/cli/src/__tests__/ci-workflow.test.ts @@ -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(); diff --git a/packages/cli/src/__tests__/cli-exit-code.test.ts b/packages/cli/src/__tests__/cli-exit-code.test.ts new file mode 100644 index 0000000000..cd078e9d77 --- /dev/null +++ b/packages/cli/src/__tests__/cli-exit-code.test.ts @@ -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): 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); +}); diff --git a/packages/cli/src/__tests__/package-config.test.ts b/packages/cli/src/__tests__/package-config.test.ts index 45c94d8a4d..d5a18d473f 100644 --- a/packages/cli/src/__tests__/package-config.test.ts +++ b/packages/cli/src/__tests__/package-config.test.ts @@ -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"); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 5a945c14b3..4ff2b6d731 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -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(); } diff --git a/packages/core/src/__tests__/postgres/embedded-free-port.test.ts b/packages/core/src/__tests__/postgres/embedded-free-port.test.ts new file mode 100644 index 0000000000..49e460b6f4 --- /dev/null +++ b/packages/core/src/__tests__/postgres/embedded-free-port.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/memory/memory-backend.ts b/packages/core/src/memory/memory-backend.ts index 739be28012..66d42b966b 100644 --- a/packages/core/src/memory/memory-backend.ts +++ b/packages/core/src/memory/memory-backend.ts @@ -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 }>(); @@ -1120,10 +1120,16 @@ async function getDefaultExecFileAsync(): Promise { 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 { await execFileAsync("qmd", ["--help"], { timeout: 3000, maxBuffer: 128 * 1024, + keepAlive: true, }); return true; } catch { diff --git a/packages/core/src/postgres/embedded-lifecycle.ts b/packages/core/src/postgres/embedded-lifecycle.ts index 630af05ab5..4c73e3725d 100644 --- a/packages/core/src/postgres/embedded-lifecycle.ts +++ b/packages/core/src/postgres/embedded-lifecycle.ts @@ -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 { +/* + * 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 { 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") { diff --git a/scripts/boot-smoke.mjs b/scripts/boot-smoke.mjs index c4c6f4f829..2025d812a4 100644 --- a/scripts/boot-smoke.mjs +++ b/scripts/boot-smoke.mjs @@ -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"], }, );