11// Run Oxlint tests cover run oxlint script behavior.
22import { spawnSync } from "node:child_process" ;
3- import { mkdtempSync , readFileSync , rmSync , writeFileSync } from "node:fs" ;
3+ import { existsSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from "node:fs" ;
44import { tmpdir } from "node:os" ;
55import { join } from "node:path" ;
66import { 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
2651describe ( "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