Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a623ac5fe4 | |||
| 34b9aa4463 | |||
| da24218bef | |||
| 862c67edbb | |||
| ad8e389ef5 | |||
| 1986b3a421 | |||
| 2e5b26c9cf | |||
| bb2831ec0f |
17
changelog.md
17
changelog.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-03 - 1.1.1 - fix(shutdown)
|
||||||
|
kill full child process trees and add synchronous exit handler to force-kill remaining child processes
|
||||||
|
|
||||||
|
- Use tree-kill via SmartExit.killTreeByPid to terminate entire process trees (attempt SIGTERM, fallback to SIGKILL).
|
||||||
|
- Replace previous delayed/process.kill calls with awaitable tree-kill for more reliable termination.
|
||||||
|
- Add a synchronous process.on('exit') handler that force-kills any remaining child processes as a last-resort safety net.
|
||||||
|
- No public API changes; internal robustness/bugfix to shutdown behavior.
|
||||||
|
|
||||||
|
## 2025-12-15 - 1.1.0 - feat(smartexit)
|
||||||
|
Add silent logging option, structured shutdown logs, and killAll return stats
|
||||||
|
|
||||||
|
- Introduce ISmartExitOptions with a silent flag to disable console logging
|
||||||
|
- Add internal log() helper and use a [smartexit] prefix for shutdown/error messages
|
||||||
|
- killAll() now returns Promise<{ processesKilled, cleanupFunctionsRan }> and tallies processes and cleanup functions run
|
||||||
|
- Constructor accepts options (backwards compatible) to configure behavior
|
||||||
|
- Documentation (readme.hints.md) updated with usage and example output
|
||||||
|
|
||||||
## 2025-12-15 - 1.0.24 - fix(deps)
|
## 2025-12-15 - 1.0.24 - fix(deps)
|
||||||
bump dependencies, update tests and docs, adjust tsconfig and npm release config
|
bump dependencies, update tests and docs, adjust tsconfig and npm release config
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartexit",
|
"name": "@push.rocks/smartexit",
|
||||||
"version": "1.0.24",
|
"version": "2.0.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A library for managing graceful shutdowns of Node.js processes by handling cleanup operations, including terminating child processes.",
|
"description": "A library for managing graceful shutdowns of Node.js processes by handling cleanup operations, including terminating child processes.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/lik": "^6.2.2",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"tree-kill": "^1.2.2"
|
"tree-kill": "^1.2.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1 +1,29 @@
|
|||||||
|
# SmartExit - Development Hints
|
||||||
|
|
||||||
|
## Logging System
|
||||||
|
|
||||||
|
The module uses consolidated logging with a `[smartexit]` prefix:
|
||||||
|
|
||||||
|
- **Default behavior**: Logs a single summary line on shutdown
|
||||||
|
- **Silent mode**: Pass `{ silent: true }` to constructor to disable all logging
|
||||||
|
|
||||||
|
### Example output
|
||||||
|
```
|
||||||
|
[smartexit] Shutdown complete: killed 3 child processes, ran 2 cleanup functions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
```typescript
|
||||||
|
// Default - logs summary
|
||||||
|
const smartExit = new SmartExit();
|
||||||
|
|
||||||
|
// Silent - no logging
|
||||||
|
const smartExit = new SmartExit({ silent: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
## killAll() Return Value
|
||||||
|
|
||||||
|
The `killAll()` method returns stats about the cleanup:
|
||||||
|
```typescript
|
||||||
|
const { processesKilled, cleanupFunctionsRan } = await smartExit.killAll();
|
||||||
|
```
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartexit',
|
name: '@push.rocks/smartexit',
|
||||||
version: '1.0.24',
|
version: '1.1.1',
|
||||||
description: 'A library for managing graceful shutdowns of Node.js processes by handling cleanup operations, including terminating child processes.'
|
description: 'A library for managing graceful shutdowns of Node.js processes by handling cleanup operations, including terminating child processes.'
|
||||||
}
|
}
|
||||||
|
|||||||
136
ts/index.ts
136
ts/index.ts
@@ -1,132 +1,4 @@
|
|||||||
import * as plugins from './smartexit.plugins.js';
|
export { SmartExit } from './smartexit.classes.smartexit.js';
|
||||||
|
export type { ISmartExitOptions, TProcessSignal } from './smartexit.classes.smartexit.js';
|
||||||
export type TProcessSignal =
|
export { ProcessLifecycle } from './smartexit.classes.lifecycle.js';
|
||||||
| 'SIGHUP' // Hangup detected on controlling terminal or death of controlling process
|
export type { ILifecycleOptions } from './smartexit.classes.lifecycle.js';
|
||||||
| 'SIGINT' // Interrupt from keyboard
|
|
||||||
| 'SIGQUIT' // Quit from keyboard
|
|
||||||
| 'SIGILL' // Illegal Instruction
|
|
||||||
| 'SIGTRAP' // Trace/breakpoint trap
|
|
||||||
| 'SIGABRT' // Abort signal from abort(3)
|
|
||||||
| 'SIGIOT' // IOT trap. A synonym for SIGABRT
|
|
||||||
| 'SIGBUS' // Bus error (bad memory access)
|
|
||||||
| 'SIGFPE' // Floating-point exception
|
|
||||||
| 'SIGKILL' // Kill signal
|
|
||||||
| 'SIGUSR1' // User-defined signal 1
|
|
||||||
| 'SIGSEGV' // Invalid memory reference
|
|
||||||
| 'SIGUSR2' // User-defined signal 2
|
|
||||||
| 'SIGPIPE' // Broken pipe: write to pipe with no readers
|
|
||||||
| 'SIGALRM' // Timer signal from alarm(2)
|
|
||||||
| 'SIGTERM' // Termination signal
|
|
||||||
| 'SIGCHLD' // Child stopped or terminated
|
|
||||||
| 'SIGCONT' // Continue if stopped
|
|
||||||
| 'SIGSTOP' // Stop process
|
|
||||||
| 'SIGTSTP' // Stop typed at terminal
|
|
||||||
| 'SIGTTIN' // Terminal input for background process
|
|
||||||
| 'SIGTTOU' // Terminal output for background process
|
|
||||||
| 'SIGURG' // Urgent condition on socket
|
|
||||||
| 'SIGXCPU' // CPU time limit exceeded
|
|
||||||
| 'SIGXFSZ' // File size limit exceeded
|
|
||||||
| 'SIGVTALRM' // Virtual alarm clock
|
|
||||||
| 'SIGPROF' // Profiling timer expired
|
|
||||||
| 'SIGWINCH' // Window resize signal
|
|
||||||
| 'SIGPOLL' // Pollable event (Sys V). Synonym for SIGIO
|
|
||||||
| 'SIGIO' // I/O now possible (4.2BSD)
|
|
||||||
| 'SIGPWR' // Power failure (System V)
|
|
||||||
| 'SIGINFO' // Information request (some systems)
|
|
||||||
| 'SIGLOST' // Resource lost (unused on most UNIX systems)
|
|
||||||
| 'SIGSYS' // Bad system call (unused on most UNIX systems)
|
|
||||||
| 'SIGUNUSED'; // Synonym for SIGSYS
|
|
||||||
|
|
||||||
export class SmartExit {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await done.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instance
|
|
||||||
public processesToEnd = new plugins.lik.ObjectMap<plugins.childProcess.ChildProcess>();
|
|
||||||
public cleanupFunctions = new plugins.lik.ObjectMap<() => Promise<any>>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* adds a process to be exited
|
|
||||||
* @param childProcessArg
|
|
||||||
*/
|
|
||||||
public addProcess(childProcessArg: plugins.childProcess.ChildProcess) {
|
|
||||||
this.processesToEnd.add(childProcessArg);
|
|
||||||
}
|
|
||||||
|
|
||||||
public addCleanupFunction(cleanupFunctionArg: () => Promise<any>) {
|
|
||||||
this.cleanupFunctions.add(cleanupFunctionArg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* removes a process to be exited
|
|
||||||
*/
|
|
||||||
public removeProcess(childProcessArg: plugins.childProcess.ChildProcess) {
|
|
||||||
this.processesToEnd.remove(childProcessArg);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async killAll() {
|
|
||||||
console.log('Checking for remaining child processes before exit...');
|
|
||||||
if (this.processesToEnd.getArray().length > 0) {
|
|
||||||
console.log('found remaining child processes');
|
|
||||||
let counter = 1;
|
|
||||||
this.processesToEnd.forEach(async (childProcessArg) => {
|
|
||||||
const pid = childProcessArg.pid;
|
|
||||||
console.log(`killing process #${counter} with pid ${pid}`);
|
|
||||||
plugins.smartdelay.delayFor(10000).then(() => {
|
|
||||||
if (childProcessArg.killed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
process.kill(pid, 'SIGKILL');
|
|
||||||
});
|
|
||||||
process.kill(pid, 'SIGINT');
|
|
||||||
|
|
||||||
counter++;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log(`ChildProcesses look clean.`);
|
|
||||||
}
|
|
||||||
if (this.cleanupFunctions.getArray.length > 0) {
|
|
||||||
this.cleanupFunctions.forEach(async (cleanupFunction) => {
|
|
||||||
await cleanupFunction();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(`Ready to exit!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// do app specific cleaning before exiting
|
|
||||||
process.on('exit', async (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
console.log('Process wants to exit');
|
|
||||||
await this.killAll();
|
|
||||||
console.log('Exited ok!');
|
|
||||||
} else {
|
|
||||||
console.error('Exited NOT OK!');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// catch ctrl+c event and exit normally
|
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
console.log('Ctrl-C... or SIGINT signal received!');
|
|
||||||
await this.killAll();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
//catch uncaught exceptions, trace, then exit normally
|
|
||||||
process.on('uncaughtException', async (err) => {
|
|
||||||
console.log('SMARTEXIT: uncaught exception...');
|
|
||||||
console.log(err);
|
|
||||||
await this.killAll();
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
210
ts/smartexit.classes.lifecycle.ts
Normal file
210
ts/smartexit.classes.lifecycle.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
export interface ILifecycleOptions {
|
||||||
|
/** Which signals to intercept. Default: ['SIGINT', 'SIGTERM'] */
|
||||||
|
signals?: Array<'SIGINT' | 'SIGTERM' | 'SIGHUP'>;
|
||||||
|
/** Handle uncaughtException. Default: true */
|
||||||
|
uncaughtExceptions?: boolean;
|
||||||
|
/** Max time in ms for graceful shutdown before force-kill. Default: 10000 */
|
||||||
|
shutdownTimeoutMs?: number;
|
||||||
|
/** Disable lifecycle logging. Default: false */
|
||||||
|
silent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<ILifecycleOptions> = {
|
||||||
|
signals: ['SIGINT', 'SIGTERM'],
|
||||||
|
uncaughtExceptions: true,
|
||||||
|
shutdownTimeoutMs: 10000,
|
||||||
|
silent: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global process lifecycle manager.
|
||||||
|
*
|
||||||
|
* Call `ProcessLifecycle.install()` ONCE from your application entry point.
|
||||||
|
* Libraries should NEVER call install() — they just create SmartExit instances.
|
||||||
|
*
|
||||||
|
* All SmartExit instances auto-register here. On shutdown, all instances'
|
||||||
|
* cleanup functions run and all tracked process trees are killed.
|
||||||
|
*/
|
||||||
|
export class ProcessLifecycle {
|
||||||
|
// Singleton
|
||||||
|
private static instance: ProcessLifecycle | null = null;
|
||||||
|
|
||||||
|
// Global registry of SmartExit instances (populated by SmartExit constructor)
|
||||||
|
// Using 'any' to avoid circular import — actual type is SmartExit
|
||||||
|
private static readonly registry: Set<any> = new Set();
|
||||||
|
private static exitHandlerInstalled = false;
|
||||||
|
|
||||||
|
private options: Required<ILifecycleOptions>;
|
||||||
|
private shuttingDown = false;
|
||||||
|
private signalCount = 0;
|
||||||
|
|
||||||
|
private constructor(options: Required<ILifecycleOptions>) {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install global signal handlers.
|
||||||
|
* Call ONCE from the application entry point. Libraries should NOT call this.
|
||||||
|
* Idempotent — subsequent calls return the existing instance.
|
||||||
|
*/
|
||||||
|
public static install(options: ILifecycleOptions = {}): ProcessLifecycle {
|
||||||
|
if (ProcessLifecycle.instance) {
|
||||||
|
return ProcessLifecycle.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
const lifecycle = new ProcessLifecycle(merged);
|
||||||
|
ProcessLifecycle.instance = lifecycle;
|
||||||
|
|
||||||
|
// Install signal handlers
|
||||||
|
for (const signal of merged.signals) {
|
||||||
|
process.on(signal, () => lifecycle.handleSignal(signal));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install uncaughtException handler
|
||||||
|
if (merged.uncaughtExceptions) {
|
||||||
|
process.on('uncaughtException', (err) => lifecycle.handleUncaughtException(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous exit safety net (single handler covers all instances)
|
||||||
|
if (!ProcessLifecycle.exitHandlerInstalled) {
|
||||||
|
ProcessLifecycle.exitHandlerInstalled = true;
|
||||||
|
process.on('exit', () => lifecycle.handleExit());
|
||||||
|
}
|
||||||
|
|
||||||
|
return lifecycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the installed lifecycle, or null if not installed. */
|
||||||
|
public static getInstance(): ProcessLifecycle | null {
|
||||||
|
return ProcessLifecycle.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether global handlers are installed. */
|
||||||
|
public static isInstalled(): boolean {
|
||||||
|
return ProcessLifecycle.instance !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Instance registry ----
|
||||||
|
|
||||||
|
/** Called by SmartExit constructor to auto-register. */
|
||||||
|
public static registerInstance(instance: any): void {
|
||||||
|
ProcessLifecycle.registry.add(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an instance from the global registry. */
|
||||||
|
public static deregisterInstance(instance: any): void {
|
||||||
|
ProcessLifecycle.registry.delete(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all registered SmartExit instances. */
|
||||||
|
public static getInstances(): any[] {
|
||||||
|
return Array.from(ProcessLifecycle.registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Shutdown orchestration ----
|
||||||
|
|
||||||
|
/** Whether a shutdown is currently in progress. */
|
||||||
|
public get isShuttingDown(): boolean {
|
||||||
|
return this.shuttingDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate orderly shutdown of all registered instances.
|
||||||
|
* Safe to call multiple times — only the first call executes.
|
||||||
|
*/
|
||||||
|
public async shutdown(exitCode: number = 0): Promise<void> {
|
||||||
|
if (this.shuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.shuttingDown = true;
|
||||||
|
process.exitCode = exitCode;
|
||||||
|
|
||||||
|
const instances = ProcessLifecycle.getInstances();
|
||||||
|
|
||||||
|
let totalProcessesKilled = 0;
|
||||||
|
let totalCleanupRan = 0;
|
||||||
|
|
||||||
|
// Kill all instances in parallel, each running cleanup then tree-kill
|
||||||
|
const shutdownPromises = instances.map(async (instance) => {
|
||||||
|
try {
|
||||||
|
const result = await instance.killAll();
|
||||||
|
totalProcessesKilled += result.processesKilled;
|
||||||
|
totalCleanupRan += result.cleanupFunctionsRan;
|
||||||
|
} catch (err) {
|
||||||
|
if (!this.options.silent) {
|
||||||
|
console.error(`[smartexit] Error during instance cleanup: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Race against timeout
|
||||||
|
await Promise.race([
|
||||||
|
Promise.allSettled(shutdownPromises),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, this.options.shutdownTimeoutMs)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!this.options.silent) {
|
||||||
|
console.log(
|
||||||
|
`[smartexit] Shutdown complete: ${totalProcessesKilled} processes killed, ` +
|
||||||
|
`${totalCleanupRan} cleanup functions ran`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Signal handlers ----
|
||||||
|
|
||||||
|
private handleSignal(signal: string): void {
|
||||||
|
this.signalCount++;
|
||||||
|
|
||||||
|
if (this.signalCount >= 2) {
|
||||||
|
if (!this.options.silent) {
|
||||||
|
console.log(`\n[smartexit] Force shutdown (received ${signal} twice)`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.options.silent) {
|
||||||
|
console.log(`\n[smartexit] Received ${signal}, shutting down...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shutdown(0).then(() => {
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUncaughtException(err: Error): void {
|
||||||
|
if (!this.options.silent) {
|
||||||
|
console.error(`[smartexit] Uncaught exception: ${err.message}`);
|
||||||
|
if (err.stack) {
|
||||||
|
console.error(err.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shutdown(1).then(() => {
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous last-resort: SIGKILL any remaining tracked PIDs. */
|
||||||
|
private handleExit(): void {
|
||||||
|
const instances = ProcessLifecycle.getInstances();
|
||||||
|
let killed = 0;
|
||||||
|
|
||||||
|
for (const instance of instances) {
|
||||||
|
for (const pid of instance.trackedPids) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
killed++;
|
||||||
|
} catch {
|
||||||
|
// Process already dead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (killed > 0 && !this.options.silent) {
|
||||||
|
console.error(`[smartexit] Exit handler: force-killed ${killed} remaining child processes`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
ts/smartexit.classes.smartexit.ts
Normal file
161
ts/smartexit.classes.smartexit.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import * as plugins from './smartexit.plugins.js';
|
||||||
|
import { ProcessLifecycle } from './smartexit.classes.lifecycle.js';
|
||||||
|
|
||||||
|
export interface ISmartExitOptions {
|
||||||
|
/** Completely disable logging for this instance. */
|
||||||
|
silent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TProcessSignal =
|
||||||
|
| 'SIGHUP'
|
||||||
|
| 'SIGINT'
|
||||||
|
| 'SIGQUIT'
|
||||||
|
| 'SIGILL'
|
||||||
|
| 'SIGTRAP'
|
||||||
|
| 'SIGABRT'
|
||||||
|
| 'SIGIOT'
|
||||||
|
| 'SIGBUS'
|
||||||
|
| 'SIGFPE'
|
||||||
|
| 'SIGKILL'
|
||||||
|
| 'SIGUSR1'
|
||||||
|
| 'SIGSEGV'
|
||||||
|
| 'SIGUSR2'
|
||||||
|
| 'SIGPIPE'
|
||||||
|
| 'SIGALRM'
|
||||||
|
| 'SIGTERM'
|
||||||
|
| 'SIGCHLD'
|
||||||
|
| 'SIGCONT'
|
||||||
|
| 'SIGSTOP'
|
||||||
|
| 'SIGTSTP'
|
||||||
|
| 'SIGTTIN'
|
||||||
|
| 'SIGTTOU'
|
||||||
|
| 'SIGURG'
|
||||||
|
| 'SIGXCPU'
|
||||||
|
| 'SIGXFSZ'
|
||||||
|
| 'SIGVTALRM'
|
||||||
|
| 'SIGPROF'
|
||||||
|
| 'SIGWINCH'
|
||||||
|
| 'SIGPOLL'
|
||||||
|
| 'SIGIO'
|
||||||
|
| 'SIGPWR'
|
||||||
|
| 'SIGINFO'
|
||||||
|
| 'SIGLOST'
|
||||||
|
| 'SIGSYS'
|
||||||
|
| 'SIGUNUSED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SmartExit — process and cleanup tracker.
|
||||||
|
*
|
||||||
|
* Lightweight: the constructor does NOT register signal handlers.
|
||||||
|
* Each instance auto-registers with ProcessLifecycle so that global
|
||||||
|
* shutdown (triggered by ProcessLifecycle.install()) kills all tracked processes.
|
||||||
|
*
|
||||||
|
* Libraries should create instances freely. Only the application entry point
|
||||||
|
* should call `ProcessLifecycle.install()`.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await done.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if (this.options.silent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefix = '[smartexit]';
|
||||||
|
if (isError) {
|
||||||
|
console.error(`${prefix} ${message}`);
|
||||||
|
} else {
|
||||||
|
console.log(`${prefix} ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(optionsArg: ISmartExitOptions = {}) {
|
||||||
|
this.options = optionsArg;
|
||||||
|
// Auto-register with the global ProcessLifecycle registry
|
||||||
|
ProcessLifecycle.registerInstance(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
public addCleanupFunction(cleanupFunctionArg: () => Promise<any>) {
|
||||||
|
this.cleanupFunctions.add(cleanupFunctionArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unregister a child process. */
|
||||||
|
public removeProcess(childProcessArg: plugins.childProcess.ChildProcess) {
|
||||||
|
this.processesToEnd.remove(childProcessArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run cleanup functions, then kill all tracked process trees.
|
||||||
|
* Called by ProcessLifecycle during global shutdown.
|
||||||
|
* Can also be called manually.
|
||||||
|
*/
|
||||||
|
public async killAll(): Promise<{ processesKilled: number; cleanupFunctionsRan: number }> {
|
||||||
|
const processes = this.processesToEnd.getArray();
|
||||||
|
const cleanupFuncs = this.cleanupFunctions.getArray();
|
||||||
|
let processesKilled = 0;
|
||||||
|
let cleanupFunctionsRan = 0;
|
||||||
|
|
||||||
|
// Phase 1: Run cleanup functions (processes still alive)
|
||||||
|
for (const cleanupFunction of cleanupFuncs) {
|
||||||
|
try {
|
||||||
|
await cleanupFunction();
|
||||||
|
cleanupFunctionsRan++;
|
||||||
|
} catch (err) {
|
||||||
|
this.log(`Cleanup function failed: ${err}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove this instance from the global ProcessLifecycle registry. */
|
||||||
|
public deregister(): void {
|
||||||
|
ProcessLifecycle.deregisterInstance(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,9 @@ export { childProcess };
|
|||||||
|
|
||||||
// pushrocks scope
|
// pushrocks scope
|
||||||
import * as lik from '@push.rocks/lik';
|
import * as lik from '@push.rocks/lik';
|
||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
|
|
||||||
export { lik, smartdelay, smartpromise };
|
export { lik, smartpromise };
|
||||||
|
|
||||||
// third party scope
|
// third party scope
|
||||||
import * as treeKill from 'tree-kill';
|
import * as treeKill from 'tree-kill';
|
||||||
|
|||||||
Reference in New Issue
Block a user