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:
7
.changeset/fn-7706-qmd-unref.md
Normal file
7
.changeset/fn-7706-qmd-unref.md
Normal 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.
|
||||
28
packages/core/src/__tests__/fixtures/qmd-refresh-fixture.mjs
Normal file
28
packages/core/src/__tests__/fixtures/qmd-refresh-fixture.mjs
Normal 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");
|
||||
136
packages/core/src/__tests__/qmd-refresh-unref.test.ts
Normal file
136
packages/core/src/__tests__/qmd-refresh-unref.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user