diff --git a/.changeset/fn-7706-qmd-unref.md b/.changeset/fn-7706-qmd-unref.md new file mode 100644 index 0000000000..cdcd6cf84c --- /dev/null +++ b/.changeset/fn-7706-qmd-unref.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Background memory-index refresh no longer keeps short-lived CLI/Node processes alive. +category: fix +dev: The default qmd exec path in `packages/core/src/memory-backend.ts` now unrefs the spawned child + stdio (replacing `promisify(execFile)`, whose internal stream buffering silently re-refs the pipes on a deferred tick, with a hand-rolled `spawn()`-based executor) so a fire-and-forget `scheduleQmd*` refresh never blocks a caller's event loop from draining; long-lived callers (e.g. the dashboard server) still see the refresh resolve/reject normally. diff --git a/packages/core/src/__tests__/fixtures/qmd-refresh-fixture.mjs b/packages/core/src/__tests__/fixtures/qmd-refresh-fixture.mjs new file mode 100644 index 0000000000..c2f1180c87 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/qmd-refresh-fixture.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * FNXC:ProjectMemory 2026-07-08-00:00: + * Symptom-verification fixture for FN-7706. Fires a background qmd project-memory + * refresh (scheduleQmdProjectMemoryRefresh, fire-and-forget) against whatever `qmd` + * resolves on PATH, then returns from main immediately. If the default exec path + * does not unref its spawned child + stdio, this process stays alive until the + * background `qmd` child exits (or is force-killed) instead of exiting on its own + * once this script's own work is done. Loaded via `tsx` so it can import the real + * TypeScript source directly (no separate build step required for the test). + */ +import { scheduleQmdProjectMemoryRefresh, scheduleQmdAgentMemoryRefresh } from "../../memory-backend.ts"; + +const rootDir = process.argv[2]; +const mode = process.argv[3] ?? "project"; +if (!rootDir) { + throw new Error("qmd-refresh-fixture: missing rootDir argument"); +} + +if (mode === "agent") { + scheduleQmdAgentMemoryRefresh(rootDir, "fn-7706-fixture-agent"); +} else { + scheduleQmdProjectMemoryRefresh(rootDir); +} + +// Print a marker so the test can confirm the fixture actually reached this point +// (i.e. the schedule call itself did not throw synchronously) before exiting. +console.log("qmd-refresh-fixture:scheduled"); diff --git a/packages/core/src/__tests__/qmd-refresh-unref.test.ts b/packages/core/src/__tests__/qmd-refresh-unref.test.ts new file mode 100644 index 0000000000..1266c47fca --- /dev/null +++ b/packages/core/src/__tests__/qmd-refresh-unref.test.ts @@ -0,0 +1,136 @@ +/** + * FNXC:ProjectMemory 2026-07-08-00:00: + * Regression coverage for FN-7706: the background qmd child spawned by the default + * (real) exec path in memory-backend.ts must be unref'd (child + stdio) so a + * short-lived caller can exit promptly, while a long-lived caller that stays alive + * anyway still sees the refresh complete. Two layers: + * 1. A fast unit test on the extracted `unrefQmdChildProcess` helper (no real spawn). + * 2. An end-to-end symptom test: a fixture Node process fires a background refresh + * against a slow fake `qmd` stub on PATH and must exit well before the stub does. + */ +import { describe, it, expect, afterEach } from "vitest"; +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +import { unrefQmdChildProcess } from "../memory-backend.js"; + +const tsxPackageJsonPath = createRequire(import.meta.url).resolve("tsx/package.json"); +const tsxCliPath = join(tsxPackageJsonPath, "..", "dist", "cli.mjs"); + +describe("unrefQmdChildProcess (unit)", () => { + it("unrefs the child process and its stdout/stderr/stdin pipes", () => { + const calls: string[] = []; + const fakeChild = { + unref: () => calls.push("child"), + stdout: { unref: () => calls.push("stdout") }, + stderr: { unref: () => calls.push("stderr") }, + stdin: { unref: () => calls.push("stdin") }, + }; + + unrefQmdChildProcess(fakeChild); + + expect(calls.sort()).toEqual(["child", "stderr", "stdin", "stdout"]); + }); + + it("tolerates a missing child or missing stdio streams without throwing", () => { + expect(() => unrefQmdChildProcess(undefined)).not.toThrow(); + expect(() => unrefQmdChildProcess(null)).not.toThrow(); + expect(() => unrefQmdChildProcess({})).not.toThrow(); + expect(() => + unrefQmdChildProcess({ unref: () => {}, stdout: null, stderr: null, stdin: null }), + ).not.toThrow(); + }); +}); + +describe("qmd background refresh does not keep a short-lived caller alive (symptom)", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function writeSlowQmdStub(stubDir: string): void { + // Fake `qmd`: resolves instantly for "collection add", but sleeps well past our + // exit-bound assertion for "update"/"embed" — this models the real symptom + // (long-sleeping qmd child) without a real multi-second wait dominating the test + // budget on the assertion side; only the *stub* sleeps long, the *test* just + // measures how fast the fixture process exits. + const stubPath = join(stubDir, "qmd"); + writeFileSync( + stubPath, + [ + "#!/usr/bin/env bash", + 'case "$1" in', + " update|embed)", + " sleep 8", + " ;;", + "esac", + "exit 0", + "", + ].join("\n"), + "utf8", + ); + chmodSync(stubPath, 0o755); + } + + async function runFixture(mode: "project" | "agent", rootDir: string, stubDir: string) { + const fixturePath = join(import.meta.dirname, "fixtures", "qmd-refresh-fixture.mjs"); + const startedAt = Date.now(); + return new Promise<{ code: number | null; elapsedMs: number; stdout: string }>((resolvePromise, reject) => { + let stdout = ""; + const child = spawn(process.execPath, [tsxCliPath, fixturePath, rootDir, mode], { + env: { + ...process.env, + PATH: `${stubDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, + FUSION_ENABLE_QMD_REFRESH_IN_TESTS: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.on("error", reject); + child.on("exit", (code) => { + resolvePromise({ code, elapsedMs: Date.now() - startedAt, stdout }); + }); + }); + } + + it("project refresh: fixture process exits promptly even though the background qmd stub is still sleeping", async () => { + const stubDir = mkdtempSync(join(tmpdir(), "fn-7706-qmd-stub-")); + tempDirs.push(stubDir); + const rootDir = mkdtempSync(join(tmpdir(), "fn-7706-qmd-root-")); + tempDirs.push(rootDir); + writeSlowQmdStub(stubDir); + + const exitInfo = await runFixture("project", rootDir, stubDir); + + expect(exitInfo.stdout).toContain("qmd-refresh-fixture:scheduled"); + expect(exitInfo.code).toBe(0); + // The fake qmd sleeps 8s on "update"/"embed"; the fixture process must exit + // well before that, proving the background child + stdio were unref'd rather + // than holding the fixture's event loop open for the qmd child's full runtime. + expect(exitInfo.elapsedMs).toBeLessThan(5_000); + }, 15_000); + + it("agent refresh: fixture process exits promptly even though the background qmd stub is still sleeping", async () => { + const stubDir = mkdtempSync(join(tmpdir(), "fn-7706-qmd-agent-stub-")); + tempDirs.push(stubDir); + const rootDir = mkdtempSync(join(tmpdir(), "fn-7706-qmd-agent-root-")); + tempDirs.push(rootDir); + writeSlowQmdStub(stubDir); + + const exitInfo = await runFixture("agent", rootDir, stubDir); + + expect(exitInfo.stdout).toContain("qmd-refresh-fixture:scheduled"); + expect(exitInfo.code).toBe(0); + // refreshQmdAgentMemoryIndex routes through the same default executor as the + // project path; this proves the agent surface inherits the unref fix too. + expect(exitInfo.elapsedMs).toBeLessThan(5_000); + }, 15_000); +}); diff --git a/packages/core/src/memory-backend.ts b/packages/core/src/memory-backend.ts index f7710991d7..49a60e9f45 100644 --- a/packages/core/src/memory-backend.ts +++ b/packages/core/src/memory-backend.ts @@ -1048,10 +1048,127 @@ async function ensureQmdProjectMemoryCollection( return collectionName; } +/** + * FNXC:ProjectMemory 2026-07-08-00:00: + * Fire-and-forget background qmd refresh (scheduleQmdProjectMemoryRefresh / + * scheduleQmdAgentMemoryRefresh / scheduleQmdInstallAndRefresh) must never hold a + * short-lived caller's event loop open. A repro showed 5 Socket + 1 ChildProcess + * (`spawnfile: qmd`) handles persisting for the child's full 15-30s runtime, keeping + * a short-lived Node process (e.g. `fn agent stop `) alive well after its own + * work finished. + * + * `promisify(execFile)` looks like the obvious fix (its returned promise exposes a + * `.child` ChildProcess we can `.unref()`), but it does NOT stick: execFile's + * internal stdout/stderr accumulation calls `stream.resume()`, which defers the + * actual `readStart()` (and an internal re-ref of the pipe handle) to a later + * `process.nextTick` tick — AFTER our synchronous `unref()` call runs — so the + * handle silently becomes ref'd again and the process hangs for the child's full + * runtime regardless of calling `.unref()`. Verified via `process._getActiveHandles()` + * repro: unref immediately after spawn briefly shows zero active handles, yet the + * process still doesn't exit until the child does. + * + * The reliable fix is to bypass `execFile`'s internal buffering entirely and drive a + * plain `spawn()` child ourselves: unref the child + its stdio synchronously right + * after spawn (nothing internal to execFile re-refs them later), and accumulate + * stdout/stderr via our own `data` listeners. This preserves the `{stdout, stderr}` + * resolve shape and reject-on-nonzero-exit / reject-on-spawn-error behavior that + * callers (e.g. `ensureQmdProjectMemoryCollection`'s "already exists" stderr check) + * depend on, so a long-lived caller that stays alive anyway still sees the refresh + * resolve/reject normally and complete. This lives only in the DEFAULT real executor; + * injected mock `execFileAsync` implementations (used by tests) are untouched. + */ +export function unrefQmdChildProcess(childProcessLike: unknown): void { + if (!childProcessLike || typeof childProcessLike !== "object") { + return; + } + const child = childProcessLike as { + unref?: () => void; + stdout?: { unref?: () => void } | null; + stderr?: { unref?: () => void } | null; + stdin?: { unref?: () => void } | null; + }; + child.unref?.(); + child.stdout?.unref?.(); + child.stderr?.unref?.(); + child.stdin?.unref?.(); +} + +interface QmdExecError extends Error { + code?: number | null; + signal?: NodeJS.Signals | null; + stdout?: string; + stderr?: string; +} + async function getDefaultExecFileAsync(): Promise { - const { execFile } = await import("node:child_process"); - const { promisify } = await import("node:util"); - return promisify(execFile); + const { spawn } = await import("node:child_process"); + + return (file, args, options) => + new Promise<{ stdout: string; stderr: string }>((resolvePromise, reject) => { + const child = spawn(file, args as string[], { + cwd: options?.cwd, + 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); + + let stdout = ""; + let stderr = ""; + let settled = false; + let killedForMaxBuffer = false; + const maxBuffer = options?.maxBuffer; + + const timeoutHandle = options?.timeout + ? setTimeout(() => { + child.kill("SIGTERM"); + }, options.timeout) + : undefined; + timeoutHandle?.unref?.(); + + const checkMaxBuffer = () => { + if (maxBuffer && !killedForMaxBuffer && (stdout.length > maxBuffer || stderr.length > maxBuffer)) { + killedForMaxBuffer = true; + child.kill(); + } + }; + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + checkMaxBuffer(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + checkMaxBuffer(); + }); + + child.on("error", (err) => { + if (settled) return; + settled = true; + if (timeoutHandle) clearTimeout(timeoutHandle); + reject(err); + }); + + child.on("close", (code, signal) => { + if (settled) return; + settled = true; + if (timeoutHandle) clearTimeout(timeoutHandle); + if (code === 0) { + resolvePromise({ stdout, stderr }); + return; + } + const error: QmdExecError = new Error( + `Command failed: ${file} ${args.join(" ")}\n${stderr || stdout}`, + ); + error.code = code; + error.signal = signal; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + }); + }); } export async function refreshQmdProjectMemoryIndex(