fix(memory): abort orphaned qmd subprocess on the mcporter search path too

The initial fix threaded the abort signal through the direct qmd
(runQmd/runQmdSearch) path, but the mcporter / QMD 1.1+ daemon search path
(runQmdSearchViaMcporter, runMcporterAcrossCollections) never received it, so
a grouped/mcporter search left its subprocess running on abort.

Thread the search signal through QmdMcporterSearchParams,
QmdMcporterAcrossCollectionsParams, all four mcporter call sites in search(),
and runMcporter, down to the shared runCliCommand spawn (which already
SIGKILLs the child on abort). Guard runQmdSearchViaMcporter on an
already-aborted signal so the multi-collection loop stops spawning. Reuses the
existing abort mechanism; no new machinery. Adds mcporter-path regression tests.
This commit is contained in:
Alix-007
2026-06-16 19:49:19 +08:00
committed by Peter Steinberger
parent 2a021f3b9b
commit eadd69b44c
2 changed files with 121 additions and 2 deletions
@@ -2874,6 +2874,107 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("aborts the in-flight mcporter search subprocess when the caller signal aborts", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
},
},
} as OpenClawConfig;
// The mcporter `call` child never closes on its own, so the only way the
// search can settle is the caller-owned abort signal reaching the mcporter
// subprocess via runMcporter -> runCliCommand and killing it.
let mcporterCallKill: ReturnType<typeof vi.fn> | undefined;
let mcporterCallChild: MockChild | undefined;
spawnMock.mockImplementation((cmd: string, args: string[]) => {
if (isMcporterCommand(cmd) && args[0] === "call") {
const child = createMockChild({ autoClose: false });
const kill = vi.fn(() => {
// Mirror a real child exiting after SIGKILL so the close handler runs.
queueMicrotask(() => child.emit("close", null));
});
Object.assign(child, { kill });
mcporterCallKill = kill;
mcporterCallChild = child;
return child;
}
return createMockChild();
});
const { manager } = await createManager();
const controller = new AbortController();
const searchPromise = manager.search("test", {
sessionKey: "agent:main:slack:dm:u123",
signal: controller.signal,
});
searchPromise.catch(() => undefined);
await waitUntil(() => mcporterCallKill !== undefined);
expect(mcporterCallChild).toBeDefined();
controller.abort(new Error("memory_search timed out after 15s"));
await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s");
expect(mcporterCallKill).toHaveBeenCalledWith("SIGKILL");
await manager.close();
});
it("rejects the mcporter search before spawning a call subprocess when the caller signal is already aborted", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((cmd: string, args: string[]) => {
const child = createMockChild({ autoClose: false });
if (isMcporterCommand(cmd) && args[0] === "call") {
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
return child;
}
emitAndClose(child, "stdout", "[]");
return child;
});
const { manager } = await createManager();
const controller = new AbortController();
controller.abort(new Error("memory_search timed out after 15s"));
const callsBefore = spawnMock.mock.calls.filter(
(call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])?.[0] === "call",
).length;
await expect(
manager.search("test", {
sessionKey: "agent:main:slack:dm:u123",
signal: controller.signal,
}),
).rejects.toThrow("memory_search timed out after 15s");
const callsAfter = spawnMock.mock.calls.filter(
(call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])?.[0] === "call",
).length;
expect(callsAfter).toBe(callsBefore);
await manager.close();
});
it("does not pass --no-rerank to direct query fallback from search mode", async () => {
cfg = {
...cfg,
@@ -342,6 +342,7 @@ type QmdMcporterSearchParams =
minScore: number;
collection?: string;
timeoutMs: number;
signal?: AbortSignal;
}
| {
mcporter: ResolvedQmdMcporterConfig;
@@ -353,6 +354,7 @@ type QmdMcporterSearchParams =
minScore: number;
collection?: string;
timeoutMs: number;
signal?: AbortSignal;
};
type QmdMcporterAcrossCollectionsParams =
| {
@@ -363,6 +365,7 @@ type QmdMcporterAcrossCollectionsParams =
limit: number;
minScore: number;
collectionNames: string[];
signal?: AbortSignal;
}
| {
tool: BuiltinQmdMcpTool;
@@ -372,6 +375,7 @@ type QmdMcporterAcrossCollectionsParams =
limit: number;
minScore: number;
collectionNames: string[];
signal?: AbortSignal;
};
export class QmdMemoryManager implements MemorySearchManager {
@@ -1353,6 +1357,7 @@ export class QmdMemoryManager implements MemorySearchManager {
limit,
minScore,
collectionNames,
signal: searchSignal,
});
}
return await this.runQmdSearchViaMcporter({
@@ -1365,6 +1370,7 @@ export class QmdMemoryManager implements MemorySearchManager {
minScore,
collection: collectionNames[0],
timeoutMs: this.qmd.limits.timeoutMs,
signal: searchSignal,
});
}
const tool = this.resolveQmdMcpTool(qmdSearchCommand);
@@ -1377,6 +1383,7 @@ export class QmdMemoryManager implements MemorySearchManager {
limit,
minScore,
collectionNames,
signal: searchSignal,
});
}
return await this.runQmdSearchViaMcporter({
@@ -1389,6 +1396,7 @@ export class QmdMemoryManager implements MemorySearchManager {
minScore,
collection: collectionNames[0],
timeoutMs: this.qmd.limits.timeoutMs,
signal: searchSignal,
});
}
const collectionGroups = await this.resolveCollectionSearchGroups(collectionNames);
@@ -2336,7 +2344,7 @@ export class QmdMemoryManager implements MemorySearchManager {
private async runMcporter(
args: string[],
opts?: { timeoutMs?: number },
opts?: { timeoutMs?: number; signal?: AbortSignal },
): Promise<{ stdout: string; stderr: string }> {
const spawnInvocation = resolveCliSpawnInvocation({
command: "mcporter",
@@ -2352,12 +2360,16 @@ export class QmdMemoryManager implements MemorySearchManager {
cwd: this.workspaceDir,
timeoutMs: opts?.timeoutMs,
maxOutputChars: this.maxQmdOutputChars,
signal: opts?.signal,
});
}
private async runQmdSearchViaMcporter(
params: QmdMcporterSearchParams,
): Promise<QmdQueryResult[]> {
if (params.signal?.aborted) {
throw asAbortError(params.signal);
}
await this.ensureMcporterDaemonStarted(params.mcporter);
// If the version is already known as v1 but we received a stale "query" tool name
@@ -2414,7 +2426,10 @@ export class QmdMemoryManager implements MemorySearchManager {
"--timeout",
String(Math.max(0, params.timeoutMs)),
],
{ timeoutMs: resolveQmdMcporterSearchProcessTimeoutMs(params.timeoutMs) },
{
timeoutMs: resolveQmdMcporterSearchProcessTimeoutMs(params.timeoutMs),
signal: params.signal,
},
);
// If we got here with the v2 "query" tool, confirm v2 for future calls.
if (useUnifiedQueryTool && this.qmdMcpToolVersion === null) {
@@ -2443,6 +2458,7 @@ export class QmdMemoryManager implements MemorySearchManager {
minScore: params.minScore,
collection: params.collection,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
}
throw err;
@@ -3470,6 +3486,7 @@ export class QmdMemoryManager implements MemorySearchManager {
minScore: params.minScore,
collection: collectionName,
timeoutMs: this.qmd.limits.timeoutMs,
signal: params.signal,
})
: await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
@@ -3481,6 +3498,7 @@ export class QmdMemoryManager implements MemorySearchManager {
minScore: params.minScore,
collection: collectionName,
timeoutMs: this.qmd.limits.timeoutMs,
signal: params.signal,
});
for (const entry of parsed) {
if (typeof entry.docid !== "string" || !entry.docid.trim()) {