Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds asynchronous support #20

Merged
merged 23 commits into from
Aug 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e5f8afa
Adds asynchronous support
Feb 16, 2022
016cce0
fixup: Applies changes from review
Feb 28, 2022
4793be5
fixup: Improves Async Notes section of readme
Feb 28, 2022
7b5779a
fixup: Includes changes from Sindre's code review
jakobo Mar 8, 2022
77487f1
fix: Makes minimumWait a required option
jakobo Mar 23, 2022
d227278
fixup: Applies review feedback, applies sindre's ts styleguide
jakobo Jul 25, 2022
4f0f64f
fixup: Applies option recommendations from sindre
jakobo Jul 25, 2022
b0673c8
fixup: Fixes test that should be failing
jakobo Jul 25, 2022
6c7584f
test: Adds tests for types in exitHook and asyncExitHook
jakobo Jul 26, 2022
6c69ff0
fixup: Fixes wording on forceAfter
jakobo Jul 26, 2022
4ec258e
Update readme.md
sindresorhus Aug 19, 2022
d256a8f
Update fixture-async.js
sindresorhus Aug 19, 2022
2ad64b2
Update index.d.ts
sindresorhus Aug 19, 2022
f026a52
Update readme.md
sindresorhus Aug 19, 2022
a42c989
Update readme.md
sindresorhus Aug 19, 2022
8bf98ab
pr: Accepts Suggestion - readme formatting
jakobo Aug 19, 2022
d923846
pr: Accepts Suggestion - readme formatting
jakobo Aug 19, 2022
811d3e5
pr: Accepts Suggestion - readme formatting
jakobo Aug 19, 2022
a56f1bb
docs: Syncs readme.md to TS docs
jakobo Aug 19, 2022
913e0c0
docs: Uses function signatures in readme
jakobo Aug 19, 2022
84fff55
fix: Removes null of minimumWait
jakobo Aug 19, 2022
530eb3e
fix: Exports gracefulExit and asyncExitHook directly
jakobo Aug 19, 2022
c7a1ae3
docs: Updates exitHook readme to match TS docs
jakobo Aug 19, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions fixture-async.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import exitHook, {asyncExitHook, gracefulExit} from './index.js';

exitHook(() => {
console.log('foo');
});

exitHook(() => {
console.log('bar');
});

const unsubscribe = exitHook(() => {
console.log('baz');
});

unsubscribe();

asyncExitHook(
async () => {
await new Promise(resolve => {
setTimeout(() => {
resolve();
}, 100);
});

console.log('quux');
},
{
minimumWait: 200,
},
);

gracefulExit();
60 changes: 59 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Run some code when the process exits.

The `process.on('exit')` event doesn't catch all the ways a process can exit.

This package is useful for cleaning up before exiting.
This is useful for cleaning synchronously before exiting.

@param onExit - The callback function to execute when the process exits.
@returns A function that removes the hook when called.
Expand Down Expand Up @@ -33,3 +33,61 @@ unsubscribe();
```
*/
export default function exitHook(onExit: () => void): () => void;

/**
Run code asynchronously when the process exits.

@see https://github.com/sindresorhus/exit-hook/blob/main/readme.md#asynchronous-exit-notes
@param onExit - The callback function to execute when the process exits via `gracefulExit`, and will be wrapped in `Promise.resolve`.
@returns A function that removes the hook when called.

@example
```
import {asyncExitHook} from 'exit-hook';

asyncExitHook(() => {
console.log('Exiting');
}, {
minimumWait: 500
});

throw new Error('🦄');

//=> 'Exiting'

// Removing an exit hook:
const unsubscribe = asyncExitHook(() => {}, {});

unsubscribe();
```
*/
export function asyncExitHook(onExit: () => (void | Promise<void>), options: Options): () => void;

/**
Exit the process and make a best-effort to complete all asynchronous hooks.

If you are using `asyncExitHook`, consider using `gracefulExit()` instead of `process.exit()` to ensure all asynchronous tasks are given an opportunity to run.

@param signal - The exit code to use. Same as the argument to `process.exit()`.
@see https://github.com/sindresorhus/exit-hook/blob/main/readme.md#asynchronous-exit-notes

@example
```
import {asyncExitHook, gracefulExit} from 'exit-hook';

