Skip to content

Commit 05b1c7d

Browse files
More robust PHP child process killing
1 parent 712d65f commit 05b1c7d

2 files changed

Lines changed: 35 additions & 9 deletions

File tree

apps/cli/php-server-child.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { ChildProcess, spawn } from 'node:child_process';
1010
import fs from 'node:fs';
11+
import os from 'node:os';
1112
import path from 'node:path';
1213
import { DEFAULT_PHP_VERSION } from '@studio/common/constants';
1314
import { DEFAULT_LOCALE } from '@studio/common/lib/locale';
@@ -642,19 +643,34 @@ async function ipcMessageHandler( packet: unknown ) {
642643
function killPhpProcess(): void {
643644
if ( phpProcess && ! phpProcess.killed ) {
644645
try {
646+
// Detach the unexpected-exit listener so the imminent SIGKILL is not logged as a crash.
647+
phpProcess.removeAllListeners( 'exit' );
645648
phpProcess.kill( 'SIGKILL' );
646649
} catch {
647650
// Best effort — nothing useful to do if this fails.
648651
}
649652
}
650653
}
651654

655+
function shutdownOnSignal( signal: NodeJS.Signals ): void {
656+
logToConsole( `Received ${ signal }, shutting down` );
657+
killPhpProcess();
658+
// Follow the Unix convention of `128 + signum` so the exit code reflects the signal.
659+
const signum = os.constants.signals[ signal ] ?? 0;
660+
process.exit( 128 + signum );
661+
}
662+
652663
// If this node process is going down (normal exit or IPC disconnect), make sure PHP goes with it.
653664
process.on( 'exit', killPhpProcess );
654665
process.on( 'disconnect', () => {
655666
killPhpProcess();
656667
} );
657668

669+
// Without explicit signal handlers, the process is terminated abruptly and the 'exit' event
670+
// does not fire — leaving the PHP child orphaned. These handlers ensure cleanup runs.
671+
process.on( 'SIGTERM', shutdownOnSignal );
672+
process.on( 'SIGINT', shutdownOnSignal );
673+
658674
if ( process.send ) {
659675
process.on( 'message', ipcMessageHandler );
660676
process.send( { topic: 'ready' } );

apps/cli/process-manager-daemon.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ export class ProcessManagerDaemon {
309309
resolve();
310310
} );
311311

312-
managedProcess.child.kill( 'SIGTERM' );
312+
this.signalProcessGroup( managedProcess, 'SIGTERM' );
313313
} );
314314
}
315315

@@ -424,23 +424,33 @@ export class ProcessManagerDaemon {
424424
}
425425

426426
private forceCleanupChild( managedProcess: ManagedProcess ) {
427-
if ( process.platform === 'win32' ) {
427+
// On Windows, child.kill() maps any signal to TerminateProcess, so SIGKILL and SIGTERM
428+
// are equivalent there. On non-Windows the helper sends SIGKILL to the whole group.
429+
this.signalProcessGroup( managedProcess, 'SIGKILL' );
430+
}
431+
432+
private signalProcessGroup( managedProcess: ManagedProcess, signal: NodeJS.Signals ): void {
433+
if ( process.platform === 'win32' || ! managedProcess.child.pid ) {
428434
try {
429-
managedProcess.child.kill( 'SIGTERM' );
435+
managedProcess.child.kill( signal );
430436
} catch {
431437
// Do nothing
432438
}
433439
return;
434440
}
435441

436-
if ( ! managedProcess.child.pid ) {
437-
return;
438-
}
439-
442+
// Children are spawned with `detached: true` on non-Windows, so each lives in its own
443+
// process group. Signalling the negative PID delivers to every member of that group,
444+
// including grandchildren (e.g. the PHP server spawned by the wrapper).
440445
try {
441-
process.kill( -managedProcess.child.pid, 'SIGKILL' );
446+
process.kill( -managedProcess.child.pid, signal );
442447
} catch {
443-
// Do nothing
448+
// Group send can fail if the leader has already exited but children remain.
449+
try {
450+
managedProcess.child.kill( signal );
451+
} catch {
452+
// Do nothing
453+
}
444454
}
445455
}
446456

0 commit comments

Comments
 (0)