diff --git a/packages/memory-host-sdk/src/host/qmd-process.test.ts b/packages/memory-host-sdk/src/host/qmd-process.test.ts index 8abf1324882..f067204980d 100644 --- a/packages/memory-host-sdk/src/host/qmd-process.test.ts +++ b/packages/memory-host-sdk/src/host/qmd-process.test.ts @@ -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("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"); + }); }); diff --git a/packages/memory-host-sdk/src/host/qmd-process.ts b/packages/memory-host-sdk/src/host/qmd-process.ts index 37a16589b18..94349e57f22 100644 --- a/packages/memory-host-sdk/src/host/qmd-process.ts +++ b/packages/memory-host-sdk/src/host/qmd-process.ts @@ -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, + 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 = 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 {