asyncExitHook(() => {
console.log('Exiting');
}, 500);

// Instead of `process.exit()`
gracefulExit();
```
*/
export function gracefulExit(signal?: number): void;

export interface Options {
/**
The amount of time in milliseconds that the `onExit` function is expected to take.
*/
minimumWait: number;
}
112 changes: 99 additions & 13 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,132 @@
import process from 'node:process';

const asyncCallbacks = new Set();
const callbacks = new Set();

let isCalled = false;
let isRegistered = false;

function exit(shouldManuallyExit, signal) {
async function exit(shouldManuallyExit, isSynchronous, signal) {
if (asyncCallbacks.size > 0 && isSynchronous) {
console.error([
'SYNCHRONOUS TERMINATION NOTICE:',
'When explicitly exiting the process via process.exit or via a parent process,',
'asynchronous tasks in your exitHooks will not run. Either remove these tasks,',
'use gracefulExit() instead of process.exit(), or ensure your parent process',
'sends a SIGINT to the process running this code.',
].join(' '));
}

if (isCalled) {
return;
}

isCalled = true;

const done = (force = false) => {
if (force === true || shouldManuallyExit === true) {
process.exit(128 + signal); // eslint-disable-line unicorn/no-process-exit
}
};

for (const callback of callbacks) {
callback();
}

if (shouldManuallyExit === true) {
process.exit(128 + signal); // eslint-disable-line unicorn/no-process-exit
if (isSynchronous) {
done();
return;
}

const promises = [];
let forceAfter = 0;
for (const [callback, wait] of asyncCallbacks) {
forceAfter = Math.max(forceAfter, wait);
promises.push(Promise.resolve(callback()));
}

// Force exit if we exceeded our wait value
const asyncTimer = setTimeout(() => {
done(true);
}, forceAfter);

await Promise.all(promises);
clearTimeout(asyncTimer);
done();
}

export default function exitHook(onExit) {
callbacks.add(onExit);
function addHook(options) {
const {onExit, minimumWait, isSynchronous} = options;
const asyncCallbackConfig = [onExit, minimumWait];

if (isSynchronous) {
callbacks.add(onExit);
} else {
asyncCallbacks.add(asyncCallbackConfig);
}

if (!isRegistered) {
isRegistered = true;

process.once('exit', exit);
process.once('SIGINT', exit.bind(undefined, true, 2));
process.once('SIGTERM', exit.bind(undefined, true, 15));
// Exit cases that support asynchronous handling
process.once('beforeExit', exit.bind(undefined, true, false, 0));
process.once('SIGINT', exit.bind(undefined, true, false, 2));
process.once('SIGTERM', exit.bind(undefined, true, false, 15));

// Explicit exit events. Calling will force an immediate exit and run all
// synchronous hooks. Explicit exits must not extend the node process
// artificially. Will log errors if asynchronous calls exist.
process.once('exit', exit.bind(undefined, false, true, 0));

// PM2 Cluster shutdown message. Caught to support async handlers with pm2, needed because
// explicitly calling process.exit() doesn't trigger the beforeExit event, and the exit
// event cannot support async handlers, since the event loop is never called after it.
// PM2 Cluster shutdown message. Caught to support async handlers with pm2,
// needed because explicitly calling process.exit() doesn't trigger the
// beforeExit event, and the exit event cannot support async handlers,
// since the event loop is never called after it.
process.on('message', message => {
if (message === 'shutdown') {
exit(true, -128);
exit(true, true, -128);
}
});
}

return () => {
callbacks.delete(onExit);
if (isSynchronous) {
callbacks.delete(onExit);
} else {
asyncCallbacks.delete(asyncCallbackConfig);
}
};
}

function exitHook(onExit) {
if (typeof onExit !== 'function') {
throw new TypeError('onExit must be a function');
}

return addHook({
onExit,
isSynchronous: true,
});
}

export function asyncExitHook(onExit, options) {
if (typeof onExit !== 'function') {
throw new TypeError('onExit must be a function');
}

if (typeof options?.minimumWait !== 'number' || options.minimumWait <= 0) {
throw new TypeError('minimumWait must be set to a positive numeric value');
}

return addHook({
onExit,
minimumWait: options.minimumWait,
isSynchronous: false,
});
}

export function gracefulExit(signal = 0) {
exit(true, false, -128 + signal);
}

export default exitHook;
9 changes: 8 additions & 1 deletion index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import {expectType} from 'tsd';
import exitHook from './index.js';
import exitHook, {asyncExitHook} from './index.js';

const unsubscribe = exitHook(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function

const asyncUnsubscribe = asyncExitHook(async () => {}, // eslint-disable-line @typescript-eslint/no-empty-function
{minimumWait: 300},
);

expectType<() => void>(unsubscribe);
unsubscribe();

expectType<() => void>(asyncUnsubscribe);
asyncUnsubscribe();
79 changes: 77 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,89 @@ unsubscribe();

### exitHook(onExit)

Returns a function that removes the hook when called.
Register a function to run during `process.exit`. Returns a function that removes the hook when called.

#### onExit

Type: `Function`
Type: `function(): void`

The callback function to execute when the process exits.

### asyncExitHook(onExit, minimumWait)

Register a function to run during `gracefulExit`. Returns a function that removes the hook when called.

jakobo marked this conversation as resolved.
Show resolved Hide resolved
Please see [Async Notes](#async-notes) for considerations when using the asynchronous API.

```js
import {asyncExitHook} from 'exit-hook';

