FN-7707: reuse hardened unref'd executor in searchWithQmd
Fixes searchWithQmd's inline promisify(execFile) copy that could hold a caller open by reusing the already-hardened, synchronously-unref'd executor established for the background refresh path. - searchWithQmd now calls getDefaultExecFileAsync() instead of building its own promisify(execFile) executor inline - Removes the second un-unref'd execFile executor that could keep a short-lived caller (e.g. one-shot CLI memory search) open up to the awaited timeout - Adds regression test fixture and test coverage (qmd-search-fixture.mjs, qmd-search-unref.test.ts) asserting the shared executor is used - Adds changeset (patch) for @runfusion/fusion Files changed: .changeset/fn-7707-qmd-search-unref.md | 7 + packages/core/src/__tests__/fixtures/qmd-search-fixture.mjs | 30 ++++ packages/core/src/__tests__/qmd-search-unref.test.ts | 166 +++++++++++++++++++++ packages/core/src/memory-backend.ts | 16 +- 4 files changed, 216 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7707 Fusion-Task-Lineage: 3f9f94a7-5613-4b9a-a3ba-8e9bcdd6b687 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7707-qmd-search-unref.md
Normal file
7
.changeset/fn-7707-qmd-search-unref.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: qmd-backed project memory search no longer keeps short-lived CLI/Node processes alive.
|
||||
category: fix
|
||||
dev: `searchWithQmd` in `packages/core/src/memory-backend.ts` no longer carries its own inline `promisify(execFile)` copy for the awaited `qmd collection add` / `qmd search` calls; it now routes through the FN-7706-hardened `getDefaultExecFileAsync()` spawn-based executor, which unrefs the child + stdio synchronously so a short-lived caller invoking a search is not held open by a slow/hung qmd child beyond its own work, while preserving the same `{stdout, stderr}` resolve / reject-on-nonzero-exit contract the search's JSON parsing depends on.
|
||||
30
packages/core/src/__tests__/fixtures/qmd-search-fixture.mjs
Normal file
30
packages/core/src/__tests__/fixtures/qmd-search-fixture.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* FNXC:ProjectMemory 2026-07-08-00:00:
|
||||
* Symptom-verification fixture for FN-7707. Invokes an AWAITED qmd project-memory
|
||||
* search (QmdMemoryBackend.search -> searchWithQmd) against whatever `qmd` resolves
|
||||
* on PATH. Prints a "started" marker BEFORE awaiting so the test can tell the
|
||||
* fixture actually reached the search call. Prints a "searched" marker AFTER the
|
||||
* await settles — but that marker is best-effort only: once searchWithQmd routes
|
||||
* through the unref'd default executor, a fixture process with nothing else to do
|
||||
* can legitimately exit before a still-hanging qmd child's promise ever settles, so
|
||||
* the test must not require "searched" to prove the fix. If the qmd child + its
|
||||
* stdio pipes are not unref'd, this process instead stays alive for the child's
|
||||
* full runtime (its pending await only settles once the child truly exits), well
|
||||
* past the strict exit-bound the test asserts. Loaded via `tsx` so it can import
|
||||
* the real TypeScript source directly (no separate build step required).
|
||||
*/
|
||||
import { QmdMemoryBackend } from "../../memory-backend.ts";
|
||||
|
||||
const rootDir = process.argv[2];
|
||||
if (!rootDir) {
|
||||
throw new Error("qmd-search-fixture: missing rootDir argument");
|
||||
}
|
||||
|
||||
console.log("qmd-search-fixture:started");
|
||||
|
||||
const backend = new QmdMemoryBackend();
|
||||
await backend.search(rootDir, { query: "fn-7707-fixture-query", limit: 5 });
|
||||
|
||||
// Best-effort marker — see file header. Not required by the test.
|
||||
console.log("qmd-search-fixture:searched");
|
||||
166
packages/core/src/__tests__/qmd-search-unref.test.ts
Normal file
166
packages/core/src/__tests__/qmd-search-unref.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* FNXC:ProjectMemory 2026-07-08-00:00:
|
||||
* Regression coverage for FN-7707: the AWAITED `searchWithQmd` search child spawned
|
||||
* by memory-backend.ts must route through the FN-7706-hardened, unref'd default
|
||||
* executor (getDefaultExecFileAsync) instead of carrying its own inline
|
||||
* `promisify(execFile)` copy, so a short-lived caller invoking a project-memory
|
||||
* search never gets held open by the qmd child's stdio pipes beyond its own actual
|
||||
* work. Two layers:
|
||||
* 1. A unit test asserting `searchWithQmd` routes both the collection-add and the
|
||||
* search call through the default executor (no private promisify(execFile)).
|
||||
* 2. An end-to-end symptom test: a fixture Node process awaits a search against a
|
||||
* slow, SIGTERM-ignoring fake `qmd` stub on PATH and must still exit well
|
||||
* before the stub's own runtime completes.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const tsxPackageJsonPath = createRequire(import.meta.url).resolve("tsx/package.json");
|
||||
const tsxCliPath = join(tsxPackageJsonPath, "..", "dist", "cli.mjs");
|
||||
|
||||
describe("searchWithQmd routes through the hardened default executor (unit)", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("calls the default (spawn-based) executor for both collection-add and search, never a private promisify(execFile)", async () => {
|
||||
vi.stubEnv("FUSION_ENABLE_QMD_REFRESH_IN_TESTS", "1");
|
||||
vi.resetModules();
|
||||
|
||||
const spawnCalls: Array<{ file: string; args: string[] }> = [];
|
||||
vi.doMock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: (file: string, args: string[], options: unknown) => {
|
||||
spawnCalls.push({ file, args });
|
||||
const lastArg = Array.isArray(args) ? args[0] : undefined;
|
||||
// Fake a fast-closing child for both "collection"/"add" and "search".
|
||||
const fakeChild = actual.spawn(
|
||||
process.execPath,
|
||||
["-e", lastArg === "search" ? "process.stdout.write('[]')" : ""],
|
||||
options as Record<string, unknown>,
|
||||
);
|
||||
return fakeChild;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "fn-7707-unit-root-"));
|
||||
tempDirs.push(rootDir);
|
||||
mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true });
|
||||
|
||||
const { QmdMemoryBackend } = await import("../memory-backend.js");
|
||||
const backend = new QmdMemoryBackend();
|
||||
const results = await backend.search(rootDir, { query: "unit-test-query", limit: 5 });
|
||||
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
// Both the collection-add and the qmd search calls must go through the mocked
|
||||
// `spawn` (the default executor's underlying primitive) — proving searchWithQmd
|
||||
// no longer constructs its own private `promisify(execFile)` copy, which would
|
||||
// bypass this mock entirely and use the real un-unref'd execFile path instead.
|
||||
const collectionAddCalls = spawnCalls.filter((call) => call.args[0] === "collection" && call.args[1] === "add");
|
||||
const searchCalls = spawnCalls.filter((call) => call.args[0] === "search");
|
||||
expect(collectionAddCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(searchCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
vi.doUnmock("node:child_process");
|
||||
});
|
||||
});
|
||||
|
||||
describe("qmd search 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 writeStubbornSlowQmdStub(stubDir: string): void {
|
||||
// Fake `qmd`: "collection add" (and anything else) responds instantly.
|
||||
// "search" traps and ignores SIGTERM, then sleeps ~8s before responding — this
|
||||
// models a qmd child that keeps running past searchWithQmd's own 4s internal
|
||||
// timeout kill attempt, so only a properly unref'd child+stdio (not a merely
|
||||
// "timed-out" JS promise) lets the caller process exit promptly.
|
||||
const stubPath = join(stubDir, "qmd");
|
||||
writeFileSync(
|
||||
stubPath,
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"trap '' TERM",
|
||||
'case "$1" in',
|
||||
" search)",
|
||||
" sleep 8",
|
||||
" echo '[]'",
|
||||
" ;;",
|
||||
" *)",
|
||||
" exit 0",
|
||||
" ;;",
|
||||
"esac",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
chmodSync(stubPath, 0o755);
|
||||
}
|
||||
|
||||
async function runFixture(rootDir: string, stubDir: string) {
|
||||
const fixturePath = join(import.meta.dirname, "fixtures", "qmd-search-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], {
|
||||
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("search: fixture process exits promptly even though the search's qmd child ignores the internal timeout kill and keeps sleeping", async () => {
|
||||
const stubDir = mkdtempSync(join(tmpdir(), "fn-7707-qmd-stub-"));
|
||||
tempDirs.push(stubDir);
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "fn-7707-qmd-root-"));
|
||||
tempDirs.push(rootDir);
|
||||
writeStubbornSlowQmdStub(stubDir);
|
||||
|
||||
const exitInfo = await runFixture(rootDir, stubDir);
|
||||
|
||||
expect(exitInfo.stdout).toContain("qmd-search-fixture:started");
|
||||
// Once the search's child + stdio are properly unref'd, nothing else keeps the
|
||||
// fixture's event loop alive, so Node exits promptly with the still-pending
|
||||
// top-level `await backend.search(...)` abandoned — Node reports this as exit
|
||||
// code 13 ("unsettled top-level await"), which is expected/desired here: it is
|
||||
// direct proof the process did NOT wait for the SIGTERM-ignoring qmd child.
|
||||
// What matters is that the process exits at all (is not null/hung) and does so
|
||||
// well before the stub's 8s sleep completes.
|
||||
expect(exitInfo.code).not.toBeNull();
|
||||
// The stub ignores SIGTERM and only responds to "search" after an 8s sleep; the
|
||||
// fixture process must exit well before that, proving the search's child +
|
||||
// stdio were unref'd rather than holding the fixture's event loop open for the
|
||||
// child's full runtime after its own work (spawning + collection-add) is done.
|
||||
expect(exitInfo.elapsedMs).toBeLessThan(5_000);
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -988,9 +988,19 @@ async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Pro
|
||||
const command = "qmd";
|
||||
const limit = Math.max(1, Math.min(options.limit ?? 5, 20));
|
||||
try {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
// FNXC:ProjectMemory 2026-07-08-00:00:
|
||||
// searchWithQmd used to carry its own inline `promisify(execFile)` copy — a
|
||||
// second, un-unref'd executor separate from the hardened default below — to
|
||||
// spawn `qmd collection add` and `qmd search`. Even though the search is
|
||||
// awaited (unlike the fire-and-forget background refresh fixed by FN-7706), a
|
||||
// short-lived caller (e.g. a one-shot CLI memory search) could be held open
|
||||
// for up to the 4s awaited timeout: per the documented nextTick re-ref pitfall
|
||||
// (see getDefaultExecFileAsync's doc comment / .fusion/memory/MEMORY.md),
|
||||
// `promisify(execFile)`'s internal stdout/stderr buffering re-refs the pipe
|
||||
// handles on a later tick, so even a manual `.unref()` wouldn't have stuck.
|
||||
// Reuse the same hardened, spawn-based, synchronously-unref'd executor FN-7706
|
||||
// established for the refresh path instead of carrying a second leaky copy.
|
||||
const execFileAsync = await getDefaultExecFileAsync();
|
||||
await ensureQmdProjectMemoryCollection(rootDir, execFileAsync);
|
||||
scheduleQmdProjectMemoryRefresh(rootDir);
|
||||
const args = buildQmdSearchArgs(rootDir, options);
|
||||
|
||||
Reference in New Issue
Block a user