diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 33f64ced17a..ad0e1e85d4f 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -1226,6 +1226,8 @@ export class NpmUpdateSmoke { let timedOut = false; let killTimer: NodeJS.Timeout | undefined; + let forceKillAt: number | undefined; + const timeoutKillGraceMs = freshLaneTimeoutKillGraceMs; const signalChild = (signal: NodeJS.Signals): void => { if (!child.pid) { return; @@ -1246,7 +1248,8 @@ export class NpmUpdateSmoke { } timedOut = true; signalChild("SIGTERM"); - killTimer = setTimeout(() => signalChild("SIGKILL"), 2_000); + forceKillAt = Date.now() + timeoutKillGraceMs; + killTimer = setTimeout(() => signalChild("SIGKILL"), timeoutKillGraceMs); killTimer.unref(); }; if (ctx.signal.aborted) { @@ -1273,8 +1276,10 @@ export class NpmUpdateSmoke { clearTimeout(killTimer); } if (timedOut) { - signalChild("SIGKILL"); - resolve(124); + void finishTimedOutLoggedProcessTree(child, { + forceKillAt, + timeoutKillGraceMs, + }).then(() => resolve(124), reject); return; } resolve(code ?? (signal ? 128 : 1)); diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index 24c00e527df..f4bc5382a78 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -495,6 +495,69 @@ exit 1 expect(vi.getTimerCount()).toBe(0); }); + it.runIf(process.platform !== "win32")( + "lets update stream descendants exit during timeout kill grace", + async () => { + const root = makeTempDir(); + const scriptPath = path.join(root, "stream-update-grace.mjs"); + const readyPath = path.join(root, "stream-ready"); + const donePath = path.join(root, "stream-done"); + const smoke = withEnv( + { OPENAI_API_KEY: "test-key" }, + () => + new NpmUpdateSmoke({ + ...TEST_AUTH, + json: false, + packageSpec: "openclaw@latest", + platforms: new Set(["linux"]), + provider: "openai", + updateTarget: "local-main", + }), + ); + const runStreamingToJobLog = Reflect.get(smoke, "runStreamingToJobLog") as ( + command: string, + args: string[], + timeoutMs: number, + ctx: { + append(chunk: string | Uint8Array): void; + logPath: string; + signal: AbortSignal; + }, + ) => Promise; + const descendantScript = [ + "import { writeFileSync } from 'node:fs';", + `writeFileSync(${JSON.stringify(readyPath)}, 'ready');`, + "process.on('SIGTERM', () => {", + ` setTimeout(() => { writeFileSync(${JSON.stringify(donePath)}, 'done'); process.exit(0); }, 75);`, + "});", + "setInterval(() => {}, 1000);", + ].join("\n"); + writeFileSync( + scriptPath, + [ + "import { spawn } from 'node:child_process';", + `spawn(process.execPath, ["--input-type=module", "--eval", ${JSON.stringify( + descendantScript, + )}], { stdio: "ignore" });`, + "process.on('SIGTERM', () => process.exit(0));", + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const command = runStreamingToJobLog.call(smoke, process.execPath, [scriptPath], 500, { + append: () => undefined, + logPath: path.join(root, "update.log"), + signal: new AbortController().signal, + }); + + await waitFor(() => existsSync(readyPath), "update stream descendant readiness"); + await expect(command).resolves.toBe(124); + expect(readFileSync(donePath, "utf8")).toBe("done"); + }, + ); + it("runs Windows updates through a detached done-file runner", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); const transports = readFileSync(GUEST_TRANSPORTS_PATH, "utf8");