Skip to content

Commit 20534c5

Browse files
committed
fix(memory-host): kill qmd process groups
1 parent 2282fcd commit 20534c5

2 files changed

Lines changed: 107 additions & 6 deletions

File tree

packages/memory-host-sdk/src/host/qmd-process.test.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,17 @@ import { EventEmitter } from "node:events";
33
import fs from "node:fs/promises";
44
import os from "node:os";
55
import path from "node:path";
6-
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6+
import {
7+
afterAll,
8+
afterEach,
9+
beforeAll,
10+
beforeEach,
11+
describe,
12+
expect,
13+
it,
14+
vi,
15+
type MockInstance,
16+
} from "vitest";
717
import { MAX_SAFE_TIMEOUT_DELAY_MS } from "../../../gateway-client/src/timeouts.js";
818

919
const spawnMock = vi.hoisted(() => vi.fn());
@@ -24,13 +34,15 @@ import {
2434
type QmdBinaryAvailability,
2535
} from "./qmd-process.js";
2636

27-
function createMockChild() {
37+
function createMockChild(params: { pid?: number } = {}) {
2838
const child = new EventEmitter() as EventEmitter & {
39+
pid?: number;
2940
stdout: EventEmitter;
3041
stderr: EventEmitter;
3142
kill: ReturnType<typeof vi.fn>;
3243
closeWith: (code?: number | null, signal?: NodeJS.Signals | null) => void;
3344
};
45+
child.pid = params.pid;
3446
child.stdout = new EventEmitter();
3547
child.stderr = new EventEmitter();
3648
child.kill = vi.fn();
@@ -42,7 +54,7 @@ function createMockChild() {
4254

4355
let fixtureRoot = "";
4456
let tempDir = "";
45-
let platformSpy: { mockRestore(): void } | null = null;
57+
let platformSpy: MockInstance<() => NodeJS.Platform> | null = null;
4658
let fixtureId = 0;
4759
const originalPath = process.env.PATH;
4860
const originalPathExt = process.env.PATHEXT;
@@ -69,6 +81,7 @@ afterEach(() => {
6981
vi.useRealTimers();
7082
process.env.PATH = originalPath;
7183
process.env.PATHEXT = originalPathExt;
84+
platformSpy?.mockReturnValue("win32");
7285
spawnMock.mockReset();
7386
tempDir = "";
7487
});
@@ -218,6 +231,34 @@ describe("checkQmdBinaryAvailability", () => {
218231

219232
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
220233
});
234+
235+
it("kills timed-out availability probes by process group on POSIX", async () => {
236+
platformSpy?.mockReturnValue("linux");
237+
const killProcess = vi.spyOn(process, "kill").mockImplementation(() => true);
238+
const child = createMockChild({ pid: 4321 });
239+
spawnMock.mockReturnValueOnce(child);
240+
241+
try {
242+
await expect(
243+
checkQmdBinaryAvailability({
244+
command: "qmd",
245+
env: process.env,
246+
cwd: tempDir,
247+
timeoutMs: 1,
248+
}),
249+
).resolves.toEqual({
250+
available: false,
251+
reason: "binary",
252+
error: "spawn qmd timed out after 1ms",
253+
});
254+
255+
expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({ detached: true });
256+
expect(killProcess).toHaveBeenCalledWith(-4321, "SIGKILL");
257+
expect(child.kill).not.toHaveBeenCalledWith("SIGKILL");
258+
} finally {
259+
killProcess.mockRestore();
260+
}
261+
});
221262
});
222263

223264
describe("runCliCommand", () => {
@@ -341,4 +382,33 @@ describe("runCliCommand", () => {
341382

342383
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
343384
});
385+
386+
it("kills timed-out cli command process groups on POSIX", async () => {
387+
platformSpy?.mockReturnValue("linux");
388+
const killProcess = vi.spyOn(process, "kill").mockImplementation(() => true);
389+
const child = createMockChild({ pid: 8765 });
390+
spawnMock.mockReturnValueOnce(child);
391+
392+
try {
393+
const pending = runCliCommand({
394+
commandSummary: "qmd query test",
395+
spawnInvocation: { command: "qmd", argv: ["query", "test", "--json"] },
396+
env: process.env,
397+
cwd: tempDir,
398+
maxOutputChars: 10_000,
399+
timeoutMs: 1,
400+
});
401+
const timeoutAssertion = expect(pending).rejects.toThrow(
402+
"qmd query test timed out after 1ms",
403+
);
404+
405+
await timeoutAssertion;
406+
407+
expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({ detached: true });
408+
expect(killProcess).toHaveBeenCalledWith(-8765, "SIGKILL");
409+
expect(child.kill).not.toHaveBeenCalledWith("SIGKILL");
410+
} finally {
411+
killProcess.mockRestore();
412+
}
413+
});
344414
});

packages/memory-host-sdk/src/host/qmd-process.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ export type CliSpawnInvocation = {
1111
windowsHide?: boolean;
1212
};
1313

14+
type QmdChildProcess = {
15+
pid?: number;
16+
kill: (signal?: NodeJS.Signals) => boolean;
17+
};
18+
1419
export type QmdBinaryUnavailableReason = "binary" | "workspace-cwd";
1520

1621
export type QmdBinaryUnavailable = {
@@ -92,10 +97,11 @@ export async function checkQmdBinaryAvailability(params: {
9297
shell: spawnInvocation.shell,
9398
windowsHide: spawnInvocation.windowsHide,
9499
stdio: "ignore",
100+
detached: shouldUseQmdProcessGroup(),
95101
});
96102
const timeoutMs = resolveSafeTimeoutDelayMs(params.timeoutMs ?? 2_000, { minMs: 0 });
97103
const timer = setTimeout(() => {
98-
child.kill("SIGKILL");
104+
signalQmdProcessTree(child, "SIGKILL");
99105
finish({
100106
available: false,
101107
reason: "binary",
@@ -108,7 +114,7 @@ export async function checkQmdBinaryAvailability(params: {
108114
});
109115
child.once("spawn", () => {
110116
didSpawn = true;
111-
child.kill();
117+
signalQmdProcessTree(child);
112118
finish({ available: true });
113119
});
114120
child.once("close", () => {
@@ -162,6 +168,7 @@ export async function runCliCommand(params: {
162168
cwd: params.cwd,
163169
shell: params.spawnInvocation.shell,
164170
windowsHide: params.spawnInvocation.windowsHide,
171+
detached: shouldUseQmdProcessGroup(),
165172
});
166173
let stdout = "";
167174
let stderr = "";
@@ -172,7 +179,7 @@ export async function runCliCommand(params: {
172179
params.timeoutMs === undefined ? undefined : resolveSafeTimeoutDelayMs(params.timeoutMs);
173180
const timer = timeoutMs
174181
? setTimeout(() => {
175-
child.kill("SIGKILL");
182+
signalQmdProcessTree(child, "SIGKILL");
176183
reject(new Error(`${params.commandSummary} timed out after ${timeoutMs}ms`));
177184
}, timeoutMs)
178185
: null;
@@ -224,6 +231,30 @@ export async function runCliCommand(params: {
224231
});
225232
}
226233

234+
function shouldUseQmdProcessGroup(): boolean {
235+
return process.platform !== "win32";
236+
}
237+
238+
function signalQmdProcessTree(child: QmdChildProcess, signal?: NodeJS.Signals): void {
239+
if (shouldUseQmdProcessGroup() && typeof child.pid === "number") {
240+
try {
241+
if (signal === undefined) {
242+
process.kill(-child.pid);
243+
} else {
244+
process.kill(-child.pid, signal);
245+
}
246+
return;
247+
} catch {
248+
// Fall back to the direct child if the process group already disappeared.
249+
}
250+
}
251+
if (signal === undefined) {
252+
child.kill();
253+
} else {
254+
child.kill(signal);
255+
}
256+
}
257+
227258
class CliCommandError extends Error {
228259
readonly code: number | null;
229260
readonly signal: NodeJS.Signals | null;

0 commit comments

Comments
 (0)