FN-7706: unref qmd child process so background refresh doesn't block exit

Replaces promisify(execFile) with a hand-rolled spawn()-based qmd executor that unrefs the child process and its stdio, so a fire-and-forget scheduleQmd* memory-index refresh never keeps a short-lived caller process (e.g. CLI) alive; long-lived callers like the dashboard server still see refresh resolve/reject normally.

- memory-backend.ts: replace promisify(execFile) qmd exec path with spawn()-based executor that unrefs child + stdio
- Add qmd-refresh-unref.test.ts covering unref behavior with a qmd-refresh-fixture.mjs test fixture
- Add changeset fn-7706-qmd-unref.md (patch, fix category)

Files changed:
 .changeset/fn-7706-qmd-unref.md                    |   7 ++
 .../src/__tests__/fixtures/qmd-refresh-fixture.mjs |  28 +++++
 .../core/src/__tests__/qmd-refresh-unref.test.ts   | 136 +++++++++++++++++++++
 packages/core/src/memory-backend.ts                | 123 ++++++++++++++++++-
 4 files changed, 291 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7706

Fusion-Task-Lineage: 713c23c2-d7da-42c4-b066-31883ba78321

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 20:21:27 -07:00
parent 081dae0e0f
commit 4fb2bf5c55
4 changed files with 291 additions and 3 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -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);
});

View File

@@ -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 <id>`) 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<ExecFileAsync> {
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(