@@ -105,6 +105,8 @@ type CliOptions = {
105105const DEFAULT_RUNS = 5 ;
106106const DEFAULT_WARMUP = 1 ;
107107const DEFAULT_TIMEOUT_MS = 30_000 ;
108+ const TIMEOUT_KILL_GRACE_MS = 1_000 ;
109+ const PROCESS_GROUP_EXIT_POLL_MS = 25 ;
108110const DEFAULT_ENTRY = "openclaw.mjs" ;
109111const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=" ;
110112const VALUE_FLAGS = new Set ( [
@@ -708,12 +710,16 @@ async function runSample(params: {
708710 let stderr = "" ;
709711 let settled = false ;
710712 let timedOut = false ;
713+ let forceKillAt : number | null = null ;
714+ let forceKillTimer : ReturnType < typeof setTimeout > | null = null ;
711715 const maxOutputLength = 32 * 1024 * 1024 ;
712716
713717 try {
714718 return await new Promise < Sample > ( ( resolve ) => {
719+ const useProcessGroup = process . platform !== "win32" ;
715720 const proc = spawn ( process . execPath , nodeArgs , {
716721 cwd : process . cwd ( ) ,
722+ detached : useProcessGroup ,
717723 env : {
718724 ...process . env ,
719725 HOME : runRoot ,
@@ -733,6 +739,10 @@ async function runSample(params: {
733739 return ;
734740 }
735741 settled = true ;
742+ if ( forceKillTimer ) {
743+ clearTimeout ( forceKillTimer ) ;
744+ forceKillTimer = null ;
745+ }
736746 const ms = Number ( process . hrtime . bigint ( ) - started ) / 1e6 ;
737747 resolve ( {
738748 ms,
@@ -751,18 +761,11 @@ async function runSample(params: {
751761
752762 const timeout = setTimeout ( ( ) => {
753763 timedOut = true ;
754- try {
755- proc . kill ( "SIGTERM" ) ;
756- } catch {
757- // Best-effort timeout cleanup.
758- }
759- setTimeout ( ( ) => {
760- try {
761- proc . kill ( "SIGKILL" ) ;
762- } catch {
763- // Best-effort timeout cleanup.
764- }
765- } , 1_000 ) . unref ?.( ) ;
764+ signalSampleProcess ( proc , "SIGTERM" , useProcessGroup ) ;
765+ forceKillAt = Date . now ( ) + TIMEOUT_KILL_GRACE_MS ;
766+ forceKillTimer = setTimeout ( ( ) => {
767+ signalSampleProcess ( proc , "SIGKILL" , useProcessGroup ) ;
768+ } , TIMEOUT_KILL_GRACE_MS ) . unref ?.( ) ;
766769 } , params . timeoutMs ) ;
767770 timeout . unref ?.( ) ;
768771
@@ -790,23 +793,108 @@ async function runSample(params: {
790793 } ) ;
791794 proc . once ( "close" , ( code , signal ) => {
792795 clearTimeout ( timeout ) ;
793- finish ( {
794- exitCode : code ,
795- signal,
796- ...( code === 0 && signal == null
797- ? { }
798- : {
799- stdoutTail : tailLines ( stdout , 20 ) ,
800- stderrTail : tailLines ( stderr , 20 ) ,
801- } ) ,
802- } ) ;
796+ const complete = ( ) =>
797+ finish ( {
798+ exitCode : code ,
799+ signal,
800+ ...( code === 0 && signal == null
801+ ? { }
802+ : {
803+ stdoutTail : tailLines ( stdout , 20 ) ,
804+ stderrTail : tailLines ( stderr , 20 ) ,
805+ } ) ,
806+ } ) ;
807+ if ( timedOut && isSampleProcessGroupAlive ( proc , useProcessGroup ) ) {
808+ void finishAfterTimeoutCleanup ( {
809+ complete,
810+ forceKillAt,
811+ proc,
812+ useProcessGroup,
813+ } ) ;
814+ return ;
815+ }
816+ complete ( ) ;
803817 } ) ;
804818 } ) ;
805819 } finally {
806820 rmSync ( runRoot , { recursive : true , force : true } ) ;
807821 }
808822}
809823
824+ async function finishAfterTimeoutCleanup ( params : {
825+ complete : ( ) => void ;
826+ forceKillAt : number | null ;
827+ proc : ReturnType < typeof spawn > ;
828+ useProcessGroup : boolean ;
829+ } ) : Promise < void > {
830+ const graceRemainingMs =
831+ params . forceKillAt === null
832+ ? TIMEOUT_KILL_GRACE_MS
833+ : Math . max ( 0 , params . forceKillAt - Date . now ( ) ) ;
834+ if ( graceRemainingMs > 0 ) {
835+ await waitForSampleProcessGroupExit ( params . proc , params . useProcessGroup , graceRemainingMs ) ;
836+ }
837+ if ( isSampleProcessGroupAlive ( params . proc , params . useProcessGroup ) ) {
838+ signalSampleProcess ( params . proc , "SIGKILL" , params . useProcessGroup ) ;
839+ }
840+ await waitForSampleProcessGroupExit ( params . proc , params . useProcessGroup , TIMEOUT_KILL_GRACE_MS ) ;
841+ params . complete ( ) ;
842+ }
843+
844+ function signalSampleProcess (
845+ proc : ReturnType < typeof spawn > ,
846+ signal : NodeJS . Signals ,
847+ useProcessGroup : boolean ,
848+ ) : void {
849+ if ( ! proc . pid ) {
850+ return ;
851+ }
852+ try {
853+ if ( useProcessGroup ) {
854+ process . kill ( - proc . pid , signal ) ;
855+ } else {
856+ proc . kill ( signal ) ;
857+ }
858+ } catch ( error ) {
859+ const code = ( error as NodeJS . ErrnoException | undefined ) ?. code ;
860+ if ( code !== "ESRCH" && code !== "EPERM" ) {
861+ throw error ;
862+ }
863+ }
864+ }
865+
866+ function isSampleProcessGroupAlive (
867+ proc : ReturnType < typeof spawn > ,
868+ useProcessGroup : boolean ,
869+ ) : boolean {
870+ if ( ! useProcessGroup || ! proc . pid ) {
871+ return false ;
872+ }
873+ try {
874+ process . kill ( - proc . pid , 0 ) ;
875+ return true ;
876+ } catch ( error ) {
877+ return ( error as NodeJS . ErrnoException | undefined ) ?. code === "EPERM" ;
878+ }
879+ }
880+
881+ async function waitForSampleProcessGroupExit (
882+ proc : ReturnType < typeof spawn > ,
883+ useProcessGroup : boolean ,
884+ timeoutMs : number ,
885+ ) : Promise < boolean > {
886+ const deadlineAt = Date . now ( ) + timeoutMs ;
887+ while ( Date . now ( ) < deadlineAt ) {
888+ if ( ! isSampleProcessGroupAlive ( proc , useProcessGroup ) ) {
889+ return true ;
890+ }
891+ await new Promise ( ( resolvePoll ) => {
892+ setTimeout ( resolvePoll , PROCESS_GROUP_EXIT_POLL_MS ) ;
893+ } ) ;
894+ }
895+ return ! isSampleProcessGroupAlive ( proc , useProcessGroup ) ;
896+ }
897+
810898async function runCase ( params : {
811899 entry : string ;
812900 commandCase : CommandCase ;
0 commit comments