asyncExitHook(async () => {
console.log('Exiting');
}, 300);

throw new Error('🦄');

//=> 'Exiting'
```

Removing an asynchronous exit hook:

```js
import {asyncExitHook} from 'exit-hook';

const unsubscribe = asyncExitHook(async () => {
console.log('Exiting');
}, {
minimumWait: 300
});

unsubscribe();
```

#### onExit

Type: `function(): void | Promise<void>`

The callback function to execute when the process exits via `gracefulExit`, and will be wrapped in `Promise.resolve`.

#### options

##### minimumWait

Type: `number`

The amount of time in milliseconds that the `onExit` function is expected to take.

### gracefulExit(signal?: number): void

Exit the process and make a best-effort to complete all asynchronous hooks.

If you are using `asyncExitHook`, consider using `gracefulExit()` instead of `process.exit()` to ensure all asynchronous tasks are given an opportunity to run.

```js
import {gracefulExit} from 'exit-hook';

gracefulExit();
```

#### signal

Type: `number`\
Default: `0`

The exit code to use. Same as the argument to `process.exit()`.

## Asynchronous Exit Notes

**tl;dr** If you have 100% control over how your process terminates, then you can swap `exitHook` and `process.exit` for `asyncExitHook` and `gracefulExit` respectively. Otherwise, keep reading to understand important tradeoffs if you're using `asyncExitHook`.

Node.js does not offer an asynchronous shutdown API by default [#1](https://github.com/nodejs/node/discussions/29480#discussioncomment-99213) [#2](https://github.com/nodejs/node/discussions/29480#discussioncomment-99217), so `asyncExitHook` and `gracefulExit` will make a "best effort" attempt to shut down the process and run your asynchronous tasks.

If you have asynchronous hooks registered and your node.js process is terminated in a synchronous manner, a `SYNCHRONOUS TERMINATION NOTICE` error will be logged to the console. To avoid this, ensure you're only exiting via `gracefulExit` or that an upstream process manager is sending a `SIGINT` or `SIGTERM` signal to node.js.

Asynchronous hooks should make a "best effort" to perform their tasks within the `minimumWait` time, but also be written to assume they may not complete their tasks before termination.

---

<div align="center">
Expand Down
Loading