3 Commits

Author SHA1 Message Date
76225c6b9f fix(lifecycle): use process group kill (-pid) in handleExit safety net
With detached:true children, the synchronous exit handler must kill
the entire process group, not just the direct PID.
2026-03-04 00:48:38 +00:00
a623ac5fe4 v2.0.1 2026-03-03 23:56:58 +00:00
34b9aa4463 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.
2026-03-03 23:56:52 +00:00
3 changed files with 31 additions and 21 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartexit",
"version": "2.0.0",
"version": "2.0.2",
"private": false,
"description": "A library for managing graceful shutdowns of Node.js processes by handling cleanup operations, including terminating child processes.",
"main": "dist_ts/index.js",

View File

@@ -187,21 +187,24 @@ export class ProcessLifecycle {
});
}
/** Synchronous last-resort: SIGKILL any remaining child processes. */
/** Synchronous last-resort: SIGKILL any remaining tracked process groups. */
private handleExit(): void {
const instances = ProcessLifecycle.getInstances();
let killed = 0;
for (const instance of instances) {
const processes = instance.processesToEnd.getArray();
for (const child of processes) {
const pid = child.pid;
if (pid && !child.killed) {
for (const pid of instance.trackedPids) {
// Kill entire process group (negative PID) for detached children
try {
process.kill(-pid, 'SIGKILL');
killed++;
} catch {
// Process group may not exist, try single PID
try {
process.kill(pid, 'SIGKILL');
killed++;
} catch {
// Process may already be dead
// Process already dead
}
}
}

View File

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