5 Commits
v2.0.0 ... main

Author SHA1 Message Date
a68711d4aa v2.0.3 2026-03-04 17:55:47 +00:00
28a09f63b2 fix(smartexit): use native OS methods to kill process trees; remove tree-kill dependency 2026-03-04 17:55:47 +00:00
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
7 changed files with 70 additions and 44 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## 2026-03-04 - 2.0.3 - fix(smartexit)
use native OS methods to kill process trees; remove tree-kill dependency
- Replaced tree-kill usage with taskkill on Windows and process.kill(-pid) on POSIX.
- Removed tree-kill package from dependencies and removed its export from plugins.
- Handle ESRCH (no such process/group) as non-error when killing process groups.
- Ensure trackedPids is cleared when a child process is removed.
## 2026-03-03 - 1.1.1 - fix(shutdown)
kill full child process trees and add synchronous exit handler to force-kill remaining child processes

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartexit",
"version": "2.0.0",
"version": "2.0.3",
"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",
@@ -20,8 +20,7 @@
},
"dependencies": {
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartpromise": "^4.2.3",
"tree-kill": "^1.2.2"
"@push.rocks/smartpromise": "^4.2.3"
},
"browserslist": [
"last 1 chrome versions"

14
pnpm-lock.yaml generated
View File

@@ -11,15 +11,9 @@ importers:
'@push.rocks/lik':
specifier: ^6.2.2
version: 6.2.2
'@push.rocks/smartdelay':
specifier: ^3.0.5
version: 3.0.5
'@push.rocks/smartpromise':
specifier: ^4.2.3
version: 4.2.3
tree-kill:
specifier: ^1.2.2
version: 1.2.2
devDependencies:
'@git.zone/tsbuild':
specifier: ^4.0.2
@@ -853,24 +847,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.52':
resolution: {integrity: sha512-ENLmSQCWqSA/+YN45V2FqTIemg7QspaiTjlm327eUAMeOLdqmSOVVyrQexJGNTQ5M8sDYCgVAig2Kk01Ggmqaw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.52':
resolution: {integrity: sha512-klahlb2EIFltSUubn/VLjuc3qxp1E7th8ukayPfdkcKvvYcQ5rJztgx8JsJSuAKVzKtNTqUGOhy4On71BuyV8g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.52':
resolution: {integrity: sha512-UuA+JqQIgqtkgGN2c/AQ5wi8M6mJHrahz/wciENPTeI6zEIbbLGoth5XN+sQe2pJDejEVofN9aOAp0kaazwnVg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.52':
resolution: {integrity: sha512-1BNQW8u4ro8bsN1+tgKENJiqmvc+WfuaUhXzMImOVSMw28pkBKdfZtX2qJPADV3terx+vNJtlsgSGeb3+W6Jiw==}
@@ -918,21 +916,25 @@ packages:
resolution: {integrity: sha512-211/XoBiooGGgUo/NxNpsrzGUXtH1d7g/4+UTtjYtfc8QHwu7ZMHcsqg0wss53fXzn/yyxd0DZ56vBHq52BiFw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rspack/binding-linux-arm64-musl@1.6.7':
resolution: {integrity: sha512-0WnqAWz3WPDsXGvOOA++or7cHpoidVsH3FlqNaAfRu6ni6n7ig/s0/jKUB+C5FtXOgmGjAGkZHfFgNHsvZ0FWw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rspack/binding-linux-x64-gnu@1.6.7':
resolution: {integrity: sha512-iMrE0Q4IuYpkE0MjpaOVaUDYbQFiCRI9D3EPoXzlXJj4kJSdNheODpHTBVRlWt8Xp7UAoWuIFXCvKFKcSMm3aQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rspack/binding-linux-x64-musl@1.6.7':
resolution: {integrity: sha512-e7gKFxpdEQwYGk7lTC/hukTgNtaoAstBXehnZNk4k3kuU6+86WDrkn18Cd949iNqfIPtIG/wIsFNGbkHsH69hQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rspack/binding-wasm32-wasi@1.6.7':
resolution: {integrity: sha512-yx88EFdE9RP3hh7VhjjW6uc6wGU0KcpOcZp8T8E/a+X8L98fX0aVrtM1IDbndhmdluIMqGbfJNap2+QqOCY9Mw==}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartexit',
version: '1.1.1',
version: '2.0.3',
description: 'A library for managing graceful shutdowns of Node.js processes by handling cleanup operations, including terminating child processes.'
}

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

@@ -56,20 +56,32 @@ export type TProcessSignal =
export class SmartExit {
/** Kill an entire process tree by PID. */
public static async killTreeByPid(pidArg: number, signalArg: TProcessSignal = 'SIGKILL') {
const done = plugins.smartpromise.defer();
plugins.treeKill.default(pidArg, signalArg, (err) => {
if (err) {
done.reject(err);
} else {
done.resolve();
if (process.platform === 'win32') {
// Windows: use native taskkill for tree kill
try {
plugins.childProcess.execSync(`taskkill /T /F /PID ${pidArg}`, { stdio: 'ignore' });
} catch {
// Process already dead
}
});
await done.promise;
} else {
// POSIX: kill the entire process group via negative PID.
// Works even for grandchildren reparented to PID 1.
try {
process.kill(-pidArg, signalArg);
} catch (err: any) {
if (err.code !== 'ESRCH') {
// ESRCH = no such process/group, already dead — that's fine
throw err;
}
}
}
}
// Instance state
public processesToEnd = new plugins.lik.ObjectMap<plugins.childProcess.ChildProcess>();
public cleanupFunctions = new plugins.lik.ObjectMap<() => Promise<any>>();
/** PIDs tracked independently for tree-killing during shutdown. */
public trackedPids = new Set<number>();
private options: ISmartExitOptions;
private log(message: string, isError = false): void {
@@ -93,6 +105,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. */
@@ -103,6 +118,9 @@ export class SmartExit {
/** Unregister a child process. */
public removeProcess(childProcessArg: plugins.childProcess.ChildProcess) {
this.processesToEnd.remove(childProcessArg);
if (childProcessArg.pid) {
this.trackedPids.delete(childProcessArg.pid);
}
}
/**
@@ -126,23 +144,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 };
}

View File

@@ -9,9 +9,3 @@ import * as smartpromise from '@push.rocks/smartpromise';
export { lik, smartpromise };
// third party scope
import * as treeKill from 'tree-kill';
export {
treeKill
}