fix: keep Windows command cleanup alive until settlement (#157042)

This commit is contained in:
Peter Steinberger 2026-09-23 22:30:32 -07:00 • committed by GitHub
parent 8bb889e8c8
commit 02720df7e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 91 additions and 2 deletions

View file

@ -0,0 +1,89 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { afterAll, beforeAll, expect, it } from "vitest";
import { requireNodeTool, stripNodeTypeScriptTypes } from "../../test/helpers/node-toolchain.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
const temp = useAutoCleanupTempDirTracker(afterAll);
let directory: string;
beforeAll(async () => {
directory = temp.make("openclaw-command-settlement-");
const source = await fs.readFile(path.resolve("src/process/exec-termination.ts"), "utf8");
const owner = stripNodeTypeScriptTypes(source).replace(
/from "(\.\.?\/[^"\n]+)"/g,
'from "./dependencies.mjs"',
);
await fs.writeFile(path.join(directory, "owner.mjs"), owner);
await fs.writeFile(
path.join(directory, "dependencies.mjs"),
`
export const COMMAND_PROCESS_TREE_KILL_GRACE_MS = 300;
export const getWindowsSystem32ExePath = () => 'taskkill.exe';
export const getFileLockProcessStartTime = () => { throw new Error('unexpected POSIX identity'); };
export const isChildProcessTreeAlive = () => { throw new Error('unexpected POSIX group'); };
export const killProcessTree = () => { throw new Error('unexpected POSIX termination'); };
export const runOutsideCommandProcessScope = operation => operation();
let child, failure;
export let completedHelpers = 0;
export const requests = [];
export function bind(value, reject) { child = value; failure = reject; }
export async function spawnCommand(args) {
requests.push(args);
// Child exit and helper completion are inputs from the process backend.
// Their completion leaves no referenced native handle in this fresh Node process.
await Promise.resolve();
child.exitCode = 0;
completedHelpers++;
if (failure) throw new Error('fixture helper failure');
return { exitCode: 0 };
}
`,
);
await fs.writeFile(
path.join(directory, "driver.mjs"),
`
import * as backend from './dependencies.mjs';
Object.defineProperty(process, 'platform', { value: 'win32' });
const { createCommandTerminationController } = await import('./owner.mjs');
const scenario = JSON.parse(process.argv[2]);
const child = { pid: 4242, exitCode: null, signalCode: null };
backend.bind(child, scenario.failedHelper);
const cancellation = new AbortController();
const owner = createCommandTerminationController({
child, cancelController: cancellation, processTree: { mode: scenario.mode },
killGraceMs: 300, isChildExited: () => child.exitCode !== null,
isCommandSettled: () => child.exitCode !== null,
});
owner.terminate();
const outcome = await owner.settle();
console.log(JSON.stringify({ outcome, completedHelpers: backend.completedHelpers,
requests: backend.requests, cancelled: cancellation.signal.aborted }));
`,
);
});
it.each([
{ mode: "graceful", failedHelper: false },
{ mode: "graceful", failedHelper: true },
{ mode: "force", failedHelper: false },
])(
"keeps Windows $mode settlement alive after backend completion (failed helper: $failedHelper)",
async (scenario) => {
const result = await promisify(execFile)(
requireNodeTool("node"),
[path.join(directory, "driver.mjs"), JSON.stringify(scenario)],
{ env: { SystemRoot: process.env.SystemRoot, NODE_DISABLE_COMPILE_CACHE: "1" } },
);
expect(JSON.parse(result.stdout)).toEqual({
outcome: "forced",
completedHelpers: 1,
requests: [
["taskkill.exe", "/PID", "4242", "/T", ...(scenario.mode === "force" ? ["/F"] : [])],
],
cancelled: false,
});
expect(result.stderr).not.toContain("unsettled top-level await");
},
);

View file

@ -68,9 +68,9 @@ export function createCommandTerminationController(params: {
windowsTerminationPromise = (async () => {
if (graceful) {
taskkills.push(spawnTaskkill(["/PID", String(childPid), "/T"]));
// Awaited cleanup stays live after both the child and taskkill handles close.
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, params.killGraceMs);
timer.unref();
setTimeout(resolve, params.killGraceMs);
});
if (isDirectChildAlive()) {
taskkills.push(spawnTaskkill(["/PID", String(childPid), "/T", "/F"]));