@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
22import { mkdir , readFile , writeFile } from "node:fs/promises" ;
33import { createRequire } from "node:module" ;
44import path from "node:path" ;
5+ import { pathToFileURL } from "node:url" ;
56
67type Options = {
78 altScreen : boolean ;
@@ -20,6 +21,24 @@ const MODE_TEST_FILES = {
2021const MIRROR_TERMINAL_QUERIES = [ "\x1b[?u" , "\x1b[16t" ] ;
2122const DEFAULT_PTY_COLS = 100 ;
2223const DEFAULT_PTY_ROWS = 30 ;
24+ const CHILD_SIGTERM_GRACE_MS = 500 ;
25+ const CHILD_SIGKILL_GRACE_MS = 5_000 ;
26+
27+ type KillableChild = {
28+ pid ?: number ;
29+ kill ( signal : NodeJS . Signals ) : boolean ;
30+ } ;
31+
32+ type ChildStopper = {
33+ cancel : ( ) => void ;
34+ stop : ( ) => void ;
35+ } ;
36+
37+ type SignalChild = ( child : KillableChild , signal : NodeJS . Signals ) => void ;
38+
39+ function unrefTimer ( timer : ReturnType < typeof setTimeout > ) : void {
40+ ( timer as { unref ?: ( ) => void } ) . unref ?.( ) ;
41+ }
2342
2443function readOption ( args : string [ ] , name : string ) : string | undefined {
2544 const idx = args . indexOf ( name ) ;
@@ -72,6 +91,64 @@ function currentTerminalDimension(value: number | undefined, fallback: number):
7291 return String ( value && value > 0 ? value : fallback ) ;
7392}
7493
94+ function signalChildProcessTree ( child : KillableChild , signal : NodeJS . Signals ) : void {
95+ if ( process . platform !== "win32" && typeof child . pid === "number" ) {
96+ try {
97+ process . kill ( - child . pid , signal ) ;
98+ return ;
99+ } catch {
100+ // Non-detached fallback or already-exited group; direct child signaling is
101+ // still useful on platforms without process groups.
102+ }
103+ }
104+ child . kill ( signal ) ;
105+ }
106+
107+ function createChildStopper (
108+ child : KillableChild ,
109+ options : {
110+ signalChild ?: SignalChild ;
111+ sigtermGraceMs ?: number ;
112+ sigkillGraceMs ?: number ;
113+ } = { } ,
114+ ) : ChildStopper {
115+ const signalChild = options . signalChild ?? signalChildProcessTree ;
116+ const sigtermGraceMs = options . sigtermGraceMs ?? CHILD_SIGTERM_GRACE_MS ;
117+ const sigkillGraceMs = options . sigkillGraceMs ?? CHILD_SIGKILL_GRACE_MS ;
118+ let stopping = false ;
119+ let termTimer : ReturnType < typeof setTimeout > | undefined ;
120+ let killTimer : ReturnType < typeof setTimeout > | undefined ;
121+
122+ const cancel = ( ) => {
123+ if ( termTimer ) {
124+ clearTimeout ( termTimer ) ;
125+ termTimer = undefined ;
126+ }
127+ if ( killTimer ) {
128+ clearTimeout ( killTimer ) ;
129+ killTimer = undefined ;
130+ }
131+ } ;
132+
133+ const stop = ( ) => {
134+ if ( stopping ) {
135+ return ;
136+ }
137+ stopping = true ;
138+ signalChild ( child , "SIGINT" ) ;
139+ termTimer = setTimeout ( ( ) => {
140+ signalChild ( child , "SIGTERM" ) ;
141+ killTimer = setTimeout ( ( ) => {
142+ signalChild ( child , "SIGKILL" ) ;
143+ } , sigkillGraceMs ) ;
144+ unrefTimer ( killTimer ) ;
145+ } , sigtermGraceMs ) ;
146+ unrefTimer ( termTimer ) ;
147+ } ;
148+
149+ return { cancel, stop } ;
150+ }
151+
75152async function createMirrorFile ( mirrorPath : string ) : Promise < void > {
76153 await mkdir ( path . dirname ( mirrorPath ) , { recursive : true } ) ;
77154 await writeFile ( mirrorPath , "" , "utf8" ) ;
@@ -108,6 +185,7 @@ async function main(): Promise<void> {
108185 ] ,
109186 {
110187 cwd : process . cwd ( ) ,
188+ detached : process . platform !== "win32" ,
111189 env : {
112190 ...process . env ,
113191 OPENCLAW_TUI_PTY_MIRROR_PATH : options . mirrorPath ,
@@ -172,10 +250,8 @@ async function main(): Promise<void> {
172250 }
173251 } ;
174252
175- const stopChild = ( ) => {
176- child . kill ( "SIGINT" ) ;
177- setTimeout ( ( ) => child . kill ( "SIGTERM" ) , 500 ) . unref ( ) ;
178- } ;
253+ const childStopper = createChildStopper ( child ) ;
254+ const stopChild = childStopper . stop ;
179255
180256 const ignoredInput = ( chunk : Buffer ) => {
181257 if ( chunk . includes ( 0x03 ) ) {
@@ -238,12 +314,20 @@ async function main(): Promise<void> {
238314 childStderr += chunk . toString ( "utf8" ) ;
239315 } ) ;
240316
241- let childExit : { code : number | null ; signal : NodeJS . Signals | null } | null = null ;
242- child . on ( "exit" , ( code , signal ) => {
243- childExit = { code, signal } ;
317+ type ChildExit = { code : number | null ; signal : NodeJS . Signals | null } ;
318+ let childExit : ChildExit | null = null ;
319+ const childFinished = new Promise < ChildExit > ( ( resolve ) => {
320+ child . once ( "exit" , ( code , signal ) => {
321+ childExit = { code, signal } ;
322+ childStopper . cancel ( ) ;
323+ resolve ( childExit ) ;
324+ } ) ;
244325 } ) ;
245326
246- process . once ( "SIGINT" , stopChild ) ;
327+ const parentSignals : NodeJS . Signals [ ] = [ "SIGINT" , "SIGTERM" , "SIGHUP" ] ;
328+ for ( const signal of parentSignals ) {
329+ process . once ( signal , stopChild ) ;
330+ }
247331
248332 try {
249333 for ( ; ; ) {
@@ -265,6 +349,12 @@ async function main(): Promise<void> {
265349 writeMirrorChunk ( result . chunk ) ;
266350 }
267351 } finally {
352+ if ( ! childExit ) {
353+ stopChild ( ) ;
354+ }
355+ for ( const signal of parentSignals ) {
356+ process . off ( signal , stopChild ) ;
357+ }
268358 await drainParentInput ( ) ;
269359 restoreInput ( ) ;
270360 if ( useAltScreen ) {
@@ -273,6 +363,10 @@ async function main(): Promise<void> {
273363 restoreScreen ( ) ;
274364 }
275365
366+ if ( ! childExit ) {
367+ childExit = await childFinished ;
368+ }
369+
276370 if ( childStdout ) {
277371 process . stdout . write ( childStdout ) ;
278372 }
@@ -288,9 +382,16 @@ async function main(): Promise<void> {
288382 }
289383}
290384
291- main ( ) . catch ( ( error : unknown ) => {
292- process . stderr . write (
293- `${ error instanceof Error ? error . stack || error . message : String ( error ) } \n` ,
294- ) ;
295- process . exit ( 1 ) ;
296- } ) ;
385+ if ( import . meta. url === pathToFileURL ( process . argv [ 1 ] ?? "" ) . href ) {
386+ main ( ) . catch ( ( error : unknown ) => {
387+ process . stderr . write (
388+ `${ error instanceof Error ? error . stack || error . message : String ( error ) } \n` ,
389+ ) ;
390+ process . exit ( 1 ) ;
391+ } ) ;
392+ }
393+
394+ export const testing = {
395+ createChildStopper,
396+ signalChildProcessTree,
397+ } ;
0 commit comments