Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
852
test/scripts/measure-rpc-rtt.test.ts
Normal file
852
test/scripts/measure-rpc-rtt.test.ts
Normal file
@@ -0,0 +1,852 @@
|
||||
// Measure Rpc Rtt tests cover measure rpc rtt script behavior.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveWindowsTaskkillPath } from "../../scripts/lib/windows-taskkill.mjs";
|
||||
import {
|
||||
assertRpcSmokeResponse,
|
||||
cleanupTempRoot,
|
||||
createGatewayClient,
|
||||
installGatewayParentCleanup,
|
||||
isGatewayProcessAlive,
|
||||
parseArgs,
|
||||
signalGatewayProcess,
|
||||
startGateway,
|
||||
stopGateway,
|
||||
summarizeRttSamples,
|
||||
waitForGatewayReady,
|
||||
} from "../../scripts/measure-rpc-rtt.mjs";
|
||||
|
||||
function expectedTaskkillPath(): string {
|
||||
return resolveWindowsTaskkillPath();
|
||||
}
|
||||
|
||||
function restoreEnvValue(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
return;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
function withDefaultWindowsSystemRoot(run: () => void): void {
|
||||
const originalSystemRoot = process.env.SystemRoot;
|
||||
const originalWindir = process.env.WINDIR;
|
||||
try {
|
||||
process.env.SystemRoot = "C:\\Windows";
|
||||
delete process.env.WINDIR;
|
||||
run();
|
||||
} finally {
|
||||
restoreEnvValue("SystemRoot", originalSystemRoot);
|
||||
restoreEnvValue("WINDIR", originalWindir);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWebSocket extends EventEmitter {
|
||||
static OPEN = 1;
|
||||
static instances: FakeWebSocket[] = [];
|
||||
|
||||
closed = false;
|
||||
readyState = 0;
|
||||
sent: string[] = [];
|
||||
terminated = false;
|
||||
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly options: unknown,
|
||||
) {
|
||||
super();
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(payload: string, callback?: (error?: Error) => void): void {
|
||||
this.sent.push(payload);
|
||||
callback?.();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.readyState = 3;
|
||||
this.emit("close", 1000, "closed");
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
|
||||
describe("scripts/measure-rpc-rtt.mjs", () => {
|
||||
it("closes websocket clients that time out before opening", async () => {
|
||||
FakeWebSocket.instances = [];
|
||||
const client = createGatewayClient({
|
||||
WebSocket: FakeWebSocket,
|
||||
openTimeoutMs: 1,
|
||||
url: "ws://127.0.0.1:12345",
|
||||
});
|
||||
|
||||
await expect(client.waitOpen()).rejects.toThrow("gateway websocket open timeout");
|
||||
expect(FakeWebSocket.instances[0]?.closed).toBe(true);
|
||||
expect(FakeWebSocket.instances[0]?.terminated).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects websocket closes before opening", async () => {
|
||||
FakeWebSocket.instances = [];
|
||||
const client = createGatewayClient({
|
||||
WebSocket: FakeWebSocket,
|
||||
openTimeoutMs: 10_000,
|
||||
url: "ws://127.0.0.1:12345",
|
||||
});
|
||||
|
||||
const opened = client.waitOpen();
|
||||
FakeWebSocket.instances[0]?.emit("close", 1006, Buffer.from("bye"));
|
||||
|
||||
await expect(opened).rejects.toThrow("closed before open (1006): bye");
|
||||
expect(FakeWebSocket.instances[0]?.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects pending websocket requests when cleanup closes the client", async () => {
|
||||
FakeWebSocket.instances = [];
|
||||
const client = createGatewayClient({
|
||||
WebSocket: FakeWebSocket,
|
||||
url: "ws://127.0.0.1:12345",
|
||||
});
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
if (!socket) {
|
||||
throw new Error("fake websocket was not created");
|
||||
}
|
||||
socket.readyState = FakeWebSocket.OPEN;
|
||||
|
||||
const request = client.request("health", {}, 10_000);
|
||||
client.close();
|
||||
|
||||
await expect(request).rejects.toThrow("gateway websocket client closed");
|
||||
expect(socket.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("clears pending websocket request timers when send throws synchronously", async () => {
|
||||
vi.useFakeTimers();
|
||||
FakeWebSocket.instances = [];
|
||||
const client = createGatewayClient({
|
||||
WebSocket: FakeWebSocket,
|
||||
url: "ws://127.0.0.1:12345",
|
||||
});
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
if (!socket) {
|
||||
throw new Error("fake websocket was not created");
|
||||
}
|
||||
socket.readyState = FakeWebSocket.OPEN;
|
||||
socket.send = () => {
|
||||
throw new Error("socket closed during send");
|
||||
};
|
||||
|
||||
await expect(client.request("health", {}, 10_000)).rejects.toThrow("socket closed during send");
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("parses bounded RPC RTT options strictly", () => {
|
||||
expect(
|
||||
parseArgs([
|
||||
"--output-dir",
|
||||
"/tmp/rpc-rtt",
|
||||
"--repo-root",
|
||||
"/repo",
|
||||
"--iterations",
|
||||
"3",
|
||||
"--methods",
|
||||
"health, config.get ",
|
||||
]),
|
||||
).toMatchObject({
|
||||
iterations: 3,
|
||||
methods: ["health", "config.get"],
|
||||
outputDir: "/tmp/rpc-rtt",
|
||||
repoRoot: "/repo",
|
||||
});
|
||||
|
||||
expect(() => parseArgs(["--output-dir", "/tmp/rpc-rtt", "--iterations", "1e3"])).toThrow(
|
||||
"--iterations must be a positive integer.",
|
||||
);
|
||||
expect(() => parseArgs(["--output-dir", "/tmp/rpc-rtt", "--iterations", "0"])).toThrow(
|
||||
"--iterations must be a positive integer.",
|
||||
);
|
||||
expect(() => parseArgs(["--output-dir", "/tmp/rpc-rtt", "--methods"])).toThrow(
|
||||
"--methods requires a value.",
|
||||
);
|
||||
expect(() =>
|
||||
parseArgs(["--output-dir", "/tmp/rpc-rtt", "--methods", "health, config.get,health"]),
|
||||
).toThrow("--methods contains duplicate gateway method: health");
|
||||
expect(() => parseArgs(["--output-dir", "/tmp/one", "--output-dir", "/tmp/two"])).toThrow(
|
||||
"--output-dir was provided more than once.",
|
||||
);
|
||||
expect(() =>
|
||||
parseArgs(["--output-dir", "/tmp/rpc-rtt", "--methods", "health", "--methods", "config.get"]),
|
||||
).toThrow("--methods was provided more than once.");
|
||||
for (const value of ["--methods", "-h"]) {
|
||||
for (const flag of ["--output-dir", "--repo-root", "--iterations", "--methods"]) {
|
||||
expect(() => parseArgs([flag, value, "health"])).toThrow(`${flag} requires a value.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("prints usage for help without requiring an output directory", () => {
|
||||
expect(parseArgs(["--help"])).toMatchObject({ help: true });
|
||||
expect(parseArgs(["-h"])).toMatchObject({ help: true });
|
||||
|
||||
const result = spawnSync(process.execPath, ["scripts/measure-rpc-rtt.mjs", "--help"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Usage: node --import tsx scripts/measure-rpc-rtt.mjs");
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("does not publish zero-millisecond RPC RTT summaries", () => {
|
||||
expect(summarizeRttSamples([0, 0.1, 0.49])).toEqual({
|
||||
avgMs: 1,
|
||||
maxMs: 1,
|
||||
minMs: 1,
|
||||
p50Ms: 1,
|
||||
p95Ms: 1,
|
||||
});
|
||||
expect(summarizeRttSamples([1.4, 2.6])).toEqual({
|
||||
avgMs: 2,
|
||||
maxMs: 3,
|
||||
minMs: 1,
|
||||
p50Ms: 1,
|
||||
p95Ms: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid RPC RTT summary samples", () => {
|
||||
expect(() => summarizeRttSamples([])).toThrow("RPC RTT measurement produced no samples.");
|
||||
expect(() => summarizeRttSamples([Number.NaN])).toThrow(
|
||||
"avgMs must be a non-negative finite duration.",
|
||||
);
|
||||
expect(() => summarizeRttSamples([-1])).toThrow(
|
||||
"avgMs must be a non-negative finite duration.",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates default RPC RTT smoke payloads", () => {
|
||||
expect(() =>
|
||||
assertRpcSmokeResponse("health", {
|
||||
ok: true,
|
||||
payload: {
|
||||
agents: [],
|
||||
channelOrder: [],
|
||||
channels: {},
|
||||
defaultAgentId: "codex",
|
||||
durationMs: 3,
|
||||
ok: true,
|
||||
sessions: { count: 0, path: "/state/sessions", recent: [] },
|
||||
ts: Date.now(),
|
||||
},
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
expect(() =>
|
||||
assertRpcSmokeResponse("config.get", {
|
||||
ok: true,
|
||||
payload: {
|
||||
config: {},
|
||||
exists: true,
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
path: "/tmp/openclaw.json",
|
||||
resolved: {},
|
||||
runtimeConfig: {},
|
||||
sourceConfig: {},
|
||||
valid: true,
|
||||
warnings: [],
|
||||
},
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
expect(() => assertRpcSmokeResponse("health", { ok: true, payload: {} })).toThrow(
|
||||
"health returned invalid payload: expected ok=true.",
|
||||
);
|
||||
expect(() => assertRpcSmokeResponse("config.get", { ok: true, payload: {} })).toThrow(
|
||||
"config.get returned invalid payload: expected config path.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps custom RPC RTT methods on the generic ok/error contract", () => {
|
||||
expect(() => assertRpcSmokeResponse("custom.method", { ok: true })).not.toThrow();
|
||||
expect(() =>
|
||||
assertRpcSmokeResponse("custom.method", {
|
||||
error: { code: "bad_request" },
|
||||
ok: false,
|
||||
}),
|
||||
).toThrow('custom.method failed: {"code":"bad_request"}');
|
||||
});
|
||||
|
||||
it("closes parent gateway log handles after spawning", async () => {
|
||||
const repoRoot = "/repo";
|
||||
const tempRoot = "/tmp/rpc-rtt";
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
signalCode: null,
|
||||
});
|
||||
const stdout = { close: vi.fn().mockResolvedValue(undefined), fd: 41 };
|
||||
const stderr = { close: vi.fn().mockResolvedValue(undefined), fd: 42 };
|
||||
const openImpl = vi.fn().mockResolvedValueOnce(stdout).mockResolvedValueOnce(stderr);
|
||||
const spawnImpl = vi.fn().mockReturnValue(child);
|
||||
|
||||
await expect(
|
||||
startGateway({
|
||||
configPath: "/tmp/openclaw.json",
|
||||
env: { PATH: "/bin" },
|
||||
openImpl,
|
||||
port: 23456,
|
||||
repoRoot,
|
||||
sourceEntryExists: () => true,
|
||||
spawnImpl,
|
||||
stderrPath: "/tmp/stderr.log",
|
||||
stdoutPath: "/tmp/stdout.log",
|
||||
tempRoot,
|
||||
token: "secret-token",
|
||||
}),
|
||||
).resolves.toBe(child);
|
||||
|
||||
expect(openImpl).toHaveBeenNthCalledWith(1, "/tmp/stdout.log", "w");
|
||||
expect(openImpl).toHaveBeenNthCalledWith(2, "/tmp/stderr.log", "w");
|
||||
expect(spawnImpl).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
path.join(repoRoot, "src", "entry.ts"),
|
||||
"gateway",
|
||||
"run",
|
||||
"--port",
|
||||
"23456",
|
||||
"--bind",
|
||||
"loopback",
|
||||
"--allow-unconfigured",
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: repoRoot,
|
||||
detached: process.platform !== "win32",
|
||||
env: expect.objectContaining({
|
||||
HOME: path.join(tempRoot, "home"),
|
||||
OPENCLAW_CONFIG_PATH: "/tmp/openclaw.json",
|
||||
OPENCLAW_GATEWAY_TOKEN: "secret-token",
|
||||
OPENCLAW_STATE_DIR: path.join(tempRoot, "state"),
|
||||
PATH: "/bin",
|
||||
}),
|
||||
stdio: ["ignore", 41, 42],
|
||||
}),
|
||||
);
|
||||
expect(stdout.close).toHaveBeenCalledTimes(1);
|
||||
expect(stderr.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("signals the gateway process group on POSIX so pnpm children do not leak", () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
pid: 12345,
|
||||
signalCode: null,
|
||||
});
|
||||
const kill = vi.fn(() => true);
|
||||
const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 }));
|
||||
|
||||
expect(signalGatewayProcess(child, "SIGTERM", kill, { runTaskkill })).toBe(true);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runTaskkill).toHaveBeenCalledWith(expectedTaskkillPath(), ["/PID", "12345", "/T"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(kill).toHaveBeenCalledWith(-12345, "SIGTERM");
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("signals Windows gateway process trees with taskkill", () => {
|
||||
withDefaultWindowsSystemRoot(() => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
pid: 12345,
|
||||
signalCode: null,
|
||||
});
|
||||
const kill = vi.fn(() => true);
|
||||
const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 }));
|
||||
|
||||
expect(
|
||||
signalGatewayProcess(child, "SIGTERM", kill, {
|
||||
platform: "win32",
|
||||
runTaskkill,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expectedTaskkillPath(),
|
||||
["/PID", "12345", "/T"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
signalGatewayProcess(child, "SIGKILL", kill, {
|
||||
platform: "win32",
|
||||
runTaskkill,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expectedTaskkillPath(),
|
||||
["/PID", "12345", "/T", "/F"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("force-kills Windows gateway process trees when graceful taskkill fails", () => {
|
||||
withDefaultWindowsSystemRoot(() => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
pid: 12345,
|
||||
signalCode: null,
|
||||
});
|
||||
const kill = vi.fn(() => true);
|
||||
const runTaskkill = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ error: undefined, status: 1 })
|
||||
.mockReturnValueOnce({ error: undefined, status: 0 });
|
||||
|
||||
expect(
|
||||
signalGatewayProcess(child, "SIGTERM", kill, {
|
||||
platform: "win32",
|
||||
runTaskkill,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expectedTaskkillPath(),
|
||||
["/PID", "12345", "/T"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expectedTaskkillPath(),
|
||||
["/PID", "12345", "/T", "/F"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("treats missing gateway process groups as already exited", () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
kill: vi.fn(() => false),
|
||||
pid: 12345,
|
||||
signalCode: null,
|
||||
});
|
||||
const kill = vi.fn(() => {
|
||||
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
||||
});
|
||||
const runTaskkill = vi.fn(() => ({ error: new Error("not found"), status: 1 }));
|
||||
|
||||
expect(signalGatewayProcess(child, "SIGTERM", kill, { runTaskkill })).toBe(false);
|
||||
});
|
||||
|
||||
it("checks process group liveness instead of only the pnpm wrapper", () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: 0,
|
||||
kill: vi.fn(),
|
||||
pid: 12345,
|
||||
signalCode: null,
|
||||
});
|
||||
const kill = vi.fn(() => true);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(isGatewayProcessAlive(child, kill)).toBe(false);
|
||||
} else {
|
||||
expect(isGatewayProcessAlive(child, kill)).toBe(true);
|
||||
expect(kill).toHaveBeenCalledWith(-12345, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("force-kills the gateway process group after the graceful stop window", async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
pid: 12346,
|
||||
signalCode: null,
|
||||
});
|
||||
const kill = vi.fn(() => true);
|
||||
const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 }));
|
||||
|
||||
await stopGateway(child, { killGraceMs: 1, killProcess: kill, runTaskkill });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expectedTaskkillPath(),
|
||||
["/PID", "12346", "/T"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expectedTaskkillPath(),
|
||||
["/PID", "12346", "/T", "/F"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(kill).toHaveBeenNthCalledWith(1, -12346, 0);
|
||||
expect(kill).toHaveBeenNthCalledWith(2, -12346, "SIGTERM");
|
||||
expect(kill).toHaveBeenCalledWith(-12346, "SIGKILL");
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for the process group to disappear after force kill", async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
pid: 12350,
|
||||
signalCode: null,
|
||||
});
|
||||
let sawForceKill = false;
|
||||
let postKillLivenessChecks = 0;
|
||||
const kill = vi.fn((_pid: number, signal: number | NodeJS.Signals) => {
|
||||
if (signal === "SIGKILL") {
|
||||
sawForceKill = true;
|
||||
return true;
|
||||
}
|
||||
if (signal === 0 && sawForceKill) {
|
||||
postKillLivenessChecks += 1;
|
||||
if (postKillLivenessChecks >= 2) {
|
||||
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 }));
|
||||
|
||||
await stopGateway(child, {
|
||||
forceKillGraceMs: 50,
|
||||
killGraceMs: 1,
|
||||
killProcess: kill,
|
||||
runTaskkill,
|
||||
});
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expectedTaskkillPath(),
|
||||
["/PID", "12350", "/T"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
expect(runTaskkill).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expectedTaskkillPath(),
|
||||
["/PID", "12350", "/T", "/F"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(kill).toHaveBeenCalledWith(-12350, "SIGKILL");
|
||||
expect(postKillLivenessChecks).toBe(2);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not trust a finished pnpm wrapper while the process group is alive", async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: 0,
|
||||
kill: vi.fn(),
|
||||
pid: 12347,
|
||||
signalCode: null,
|
||||
});
|
||||
const kill = vi.fn(() => true);
|
||||
const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 }));
|
||||
|
||||
await stopGateway(child, { killGraceMs: 1, killProcess: kill, runTaskkill });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(kill).toHaveBeenNthCalledWith(1, -12347, 0);
|
||||
expect(kill).toHaveBeenNthCalledWith(2, -12347, "SIGTERM");
|
||||
expect(kill).toHaveBeenCalledWith(-12347, "SIGKILL");
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives gateway process groups a grace window before re-raising parent signals", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
pid: 12348,
|
||||
signalCode: null,
|
||||
});
|
||||
const processLike = Object.assign(new EventEmitter(), {
|
||||
kill: vi.fn(),
|
||||
pid: 98765,
|
||||
});
|
||||
const kill = vi.fn(() => true);
|
||||
const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 }));
|
||||
|
||||
try {
|
||||
const removeCleanup = installGatewayParentCleanup(child, {
|
||||
killProcess: kill,
|
||||
processLike,
|
||||
runTaskkill,
|
||||
});
|
||||
processLike.emit("SIGTERM");
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runTaskkill).toHaveBeenCalledWith(expectedTaskkillPath(), ["/PID", "12348", "/T"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(processLike.kill).toHaveBeenCalledWith(98765, "SIGTERM");
|
||||
} else {
|
||||
expect(kill).toHaveBeenNthCalledWith(1, -12348, "SIGTERM");
|
||||
expect(kill).toHaveBeenNthCalledWith(2, -12348, 0);
|
||||
expect(kill).not.toHaveBeenCalledWith(-12348, "SIGKILL");
|
||||
expect(processLike.kill).not.toHaveBeenCalled();
|
||||
removeCleanup();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(kill).toHaveBeenCalledWith(-12348, "SIGKILL");
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(processLike.kill).toHaveBeenCalledWith(98765, "SIGTERM");
|
||||
}
|
||||
expect(processLike.listenerCount("SIGTERM")).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans up the gateway process group on parent exit", () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
pid: 12349,
|
||||
signalCode: null,
|
||||
});
|
||||
const processLike = Object.assign(new EventEmitter(), {
|
||||
kill: vi.fn(),
|
||||
pid: 98766,
|
||||
});
|
||||
const kill = vi.fn(() => true);
|
||||
const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 }));
|
||||
|
||||
const removeCleanup = installGatewayParentCleanup(child, {
|
||||
killProcess: kill,
|
||||
processLike,
|
||||
runTaskkill,
|
||||
});
|
||||
processLike.emit("exit");
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runTaskkill).toHaveBeenCalledWith(expectedTaskkillPath(), ["/PID", "12349", "/T"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(kill).toHaveBeenNthCalledWith(1, -12349, "SIGTERM");
|
||||
expect(kill).toHaveBeenNthCalledWith(2, -12349, "SIGKILL");
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(processLike.kill).not.toHaveBeenCalled();
|
||||
|
||||
removeCleanup();
|
||||
});
|
||||
|
||||
it("fails readiness immediately when the gateway already exited", async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: 1,
|
||||
signalCode: null,
|
||||
});
|
||||
const fetchImpl = vi.fn();
|
||||
|
||||
await expect(
|
||||
waitForGatewayReady({
|
||||
child,
|
||||
fetchImpl,
|
||||
port: 12345,
|
||||
readyTimeoutMs: 10_000,
|
||||
sleepMs: 1,
|
||||
stderrPath: "/no/such/stderr.log",
|
||||
}),
|
||||
).rejects.toThrow("gateway exited before readiness code=1 signal=null");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces temp root cleanup failures", async () => {
|
||||
const rmImpl = vi.fn().mockRejectedValue(new Error("device busy"));
|
||||
|
||||
await expect(cleanupTempRoot("/tmp/rpc-rtt-stuck", { rmImpl })).rejects.toThrow(
|
||||
"failed to remove RPC RTT temp root: device busy",
|
||||
);
|
||||
expect(rmImpl).toHaveBeenCalledWith("/tmp/rpc-rtt-stuck", {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds readiness probes and keeps polling after a stalled response", async () => {
|
||||
const child = new EventEmitter();
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(new ReadableStream<Uint8Array>({ start() {} })))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true, status: "live" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ failing: [], ready: true }));
|
||||
|
||||
await waitForGatewayReady({
|
||||
child,
|
||||
fetchImpl,
|
||||
port: 12345,
|
||||
probeTimeoutMs: 7,
|
||||
readyTimeoutMs: 50,
|
||||
sleepMs: 1,
|
||||
stderrPath: "/no/such/stderr.log",
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://127.0.0.1:12345/readyz",
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://127.0.0.1:12345/healthz",
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"http://127.0.0.1:12345/readyz",
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for /readyz even when /healthz is live", async () => {
|
||||
const child = new EventEmitter();
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ failing: ["gateway"], ready: false }, { status: 503 }))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true, status: "live" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ failing: [], ready: true }));
|
||||
|
||||
await waitForGatewayReady({
|
||||
child,
|
||||
fetchImpl,
|
||||
port: 12345,
|
||||
probeTimeoutMs: 7,
|
||||
readyTimeoutMs: 50,
|
||||
sleepMs: 1,
|
||||
stderrPath: "/no/such/stderr.log",
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://127.0.0.1:12345/readyz",
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://127.0.0.1:12345/healthz",
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"http://127.0.0.1:12345/readyz",
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels unconsumed readiness probe response bodies", async () => {
|
||||
const child = new EventEmitter();
|
||||
let readyzCanceled = false;
|
||||
let healthzCanceled = false;
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
body: {
|
||||
async cancel() {
|
||||
readyzCanceled = true;
|
||||
},
|
||||
},
|
||||
ok: false,
|
||||
status: 503,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
body: {
|
||||
async cancel() {
|
||||
healthzCanceled = true;
|
||||
},
|
||||
},
|
||||
ok: true,
|
||||
status: 200,
|
||||
})
|
||||
.mockResolvedValueOnce(jsonResponse({ failing: [], ready: true }));
|
||||
|
||||
await waitForGatewayReady({
|
||||
child,
|
||||
fetchImpl,
|
||||
port: 12345,
|
||||
probeTimeoutMs: 7,
|
||||
readyTimeoutMs: 50,
|
||||
sleepMs: 1,
|
||||
stderrPath: "/no/such/stderr.log",
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(readyzCanceled).toBe(true);
|
||||
expect(healthzCanceled).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user