diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mjs b/scripts/e2e/kitchen-sink-rpc-walk.mjs index 6200642e631..a7126f6623a 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mjs +++ b/scripts/e2e/kitchen-sink-rpc-walk.mjs @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { setTimeout as delay } from "node:timers/promises"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const PLUGIN_SPEC = process.env.OPENCLAW_KITCHEN_SINK_NPM_SPEC || "npm:@openclaw/kitchen-sink@latest"; @@ -517,12 +517,21 @@ function assertToolInvokeResult(payload) { } } -async function sampleProcess(pid) { - if (!pid || process.platform === "win32") { +export async function sampleProcess(pid, options = {}) { + const platform = options.platform ?? process.platform; + const run = options.runCommand ?? runCommand; + if (!pid) { return null; } + if (platform === "win32") { + return sampleWindowsProcess(pid, run); + } + return samplePosixProcess(pid, run); +} + +async function samplePosixProcess(pid, run) { try { - const { stdout } = await runCommand("ps", ["-o", "rss=,pcpu=", "-p", String(pid)], { + const { stdout } = await run("ps", ["-o", "rss=,pcpu=", "-p", String(pid)], { timeoutMs: 5000, }); const [rssKbRaw, cpuRaw] = stdout.trim().split(/\s+/u); @@ -540,7 +549,44 @@ async function sampleProcess(pid) { } } -function assertResourceCeiling(sample) { +async function sampleWindowsProcess(pid, run) { + const safePid = Number(pid); + if (!Number.isInteger(safePid) || safePid <= 0) { + return null; + } + const command = [ + "$ErrorActionPreference = 'Stop'", + `$process = Get-Process -Id ${safePid} -ErrorAction Stop`, + "$cpu = 0", + "if ($null -ne $process.CPU) { $cpu = $process.CPU }", + "[Console]::Out.Write(('{0} {1}' -f $process.WorkingSet64, $cpu))", + ].join("; "); + for (const powershell of ["powershell.exe", "powershell"]) { + try { + const { stdout } = await run( + powershell, + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command], + { timeoutMs: 5000 }, + ); + const [workingSetBytesRaw, cpuSecondsRaw] = stdout.trim().split(/\s+/u); + const workingSetBytes = Number.parseInt(workingSetBytesRaw ?? "", 10); + const cpuSeconds = Number.parseFloat(cpuSecondsRaw ?? ""); + if (!Number.isFinite(workingSetBytes)) { + return null; + } + return { + rssMiB: Math.round((workingSetBytes / 1024 / 1024) * 10) / 10, + cpuPercent: null, + cpuSeconds: Number.isFinite(cpuSeconds) ? cpuSeconds : null, + }; + } catch { + // Try the next Windows PowerShell command name. + } + } + return null; +} + +export function assertResourceCeiling(sample) { if (!sample) { return; } @@ -590,7 +636,7 @@ function isNonEmptyString(value) { return typeof value === "string" && value.trim().length > 0; } -async function main() { +export async function main() { const runner = resolveOpenClawRunner(); const port = readPositiveInt(process.env.OPENCLAW_KITCHEN_SINK_RPC_PORT, DEFAULT_PORT); const { root, env } = makeEnv(); @@ -725,4 +771,6 @@ async function main() { } } -await main(); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/test/scripts/kitchen-sink-rpc-walk.test.ts b/test/scripts/kitchen-sink-rpc-walk.test.ts new file mode 100644 index 00000000000..b09173eb910 --- /dev/null +++ b/test/scripts/kitchen-sink-rpc-walk.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { assertResourceCeiling, sampleProcess } from "../../scripts/e2e/kitchen-sink-rpc-walk.mjs"; + +describe("kitchen-sink RPC process sampling", () => { + it("samples RSS on Windows instead of silently disabling the resource guard", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const sample = await sampleProcess(1234, { + platform: "win32", + runCommand: async (command: string, args: string[]) => { + calls.push({ command, args }); + return { stdout: `${256 * 1024 * 1024} 1.5`, stderr: "" }; + }, + }); + + expect(sample).toEqual({ cpuPercent: null, cpuSeconds: 1.5, rssMiB: 256 }); + expect(calls[0]?.command).toBe("powershell.exe"); + expect(calls[0]?.args.join(" ")).toContain("Get-Process -Id 1234"); + }); + + it("falls back to the legacy powershell command name on Windows", async () => { + const commands: string[] = []; + const sample = await sampleProcess(1234, { + platform: "win32", + runCommand: async (command: string) => { + commands.push(command); + if (command === "powershell.exe") { + throw new Error("missing powershell.exe"); + } + return { stdout: `${96 * 1024 * 1024} 0`, stderr: "" }; + }, + }); + + expect(commands).toEqual(["powershell.exe", "powershell"]); + expect(sample?.rssMiB).toBe(96); + }); + + it("samples RSS and CPU percent with ps on POSIX", async () => { + const sample = await sampleProcess(4321, { + platform: "linux", + runCommand: async (command: string, args: string[]) => { + expect(command).toBe("ps"); + expect(args).toEqual(["-o", "rss=,pcpu=", "-p", "4321"]); + return { stdout: "262144 12.5\n", stderr: "" }; + }, + }); + + expect(sample).toEqual({ cpuPercent: 12.5, rssMiB: 256 }); + }); + + it("fails when the sampled RSS exceeds the configured ceiling", () => { + expect(() => assertResourceCeiling({ rssMiB: 2049 })).toThrow( + "gateway RSS exceeded 2048 MiB: 2049 MiB", + ); + }); +});