11// Check Extension Package Tsc Boundary tests cover check extension package tsc boundary script behavior.
2+ import { spawn } from "node:child_process" ;
23import { EventEmitter } from "node:events" ;
34import fs from "node:fs" ;
45import os from "node:os" ;
@@ -46,6 +47,43 @@ function createMockPipe() {
4647 return pipe ;
4748}
4849
50+ function isProcessAlive ( pid : number ) : boolean {
51+ try {
52+ process . kill ( pid , 0 ) ;
53+ return true ;
54+ } catch {
55+ return false ;
56+ }
57+ }
58+
59+ async function sleep ( ms : number ) : Promise < void > {
60+ await new Promise ( ( resolve ) => {
61+ setTimeout ( resolve , ms ) ;
62+ } ) ;
63+ }
64+
65+ async function waitForFile ( filePath : string , timeoutMs : number ) : Promise < void > {
66+ const deadlineAt = Date . now ( ) + timeoutMs ;
67+ while ( Date . now ( ) < deadlineAt ) {
68+ if ( fs . existsSync ( filePath ) ) {
69+ return ;
70+ }
71+ await sleep ( 25 ) ;
72+ }
73+ throw new Error ( `timeout waiting for ${ filePath } ` ) ;
74+ }
75+
76+ async function waitForDead ( pid : number , timeoutMs : number ) : Promise < void > {
77+ const deadlineAt = Date . now ( ) + timeoutMs ;
78+ while ( Date . now ( ) < deadlineAt ) {
79+ if ( ! isProcessAlive ( pid ) ) {
80+ return ;
81+ }
82+ await sleep ( 25 ) ;
83+ }
84+ throw new Error ( `process still alive: ${ pid } ` ) ;
85+ }
86+
4987afterEach ( ( ) => {
5088 for ( const rootDir of tempRoots ) {
5189 fs . rmSync ( rootDir , { force : true , recursive : true } ) ;
@@ -423,6 +461,7 @@ describe("check-extension-package-tsc-boundary", () => {
423461
424462 it ( "hard-kills timed out async node steps" , async ( ) => {
425463 const processSignals : Array < [ number , NodeJS . Signals | number | undefined ] > = [ ] ;
464+ let processGroupAlive = true ;
426465 const child = new EventEmitter ( ) as EventEmitter & {
427466 kill : ( signal ?: NodeJS . Signals | number ) => boolean ;
428467 pid : number ;
@@ -445,6 +484,13 @@ describe("check-extension-package-tsc-boundary", () => {
445484 return child ;
446485 } ,
447486 killProcess ( pid : number , signal ?: NodeJS . Signals | number ) {
487+ if ( signal === "SIGKILL" ) {
488+ processGroupAlive = false ;
489+ }
490+ if ( signal === 0 && ! processGroupAlive ) {
491+ processSignals . push ( [ pid , signal ] ) ;
492+ throw Object . assign ( new Error ( "gone" ) , { code : "ESRCH" } ) ;
493+ }
448494 processSignals . push ( [ pid , signal ] ) ;
449495 return true ;
450496 } ,
@@ -457,7 +503,10 @@ describe("check-extension-package-tsc-boundary", () => {
457503 ( error : unknown ) => error ,
458504 ) ;
459505
460- expect ( processSignals ) . toEqual ( [ [ - 1234 , "SIGKILL" ] ] ) ;
506+ expect ( processSignals ) . toEqual ( [
507+ [ - 1234 , "SIGKILL" ] ,
508+ [ - 1234 , 0 ] ,
509+ ] ) ;
461510 expect ( failure ) . toBeInstanceOf ( Error ) ;
462511 if ( ! ( failure instanceof Error ) ) {
463512 throw new Error ( "expected timeout failure to reject with an Error" ) ;
@@ -466,6 +515,57 @@ describe("check-extension-package-tsc-boundary", () => {
466515 expect ( ( failure as { kind ?: unknown } ) . kind ) . toBe ( "timeout" ) ;
467516 } ) ;
468517
518+ it . skipIf ( process . platform === "win32" ) (
519+ "waits for timed-out async node step process groups" ,
520+ async ( ) => {
521+ const root = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "openclaw-extension-tsc-timeout-" ) ) ;
522+ tempRoots . add ( root ) ;
523+ const childPidPath = path . join ( root , "child.pid" ) ;
524+ let childPid = 0 ;
525+ const childScript = [
526+ "process.on('SIGTERM', () => {});" ,
527+ "setInterval(() => {}, 1000);" ,
528+ ] . join ( "" ) ;
529+ const parentScript = [
530+ "const { spawn } = require('node:child_process');" ,
531+ "const fs = require('node:fs');" ,
532+ `const child = spawn(process.execPath, ['-e', ${ JSON . stringify ( childScript ) } ], { stdio: 'ignore' });` ,
533+ `fs.writeFileSync(${ JSON . stringify ( childPidPath ) } , String(child.pid));` ,
534+ "setInterval(() => {}, 1000);" ,
535+ ] . join ( "" ) ;
536+
537+ try {
538+ const failurePromise = runNodeStepAsync (
539+ "hung-step-group" ,
540+ [ "--eval" , parentScript ] ,
541+ 100 ,
542+ {
543+ spawnImpl ( command : string , args : string [ ] , options : unknown ) {
544+ return spawn ( command , args , options as Parameters < typeof spawn > [ 2 ] ) ;
545+ } ,
546+ } ,
547+ ) . then (
548+ ( ) => {
549+ throw new Error ( "expected hung-step-group to time out" ) ;
550+ } ,
551+ ( error : unknown ) => error ,
552+ ) ;
553+
554+ await waitForFile ( childPidPath , 2_000 ) ;
555+ childPid = Number . parseInt ( fs . readFileSync ( childPidPath , "utf8" ) , 10 ) ;
556+ expect ( isProcessAlive ( childPid ) ) . toBe ( true ) ;
557+
558+ const failure = await failurePromise ;
559+ expect ( failure ) . toBeInstanceOf ( Error ) ;
560+ await waitForDead ( childPid , 2_000 ) ;
561+ } finally {
562+ if ( childPid && isProcessAlive ( childPid ) ) {
563+ process . kill ( childPid , "SIGKILL" ) ;
564+ }
565+ }
566+ } ,
567+ ) ;
568+
469569 it ( "aborts concurrent sibling steps after the first failure" , async ( ) => {
470570 const startedAt = Date . now ( ) ;
471571 const slowStepTimeoutMs = 60_000 ;
0 commit comments