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 3 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
29 changes: 29 additions & 0 deletions fixture-async.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import exitHook from './index.js';

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

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

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

unsubscribe();

exitHook.async({
async onExit() {
await new Promise((resolve, _reject) => {
setTimeout(() => {
resolve();
}, 100);
});
console.log('quux');
},
minWait: 200,
});

exitHook.exit();
64 changes: 63 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,66 @@ const unsubscribe = exitHook(() => {});
unsubscribe();
```
*/
export default function exitHook(onExit: () => void): () => void;
declare function exitHook(onExit: onExitCallback): unsubscribeCallback;

declare namespace exitHook {
/**
Run some code when the process exits.

Please see https://github.com/sindresorhus/exit-hook/blob/main/readme.md#async-notes
for important considerations before using `exitHook.async`.

By default, `onExit` works to shut down a node.js process in a synchronous
manner. If you have pending IO operations, it may be useful to wait for
those tasks to complete before performing the shutdown of the node.js
process.
*/
function async(options: asyncHookOptions): unsubscribeCallback;

/**
Exit the process and complete all asynchronous hooks.

Please see https://github.com/sindresorhus/exit-hook/blob/main/readme.md#async-notes
for important considerations before using `exitHook.async`.

When using asynchronous hooks, you should use `exitHook.exit` instead of
calling `process.exit` directly. In node, `process.exit` does not wait for
asynchronous tasks to complete before termination.

@param signal - The exit code to use, identical to `process.exit`
@returns void

@example
```
import exitHook from 'exit-hook';

exitHook.async({
async onExit() {
console.log('Exiting');
},
minWait: 100
});

// instead of process.exit
exitHook.exit();
```
*/
function exit(signal: number): void;
}

/** The onExit callback */
type onExitCallback = () => void;
/** The onExit callback */
type onExitAsyncCallback = () => Promise<void>;
/** An unsubscribe method that unregisters the hook */
type unsubscribeCallback = () => void;

/** Options for asynchronous hooks */
type asyncHookOptions = {
jakobo marked this conversation as resolved.
Show resolved Hide resolved
/** An asynchronous callback to run on exit. Returns an unsubscribe callback */
onExit: onExitAsyncCallback;
/** The minimum amount of time to wait for this process to terminate */
jakobo marked this conversation as resolved.
Show resolved Hide resolved
minWait?: number;
};

export default exitHook;
98 changes: 85 additions & 13 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,118 @@
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 exitHook.exit() instead of process.exit(), or ensure your parent process
sends a SIGINT to the process running this code.
`);
}

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 maxWait 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, minWait, isSynchronous} = options;
const asyncCallbackConfig = [onExit, minWait];
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));

// 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.
// 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.
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) {
return addHook({
onExit,
minWait: null,
isSynchronous: true,
});
}

exitHook.async = hookOptions => addHook({
onExit: hookOptions.onExit,
minWait: hookOptions.minWait ?? 1000,
isSynchronous: false,
});

exitHook.exit = (signal = 0) => {
exit(true, false, -128 + signal);
};

export default exitHook;
8 changes: 8 additions & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,13 @@ import exitHook from './index.js';

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

const asyncUnsubscribe = exitHook.async({
async onExit() {}, // eslint-disable-line @typescript-eslint/no-empty-function
minWait: 100,
});
jakobo marked this conversation as resolved.
Show resolved Hide resolved

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

expectType<() => void>(asyncUnsubscribe);
asyncUnsubscribe();
43 changes: 42 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,47 @@ Type: `Function`

The callback function to execute when the process exits.

### exitHook.async(asyncHookOptions)

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

jakobo marked this conversation as resolved.
Show resolved Hide resolved
#### asyncHookOptions

Type: `Object`

A set of options for registering an asynchronous hook

##### asyncHookOptions.onExit

An asynchronous function that will be called on shutdown, returning a promise.

##### asyncHookOptions.minWait

The minimum amount of time to wait for this asynchronous hook to complete. Defaults to `1000`ms.

# Async Notes

`exitHook` comes with an asynchronous API via `exitHook.async` which under **specific conditions** will allow you to complete asynchronous tasks such as writing to a log file or completing pending network operations. Because node.js does not offer an asynchronous shutdown API [#1](https://github.com/nodejs/node/discussions/29480#discussioncomment-99213) [#2](https://github.com/nodejs/node/discussions/29480#discussioncomment-99217), `exitHook.async` will make a "best effort" attempt to shut down the process and run your asynchronous tasks.

```
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 exitHook.exit() instead of process.exit(), or ensure your parent process
jakobo marked this conversation as resolved.
Show resolved Hide resolved
sends a SIGINT to the process running this code.
jakobo marked this conversation as resolved.
Show resolved Hide resolved
```

The above error will be generated if your exit hooks are ran in a synchronous manner but there are asynchronous callbacks registered to the shutdown handler. To avoid this, ensure you're only exiting via `exitHook.exit(signal)` or that an upstream process manager is sending a `SIGINT` or `SIGTERM` signal to node.js.

## Caveat: Avoid `process.exit()`
The `process.exit()` function requires all exit handlers to be synchronous and will not run with `exitHook.async`. If you wish to manually exit the process and have asynchronous callbacks, please use `exitHook.exit(signal)` instead which will manually exit the process after all shutdown tasks are complete.

## Caveat: Upstream Termination
Process managers may not send a `SIGINT` or `SIGTERM` when ending your node.js process, which are the signals `exitHook` is designed to understand. If an unhandled signal forces a synchronous exit, your asynchronous exit hooks will not run. A console error will be generated to make you aware that a synchronous exit occured.

## Caveat: Best Effort
Asynchronous exit hooks should be a "best effort" attempt to clean up remaining tasks. Because tasks may not run under certain circumstances, your hooks should treat a clean exit as an ideal scenario.

---

<div align="center">
Expand All @@ -64,4 +105,4 @@ The callback function to execute when the process exits.
<sub>
Tidelift helps make open source sustainable for maintainers while giving companies<br>assurances about security, maintenance, and licensing for their dependencies.
</sub>
</div>
</div>
16 changes: 16 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ test('main', async t => {
t.is(stdout, 'foo\nbar');
});

test('main-async', async t => {
const {stdout} = await execa(process.execPath, ['fixture-async.js']);
t.is(stdout, 'foo\nbar\nquux');
});

test('listener count', t => {
t.is(process.listenerCount('exit'), 0);

Expand All @@ -27,4 +32,15 @@ test('listener count', t => {
// Remove again
unsubscribe3();
t.is(process.listenerCount('exit'), 1);

// Add async style listener
const unsubscribe4 = exitHook({
async onExit() {},
maxWait: 100,
});
t.is(process.listenerCount('exit'), 1);

// Remove again
unsubscribe4();
t.is(process.listenerCount('exit'), 1);
});