fix(core): track PIDs independently to survive removeProcess() race during shutdown

The direct child process may die from terminal SIGINT before ProcessLifecycle
runs shutdown, causing removeProcess() to clear it. Now killAll() uses a
persistent trackedPids Set that is never cleared by removeProcess(), ensuring
grandchild process trees are always killed.
This commit is contained in:
2026-03-03 23:56:52 +00:00
parent da24218bef
commit 34b9aa4463
2 changed files with 28 additions and 25 deletions

View File

@@ -187,22 +187,18 @@ export class ProcessLifecycle {
}); });
} }
/** Synchronous last-resort: SIGKILL any remaining child processes. */ /** Synchronous last-resort: SIGKILL any remaining tracked PIDs. */
private handleExit(): void { private handleExit(): void {
const instances = ProcessLifecycle.getInstances(); const instances = ProcessLifecycle.getInstances();
let killed = 0; let killed = 0;
for (const instance of instances) { for (const instance of instances) {
const processes = instance.processesToEnd.getArray(); for (const pid of instance.trackedPids) {
for (const child of processes) { try {
const pid = child.pid; process.kill(pid, 'SIGKILL');
if (pid && !child.killed) { killed++;
try { } catch {
process.kill(pid, 'SIGKILL'); // Process already dead
killed++;
} catch {
// Process may already be dead
}
} }
} }
} }

View File

@@ -70,6 +70,8 @@ export class SmartExit {
// Instance state // Instance state
public processesToEnd = new plugins.lik.ObjectMap<plugins.childProcess.ChildProcess>(); public processesToEnd = new plugins.lik.ObjectMap<plugins.childProcess.ChildProcess>();
public cleanupFunctions = new plugins.lik.ObjectMap<() => Promise<any>>(); public cleanupFunctions = new plugins.lik.ObjectMap<() => Promise<any>>();
/** PIDs tracked independently — survives removeProcess() so shutdown can still kill the tree. */
public trackedPids = new Set<number>();
private options: ISmartExitOptions; private options: ISmartExitOptions;
private log(message: string, isError = false): void { private log(message: string, isError = false): void {
@@ -93,6 +95,9 @@ export class SmartExit {
/** Register a child process for cleanup on shutdown. */ /** Register a child process for cleanup on shutdown. */
public addProcess(childProcessArg: plugins.childProcess.ChildProcess) { public addProcess(childProcessArg: plugins.childProcess.ChildProcess) {
this.processesToEnd.add(childProcessArg); this.processesToEnd.add(childProcessArg);
if (childProcessArg.pid) {
this.trackedPids.add(childProcessArg.pid);
}
} }
/** Register an async cleanup function to run on shutdown. */ /** Register an async cleanup function to run on shutdown. */
@@ -126,23 +131,25 @@ export class SmartExit {
} }
} }
// Phase 2: Kill child process trees // Phase 2: Kill ALL tracked process trees by PID.
for (const childProcessArg of processes) { // We use trackedPids (not processesToEnd) because the process object may have
const pid = childProcessArg.pid; // been removed by smartshell's exit handler before shutdown runs.
if (pid && !childProcessArg.killed) { // We do NOT check .killed — the direct child may be dead but grandchildren alive.
try { for (const pid of this.trackedPids) {
await SmartExit.killTreeByPid(pid, 'SIGTERM'); try {
} catch { await SmartExit.killTreeByPid(pid, 'SIGTERM');
// SIGTERM failed — force kill
try {
await SmartExit.killTreeByPid(pid, 'SIGKILL');
} catch {
// Process may already be dead
}
}
processesKilled++; processesKilled++;
} catch {
// SIGTERM failed — force kill
try {
await SmartExit.killTreeByPid(pid, 'SIGKILL');
processesKilled++;
} catch {
// Process tree already dead — fine
}
} }
} }
this.trackedPids.clear();
return { processesKilled, cleanupFunctionsRan }; return { processesKilled, cleanupFunctionsRan };
} }