fix(update): detect nested macOS gateway ancestry (#85391)

* fix(update): detect nested macOS gateway ancestry

* fix(release): refresh shrinkwrap for CI npm

* fix(update): inherit gateway runtime pid for update guard
This commit is contained in:
Vincent Koc
2026-05-23 00:00:38 +08:00
committed by GitHub
parent faf2a6cb9e
commit adc6adccd8
20 changed files with 286 additions and 24 deletions

View File

@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
- Codex app-server: disable native Code Mode when the effective exec host is `node` and keep OpenClaw `exec`/`process` available, so `/exec host=node` routes shell commands through the selected node instead of the gateway. Fixes #85012. (#85090) Thanks @sahilsatralkar.
- Gateway: defer provider auth-state prewarm until after startup readiness so early gateway tool/session requests are not blocked by provider auth discovery. (#85272) Thanks @dutifulbob.
- Agents/Codex: show the first plan update as a transient chat status notice without counting it as final assistant content.
- CLI/update: walk the macOS process ancestry and honor the inherited Gateway runtime PID before package updates stop the managed Gateway service, so nested in-band updater children can refuse instead of killing the LaunchAgent-supervised Gateway that owns them. Fixes #85120.
- Gateway/LaunchAgent: wait for launchd reload bootout to finish and fall back to kickstart when bootstrap races, so reload handoff does not leave the service deregistered. Fixes #84630. (#84641) Thanks @NianJiuZst.
- Gateway/LaunchAgent: treat a concurrent launchd bootstrap as a successful restart when the service is already loaded, avoiding false macOS Gateway restart failures. Fixes #84721. (#84722) Thanks @googlerest.
- Gateway/service: include the active `openclaw` command bin directory in managed service PATH generation and doctor audit expectations for npm-global macOS installs. Fixes #84201. (#84475) Thanks @jbetala7.

View File

@@ -903,6 +903,7 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz",
"integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -2238,6 +2239,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1314,6 +1314,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -1350,6 +1351,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1188,6 +1188,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -1224,6 +1225,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1321,6 +1321,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -1372,6 +1373,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1866,6 +1866,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -1917,6 +1918,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -67,6 +67,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -580,6 +581,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},

View File

@@ -432,7 +432,8 @@
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.1.1.tgz",
"integrity": "sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/prism-media": {
"version": "1.3.5",

View File

@@ -209,6 +209,7 @@
"resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-18.1.0.tgz",
"integrity": "sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.11",
"@types/command-line-args": "^5.2.3",

View File

@@ -168,6 +168,7 @@
"resolved": "https://registry.npmjs.org/@twurple/auth/-/auth-8.1.4.tgz",
"integrity": "sha512-ylsJoPInCw9BwOqxKcx+1k2ce9QG3vJpKFzPdIyHh49HvM/ulQZ0CAGysydugDYXF0iO/TGryh7PluSwx5fIwA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@d-fischer/logger": "^4.2.1",
"@d-fischer/shared-utils": "^3.6.1",
@@ -288,6 +289,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},

View File

@@ -1178,6 +1178,7 @@
"resolved": "https://registry.npmjs.org/audio-decode/-/audio-decode-2.2.3.tgz",
"integrity": "sha512-Z0lHvMayR/Pad9+O9ddzaBJE0DrhZkQlStrC1RwcAHF3AhQAsdwKHeLGK8fYKyp2DDU6xHxzGb4CLMui12yVrg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@wasm-audio-decoders/flac": "^0.2.4",
"@wasm-audio-decoders/ogg-vorbis": "^0.1.15",
@@ -1417,6 +1418,7 @@
"resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz",
"integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/core": "1.6.1",
"@jimp/diff": "1.6.1",
@@ -1461,6 +1463,7 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}

6
npm-shrinkwrap.json generated
View File

@@ -1567,6 +1567,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -2813,6 +2814,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -3230,6 +3232,7 @@
"resolved": "https://registry.npmjs.org/grammy/-/grammy-1.43.0.tgz",
"integrity": "sha512-7dYm06A945mXuIk/5HUlSjeyIYChW8vCEiU2dkOKKqJJzwAWxTkCc91Eqbz7TgODh2rtFFKWI/fekowWHOkmjQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@grammyjs/types": "3.27.3",
"abort-controller": "^3.0.0",
@@ -3298,6 +3301,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -5064,6 +5068,7 @@
"resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz",
"integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=22.19.0"
}
@@ -5287,6 +5292,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../../daemon/constants.js";
import { SUPERVISOR_HINT_ENV_VARS } from "../../infra/supervisor-markers.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js";
@@ -17,6 +18,7 @@ const forceFreePortAndWait = vi.fn(async (_port: number, _opts: unknown) => ({
waitedMs: 0,
escalatedToSigkill: false,
}));
const cleanStaleGatewayProcessesSync = vi.fn((_port?: number) => []);
const waitForPortBindable = vi.fn(async (_port: number, _opts?: unknown) => 0);
const ensureDevGatewayConfig = vi.fn(async (_opts?: unknown) => {});
type GatewayLoopStart = (params?: { startupStartedAt?: number }) => Promise<unknown>;
@@ -122,6 +124,10 @@ vi.mock("../../gateway/net.js", async (importOriginal) => {
};
});
vi.mock("../../infra/restart-stale-pids.js", () => ({
cleanStaleGatewayProcessesSync: (port?: number) => cleanStaleGatewayProcessesSync(port),
}));
vi.mock("../../gateway/server.js", () => ({
startGatewayServer: (port: number, opts?: unknown) => startGatewayServer(port, opts),
}));
@@ -226,6 +232,7 @@ describe("gateway run option collisions", () => {
setVerbose.mockClear();
setConsoleSubsystemFilter.mockClear();
forceFreePortAndWait.mockClear();
cleanStaleGatewayProcessesSync.mockClear();
waitForPortBindable.mockClear();
ensureDevGatewayConfig.mockClear();
runGatewayLoop.mockClear();
@@ -278,6 +285,20 @@ describe("gateway run option collisions", () => {
expect(gatewayStartOptions().auth?.token).toBe("tok_run");
});
it("marks service-mode gateway descendants with the live gateway pid", async () => {
await withEnvAsync(
{
OPENCLAW_SERVICE_MARKER: "openclaw",
[GATEWAY_SERVICE_RUNTIME_PID_ENV]: undefined,
},
async () => {
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
expect(process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]).toBe(String(process.pid));
},
);
});
it("blocks --force port cleanup from an older binary with newer config", async () => {
configState.snapshot = {
exists: true,

View File

@@ -12,6 +12,7 @@ import type {
import { CONFIG_PATH, resolveGatewayPort, resolveStateDir } from "../../config/paths.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../../daemon/constants.js";
import {
defaultGatewayBindMode,
isContainerEnvironment,
@@ -465,6 +466,9 @@ async function maybeWriteGatewayStartupFailureBundle(err: unknown): Promise<void
export async function runGatewayCommand(opts: GatewayRunOpts) {
installQaParentWatchdog();
if (process.env.OPENCLAW_SERVICE_MARKER?.trim()) {
process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV] = String(process.pid);
}
const isDevProfile = normalizeOptionalLowercaseString(process.env.OPENCLAW_PROFILE) === "dev";
const devMode = Boolean(opts.dev) || isDevProfile;
if (opts.reset && !devMode) {

View File

@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TEST_BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../test/helpers/bundled-runtime-sidecars.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js";
import { writePackageDistInventory } from "../infra/package-dist-inventory.js";
import { isBetaTag } from "../infra/update-channels.js";
import type { UpdateRunResult } from "../infra/update-runner.js";
@@ -929,6 +930,7 @@ describe("update-cli", () => {
{
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
[GATEWAY_SERVICE_RUNTIME_PID_ENV]: "7777",
},
async () => {
await updateCommand({ yes: true });
@@ -938,6 +940,7 @@ describe("update-cli", () => {
const spawnEnv = spawnCall()?.[2]?.env;
expect(spawnEnv?.OPENCLAW_SERVICE_MARKER).toBeUndefined();
expect(spawnEnv?.OPENCLAW_SERVICE_KIND).toBeUndefined();
expect(spawnEnv?.[GATEWAY_SERVICE_RUNTIME_PID_ENV]).toBeUndefined();
});
it("passes pre-update plugin install records into the post-core update process", async () => {
@@ -2044,6 +2047,37 @@ describe("update-cli", () => {
expect(packageInstallCommandCall()).toBeUndefined();
});
it("refuses package updates from inherited gateway runtime pid when process ancestry is truncated", async () => {
mockPackageInstallStatus(createCaseDir("openclaw-update"));
serviceLoaded.mockResolvedValue(true);
serviceReadRuntime.mockResolvedValue({
status: "running",
pid: 4242,
state: "running",
});
mockGetSelfAndAncestorPidsSync.mockReturnValue(new Set<number>([process.pid]));
await withEnvAsync(
{
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
[GATEWAY_SERVICE_RUNTIME_PID_ENV]: "4242",
},
async () => {
await updateCommand({ yes: true });
},
);
const errors = vi.mocked(defaultRuntime.error).mock.calls.map((call) => String(call[0]));
expect(errors.join("\n")).toContain(
"openclaw update detected it is running inside the gateway process tree.",
);
expect(errors.join("\n")).toContain("Gateway PID 4242 is an ancestor");
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(serviceStop).not.toHaveBeenCalled();
expect(packageInstallCommandCall()).toBeUndefined();
});
it("blocks package updates when the target requires a newer Node runtime", async () => {
mockPackageInstallStatus(createCaseDir("openclaw-update"));
vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({

View File

@@ -29,7 +29,11 @@ import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materializ
import { CONFIG_PATH, resolveIncludeRoots } from "../../config/paths.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "../../daemon/constants.js";
import {
GATEWAY_SERVICE_KIND,
GATEWAY_SERVICE_MARKER,
GATEWAY_SERVICE_RUNTIME_PID_ENV,
} from "../../daemon/constants.js";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import { disableCurrentOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
@@ -770,8 +774,40 @@ Gateway PID ${pid} is an ancestor of this process, so this updater cannot safely
Run \`${replaceCliName(formatCliCommand("openclaw update"), CLI_NAME)}\` from a shell outside the gateway service, or stop the gateway service first and then update.`;
}
function isGatewayAncestorPid(pid: unknown): pid is number {
return typeof pid === "number" && pid > 0 && getSelfAndAncestorPidsSync().has(pid);
function parsePositivePid(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!/^\d+$/u.test(trimmed)) {
return null;
}
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function isInheritedGatewayRuntimePid(
pid: number,
env: Record<string, string | undefined> = process.env,
): boolean {
if (!isRunningInsideGatewayService(env)) {
return false;
}
return parsePositivePid(env[GATEWAY_SERVICE_RUNTIME_PID_ENV]) === pid;
}
function isGatewayAncestorPid(
pid: unknown,
env: Record<string, string | undefined> = process.env,
): pid is number {
const parsed = parsePositivePid(pid);
if (parsed === null) {
return false;
}
return isInheritedGatewayRuntimePid(parsed, env) || getSelfAndAncestorPidsSync().has(parsed);
}
function gatewayAncestryBlockMessage(pid: unknown): string | undefined {
@@ -1048,6 +1084,7 @@ function stripGatewayServiceMarkerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv
const resolvedEnv = { ...env };
delete resolvedEnv.OPENCLAW_SERVICE_MARKER;
delete resolvedEnv.OPENCLAW_SERVICE_KIND;
delete resolvedEnv[GATEWAY_SERVICE_RUNTIME_PID_ENV];
return resolvedEnv;
}

View File

@@ -6,6 +6,7 @@ export const GATEWAY_SYSTEMD_SERVICE_NAME = "openclaw-gateway";
export const GATEWAY_WINDOWS_TASK_NAME = "OpenClaw Gateway";
export const GATEWAY_SERVICE_MARKER = "openclaw";
export const GATEWAY_SERVICE_KIND = "gateway";
export const GATEWAY_SERVICE_RUNTIME_PID_ENV = "OPENCLAW_GATEWAY_SERVICE_PID";
const NODE_LAUNCH_AGENT_LABEL = "ai.openclaw.node";
const NODE_SYSTEMD_SERVICE_NAME = "openclaw-node";
const NODE_WINDOWS_TASK_NAME = "OpenClaw Node";

View File

@@ -151,7 +151,10 @@ function installInitialBusyPoll(
resolvePoll: (call: number) => MockLsofResult,
): () => number {
let call = 0;
mockSpawnSync.mockImplementation(() => {
mockSpawnSync.mockImplementation((command: unknown) => {
if (command !== "lsof") {
return createLsofResult();
}
call += 1;
if (call === 1) {
return createOpenClawBusyResult(stalePid);
@@ -301,7 +304,9 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
});
expect(findGatewayPidsOnPortSync(18789)).toEqual([stalePid]);
const psCall = mockSpawnSync.mock.calls.find((call) => call[0] === "ps");
const psCall = mockSpawnSync.mock.calls.find(
(call) => call[0] === "ps" && Array.isArray(call[1]) && (call[1] as unknown[])[0] === "-ww",
);
expect(psCall?.[1]).toEqual(["-ww", "-p", String(stalePid), "-o", "command="]);
expect(psCall?.[2]).toEqual({ timeout: 2000, encoding: "utf8" });
});
@@ -442,6 +447,47 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
},
);
it("excludes the full ancestor chain on macOS via ps - nested in-band updater regression for #85120", () => {
const origDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const toolHostPid = process.pid + 3101;
const gatewayGrandparentPid = process.pid + 3102;
const benignStalePid = process.pid + 3103;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
try {
mockSpawnSync.mockImplementation((command: unknown, args: unknown) => {
if (command === "ps" && Array.isArray(args) && args[0] === "-o") {
const targetPid = args[3];
if (targetPid === String(toolHostPid)) {
return { error: null, status: 0, stdout: `${gatewayGrandparentPid}\n`, stderr: "" };
}
if (targetPid === String(gatewayGrandparentPid)) {
return { error: null, status: 0, stdout: "1\n", stderr: "" };
}
return { error: null, status: 0, stdout: "0\n", stderr: "" };
}
return {
error: null,
status: 0,
stdout: lsofOutput([
{ pid: toolHostPid, cmd: "openclaw-gateway" },
{ pid: gatewayGrandparentPid, cmd: "openclaw-gateway" },
{ pid: benignStalePid, cmd: "openclaw-gateway" },
]),
stderr: "",
};
});
const pids = withStubbedPpid(toolHostPid, () => findGatewayPidsOnPortSync(18789));
expect(pids).not.toContain(toolHostPid);
expect(pids).not.toContain(gatewayGrandparentPid);
expect(pids).toContain(benignStalePid);
} finally {
if (origDescriptor) {
Object.defineProperty(process, "platform", origDescriptor);
}
}
});
it("excludes pids whose command does not include 'openclaw'", () => {
const otherPid = process.pid + 2;
mockSpawnSync.mockReturnValue({
@@ -462,6 +508,36 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
expect(mockCallRecordArg(mockSpawnSync, 0, 2, "lsof options").timeout).toBe(400);
});
it("uses the caller timeout for macOS ancestor ps probes", () => {
const origDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const gatewayParentPid = process.pid + 3151;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
try {
mockSpawnSync.mockImplementation((command: unknown, args: unknown) => {
if (command === "ps" && Array.isArray(args) && args[0] === "-o") {
return { error: null, status: 0, stdout: "1\n", stderr: "" };
}
return {
error: null,
status: 0,
stdout: lsofOutput([{ pid: process.pid + 3152, cmd: "openclaw-gateway" }]),
stderr: "",
};
});
withStubbedPpid(gatewayParentPid, () => findGatewayPidsOnPortSync(18789, 400));
const ancestorPsCall = mockSpawnSync.mock.calls.find(
(call) =>
call[0] === "ps" && Array.isArray(call[1]) && (call[1] as unknown[])[0] === "-o",
);
expect(ancestorPsCall?.[2]).toEqual({ timeout: 400, encoding: "utf8" });
} finally {
if (origDescriptor) {
Object.defineProperty(process, "platform", origDescriptor);
}
}
});
it("deduplicates pids from dual-stack listeners (IPv4+IPv6 emit same pid twice)", () => {
// Dual-stack listeners cause lsof to emit the same PID twice in -Fpc output
// (once for the IPv4 socket, once for IPv6). Without dedup, terminateStaleProcessesSync
@@ -1213,9 +1289,50 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
vi.spyOn(process, "kill").mockReturnValue(true);
// No openclaw pids in status-1 output means the port is free for this cleanup.
expect(cleanStaleGatewayProcessesSync()).toContain(stalePid);
// Completed with one argv verification after the status-1 poll output:
// initial lsof + poll lsof + ps argv check.
expect(getCallCount()).toBe(3);
// Completed with one initial lsof and one status-1 poll lsof. The
// separate `ps` argv verification is intentionally not counted here.
expect(getCallCount()).toBe(2);
});
it("uses the short poll timeout for macOS ancestor ps probes", () => {
const origDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const stalePid = process.pid + 810;
const gatewayParentPid = process.pid + 811;
let lsofCall = 0;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
try {
mockSpawnSync.mockImplementation((command: unknown, args: unknown) => {
if (command === "ps" && Array.isArray(args) && args[0] === "-o") {
return { error: null, status: 0, stdout: "1\n", stderr: "" };
}
if (command === "lsof") {
lsofCall += 1;
if (lsofCall === 1) {
return createOpenClawBusyResult(stalePid);
}
return createLsofResult({
stdout: lsofOutput([{ pid: gatewayParentPid, cmd: "openclaw-gateway" }]),
});
}
return createLsofResult();
});
vi.spyOn(process, "kill").mockReturnValue(true);
const killed = withStubbedPpid(gatewayParentPid, () => cleanStaleGatewayProcessesSync());
expect(killed).toContain(stalePid);
const ancestorPsTimeouts = mockSpawnSync.mock.calls
.filter(
(call) =>
call[0] === "ps" && Array.isArray(call[1]) && (call[1] as unknown[])[0] === "-o",
)
.map((call) => (call[2] as { timeout?: number } | undefined)?.timeout);
expect(ancestorPsTimeouts).toContain(2000);
expect(ancestorPsTimeouts).toContain(400);
} finally {
if (origDescriptor) {
Object.defineProperty(process, "platform", origDescriptor);
}
}
});
});

View File

@@ -104,6 +104,22 @@ function readParentPidFromProc(pid: number): number | null {
}
}
function readParentPidFromPs(pid: number, spawnTimeoutMs: number): number | null {
try {
const res = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], {
encoding: "utf8",
timeout: spawnTimeoutMs,
});
if (res.error || res.status !== 0 || !res.stdout.trim()) {
return null;
}
const parsed = Number.parseInt(res.stdout.trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
} catch {
return null;
}
}
/**
* Collect the set of PIDs whose termination would cascade-kill the caller:
* the current process, its direct parent, and — where the platform permits
@@ -129,24 +145,28 @@ function readParentPidFromProc(pid: number): number | null {
* install where the gateway spawns a direct-child sidecar.
*
* The walk is best-effort. `process.ppid` is provided by Node via a direct
* syscall and is always available; transitive ancestors are only read on
* Linux via `/proc`. macOS/Windows stop at ppid, which is sufficient for
* the direct-child sidecar topology this bug describes; extending those
* platforms can be done without touching the call sites.
* syscall and is always available; transitive ancestors are read on Linux via
* `/proc` and on macOS via `ps`. Windows stops at ppid.
*
* The function takes no parameters and exposes no hooks. Tests exercise
* the real walk by stubbing `process.ppid` (and, on Linux, by mocking
* `node:fs` to inject `/proc/<pid>/status` payloads) — there is no
* reachable override for runtime callers to mutate.
* The function exposes no runtime hooks. Tests exercise the real walk by
* stubbing `process.ppid` (and, on Linux, by mocking `node:fs` to inject
* `/proc/<pid>/status` payloads) — there is no reachable override for
* runtime callers to mutate.
*/
export function getSelfAndAncestorPidsSync(): Set<number> {
export function getSelfAndAncestorPidsSync(spawnTimeoutMs = SPAWN_TIMEOUT_MS): Set<number> {
const pids = new Set<number>([process.pid]);
const immediateParent = getParentPid();
if (!Number.isFinite(immediateParent) || immediateParent <= 0) {
return pids;
}
pids.add(immediateParent);
if (process.platform !== "linux") {
const readTransitiveParent =
process.platform === "linux"
? readParentPidFromProc
: process.platform === "darwin"
? (pid: number) => readParentPidFromPs(pid, spawnTimeoutMs)
: null;
if (!readTransitiveParent) {
return pids;
}
// Transitive ancestor walk. Each hop's validity (positive pid, not already
@@ -155,7 +175,7 @@ export function getSelfAndAncestorPidsSync(): Set<number> {
// parent` after the same check, so no separate top-of-loop guard is needed.
let current = immediateParent;
for (let depth = 0; depth < MAX_ANCESTOR_WALK_DEPTH; depth++) {
const parent = readParentPidFromProc(current);
const parent = readTransitiveParent(current);
if (parent == null || parent <= 0 || pids.has(parent)) {
break;
}
@@ -171,8 +191,9 @@ export function getSelfAndAncestorPidsSync(): Set<number> {
* rationale). On Linux the ancestor lookup reads up to
* `MAX_ANCESTOR_WALK_DEPTH` entries from `/proc/<pid>/status`; each read is
* a virtual-filesystem access (no disk I/O, no external process), wrapped
* in try/catch and degrades silently. On macOS/Windows the lookup is
* in-memory via `process.ppid` only.
* in try/catch and degrades silently. On macOS the lookup shells out to `ps`
* with the caller's spawn timeout. Windows only uses the in-memory direct
* parent from `process.ppid`.
*/
function parseLsofEntries(stdout: string): Array<{ pid: number; cmd?: string }> {
const entries: Array<{ pid: number; cmd?: string }> = [];
@@ -239,7 +260,7 @@ function parsePidsFromLsofOutput(stdout: string, spawnTimeoutMs: number): number
// same PID twice. Return each PID at most once to avoid double-killing.
// Exclude self and ancestors — terminating any ancestor cascade-kills the
// caller via the supervisor, recreating the #68451 restart loop.
const excluded = getSelfAndAncestorPidsSync();
const excluded = getSelfAndAncestorPidsSync(spawnTimeoutMs);
const pids: number[] = [];
for (const entry of parseLsofEntries(stdout)) {
if (excluded.has(entry.pid)) {

View File

@@ -166,7 +166,8 @@ describe.runIf(process.platform !== "win32")("cleanStaleGatewayProcessesSync", (
expect(killed).toEqual([stalePid]);
expect(resolveGatewayPortMock).not.toHaveBeenCalled();
expect(spawnSyncMock).toHaveBeenCalledTimes(2);
const lsofCalls = spawnSyncMock.mock.calls.filter((call) => call[0] === "/usr/sbin/lsof");
expect(lsofCalls).toHaveLength(2);
const [command, args, options] = requireFirstSpawnSyncCall();
expect(command).toBe("/usr/sbin/lsof");
expect(args).toEqual(["-nP", "-iTCP:19999", "-sTCP:LISTEN", "-Fpc"]);