mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(cli): preserve failure exit semantics (#112210)
This commit is contained in:
+1
-1
@@ -58,7 +58,7 @@ openclaw agent --agent ops --message "Run locally" --local
|
||||
- `--session-key` selects an explicit session key. Agent-prefixed keys must use `agent:<agent-id>:<session-key>`, and `--agent` must match the key's agent id when both are given. Bare non-sentinel keys scope to `--agent` when supplied, or to the configured default agent otherwise; for example `--agent ops --session-key incident-42` routes to `agent:ops:incident-42`. The literal keys `global` and `unknown` stay unscoped only when no `--agent` is supplied.
|
||||
- `--json` reserves stdout for the JSON response; Gateway, plugin, and `--local` diagnostics go to stderr so scripts can parse stdout directly.
|
||||
- After transient handshake retries are exhausted, a Gateway timeout or closed connection fails the command; the CLI never silently reruns the turn embedded. Transport loss is ambiguous — the Gateway may have accepted and may still finish the turn — so the stderr hint says to check `openclaw gateway status` and the session transcript before retrying or rerunning with `--local`, to avoid executing the turn twice.
|
||||
- `SIGTERM`/`SIGINT` interrupt a waiting Gateway-backed request; if the Gateway already accepted the run, the CLI also sends `chat.abort` for that run id before exiting. `--local` runs receive the same signal but do not send `chat.abort`. If the internal run-dedup key already has an active run for this session, the response reports `status: "in_flight"` and the non-JSON CLI prints a stderr diagnostic instead of an empty reply. For external cron/systemd wrappers, keep a hard-kill backstop such as `timeout -k 60 600 openclaw agent ...` so the supervisor can reap the process if shutdown cannot drain.
|
||||
- `SIGTERM`/`SIGINT` interrupt a waiting Gateway-backed request; if the Gateway already accepted the run, the CLI also sends `chat.abort` for that run id before exiting. `--local` runs receive the same signal but do not send `chat.abort`. A launcher child that terminates from the first forwarded `SIGINT` or `SIGTERM` exits with status 130 or 143, respectively. If the internal run-dedup key already has an active run for this session, the response reports `status: "in_flight"` and the non-JSON CLI prints a stderr diagnostic instead of an empty reply. For external cron/systemd wrappers, keep a hard-kill backstop such as `timeout -k 60 600 openclaw agent ...` so the supervisor can reap the process if shutdown cannot drain.
|
||||
- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), never resolved secret plaintext. Marker writes come from the active source config snapshot, not from resolved runtime secret values.
|
||||
|
||||
## JSON delivery status
|
||||
|
||||
+15
-3
@@ -140,6 +140,8 @@ const runRespawnedChild = (command, args, env) => {
|
||||
let signalExitTimer = null;
|
||||
let signalForceKillTimer = null;
|
||||
let signalHardExitTimer = null;
|
||||
let firstForwardedSignal = null;
|
||||
let hardKillBackstopStarted = false;
|
||||
const detach = () => {
|
||||
for (const [signal, listener] of listeners) {
|
||||
process.off(signal, listener);
|
||||
@@ -172,6 +174,7 @@ const runRespawnedChild = (command, args, env) => {
|
||||
// Best-effort shutdown fallback.
|
||||
}
|
||||
signalForceKillTimer = setTimeout(() => {
|
||||
hardKillBackstopStarted = true;
|
||||
forceKillChild();
|
||||
signalHardExitTimer = setTimeout(() => {
|
||||
process.exit(1);
|
||||
@@ -180,7 +183,8 @@ const runRespawnedChild = (command, args, env) => {
|
||||
}, respawnSignalForceKillGraceMs);
|
||||
signalForceKillTimer.unref?.();
|
||||
};
|
||||
const scheduleParentExit = () => {
|
||||
const scheduleParentExit = (signal) => {
|
||||
firstForwardedSignal ??= signal;
|
||||
if (signalExitTimer) {
|
||||
return;
|
||||
}
|
||||
@@ -196,7 +200,7 @@ const runRespawnedChild = (command, args, env) => {
|
||||
} catch {
|
||||
// Best-effort signal forwarding.
|
||||
}
|
||||
scheduleParentExit();
|
||||
scheduleParentExit(signal);
|
||||
};
|
||||
try {
|
||||
process.on(signal, listener);
|
||||
@@ -208,7 +212,15 @@ const runRespawnedChild = (command, args, env) => {
|
||||
child.once("exit", (code, signal) => {
|
||||
detach();
|
||||
if (signal) {
|
||||
process.exit(1);
|
||||
const forwardedSignalExitCode =
|
||||
!hardKillBackstopStarted && signal === firstForwardedSignal
|
||||
? signal === "SIGINT"
|
||||
? 130
|
||||
: signal === "SIGTERM"
|
||||
? 143
|
||||
: undefined
|
||||
: undefined;
|
||||
process.exit(forwardedSignalExitCode ?? 1);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Gateway run option collision tests cover gateway run flag registration boundaries.
|
||||
import { createServer } from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CONFIG_AUDIT_STORE_LABEL } from "../../config/io.audit.js";
|
||||
@@ -1595,6 +1596,46 @@ describe("gateway run option collisions", () => {
|
||||
expect(writeDiagnosticStabilityBundleForFailureSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"gateway already running (pid 4242); lock timeout after 5000ms",
|
||||
"another gateway instance is already listening on ws://127.0.0.1",
|
||||
])("exits 1 for unmanaged healthy-port lock conflicts: %s", async (message) => {
|
||||
const healthyGateway = createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, status: "live" }));
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
healthyGateway.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = healthyGateway.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected TCP server address");
|
||||
}
|
||||
const port = address.port;
|
||||
configState.snapshot = {
|
||||
config: { gateway: { port } },
|
||||
exists: false,
|
||||
sourceConfig: {},
|
||||
valid: true,
|
||||
};
|
||||
const err = Object.assign(new Error(`${message}:${port}`), {
|
||||
name: "GatewayLockError",
|
||||
});
|
||||
startGatewayServer.mockRejectedValueOnce(err);
|
||||
|
||||
try {
|
||||
await withEnvAsync(withoutSupervisorEnv, async () => {
|
||||
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
healthyGateway.close((closeError) => (closeError ? reject(closeError) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks startup when the observed snapshot loses gateway.mode", async () => {
|
||||
configState.cfg = {
|
||||
gateway: {
|
||||
|
||||
@@ -4,6 +4,14 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { GatewayLockError } from "../../infra/gateway-lock.js";
|
||||
import { testing } from "./run.test-support.js";
|
||||
|
||||
const loadGatewayTlsRuntimeMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ enabled: false, required: true })),
|
||||
);
|
||||
|
||||
vi.mock("../../infra/tls/gateway.js", () => ({
|
||||
loadGatewayTlsRuntime: loadGatewayTlsRuntimeMock,
|
||||
}));
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
@@ -61,26 +69,28 @@ describe("supervised gateway lock recovery", () => {
|
||||
});
|
||||
const probeHealth = vi.fn(async () => true);
|
||||
|
||||
await expect(
|
||||
testing.runGatewayLoopWithSupervisedLockRecovery({
|
||||
let failure: unknown;
|
||||
try {
|
||||
await testing.runGatewayLoopWithSupervisedLockRecovery({
|
||||
startLoop,
|
||||
supervisor: "systemd",
|
||||
port: 18789,
|
||||
healthHost: "127.0.0.1",
|
||||
log: createLogger(),
|
||||
probeHealth,
|
||||
}),
|
||||
).rejects.toThrow("exiting with code 78 to prevent a systemd Restart=always loop");
|
||||
});
|
||||
} catch (err) {
|
||||
failure = err;
|
||||
}
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"exiting with code 78 to prevent a systemd Restart=always loop",
|
||||
),
|
||||
});
|
||||
expect(startLoop).toHaveBeenCalledTimes(1);
|
||||
expect(probeHealth).toHaveBeenCalledWith({ host: "127.0.0.1", port: 18789 });
|
||||
expect(
|
||||
testing.resolveGatewayLockErrorExitCode(
|
||||
new GatewayLockError("gateway already running under systemd; existing gateway is healthy"),
|
||||
"systemd",
|
||||
true,
|
||||
),
|
||||
).toBe(78);
|
||||
expect(testing.resolveGatewayLockErrorExitCode(failure)).toBe(78);
|
||||
});
|
||||
|
||||
it("bounds supervised retries when the existing gateway stays unhealthy", async () => {
|
||||
@@ -92,8 +102,9 @@ describe("supervised gateway lock recovery", () => {
|
||||
now += ms;
|
||||
});
|
||||
|
||||
await expect(
|
||||
testing.runGatewayLoopWithSupervisedLockRecovery({
|
||||
let failure: unknown;
|
||||
try {
|
||||
await testing.runGatewayLoopWithSupervisedLockRecovery({
|
||||
startLoop,
|
||||
supervisor: "systemd",
|
||||
port: 18789,
|
||||
@@ -104,11 +115,16 @@ describe("supervised gateway lock recovery", () => {
|
||||
sleep,
|
||||
retryMs: 5,
|
||||
timeoutMs: 12,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"gateway already running under systemd; existing gateway did not become healthy after 12ms",
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
failure = err;
|
||||
}
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
message:
|
||||
"gateway already running under systemd; existing gateway did not become healthy after 12ms",
|
||||
});
|
||||
expect(testing.resolveGatewayLockErrorExitCode(failure)).toBe(1);
|
||||
expect(startLoop).toHaveBeenCalledTimes(4);
|
||||
expect(sleep).toHaveBeenNthCalledWith(1, 5);
|
||||
expect(sleep).toHaveBeenNthCalledWith(2, 5);
|
||||
@@ -149,11 +165,31 @@ describe("supervised gateway lock recovery", () => {
|
||||
expect(sleep).toHaveBeenNthCalledWith(3, 2);
|
||||
});
|
||||
|
||||
it("requires a confirmed healthy gateway for unmanaged duplicate starts", () => {
|
||||
const err = new GatewayLockError("another gateway instance is already listening");
|
||||
it.each(["gateway already running", "another gateway instance is already listening"])(
|
||||
"uses exit 1 for unmanaged lock errors: %s",
|
||||
(message) => {
|
||||
expect(testing.resolveGatewayLockErrorExitCode(new GatewayLockError(message))).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
expect(testing.resolveGatewayLockErrorExitCode(err, null, false)).toBe(1);
|
||||
expect(testing.resolveGatewayLockErrorExitCode(err, null, true)).toBe(0);
|
||||
it("retries non-mutating TLS fingerprint loads until certificate material is ready", async () => {
|
||||
loadGatewayTlsRuntimeMock.mockClear();
|
||||
const probeHealth = testing.createConfiguredGatewayHealthProbe({
|
||||
gateway: { tls: { enabled: true, autoGenerate: true } },
|
||||
});
|
||||
|
||||
await expect(probeHealth({ host: "127.0.0.1", port: 18789 })).resolves.toBe(false);
|
||||
await expect(probeHealth({ host: "127.0.0.1", port: 18789 })).resolves.toBe(false);
|
||||
|
||||
expect(loadGatewayTlsRuntimeMock).toHaveBeenCalledTimes(2);
|
||||
expect(loadGatewayTlsRuntimeMock).toHaveBeenNthCalledWith(1, {
|
||||
enabled: true,
|
||||
autoGenerate: false,
|
||||
});
|
||||
expect(loadGatewayTlsRuntimeMock).toHaveBeenNthCalledWith(2, {
|
||||
enabled: true,
|
||||
autoGenerate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes only the OpenClaw health response", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { RespawnSupervisor } from "../../infra/supervisor-markers.js";
|
||||
import "./run.js";
|
||||
|
||||
@@ -7,6 +8,9 @@ type GatewayRunTestLogger = {
|
||||
};
|
||||
|
||||
type GatewayRunTestApi = {
|
||||
createConfiguredGatewayHealthProbe(
|
||||
cfg: OpenClawConfig,
|
||||
): (params: { host: string; port: number }) => Promise<boolean>;
|
||||
isGatewayHealthzResponse(statusCode: number | undefined, body: string): boolean;
|
||||
normalizeGatewayHealthProbeHost(host: string): string;
|
||||
probeGatewayHealthz(params: {
|
||||
@@ -15,11 +19,7 @@ type GatewayRunTestApi = {
|
||||
timeoutMs?: number;
|
||||
tlsFingerprint?: string;
|
||||
}): Promise<boolean>;
|
||||
resolveGatewayLockErrorExitCode(
|
||||
err: unknown,
|
||||
supervisor: RespawnSupervisor | null,
|
||||
healthyGatewayConfirmed: boolean,
|
||||
): number;
|
||||
resolveGatewayLockErrorExitCode(err: unknown): number;
|
||||
resolveGatewayStartupFailureExitCode(err: unknown): number;
|
||||
runGatewayLoopWithSupervisedLockRecovery(params: {
|
||||
startLoop: () => Promise<void>;
|
||||
@@ -42,6 +42,9 @@ function getTestApi(): GatewayRunTestApi {
|
||||
}
|
||||
|
||||
export const testing: GatewayRunTestApi = {
|
||||
createConfiguredGatewayHealthProbe(cfg) {
|
||||
return getTestApi().createConfiguredGatewayHealthProbe(cfg);
|
||||
},
|
||||
isGatewayHealthzResponse(statusCode, body) {
|
||||
return getTestApi().isGatewayHealthzResponse(statusCode, body);
|
||||
},
|
||||
@@ -51,8 +54,8 @@ export const testing: GatewayRunTestApi = {
|
||||
async probeGatewayHealthz(params) {
|
||||
return await getTestApi().probeGatewayHealthz(params);
|
||||
},
|
||||
resolveGatewayLockErrorExitCode(err, supervisor, healthyGatewayConfirmed) {
|
||||
return getTestApi().resolveGatewayLockErrorExitCode(err, supervisor, healthyGatewayConfirmed);
|
||||
resolveGatewayLockErrorExitCode(err) {
|
||||
return getTestApi().resolveGatewayLockErrorExitCode(err);
|
||||
},
|
||||
resolveGatewayStartupFailureExitCode(err) {
|
||||
return getTestApi().resolveGatewayStartupFailureExitCode(err);
|
||||
|
||||
+40
-33
@@ -458,15 +458,18 @@ function isGatewayAlreadyRunningLockError(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGatewayLockErrorExitCode(
|
||||
err: unknown,
|
||||
supervisor: RespawnSupervisor | null,
|
||||
healthyGatewayConfirmed: boolean,
|
||||
): number {
|
||||
if (supervisor === "systemd" && isGatewayAlreadyRunningLockError(err)) {
|
||||
return EXIT_CONFIG_ERROR;
|
||||
class SupervisedGatewayLockError extends GatewayLockError {
|
||||
constructor(
|
||||
message: string,
|
||||
cause: unknown,
|
||||
readonly exitCode: 1 | typeof EXIT_CONFIG_ERROR,
|
||||
) {
|
||||
super(message, cause);
|
||||
}
|
||||
return healthyGatewayConfirmed && isGatewayAlreadyRunningLockError(err) ? 0 : 1;
|
||||
}
|
||||
|
||||
function resolveGatewayLockErrorExitCode(err: unknown): number {
|
||||
return err instanceof SupervisedGatewayLockError ? err.exitCode : 1;
|
||||
}
|
||||
|
||||
function resolveGatewayStartupFailureExitCode(err: unknown): number {
|
||||
@@ -566,6 +569,28 @@ async function probeGatewayHealthz(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function createConfiguredGatewayHealthProbe(cfg: OpenClawConfig) {
|
||||
const tlsConfig = cfg.gateway?.tls;
|
||||
let tlsFingerprint: string | undefined;
|
||||
return async (params: { host: string; port: number }): Promise<boolean> => {
|
||||
if (tlsConfig?.enabled !== true) {
|
||||
return await probeGatewayHealthz(params);
|
||||
}
|
||||
if (!tlsFingerprint) {
|
||||
const gatewayTls = await import("../../infra/tls/gateway.js")
|
||||
.then(({ loadGatewayTlsRuntime }) =>
|
||||
loadGatewayTlsRuntime({ ...tlsConfig, autoGenerate: false }),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
tlsFingerprint = gatewayTls?.fingerprintSha256;
|
||||
}
|
||||
if (!tlsFingerprint) {
|
||||
return false;
|
||||
}
|
||||
return await probeGatewayHealthz({ ...params, tlsFingerprint });
|
||||
};
|
||||
}
|
||||
|
||||
async function runGatewayLoopWithSupervisedLockRecovery(params: {
|
||||
startLoop: () => Promise<void>;
|
||||
supervisor: RespawnSupervisor | null;
|
||||
@@ -607,9 +632,10 @@ async function runGatewayLoopWithSupervisedLockRecovery(params: {
|
||||
|
||||
if (await probeHealth({ host: params.healthHost, port: params.port })) {
|
||||
if (supervisor === "systemd") {
|
||||
throw new GatewayLockError(
|
||||
throw new SupervisedGatewayLockError(
|
||||
"gateway already running under systemd; existing gateway is healthy, exiting with code 78 to prevent a systemd Restart=always loop",
|
||||
err,
|
||||
EXIT_CONFIG_ERROR,
|
||||
);
|
||||
}
|
||||
params.log.info(
|
||||
@@ -620,9 +646,10 @@ async function runGatewayLoopWithSupervisedLockRecovery(params: {
|
||||
|
||||
const elapsedMs = now() - startedAt;
|
||||
if (elapsedMs >= timeoutMs) {
|
||||
throw new GatewayLockError(
|
||||
throw new SupervisedGatewayLockError(
|
||||
`gateway already running under ${supervisor}; existing gateway did not become healthy after ${timeoutMs}ms`,
|
||||
err,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1162,6 +1189,7 @@ async function runGatewayCommandOnce(opts: GatewayRunOpts, hooks: GatewayRunRunt
|
||||
port,
|
||||
healthHost,
|
||||
log: gatewayLog,
|
||||
probeHealth: createConfiguredGatewayHealthProbe(cfg),
|
||||
});
|
||||
} catch (err) {
|
||||
if (isGatewayLockError(err)) {
|
||||
@@ -1182,29 +1210,7 @@ async function runGatewayCommandOnce(opts: GatewayRunOpts, hooks: GatewayRunRunt
|
||||
}
|
||||
const { maybeExplainGatewayServiceStop } = await import("./shared.js");
|
||||
await maybeExplainGatewayServiceStop();
|
||||
// An occupied port is only a healthy duplicate when the listener proves
|
||||
// the OpenClaw liveness contract. Arbitrary HTTP listeners must fail.
|
||||
const gatewayTls =
|
||||
isGatewayAlreadyRunningLockError(err) && cfg.gateway?.tls?.enabled === true
|
||||
? await import("../../infra/tls/gateway.js")
|
||||
.then(({ loadGatewayTlsRuntime }) => loadGatewayTlsRuntime(cfg.gateway?.tls))
|
||||
.catch(() => undefined)
|
||||
: undefined;
|
||||
const canProbeGateway =
|
||||
cfg.gateway?.tls?.enabled !== true || Boolean(gatewayTls?.fingerprintSha256);
|
||||
const healthyGatewayConfirmed =
|
||||
isGatewayAlreadyRunningLockError(err) &&
|
||||
canProbeGateway &&
|
||||
(await probeGatewayHealthz({
|
||||
host: healthHost,
|
||||
port,
|
||||
...(gatewayTls?.fingerprintSha256
|
||||
? { tlsFingerprint: gatewayTls.fingerprintSha256 }
|
||||
: {}),
|
||||
}));
|
||||
defaultRuntime.exit(
|
||||
resolveGatewayLockErrorExitCode(err, supervisor, healthyGatewayConfirmed),
|
||||
);
|
||||
defaultRuntime.exit(resolveGatewayLockErrorExitCode(err));
|
||||
return;
|
||||
}
|
||||
if (isInvalidConfigError(err)) {
|
||||
@@ -1249,6 +1255,7 @@ export async function runGatewayCommand(
|
||||
}
|
||||
|
||||
const testing = {
|
||||
createConfiguredGatewayHealthProbe,
|
||||
isGatewayHealthzResponse,
|
||||
normalizeGatewayHealthProbeHost,
|
||||
probeGatewayHealthz,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Process coverage for help rendering without loading live Gateway transports.
|
||||
// Process coverage for CLI help exits and route-first fallback validation.
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -52,7 +52,7 @@ registerHooks({
|
||||
return { root, stateDir, configPath, tlsImportGuardPath, keepAlivePath };
|
||||
}
|
||||
|
||||
async function runHelpProcess(params: {
|
||||
async function runCliProcess(params: {
|
||||
args: string[];
|
||||
forbidTlsImport?: boolean;
|
||||
keepAlive?: boolean;
|
||||
@@ -90,12 +90,26 @@ async function runHelpProcess(params: {
|
||||
);
|
||||
}
|
||||
|
||||
type CliProcessFailure = Error & {
|
||||
code?: number | string;
|
||||
stderr?: string;
|
||||
};
|
||||
|
||||
async function runCliProcessExpectFailure(args: string[]): Promise<CliProcessFailure> {
|
||||
try {
|
||||
await runCliProcess({ args });
|
||||
} catch (error) {
|
||||
return error as CliProcessFailure;
|
||||
}
|
||||
throw new Error(`expected CLI process failure for ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
describe("CLI help process exit", () => {
|
||||
it.each([
|
||||
{ args: ["--help"], usage: "Usage: openclaw [options] [command]" },
|
||||
{ args: ["path", "--help"], usage: "Usage: openclaw path [options] [command]" },
|
||||
])("exits promptly after $args", async ({ args, usage }) => {
|
||||
const result = await runHelpProcess({ args, forbidTlsImport: true });
|
||||
const result = await runCliProcess({ args, forbidTlsImport: true });
|
||||
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain(usage);
|
||||
@@ -103,9 +117,24 @@ describe("CLI help process exit", () => {
|
||||
|
||||
it.each(LAZY_GROUP_HELP_CASES)("exits promptly after $group --help", async (testCase) => {
|
||||
const { group, usageCommand } = testCase;
|
||||
const result = await runHelpProcess({ args: [group, "--help"], keepAlive: true });
|
||||
const result = await runCliProcess({ args: [group, "--help"], keepAlive: true });
|
||||
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain(`Usage: openclaw ${usageCommand} [options] [command]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route-first CLI process rejection", () => {
|
||||
it.each([
|
||||
{ name: "health", args: ["health", "--wat"], option: "--wat" },
|
||||
{ name: "status", args: ["status", "--wat"], option: "--wat" },
|
||||
{ name: "sessions", args: ["sessions", "--wat"], option: "--wat" },
|
||||
{ name: "agents list", args: ["agents", "list", "--wat"], option: "--wat" },
|
||||
{ name: "bare agents", args: ["agents", "--wat"], option: "--wat" },
|
||||
])("rejects unknown $name options with a nonzero exit", async ({ args, option }) => {
|
||||
const failure = await runCliProcessExpectFailure(args);
|
||||
|
||||
expect(failure.code).toBe(1);
|
||||
expect(failure.stderr).toContain(`does not recognize option "${option}"`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseAgentsListRouteArgs,
|
||||
parseChannelsListRouteArgs,
|
||||
parseChannelsStatusRouteArgs,
|
||||
parseConfigGetRouteArgs,
|
||||
parseConfigUnsetRouteArgs,
|
||||
parseGatewayStatusRouteArgs,
|
||||
@@ -86,6 +88,101 @@ describe("route-args", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "health unknown flag",
|
||||
parse: parseHealthRouteArgs,
|
||||
argv: ["node", "openclaw", "health", "--wat"],
|
||||
},
|
||||
{
|
||||
name: "health stray positional",
|
||||
parse: parseHealthRouteArgs,
|
||||
argv: ["node", "openclaw", "health", "extra"],
|
||||
},
|
||||
{
|
||||
name: "health flag terminator",
|
||||
parse: parseHealthRouteArgs,
|
||||
argv: ["node", "openclaw", "health", "--", "--json"],
|
||||
},
|
||||
{
|
||||
name: "status malformed arity",
|
||||
parse: parseStatusRouteArgs,
|
||||
argv: ["node", "openclaw", "status", "--timeout"],
|
||||
},
|
||||
{
|
||||
name: "status unknown flag",
|
||||
parse: parseStatusRouteArgs,
|
||||
argv: ["node", "openclaw", "status", "--wat"],
|
||||
},
|
||||
{
|
||||
name: "sessions stray subcommand",
|
||||
parse: parseSessionsRouteArgs,
|
||||
argv: ["node", "openclaw", "sessions", "cleanup"],
|
||||
},
|
||||
{
|
||||
name: "sessions unknown flag",
|
||||
parse: parseSessionsRouteArgs,
|
||||
argv: ["node", "openclaw", "sessions", "--wat"],
|
||||
},
|
||||
{
|
||||
name: "sessions flag terminator",
|
||||
parse: parseSessionsRouteArgs,
|
||||
argv: ["node", "openclaw", "sessions", "--", "--json"],
|
||||
},
|
||||
{
|
||||
name: "agents list stray positional",
|
||||
parse: parseAgentsListRouteArgs,
|
||||
argv: ["node", "openclaw", "agents", "list", "extra"],
|
||||
},
|
||||
{
|
||||
name: "agents list unknown flag",
|
||||
parse: parseAgentsListRouteArgs,
|
||||
argv: ["node", "openclaw", "agents", "list", "--wat"],
|
||||
},
|
||||
{
|
||||
name: "agents list flag terminator",
|
||||
parse: parseAgentsListRouteArgs,
|
||||
argv: ["node", "openclaw", "agents", "list", "--", "--json"],
|
||||
},
|
||||
{
|
||||
name: "bare agents unknown flag",
|
||||
parse: parseAgentsListRouteArgs,
|
||||
argv: ["node", "openclaw", "agents", "--wat"],
|
||||
},
|
||||
])("defers unsupported routed argv: $name", ({ parse, argv }) => {
|
||||
expect(parse(argv)).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves equals forms and root options on routed argv", () => {
|
||||
expect(
|
||||
parseHealthRouteArgs([
|
||||
"node",
|
||||
"openclaw",
|
||||
"--profile",
|
||||
"work",
|
||||
"health",
|
||||
"--timeout=5000",
|
||||
"--json",
|
||||
]),
|
||||
).toEqual({ json: true, verbose: false, timeoutMs: 5000 });
|
||||
expect(
|
||||
parseSessionsRouteArgs(["node", "openclaw", "sessions", "--agent=default", "--limit=25"]),
|
||||
).toMatchObject({ agent: "default", limit: "25" });
|
||||
expect(
|
||||
parseAgentsListRouteArgs([
|
||||
"node",
|
||||
"openclaw",
|
||||
"--log-level=debug",
|
||||
"agents",
|
||||
"list",
|
||||
"--json",
|
||||
]),
|
||||
).toEqual({ json: true, bindings: false });
|
||||
expect(
|
||||
parseAgentsListRouteArgs(["node", "openclaw", "agents", "--json", "--bindings"]),
|
||||
).toEqual({ json: true, bindings: true });
|
||||
});
|
||||
|
||||
it("parses gateway status route args and rejects probe-only ssh flags", () => {
|
||||
expect(
|
||||
parseGatewayStatusRouteArgs([
|
||||
@@ -282,4 +379,34 @@ describe("route-args", () => {
|
||||
parseModelsStatusRouteArgs(["node", "openclaw", "models", "status", "--probe-profile"]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "gateway status",
|
||||
parse: parseGatewayStatusRouteArgs,
|
||||
argv: ["node", "openclaw", "gateway", "status", "--wat"],
|
||||
},
|
||||
{
|
||||
name: "models list",
|
||||
parse: parseModelsListRouteArgs,
|
||||
argv: ["node", "openclaw", "models", "list", "--wat"],
|
||||
},
|
||||
{
|
||||
name: "models status",
|
||||
parse: parseModelsStatusRouteArgs,
|
||||
argv: ["node", "openclaw", "models", "status", "--wat"],
|
||||
},
|
||||
{
|
||||
name: "channels list",
|
||||
parse: parseChannelsListRouteArgs,
|
||||
argv: ["node", "openclaw", "channels", "list", "--wat"],
|
||||
},
|
||||
{
|
||||
name: "channels status",
|
||||
parse: parseChannelsStatusRouteArgs,
|
||||
argv: ["node", "openclaw", "channels", "status", "--wat"],
|
||||
},
|
||||
])("defers unknown options for sibling routed parser: $name", ({ parse, argv }) => {
|
||||
expect(parse(argv)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,22 @@ function parseRepeatedFlagValues(argv: string[], name: string): string[] | null
|
||||
return values;
|
||||
}
|
||||
|
||||
type RoutedCommandArgShape = {
|
||||
commandPath: string[];
|
||||
booleanFlags?: string[];
|
||||
valueFlags?: string[];
|
||||
};
|
||||
|
||||
function getRoutedCommandPositionals(
|
||||
argv: string[],
|
||||
shape: RoutedCommandArgShape,
|
||||
): string[] | null {
|
||||
if (argv.slice(2).includes("--")) {
|
||||
return null;
|
||||
}
|
||||
return getCommandPositionalsWithRootOptions(argv, shape);
|
||||
}
|
||||
|
||||
function parseSinglePositional(
|
||||
argv: string[],
|
||||
params: {
|
||||
@@ -58,7 +74,7 @@ function parseSinglePositional(
|
||||
booleanFlags?: string[];
|
||||
},
|
||||
): string | null {
|
||||
const positionals = getCommandPositionalsWithRootOptions(argv, params);
|
||||
const positionals = getRoutedCommandPositionals(argv, params);
|
||||
if (!positionals || positionals.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -67,6 +83,14 @@ function parseSinglePositional(
|
||||
|
||||
/** Parse `openclaw health` flags for the route-first status family. */
|
||||
export function parseHealthRouteArgs(argv: string[]) {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["health"],
|
||||
booleanFlags: ["--json", "--verbose", "--debug"],
|
||||
valueFlags: ["--timeout"],
|
||||
});
|
||||
if (!positionals || positionals.length !== 0) {
|
||||
return null;
|
||||
}
|
||||
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
|
||||
if (timeoutMs === null) {
|
||||
return null;
|
||||
@@ -80,6 +104,14 @@ export function parseHealthRouteArgs(argv: string[]) {
|
||||
|
||||
/** Parse `openclaw status` flags without registering the full command tree. */
|
||||
export function parseStatusRouteArgs(argv: string[]) {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["status"],
|
||||
booleanFlags: ["--json", "--deep", "--all", "--usage", "--verbose", "--debug"],
|
||||
valueFlags: ["--timeout"],
|
||||
});
|
||||
if (!positionals || positionals.length !== 0) {
|
||||
return null;
|
||||
}
|
||||
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
|
||||
if (timeoutMs === null) {
|
||||
return null;
|
||||
@@ -96,6 +128,14 @@ export function parseStatusRouteArgs(argv: string[]) {
|
||||
|
||||
/** Parse `openclaw gateway status` RPC-only flags accepted by the fast route. */
|
||||
export function parseGatewayStatusRouteArgs(argv: string[]) {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["gateway", "status"],
|
||||
booleanFlags: ["--deep", "--json", "--require-rpc", "--no-probe", "--ssh-auto"],
|
||||
valueFlags: ["--url", "--token", "--password", "--timeout", "--ssh", "--ssh-identity"],
|
||||
});
|
||||
if (!positionals || positionals.length !== 0) {
|
||||
return null;
|
||||
}
|
||||
const url = parseOptionalFlagValue(argv, "--url");
|
||||
if (!url.ok) {
|
||||
return null;
|
||||
@@ -140,6 +180,14 @@ export function parseGatewayStatusRouteArgs(argv: string[]) {
|
||||
|
||||
/** Parse `openclaw sessions` filters for JSON/list route execution. */
|
||||
export function parseSessionsRouteArgs(argv: string[]) {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["sessions"],
|
||||
booleanFlags: ["--json", "--all-agents"],
|
||||
valueFlags: ["--agent", "--store", "--active", "--limit"],
|
||||
});
|
||||
if (!positionals || positionals.length !== 0) {
|
||||
return null;
|
||||
}
|
||||
const agent = parseOptionalFlagValue(argv, "--agent");
|
||||
if (!agent.ok) {
|
||||
return null;
|
||||
@@ -168,10 +216,23 @@ export function parseSessionsRouteArgs(argv: string[]) {
|
||||
|
||||
/** Parse `openclaw agents list` display switches for route-first execution. */
|
||||
export function parseAgentsListRouteArgs(argv: string[]) {
|
||||
return {
|
||||
json: hasFlag(argv, "--json"),
|
||||
bindings: hasFlag(argv, "--bindings"),
|
||||
};
|
||||
const listPositionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["agents", "list"],
|
||||
booleanFlags: ["--json", "--bindings"],
|
||||
});
|
||||
if (listPositionals && listPositionals.length === 0) {
|
||||
return {
|
||||
json: hasFlag(argv, "--json"),
|
||||
bindings: hasFlag(argv, "--bindings"),
|
||||
};
|
||||
}
|
||||
const aliasPositionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["agents"],
|
||||
booleanFlags: ["--json", "--bindings"],
|
||||
});
|
||||
return aliasPositionals?.length === 0
|
||||
? { json: hasFlag(argv, "--json"), bindings: hasFlag(argv, "--bindings") }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Parse `openclaw config get <path>` while preserving root option handling. */
|
||||
@@ -210,6 +271,14 @@ export function parseConfigUnsetRouteArgs(argv: string[]) {
|
||||
|
||||
/** Parse `openclaw models list` filters for the lightweight model catalog route. */
|
||||
export function parseModelsListRouteArgs(argv: string[]) {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["models", "list"],
|
||||
booleanFlags: ["--all", "--local", "--json", "--plain"],
|
||||
valueFlags: ["--provider"],
|
||||
});
|
||||
if (!positionals || positionals.length !== 0) {
|
||||
return null;
|
||||
}
|
||||
const provider = parseOptionalFlagValue(argv, "--provider");
|
||||
if (!provider.ok) {
|
||||
return null;
|
||||
@@ -225,6 +294,21 @@ export function parseModelsListRouteArgs(argv: string[]) {
|
||||
|
||||
/** Parse `openclaw models status` probe controls for the route-first status path. */
|
||||
export function parseModelsStatusRouteArgs(argv: string[]) {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["models", "status"],
|
||||
booleanFlags: ["--json", "--plain", "--check", "--probe"],
|
||||
valueFlags: [
|
||||
"--probe-provider",
|
||||
"--probe-timeout",
|
||||
"--probe-concurrency",
|
||||
"--probe-max-tokens",
|
||||
"--probe-profile",
|
||||
"--agent",
|
||||
],
|
||||
});
|
||||
if (!positionals || positionals.length !== 0) {
|
||||
return null;
|
||||
}
|
||||
const probeProvider = parseOptionalFlagValue(argv, "--probe-provider");
|
||||
if (!probeProvider.ok) {
|
||||
return null;
|
||||
@@ -271,6 +355,13 @@ export function parseModelsStatusRouteArgs(argv: string[]) {
|
||||
|
||||
/** Parse `openclaw channels list` display flags for the route-first list path. */
|
||||
export function parseChannelsListRouteArgs(argv: string[]) {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["channels", "list"],
|
||||
booleanFlags: ["--json", "--all"],
|
||||
});
|
||||
if (!positionals || positionals.length !== 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
json: hasFlag(argv, "--json"),
|
||||
all: hasFlag(argv, "--all"),
|
||||
@@ -279,6 +370,14 @@ export function parseChannelsListRouteArgs(argv: string[]) {
|
||||
|
||||
/** Parse `openclaw channels status` probe flags without full CLI registration. */
|
||||
export function parseChannelsStatusRouteArgs(argv: string[]) {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["channels", "status"],
|
||||
booleanFlags: ["--json", "--probe"],
|
||||
valueFlags: ["--timeout", "--channel"],
|
||||
});
|
||||
if (!positionals || positionals.length !== 0) {
|
||||
return null;
|
||||
}
|
||||
const timeout = parseOptionalFlagValue(argv, "--timeout");
|
||||
const channel = parseOptionalFlagValue(argv, "--channel");
|
||||
if (!timeout.ok) {
|
||||
@@ -300,7 +399,7 @@ export function parsePluginsListRouteArgs(argv: string[]) {
|
||||
if (!hasFlag(argv, "--json")) {
|
||||
return null;
|
||||
}
|
||||
const positionals = getCommandPositionalsWithRootOptions(argv, {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["plugins", "list"],
|
||||
booleanFlags: ["--json", "--enabled", "--verbose"],
|
||||
});
|
||||
@@ -318,7 +417,7 @@ function parseTasksListRouteArgsForCommandPath(argv: string[], commandPath: stri
|
||||
if (!hasFlag(argv, "--json")) {
|
||||
return null;
|
||||
}
|
||||
const positionals = getCommandPositionalsWithRootOptions(argv, {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath,
|
||||
booleanFlags: ["--json"],
|
||||
valueFlags: ["--runtime", "--status"],
|
||||
@@ -354,7 +453,7 @@ export function parseTasksAuditRouteArgs(argv: string[]) {
|
||||
if (!hasFlag(argv, "--json")) {
|
||||
return null;
|
||||
}
|
||||
const positionals = getCommandPositionalsWithRootOptions(argv, {
|
||||
const positionals = getRoutedCommandPositionals(argv, {
|
||||
commandPath: ["tasks", "audit"],
|
||||
booleanFlags: ["--json"],
|
||||
valueFlags: ["--severity", "--code", "--limit"],
|
||||
|
||||
@@ -284,6 +284,22 @@ describe("program routes", () => {
|
||||
await expectRunFalse(["status"], ["node", "openclaw", "status", "--timeout"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ path: ["health"], argv: ["node", "openclaw", "health", "--wat"] },
|
||||
{ path: ["status"], argv: ["node", "openclaw", "status", "--wat"] },
|
||||
{ path: ["sessions"], argv: ["node", "openclaw", "sessions", "--wat"] },
|
||||
{
|
||||
path: ["agents", "list"],
|
||||
argv: ["node", "openclaw", "agents", "list", "--wat"],
|
||||
},
|
||||
{ path: ["agents"], argv: ["node", "openclaw", "agents", "--wat"] },
|
||||
])(
|
||||
"returns false instead of handling unknown routed option for $path",
|
||||
async ({ path, argv }) => {
|
||||
await expectRunFalse(path, argv);
|
||||
},
|
||||
);
|
||||
|
||||
it("routes status --json through the lean JSON command", async () => {
|
||||
const route = expectRoute(["status"]);
|
||||
await expect(
|
||||
|
||||
@@ -50,10 +50,41 @@ describe("runRespawnChildWithSignalBridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ signal: "SIGINT" as const, laterSignal: "SIGTERM" as const, exitCode: 130 },
|
||||
{ signal: "SIGTERM" as const, laterSignal: "SIGINT" as const, exitCode: 143 },
|
||||
])("exits $exitCode when the child exits by forwarded $signal", (testCase) => {
|
||||
const { child } = createChild(2345);
|
||||
const exit = vi.fn();
|
||||
let onSignal: ((signal: NodeJS.Signals) => void) | undefined;
|
||||
|
||||
runRespawnChildWithSignalBridge({
|
||||
command: "/usr/bin/node",
|
||||
args: ["/repo/openclaw/dist/entry.js"],
|
||||
env: {},
|
||||
runtime: {
|
||||
spawn: vi.fn(() => child) as unknown as typeof spawn,
|
||||
attachChildProcessBridge: vi.fn((_child, options) => {
|
||||
onSignal = options?.onSignal;
|
||||
return { detach: vi.fn() };
|
||||
}),
|
||||
exit: exit as unknown as (code?: number) => never,
|
||||
},
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
onSignal?.(testCase.signal);
|
||||
onSignal?.(testCase.laterSignal);
|
||||
child.emit("exit", null, testCase.signal);
|
||||
|
||||
expect(exit).toHaveBeenCalledWith(testCase.exitCode);
|
||||
});
|
||||
|
||||
it("signals detached respawn process groups after forwarded signal grace", () => {
|
||||
vi.useFakeTimers();
|
||||
const { child, kill } = createChild(2468);
|
||||
const spawnChild = vi.fn(() => child);
|
||||
const exit = vi.fn();
|
||||
let onSignal: ((signal: NodeJS.Signals) => void) | undefined;
|
||||
|
||||
try {
|
||||
@@ -69,7 +100,7 @@ describe("runRespawnChildWithSignalBridge", () => {
|
||||
onSignal = options?.onSignal;
|
||||
return { detach: vi.fn() };
|
||||
}),
|
||||
exit: vi.fn() as unknown as (code?: number) => never,
|
||||
exit: exit as unknown as (code?: number) => never,
|
||||
},
|
||||
onError: vi.fn(),
|
||||
});
|
||||
@@ -98,6 +129,7 @@ describe("runRespawnChildWithSignalBridge", () => {
|
||||
}
|
||||
|
||||
child.emit("exit", null, "SIGKILL");
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export function runRespawnChildWithSignalBridge(params: {
|
||||
let signalForceKillTimer: NodeJS.Timeout | undefined;
|
||||
let signalHardExitTimer: NodeJS.Timeout | undefined;
|
||||
let parentSignalReceived = false;
|
||||
let firstForwardedSignal: NodeJS.Signals | undefined;
|
||||
let hardKillBackstopStarted = false;
|
||||
const clearSignalTimers = (): void => {
|
||||
if (signalExitTimer) {
|
||||
clearTimeout(signalExitTimer);
|
||||
@@ -73,6 +75,7 @@ export function runRespawnChildWithSignalBridge(params: {
|
||||
// Best-effort shutdown fallback.
|
||||
}
|
||||
signalForceKillTimer = setTimeout(() => {
|
||||
hardKillBackstopStarted = true;
|
||||
forceKillChild();
|
||||
signalHardExitTimer = setTimeout(() => {
|
||||
runtime.exit(1);
|
||||
@@ -81,8 +84,9 @@ export function runRespawnChildWithSignalBridge(params: {
|
||||
}, RESPAWN_SIGNAL_FORCE_KILL_GRACE_MS);
|
||||
signalForceKillTimer.unref?.();
|
||||
};
|
||||
const scheduleParentExit = (): void => {
|
||||
const scheduleParentExit = (signal: NodeJS.Signals): void => {
|
||||
parentSignalReceived = true;
|
||||
firstForwardedSignal ??= signal;
|
||||
if (signalExitTimer) {
|
||||
return;
|
||||
}
|
||||
@@ -102,7 +106,15 @@ export function runRespawnChildWithSignalBridge(params: {
|
||||
}
|
||||
clearSignalTimers();
|
||||
if (signal) {
|
||||
runtime.exit(1);
|
||||
const forwardedSignalExitCode =
|
||||
!hardKillBackstopStarted && signal === firstForwardedSignal
|
||||
? signal === "SIGINT"
|
||||
? 130
|
||||
: signal === "SIGTERM"
|
||||
? 143
|
||||
: undefined
|
||||
: undefined;
|
||||
runtime.exit(forwardedSignalExitCode ?? 1);
|
||||
return;
|
||||
}
|
||||
runtime.exit(code ?? 1);
|
||||
|
||||
@@ -863,6 +863,55 @@ describe("openclaw launcher", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32").each([
|
||||
{ signal: "SIGINT" as const, exitCode: 130 },
|
||||
{ signal: "SIGTERM" as const, exitCode: 143 },
|
||||
])("exits $exitCode when the respawn child terminates from $signal", async (testCase) => {
|
||||
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
|
||||
await addGitMarker(fixtureRoot);
|
||||
const childInfoPath = path.join(fixtureRoot, "child-info.json");
|
||||
await fs.writeFile(
|
||||
path.join(fixtureRoot, "dist", "entry.js"),
|
||||
[
|
||||
'import { writeFileSync } from "node:fs";',
|
||||
`writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`,
|
||||
'process.title = "openclaw-launcher-default-signal-test-child";',
|
||||
"setInterval(() => {}, 1000);",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], {
|
||||
cwd: fixtureRoot,
|
||||
env: launcherEnv({
|
||||
NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"),
|
||||
}),
|
||||
stdio: "ignore",
|
||||
});
|
||||
let respawnChildPid: number | undefined;
|
||||
|
||||
try {
|
||||
const childInfo = await waitForJsonFile<{ pid: number }>(childInfoPath, 5000);
|
||||
respawnChildPid = childInfo.pid;
|
||||
|
||||
launcher.kill(testCase.signal);
|
||||
|
||||
await expect(waitForProcessExit(launcher, "launcher", 5000)).resolves.toEqual({
|
||||
code: testCase.exitCode,
|
||||
signal: null,
|
||||
});
|
||||
expect(isProcessAlive(respawnChildPid)).toBe(false);
|
||||
} finally {
|
||||
if (isProcessAlive(respawnChildPid)) {
|
||||
process.kill(respawnChildPid!, "SIGKILL");
|
||||
}
|
||||
if (isProcessAlive(launcher.pid)) {
|
||||
process.kill(launcher.pid!, "SIGKILL");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"exits after SIGTERM when the respawn child ignores the forwarded signal",
|
||||
async () => {
|
||||
@@ -873,9 +922,9 @@ describe("openclaw launcher", () => {
|
||||
path.join(fixtureRoot, "dist", "entry.js"),
|
||||
[
|
||||
'import { writeFileSync } from "node:fs";',
|
||||
`writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`,
|
||||
'process.title = "openclaw-launcher-sigterm-ignore-test-child";',
|
||||
'process.on("SIGTERM", () => {});',
|
||||
`writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`,
|
||||
"setInterval(() => {}, 1000);",
|
||||
"",
|
||||
].join("\n"),
|
||||
|
||||
Reference in New Issue
Block a user