Skip to content

Commit e6abe1f

Browse files
Merge remote-tracking branch 'upstream/main' into codex/pr-93446-conflict-repair
2 parents c40ad3d + 7c6ad23 commit e6abe1f

4 files changed

Lines changed: 113 additions & 24 deletions

File tree

src/cli/gateway-cli/run.option-collisions.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ const forceFreePortAndWait = vi.fn(async (_port: number, _opts: unknown) => ({
2121
waitedMs: 0,
2222
escalatedToSigkill: false,
2323
}));
24-
const cleanStaleGatewayProcessesSync = vi.fn((_port?: number) => []);
24+
const cleanStaleGatewayProcessesSync = vi.fn(
25+
(_port?: number, _options?: { protectedPid?: number }) => [],
26+
);
2527
const waitForPortBindable = vi.fn(async (_port: number, _opts?: unknown) => 0);
2628
const ensureDevGatewayConfig = vi.fn(async (_opts?: unknown) => {});
2729
type GatewayLoopStart = (params?: { startupStartedAt?: number }) => Promise<unknown>;
@@ -193,7 +195,8 @@ vi.mock("../../gateway/net.js", async (importOriginal) => {
193195
});
194196

195197
vi.mock("../../infra/restart-stale-pids.js", () => ({
196-
cleanStaleGatewayProcessesSync: (port?: number) => cleanStaleGatewayProcessesSync(port),
198+
cleanStaleGatewayProcessesSync: (port?: number, options?: { protectedPid?: number }) =>
199+
cleanStaleGatewayProcessesSync(port, options),
197200
}));
198201

