Skip to content

Commit f26f45c

Browse files
committed
fix(lint): reap timed-out oxlint shard groups
1 parent a127183 commit f26f45c

2 files changed

Lines changed: 226 additions & 8 deletions

File tree

scripts/run-oxlint-shards.mjs

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const DEFAULT_WINDOWS_EXTENSION_CHUNK_SIZE = 8;
1313
const DEFAULT_SHARD_HEARTBEAT_MS = 30_000;
1414
const DEFAULT_SHARD_TIMEOUT_MS = 15 * 60_000;
1515
const DEFAULT_SHARD_KILL_GRACE_MS = 5_000;
16+
const POST_FORCE_KILL_WAIT_MS = 1_000;
17+
const PROCESS_GROUP_EXIT_POLL_MS = 25;
1618
const DEFAULT_SPLIT_CORE_SHARD_CONCURRENCY = 4;
1719
const FAST_LOCAL_CHECK_MIN_CPUS = 12;
1820
const FAST_LOCAL_CHECK_MIN_MEMORY_BYTES = 48 * 1024 ** 3;
@@ -443,6 +445,7 @@ export async function runShard({ env, extraArgs, runner, shard }) {
443445
let finished = false;
444446
let timedOut = false;
445447
let forceKill = null;
448+
let forceKillAt = null;
446449
const heartbeat =
447450
heartbeatMs > 0
448451
? setInterval(() => {
@@ -461,6 +464,7 @@ export async function runShard({ env, extraArgs, runner, shard }) {
461464
);
462465
signalChildProcess({ child, signal: "SIGTERM", useProcessGroup });
463466
if (killGraceMs > 0) {
467+
forceKillAt = Date.now() + killGraceMs;
464468
forceKill = setTimeout(() => {
465469
console.error(`[oxlint:${shard.name}] did not exit cleanly; killing shard`);
466470
signalChildProcess({ child, signal: "SIGKILL", useProcessGroup });
@@ -486,22 +490,49 @@ export async function runShard({ env, extraArgs, runner, shard }) {
486490
if (forceKill) {
487491
clearTimeout(forceKill);
488492
}
493+
forceKillAt = null;
489494
unregisterShardChild();
490495
console.error(`[oxlint:${shard.name}] finished`);
491496
resolve(status);
492497
};
498+
const finishAfterForcedTeardown = async (status) => {
499+
const graceRemainingMs =
500+
forceKillAt === null ? killGraceMs : Math.max(0, forceKillAt - Date.now());
501+
if (graceRemainingMs > 0) {
502+
await waitForChildProcessGroupExit({
503+
child,
504+
timeoutMs: graceRemainingMs,
505+
useProcessGroup,
506+
});
507+
}
508+
if (isChildProcessGroupAlive({ child, useProcessGroup })) {
509+
signalChildProcess({ child, signal: "SIGKILL", useProcessGroup });
510+
}
511+
await waitForChildProcessGroupExit({
512+
child,
513+
timeoutMs: POST_FORCE_KILL_WAIT_MS,
514+
useProcessGroup,
515+
});
516+
finish(status);
517+
};
493518
child.once("error", (error) => {
494519
console.error(error);
495520
finish(1);
496521
});
497522
child.once("close", (status) => {
498-
finish(
499-
parentTerminationSignal
500-
? getSignalExitCode(parentTerminationSignal)
501-
: timedOut
502-
? 124
503-
: (status ?? 1),
504-
);
523+
const exitStatus = parentTerminationSignal
524+
? getSignalExitCode(parentTerminationSignal)
525+
: timedOut
526+
? 124
527+
: (status ?? 1);
528+
if (
529+
(timedOut || parentTerminationSignal) &&
530+
isChildProcessGroupAlive({ child, useProcessGroup })
531+
) {
532+
void finishAfterForcedTeardown(exitStatus);
533+
return;
534+
}
535+
finish(exitStatus);
505536
});
506537
});
507538
}
@@ -604,6 +635,31 @@ function signalChildProcess({ child, signal, useProcessGroup }) {
604635
}
605636
}
606637

638+
function isChildProcessGroupAlive({ child, useProcessGroup }) {
639+
if (!useProcessGroup || !child.pid) {
640+
return false;
641+
}
642+
try {
643+
process.kill(-child.pid, 0);
644+
return true;
645+
} catch (error) {
646+
return error?.code === "EPERM";
647+
}
648+
}
649+
650+
async function waitForChildProcessGroupExit({ child, timeoutMs, useProcessGroup }) {
651+
const deadlineAt = Date.now() + timeoutMs;
652+
while (Date.now() < deadlineAt) {
653+
if (!isChildProcessGroupAlive({ child, useProcessGroup })) {
654+
return true;
655+
}
656+
await new Promise((resolvePoll) => {
657+
setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS);
658+
});
659+
}
660+
return !isChildProcessGroupAlive({ child, useProcessGroup });
661+
}
662+
607663
function registerShardChild(entry) {
608664
installParentSignalForwarding();
609665
ACTIVE_SHARD_CHILDREN.add(entry);

test/scripts/run-oxlint.test.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Run Oxlint tests cover run oxlint script behavior.
22
import { spawnSync } from "node:child_process";
3-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { pathToFileURL } from "node:url";
@@ -22,6 +22,31 @@ import {
2222
filterSparseMissingOxlintTargets,
2323
shouldPrepareExtensionPackageBoundaryArtifacts,
2424
} from "../../scripts/run-oxlint.mjs";
25+
import { createScriptTestHarness } from "./test-helpers.js";
26+
27+
const { createTempDir } = createScriptTestHarness();
28+
29+
async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
30+
const deadlineAt = Date.now() + timeoutMs;
31+
while (Date.now() < deadlineAt) {
32+
if (predicate()) {
33+
return;
34+
}
35+
await new Promise((resolvePoll) => {
36+
setTimeout(resolvePoll, 25);
37+
});
38+
}
39+
throw new Error("condition was not met before timeout");
40+
}
41+
42+
function isProcessAlive(pid: number): boolean {
43+
try {
44+
process.kill(pid, 0);
45+
return true;
46+
} catch {
47+
return false;
48+
}
49+
}
2550

2651
describe("run-oxlint", () => {
2752
it("prepares extension package boundary artifacts for normal lint runs", () => {
@@ -256,6 +281,57 @@ describe("run-oxlint", () => {
256281
}
257282
});
258283

284+
it.runIf(process.platform !== "win32")(
285+
"kills timed-out shard process groups when the leader exits first",
286+
async () => {
287+
const tempDir = createTempDir("openclaw-oxlint-timeout-group-");
288+
const runner = join(tempDir, "timeout-runner.mjs");
289+
const childPidPath = join(tempDir, "child.pid");
290+
let childPid = 0;
291+
const childScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
292+
try {
293+
writeFileSync(
294+
runner,
295+
[
296+
"import { spawn } from 'node:child_process';",
297+
"import { writeFileSync } from 'node:fs';",
298+
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
299+
"writeFileSync(process.env.CHILD_PID_PATH, String(child.pid));",
300+
"process.on('SIGTERM', () => process.exit(0));",
301+
"setInterval(() => {}, 1000);",
302+
"",
303+
].join("\n"),
304+
"utf8",
305+
);
306+
307+
const command = runShard({
308+
env: {
309+
...process.env,
310+
CHILD_PID_PATH: childPidPath,
311+
OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS: "0",
312+
OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS: "25",
313+
OPENCLAW_OXLINT_SHARD_TIMEOUT_MS: "1000",
314+
},
315+
extraArgs: [],
316+
runner,
317+
shard: { name: "timeout-group-test", args: [] },
318+
});
319+
320+
await waitFor(() => existsSync(childPidPath), 2_000);
321+
childPid = Number(readFileSync(childPidPath, "utf8"));
322+
expect(isProcessAlive(childPid)).toBe(true);
323+
324+
await expect(command).resolves.toBe(124);
325+
await waitFor(() => !isProcessAlive(childPid), 2_000);
326+
} finally {
327+
if (childPid && isProcessAlive(childPid)) {
328+
process.kill(childPid, "SIGKILL");
329+
}
330+
rmSync(tempDir, { force: true, recursive: true });
331+
}
332+
},
333+
);
334+
259335
it.runIf(process.platform !== "win32")(
260336
"forwards parent termination to detached oxlint shard processes",
261337
() => {
@@ -406,6 +482,92 @@ describe("run-oxlint", () => {
406482
},
407483
);
408484

485+
it.runIf(process.platform !== "win32")(
486+
"kills parent-terminated shard process groups when the leader exits first",
487+
() => {
488+
const tempDir = createTempDir("openclaw-oxlint-parent-group-");
489+
const runner = join(tempDir, "signal-runner.mjs");
490+
const harness = join(tempDir, "signal-harness.mjs");
491+
const childPidPath = join(tempDir, "child.pid");
492+
const readyFile = join(tempDir, "ready");
493+
const childScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
494+
try {
495+
writeFileSync(
496+
runner,
497+
[
498+
"import { spawn } from 'node:child_process';",
499+
"import { writeFileSync } from 'node:fs';",
500+
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
501+
"writeFileSync(process.env.CHILD_PID_PATH, String(child.pid));",
502+
"writeFileSync(process.env.READY_FILE, String(process.pid));",
503+
"process.on('SIGTERM', () => process.exit(0));",
504+
"setInterval(() => {}, 1000);",
505+
"",
506+
].join("\n"),
507+
"utf8",
508+
);
509+
writeFileSync(
510+
harness,
511+
[
512+
"import { existsSync, readFileSync } from 'node:fs';",
513+
`import { runShard } from ${JSON.stringify(pathToFileURL(join(process.cwd(), "scripts/run-oxlint-shards.mjs")).href)};`,
514+
"const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));",
515+
"const isAlive = (pid) => {",
516+
" try { process.kill(pid, 0); return true; } catch { return false; }",
517+
"};",
518+
"const waitFor = async (predicate) => {",
519+
" for (let attempt = 0; attempt < 100; attempt += 1) {",
520+
" if (predicate()) return true;",
521+
" await sleep(25);",
522+
" }",
523+
" return false;",
524+
"};",
525+
"const promise = runShard({",
526+
" env: {",
527+
" ...process.env,",
528+
" OPENCLAW_OXLINT_SHARD_HEARTBEAT_MS: '0',",
529+
" OPENCLAW_OXLINT_SHARD_KILL_GRACE_MS: '25',",
530+
" OPENCLAW_OXLINT_SHARD_TIMEOUT_MS: '0',",
531+
" },",
532+
" extraArgs: [],",
533+
" runner: process.env.RUNNER_FILE,",
534+
" shard: { name: 'signal-group-test', args: [] },",
535+
"});",
536+
"if (!(await waitFor(() => existsSync(process.env.CHILD_PID_PATH)))) {",
537+
" process.exit(2);",
538+
"}",
539+
"const childPid = Number(readFileSync(process.env.CHILD_PID_PATH, 'utf8'));",
540+
"process.kill(process.pid, 'SIGTERM');",
541+
"const status = await promise;",
542+
"if (await waitFor(() => !isAlive(childPid))) {",
543+
" process.exit(status === 143 ? 0 : 4);",
544+
"}",
545+
"process.kill(childPid, 'SIGKILL');",
546+
"process.exit(5);",
547+
"",
548+
].join("\n"),
549+
"utf8",
550+
);
551+
552+
const result = spawnSync(process.execPath, [harness], {
553+
encoding: "utf8",
554+
env: {
555+
...process.env,
556+
CHILD_PID_PATH: childPidPath,
557+
READY_FILE: readyFile,
558+
RUNNER_FILE: runner,
559+
},
560+
timeout: 5_000,
561+
});
562+
563+
expect(result.status).toBe(0);
564+
expect(result.signal).toBeNull();
565+
} finally {
566+
rmSync(tempDir, { force: true, recursive: true });
567+
}
568+
},
569+
);
570+
409571
it("chunks extension oxlint shards on Windows", () => {
410572
const shards = createOxlintShards({
411573
cwd: "/repo",

0 commit comments

Comments
 (0)