Skip to content

Commit 4612b16

Browse files
krissdingclaudesteipete
authored
fix: guard terminateStaleProcessesSync against non-ESRCH kill errors (#109702)
* fix: guard terminateStaleProcessesSync against non-ESRCH kill errors The two catch blocks silently swallowed all errors, not just ESRCH. Non-ESRCH errors (e.g. EPERM) indicate the process is still alive but cannot be signaled — continuing as if it were killed risks leaving a stale process on the port. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: continue stale PID cleanup after signal errors Co-authored-by: 丁宇婷0668001435 <[email protected]> --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 8ff4f01 commit 4612b16

2 files changed

Lines changed: 57 additions & 9 deletions

File tree

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,45 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
836836
expect(killSpy).toHaveBeenCalledWith(stalePid, "SIGTERM");
837837
});
838838

839+
it("continues cleanup and port polling when individual process signals fail", () => {
840+
const termDeniedPid = process.pid + 198;
841+
const killDeniedPid = process.pid + 199;
842+
let lsofCall = 0;
843+
mockSpawnSync.mockImplementation((command: unknown) => {
844+
if (command !== "lsof") {
845+
return createLsofResult();
846+
}
847+
lsofCall += 1;
848+
return lsofCall === 1
849+
? createLsofResult({
850+
stdout: lsofOutput([
851+
{ pid: termDeniedPid, cmd: "openclaw-gateway" },
852+
{ pid: killDeniedPid, cmd: "openclaw-gateway" },
853+
]),
854+
})
855+
: createLsofResult({ status: 1 });
856+
});
857+
858+
const killSpy = vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
859+
if (pid === termDeniedPid && signal === "SIGTERM") {
860+
throw Object.assign(new Error("term permission denied"), { code: "EPERM" });
861+
}
862+
if (pid === killDeniedPid && signal === "SIGKILL") {
863+
throw Object.assign(new Error("kill permission denied"), { code: "EACCES" });
864+
}
865+
return true;
866+
});
867+
868+
expect(cleanStaleGatewayProcessesSync()).toEqual([killDeniedPid]);
869+
expect(killSpy).toHaveBeenCalledWith(termDeniedPid, "SIGTERM");
870+
expect(killSpy).toHaveBeenCalledWith(killDeniedPid, "SIGTERM");
871+
expect(killSpy).toHaveBeenCalledWith(killDeniedPid, 0);
872+
expect(killSpy).toHaveBeenCalledWith(killDeniedPid, "SIGKILL");
873+
expectWarningContaining(`failed to send SIGTERM to stale gateway process ${termDeniedPid}`);
874+
expectWarningContaining(`failed to send SIGKILL to stale gateway process ${killDeniedPid}`);
875+
expect(lsofCall).toBe(2);
876+
});
877+
839878
it("does not kill a protected gateway pid after reparenting", () => {
840879
const protectedPid = process.pid + 4001;
841880
const stalePid = process.pid + 4002;

src/infra/restart-stale-pids.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
66
import { uniqueValues } from "@openclaw/normalization-core/string-normalization";
77
import { resolveGatewayPort } from "../config/paths.js";
88
import { createSubsystemLogger } from "../logging/subsystem.js";
9+
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
910
import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js";
1011
import { parseStrictPositiveInteger } from "./parse-finite-number.js";
1112
import { resolveLsofCommandSync } from "./ports-lsof.js";
@@ -494,29 +495,37 @@ function terminateStaleProcessesSync(pids: number[]): number[] {
494495
}
495496
const killed: number[] = [];
496497
for (const pid of pids) {
497-
try {
498-
process.kill(pid, "SIGTERM");
498+
if (trySignalStaleProcess(pid, "SIGTERM")) {
499499
killed.push(pid);
500-
} catch {
501-
// ESRCH — already gone
502500
}
503501
}
504502
if (killed.length === 0) {
505503
return killed;
506504
}
507505
sleepSync(STALE_SIGTERM_WAIT_MS);
508506
for (const pid of killed) {
509-
try {
510-
process.kill(pid, 0);
511-
process.kill(pid, "SIGKILL");
512-
} catch {
513-
// already gone
507+
if (isProcessAlive(pid)) {
508+
trySignalStaleProcess(pid, "SIGKILL");
514509
}
515510
}
516511
sleepSync(STALE_SIGKILL_WAIT_MS);
517512
return killed;
518513
}
519514

515+
function trySignalStaleProcess(pid: number, signal: NodeJS.Signals): boolean {
516+
try {
517+
process.kill(pid, signal);
518+
return true;
519+
} catch (error) {
520+
if (!hasErrnoCode(error, "ESRCH")) {
521+
restartLog.warn(
522+
`failed to send ${signal} to stale gateway process ${pid}: ${formatErrorMessage(error)}`,
523+
);
524+
}
525+
return false;
526+
}
527+
}
528+
520529
/**
521530
* Windows-specific process termination using taskkill.
522531
* Sends a graceful taskkill first (/T for tree), waits, then escalates to /F.

0 commit comments

Comments
 (0)