199202
vi.mock("../../gateway/server.js", () => ({
@@ -823,6 +826,23 @@ describe("gateway run option collisions", () => {
823826
expect(normalizeStateDirEnv).toHaveBeenCalledWith(process.env);
824827
});
825828

829+
it("protects the inherited service pid before replacing it", async () => {
830+
await withEnvAsync(
831+
{
832+
OPENCLAW_SERVICE_MARKER: "openclaw",
833+
[GATEWAY_SERVICE_RUNTIME_PID_ENV]: "4242",
834+
},
835+
async () => {
836+
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
837+
838+
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(18789, {
839+
protectedPid: 4242,
840+
});
841+
expect(process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]).toBe(String(process.pid));
842+
},
843+
);
844+
});
845+
826846
it("marks descendants when the final config supplies the service marker", async () => {
827847
await withEnvAsync(
828848
{

src/cli/gateway-cli/run.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { setVerbose } from "../../globals.js";
3535
import { isTruthyEnvValue } from "../../infra/env.js";
3636
import { formatErrorMessage } from "../../infra/errors.js";
3737
import { GatewayLockError } from "../../infra/gateway-lock.js";
38+
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
3839
import type { RespawnSupervisor } from "../../infra/supervisor-markers.js";
3940
import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logging/console.js";
4041
import { withDiagnosticPhase } from "../../logging/diagnostic-phase.js";
@@ -561,6 +562,11 @@ async function maybeWriteGatewayStartupFailureBundle(err: unknown): Promise<void
561562
}
562563

563564
export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunRuntimeHooks = {}) {
565+
// Reparenting can hide the running service from the ancestor walk.
566+
// Preserve its inherited PID before config env rebuilding overwrites it.
567+
const inheritedGatewayServicePid = parseStrictPositiveInteger(
568+
process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV],
569+
);
564570
normalizeStateDirEnv(process.env);
565571
const { clearGatewayRunConfigEnvironment } = await import("./pre-bootstrap.js");
566572
clearGatewayRunConfigEnvironment();
@@ -716,7 +722,9 @@ export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunR
716722
const bindExplicitRaw = bindExplicitRawStr as GatewayBindMode | undefined;
717723
if (process.env.OPENCLAW_SERVICE_MARKER?.trim()) {
718724
const { cleanStaleGatewayProcessesSync } = await import("../../infra/restart-stale-pids.js");
719-
const stale = cleanStaleGatewayProcessesSync(port);
725+
const stale = cleanStaleGatewayProcessesSync(port, {
726+
protectedPid: inheritedGatewayServicePid,
727+
});
720728
if (stale.length > 0) {
721729
gatewayLog.info(
722730
`service-mode: cleared ${stale.length} stale gateway pid(s) before bind on port ${port}`,

src/infra/restart-stale-pids.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,32 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
798798
expect(killSpy).toHaveBeenCalledWith(stalePid, "SIGTERM");
799799
});
800800

801+
it("does not kill a protected gateway pid after reparenting", () => {
802+
const protectedPid = process.pid + 4001;
803+
const stalePid = process.pid + 4002;
804+
let lsofCall = 0;
805+
mockSpawnSync.mockImplementation(() => {
806+
lsofCall += 1;
807+
return lsofCall === 1
808+
? createLsofResult({
809+
stdout: lsofOutput([
810+
{ pid: protectedPid, cmd: "openclaw-gateway" },
811+
{ pid: stalePid, cmd: "openclaw-gateway" },
812+
]),
813+
})
814+
: createLsofResult({ status: 1 });
815+
});
816+
const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
817+
818+
const result = withStubbedPpid(1, () =>
819+
cleanStaleGatewayProcessesSync(18789, { protectedPid }),
820+
);
821+
822+
expect(result).toEqual([stalePid]);
823+
expect(killSpy).toHaveBeenCalledWith(stalePid, "SIGTERM");
824+
expect(killSpy).not.toHaveBeenCalledWith(protectedPid, expect.anything());
825+
});
826+
801827
it("escalates to SIGKILL when process survives the SIGTERM window", () => {
802828
const stalePid = process.pid + 101;
803829
let call = 0;

src/infra/restart-stale-pids.ts

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,15 @@ export function getSelfAndAncestorPidsSync(spawnTimeoutMs = SPAWN_TIMEOUT_MS): S
187187
return pids;
188188
}
189189

190+
function getExcludedGatewayPidsSync(spawnTimeoutMs: number, protectedPid?: number): Set<number> {
191+
const excluded = getSelfAndAncestorPidsSync(spawnTimeoutMs);
192+
if (typeof protectedPid === "number" && Number.isSafeInteger(protectedPid) && protectedPid > 0) {
193+
// A reparented service can become a sibling and disappear from the ancestor walk.
194+
excluded.add(protectedPid);
195+
}
196+
return excluded;
197+
}
198+
190199
/**
191200
* Parse raw PIDs from lsof -Fpc stdout, excluding the current
192201
* process and its ancestors (see `getSelfAndAncestorPidsSync` for the full
@@ -257,12 +266,16 @@ function verifyGatewayPidByArgvSync(pid: number, spawnTimeoutMs: number): boolea
257266
return args != null && isGatewayArgv(args, { allowGatewayBinary: true });
258267
}
259268

260-
function parsePidsFromLsofOutput(stdout: string, spawnTimeoutMs: number): number[] {
269+
function parsePidsFromLsofOutput(
270+
stdout: string,
271+
spawnTimeoutMs: number,
272+
protectedPid?: number,
273+
): number[] {
261274
// Deduplicate: dual-stack listeners (IPv4 + IPv6) cause lsof to emit the
262275
// same PID twice. Return each PID at most once to avoid double-killing.
263276
// Exclude self and ancestors — terminating any ancestor cascade-kills the
264277
// caller via the supervisor, recreating the #68451 restart loop.
265-
const excluded = getSelfAndAncestorPidsSync(spawnTimeoutMs);
278+
const excluded = getExcludedGatewayPidsSync(spawnTimeoutMs, protectedPid);
266279
const pids: number[] = [];
267280
for (const entry of parseLsofEntries(stdout)) {
268281
if (excluded.has(entry.pid)) {
@@ -285,8 +298,8 @@ function parsePidsFromLsofOutput(stdout: string, spawnTimeoutMs: number): number
285298
* and its ancestors (same invariant as the lsof path — see
286299
* `getSelfAndAncestorPidsSync`).
287300
*/
288-
function filterVerifiedWindowsGatewayPids(rawPids: number[]): number[] {
289-
const excluded = getSelfAndAncestorPidsSync();
301+
function filterVerifiedWindowsGatewayPids(rawPids: number[], protectedPid?: number): number[] {
302+
const excluded = getExcludedGatewayPidsSync(SPAWN_TIMEOUT_MS, protectedPid);
290303
return uniqueValues(rawPids)
291304
.filter((pid) => Number.isFinite(pid) && pid > 0 && !excluded.has(pid))
292305
.filter((pid) => {
@@ -298,8 +311,9 @@ function filterVerifiedWindowsGatewayPids(rawPids: number[]): number[] {
298311
function filterVerifiedWindowsGatewayPidsResult(
299312
rawPids: number[],
300313
processArgsResult: (pid: number) => WindowsProcessArgsResult,
314+
protectedPid?: number,
301315
): WindowsListeningPidsResult {
302-
const excluded = getSelfAndAncestorPidsSync();
316+
const excluded = getExcludedGatewayPidsSync(SPAWN_TIMEOUT_MS, protectedPid);
303317
const verified: number[] = [];
304318
for (const pid of uniqueValues(rawPids)) {
305319
if (!Number.isFinite(pid) || pid <= 0 || excluded.has(pid)) {
@@ -316,32 +330,34 @@ function filterVerifiedWindowsGatewayPidsResult(
316330
return { ok: true, pids: verified };
317331
}
318332

319-
function findVerifiedWindowsGatewayPidsOnPortSync(port: number): number[] {
320-
return filterVerifiedWindowsGatewayPids(readWindowsListeningPidsOnPortSync(port));
333+
function findVerifiedWindowsGatewayPidsOnPortSync(port: number, protectedPid?: number): number[] {
334+
return filterVerifiedWindowsGatewayPids(readWindowsListeningPidsOnPortSync(port), protectedPid);
321335
}
322336

323-
function findVerifiedWindowsGatewayPidsOnPortResultSync(port: number): WindowsListeningPidsResult {
337+
function findVerifiedWindowsGatewayPidsOnPortResultSync(
338+
port: number,
339+
protectedPid?: number,
340+
): WindowsListeningPidsResult {
324341
const result = readWindowsListeningPidsResultSync(port);
325342
if (!result.ok) {
326343
return result;
327344
}
328-
return filterVerifiedWindowsGatewayPidsResult(result.pids, (pid) =>
329-
readWindowsProcessArgsResultSync(pid),
345+
return filterVerifiedWindowsGatewayPidsResult(
346+
result.pids,
347+
(pid) => readWindowsProcessArgsResultSync(pid),
348+
protectedPid,
330349
);
331350
}
332351

333-
/**
334-
* Find PIDs of gateway processes listening on the given port using synchronous lsof.
335-
* Returns only PIDs that belong to openclaw gateway processes (not the current process).
336-
*/
337-
export function findGatewayPidsOnPortSync(
352+
function findGatewayPidsOnPortWithProtectedPidSync(
338353
port: number,
339-
spawnTimeoutMs = SPAWN_TIMEOUT_MS,
354+
spawnTimeoutMs: number,
355+
protectedPid?: number,
340356
): number[] {
341357
if (process.platform === "win32") {
342358
// Use the shared Windows port inspection (PowerShell / netstat) with
343359
// command-line verification to find only openclaw gateway processes.
344-
return findVerifiedWindowsGatewayPidsOnPortSync(port);
360+
return findVerifiedWindowsGatewayPidsOnPortSync(port, protectedPid);
345361
}
346362
const lsof = resolveLsofCommandSync();
347363
const res = spawnSync(lsof, ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"], {
@@ -368,7 +384,18 @@ export function findGatewayPidsOnPortSync(
368384
);
369385
return [];
370386
}
371-
return parsePidsFromLsofOutput(res.stdout, spawnTimeoutMs);
387+
return parsePidsFromLsofOutput(res.stdout, spawnTimeoutMs, protectedPid);
388+
}
389+
390+
/**
391+
* Find PIDs of gateway processes listening on the given port using synchronous lsof.
392+
* Returns only PIDs that belong to openclaw gateway processes (not the current process).
393+
*/
394+
export function findGatewayPidsOnPortSync(
395+
port: number,
396+
spawnTimeoutMs = SPAWN_TIMEOUT_MS,
397+
): number[] {
398+
return findGatewayPidsOnPortWithProtectedPidSync(port, spawnTimeoutMs);
372399
}
373400

374401
/**
@@ -586,23 +613,31 @@ function waitForPortFreeSync(port: number): void {
586613
*
587614
* Called before service restart commands to prevent port conflicts.
588615
*/
589-
export function cleanStaleGatewayProcessesSync(portOverride?: number): number[] {
616+
type CleanStaleGatewayProcessesOptions = {
617+
protectedPid?: number;
618+
};
619+
620+
export function cleanStaleGatewayProcessesSync(
621+
portOverride?: number,
622+
options?: CleanStaleGatewayProcessesOptions,
623+
): number[] {
590624
try {
591625
const port =
592626
typeof portOverride === "number" && Number.isFinite(portOverride) && portOverride > 0
593627
? Math.floor(portOverride)
594628
: resolveGatewayPort(undefined, process.env);
629+
const protectedPid = options?.protectedPid;
595630
const stalePids =
596631
process.platform === "win32"
597632
? (() => {
598-
const result = findVerifiedWindowsGatewayPidsOnPortResultSync(port);
633+
const result = findVerifiedWindowsGatewayPidsOnPortResultSync(port, protectedPid);
599634
if (result.ok) {
600635
return result.pids;
601636
}
602637
waitForPortFreeSync(port);
603638
return [];
604639
})()
605-
: findGatewayPidsOnPortSync(port);
640+
: findGatewayPidsOnPortWithProtectedPidSync(port, SPAWN_TIMEOUT_MS, protectedPid);
606641
if (stalePids.length === 0) {
607642
return [];
608643
}

0 commit comments

Comments
 (0)