mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 12:39:32 +00:00
fix(memory-host-sdk): taskkill qmd process trees on windows
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user