fix(memory-host-sdk): taskkill qmd process trees on windows

This commit is contained in:
Vincent Koc
2026-06-21 08:46:16 +02:00
parent ab39bab52a
commit 830691b201
2 changed files with 138 additions and 4 deletions

View File

@@ -17,12 +17,14 @@ import {
import { MAX_SAFE_TIMEOUT_DELAY_MS } from "../../../gateway-client/src/timeouts.js";
const spawnMock = vi.hoisted(() => vi.fn());
const spawnSyncMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: spawnMock,
spawnSync: spawnSyncMock,
};
});
@@ -58,6 +60,17 @@ let platformSpy: MockInstance<() => NodeJS.Platform> | null = null;
let fixtureId = 0;
const originalPath = process.env.PATH;
const originalPathExt = process.env.PATHEXT;
const originalSystemRoot = process.env.SystemRoot;
const originalWindir = process.env.WINDIR;
const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe");
function restoreEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
}
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-win-spawn-"));
@@ -75,14 +88,20 @@ afterAll(async () => {
beforeEach(async () => {
tempDir = path.join(fixtureRoot, `case-${fixtureId++}`);
await fs.mkdir(tempDir, { recursive: true });
process.env.SystemRoot = "C:\\Windows";
delete process.env.WINDIR;
});
afterEach(() => {
vi.useRealTimers();
process.env.PATH = originalPath;
process.env.PATHEXT = originalPathExt;
restoreEnvValue("SystemRoot", originalSystemRoot);
restoreEnvValue("WINDIR", originalWindir);
platformSpy?.mockReturnValue("win32");
spawnMock.mockReset();
spawnSyncMock.mockReset();
spawnSyncMock.mockReturnValue({ status: 0 });
tempDir = "";
});
@@ -163,7 +182,7 @@ describe("checkQmdBinaryAvailability", () => {
});
it("returns available when the qmd process spawns successfully", async () => {
const child = createMockChild();
const child = createMockChild({ pid: 12344 });
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => child.emit("spawn"));
return child;
@@ -172,8 +191,35 @@ describe("checkQmdBinaryAvailability", () => {
await expect(
checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }),
).resolves.toEqual({ available: true });
expect(child.kill).toHaveBeenCalledTimes(1);
expect(child.kill).toHaveBeenCalledWith();
expect(spawnSyncMock).toHaveBeenCalledWith(taskkillPath, ["/PID", String(child.pid), "/T"], {
stdio: "ignore",
windowsHide: true,
});
expect(child.kill).not.toHaveBeenCalled();
});
it("force-kills Windows availability probes when graceful taskkill fails", async () => {
const child = createMockChild({ pid: 12345 });
spawnMock.mockImplementationOnce(() => {
queueMicrotask(() => child.emit("spawn"));
return child;
});
spawnSyncMock.mockReset();
spawnSyncMock.mockReturnValueOnce({ status: 1 }).mockReturnValueOnce({ status: 0 });
await expect(
checkQmdBinaryAvailability({ command: "qmd", env: process.env, cwd: tempDir }),
).resolves.toEqual({ available: true });
expect(spawnSyncMock).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
stdio: "ignore",
windowsHide: true,
});
expect(spawnSyncMock).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
expect(child.kill).not.toHaveBeenCalled();
});
it("returns unavailable when the qmd process cannot be spawned", async () => {
@@ -411,4 +457,26 @@ describe("runCliCommand", () => {
killProcess.mockRestore();
}
});
it("force-kills timed-out Windows cli commands with taskkill", async () => {
const child = createMockChild({ pid: 12346 });
spawnMock.mockReturnValueOnce(child);
const pending = runCliCommand({
commandSummary: "qmd query test",
spawnInvocation: { command: "qmd", argv: ["query", "test", "--json"] },
env: process.env,
cwd: tempDir,
maxOutputChars: 10_000,
timeoutMs: 1,
});
await expect(pending).rejects.toThrow("qmd query test timed out after 1ms");
expect(spawnSyncMock).toHaveBeenCalledWith(taskkillPath, ["/PID", "12346", "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
expect(child.kill).not.toHaveBeenCalledWith("SIGKILL");
});
});

View File

@@ -1,6 +1,7 @@
// Memory Host SDK module implements qmd process behavior.
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { statSync } from "node:fs";
import path from "node:path";
import { resolveSafeTimeoutDelayMs } from "../../../gateway-client/src/timeouts.js";
import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram } from "./windows-spawn.js";
@@ -16,6 +17,8 @@ type QmdChildProcess = {
kill: (signal?: NodeJS.Signals) => boolean;
};
const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows";
export type QmdBinaryUnavailableReason = "binary" | "workspace-cwd";
export type QmdBinaryUnavailable = {
@@ -235,6 +238,49 @@ function shouldUseQmdProcessGroup(): boolean {
return process.platform !== "win32";
}
function getEnvValueCaseInsensitive(
env: Record<string, string | undefined>,
expectedKey: string,
): string | undefined {
const direct = env[expectedKey];
if (direct !== undefined) {
return direct;
}
const expected = expectedKey.toUpperCase();
const actualKey = Object.keys(env).find((key) => key.toUpperCase() === expected);
return actualKey ? env[actualKey] : undefined;
}
function normalizeWindowsSystemRoot(raw: string | undefined): string | null {
const trimmed = raw?.trim();
if (
!trimmed ||
trimmed.includes("\0") ||
trimmed.includes("\r") ||
trimmed.includes("\n") ||
trimmed.includes(";")
) {
return null;
}
const normalized = path.win32.normalize(trimmed);
if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) {
return null;
}
const parsed = path.win32.parse(normalized);
if (!/^[A-Za-z]:\\$/.test(parsed.root) || normalized.length <= parsed.root.length) {
return null;
}
return normalized.replace(/[\\/]+$/, "");
}
function resolveWindowsTaskkillPath(env: Record<string, string | undefined> = process.env): string {
const systemRoot =
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ??
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ??
DEFAULT_WINDOWS_SYSTEM_ROOT;
return path.win32.join(systemRoot, "System32", "taskkill.exe");
}
function signalQmdProcessTree(child: QmdChildProcess, signal?: NodeJS.Signals): void {
if (shouldUseQmdProcessGroup() && typeof child.pid === "number") {
try {
@@ -248,6 +294,26 @@ function signalQmdProcessTree(child: QmdChildProcess, signal?: NodeJS.Signals):
// Fall back to the direct child if the process group already disappeared.
}
}
if (!shouldUseQmdProcessGroup() && typeof child.pid === "number") {
const taskkillPath = resolveWindowsTaskkillPath();
const args = ["/PID", String(child.pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
const result = spawnSync(taskkillPath, args, { stdio: "ignore", windowsHide: true });
if (!result.error && result.status === 0) {
return;
}
if (signal !== "SIGKILL") {
const forceResult = spawnSync(taskkillPath, [...args, "/F"], {
stdio: "ignore",
windowsHide: true,
});
if (!forceResult.error && forceResult.status === 0) {
return;
}
}
}
if (signal === undefined) {
child.kill();
} else {