From e5f8afadee87567d5ed65f682d6e9a2869a45a1f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 16 Feb 2022 15:54:56 -0800 Subject: [PATCH 01/23] Adds asynchronous support --- fixture-async.js | 26 +++++++++++++++ index.d.ts | 69 +++++++++++++++++++++++++-------------- index.js | 84 ++++++++++++++++++++++++++++++++++++++++-------- readme.md | 14 +++++++- test.js | 13 ++++++++ 5 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 fixture-async.js diff --git a/fixture-async.js b/fixture-async.js new file mode 100644 index 0000000..61c8a6e --- /dev/null +++ b/fixture-async.js @@ -0,0 +1,26 @@ +import exitHook from './index.js'; + +exitHook(() => { + console.log('foo'); +}); + +exitHook(() => { + console.log('bar'); +}); + +const unsubscribe = exitHook(() => { + console.log('baz'); +}); + +unsubscribe(); + +exitHook(async () => { + await new Promise((resolve, _reject) => { + setTimeout(() => { + resolve(); + }, 100); + }); + console.log('quux'); +}, 200); + +exitHook.exit(); diff --git a/index.d.ts b/index.d.ts index 82360e5..43c7716 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,35 +1,54 @@ -/** -Run some code when the process exits. +type ExitHook = { + /** + Run some code when the process exits. -The `process.on('exit')` event doesn't catch all the ways a process can exit. + 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 package is useful for cleaning up before exiting. To exit safely, call + `exitHook.exit()` instead of `process.exit()`. -@param onExit - The callback function to execute when the process exits. -@returns A function that removes the hook when called. + @param onExit - The callback function to execute when the process exits. If asynchronous, maxWait is required. + @param maxWait - An optional duration in ms to wait for onExit to complete. Required for asynchronous exit handlers. + @returns A function that removes the hook when called. -@example -``` -import exitHook from 'exit-hook'; + @example + ``` + import exitHook from 'exit-hook'; -exitHook(() => { - console.log('Exiting'); -}); + exitHook(() => { + console.log('Exiting'); + }); -// You can add multiple hooks, even across files -exitHook(() => { - console.log('Exiting 2'); -}); + // You can add multiple hooks, even across files + // asynchronous hooks should include an amount of time to wait for + // their completion + exitHook(async () => { + console.log('Exiting 2'); + }, 100); -throw new Error('🦄'); + throw new Error('🦄'); -//=> 'Exiting' -//=> 'Exiting 2' + //=> 'Exiting' + //=> 'Exiting 2' -// Removing an exit hook: -const unsubscribe = exitHook(() => {}); + // Removing an exit hook: + const unsubscribe = exitHook(() => {}); -unsubscribe(); -``` -*/ -export default function exitHook(onExit: () => void): () => void; + unsubscribe(); + ``` + */ + (onExit: () => void | (() => Promise), maxWait?: number): () => void; + + /** + Exit the process safely instead of calling process.exit() + + Because `process.exit()` skips asynchronous calls, it is recommended to call + `exitHook.exit()` instead. The exit hook will ensure asynchronous calls are + completed (within their maximum wait time) before exiting the process. + */ + exit: () => void; +}; + +declare const exitHook: ExitHook; + +export = exitHook; diff --git a/index.js b/index.js index ce5e289..c792003 100644 --- a/index.js +++ b/index.js @@ -1,46 +1,102 @@ import process from 'node:process'; const callbacks = new Set(); +const callbacksSync = new Set(); + let isCalled = false; let isRegistered = false; -function exit(shouldManuallyExit, signal) { +function exit(shouldManuallyExit, isSynchronous, signal) { + if (callbacks.size > 0 && isSynchronous) { + console.error('SYNCHRONOUS TERMINATION NOTICE:'); + console.error('When explicitly exiting the process via process.exit, asynchronous'); + console.error('tasks in your exitHooks will not run. Either remove these tasks,'); + console.error('or use exitHook.exit() instead.'); + } + if (isCalled) { return; } isCalled = true; - for (const callback of callbacks) { + const done = (force = false) => { + if (force === true || shouldManuallyExit === true) { + process.exit(128 + signal); // eslint-disable-line unicorn/no-process-exit + } + }; + + for (const callback of callbacksSync) { callback(); } - if (shouldManuallyExit === true) { - process.exit(128 + signal); // eslint-disable-line unicorn/no-process-exit + if (isSynchronous) { + done(); + return; + } + + const promises = []; + let maxWait = 0; + for (const [callback, wait] of callbacks) { + maxWait = Math.max(maxWait, wait); + promises.push(Promise.resolve(callback())); } + + // Force exit if we exceeded our maxWait value + const asyncTimer = setTimeout(() => { + done(true); + }, maxWait); + + Promise.all(promises).then(() => { // eslint-disable-line promise/prefer-await-to-then + clearTimeout(asyncTimer); + done(); + }); } -export default function exitHook(onExit) { - callbacks.add(onExit); +function exitHook(onExit, maxWait) { + const isSync = typeof maxWait === 'undefined'; + const asyncCallbackConfig = [onExit, maxWait]; + if (isSync) { + callbacksSync.add(onExit); + } else { + callbacks.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 (isSync) { + callbacksSync.delete(onExit); + } else { + callbacks.delete(asyncCallbackConfig); + } }; } + +exitHook.exit = (signal = 0) => { + exit(true, false, -128 + signal); +}; + +export default exitHook; diff --git a/readme.md b/readme.md index a928198..8a28a45 100644 --- a/readme.md +++ b/readme.md @@ -26,10 +26,16 @@ exitHook(() => { console.log('Exiting 2'); }); +// Hooks can be asynchronous by telling exitHooks to wait +exitHook(async () => { + console.log('Exiting 3, wait max 100ms'); +}, 100); + throw new Error('🦄'); //=> 'Exiting' //=> 'Exiting 2' +//=> 'Exiting 3, wait max 100ms' ``` Removing an exit hook: @@ -44,7 +50,7 @@ unsubscribe(); ## API -### exitHook(onExit) +### exitHook(onExit, maxWait?) Returns a function that removes the hook when called. @@ -54,6 +60,12 @@ Type: `Function` The callback function to execute when the process exits. +#### maxWait + +Type: `Number` (optional) + +If provided, process exit will be delayed by at least this amount of time in ms to allow the `onExit` to complete. + ---
diff --git a/test.js b/test.js index 46f1e1e..b6e20ff 100644 --- a/test.js +++ b/test.js @@ -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); @@ -27,4 +32,12 @@ test('listener count', t => { // Remove again unsubscribe3(); t.is(process.listenerCount('exit'), 1); + + // Add async style listener + const unsubscribe4 = exitHook(async () => {}, 100); + t.is(process.listenerCount('exit'), 1); + + // Remove again + unsubscribe4(); + t.is(process.listenerCount('exit'), 1); }); From 016cce0f60cf9614da3dbf0827e08ba6febcfdb0 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 28 Feb 2022 13:23:52 -0800 Subject: [PATCH 02/23] fixup: Applies changes from review --- fixture-async.js | 19 ++++---- index.d.ts | 115 ++++++++++++++++++++++++++++++++--------------- index.js | 66 ++++++++++++++++----------- index.test-d.ts | 8 ++++ readme.md | 35 ++++++++++----- test.js | 5 ++- 6 files changed, 167 insertions(+), 81 deletions(-) diff --git a/fixture-async.js b/fixture-async.js index 61c8a6e..17b4316 100644 --- a/fixture-async.js +++ b/fixture-async.js @@ -14,13 +14,16 @@ const unsubscribe = exitHook(() => { unsubscribe(); -exitHook(async () => { - await new Promise((resolve, _reject) => { - setTimeout(() => { - resolve(); - }, 100); - }); - console.log('quux'); -}, 200); +exitHook.async({ + async onExit() { + await new Promise((resolve, _reject) => { + setTimeout(() => { + resolve(); + }, 100); + }); + console.log('quux'); + }, + minWait: 200, +}); exitHook.exit(); diff --git a/index.d.ts b/index.d.ts index 43c7716..82e037d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,54 +1,97 @@ -type ExitHook = { +/** +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. + +@param onExit - The callback function to execute when the process exits. +@returns A function that removes the hook when called. + +@example +``` +import exitHook from 'exit-hook'; + +exitHook(() => { + console.log('Exiting'); +}); + +// You can add multiple hooks, even across files +exitHook(() => { + console.log('Exiting 2'); +}); + +throw new Error('🦄'); + +//=> 'Exiting' +//=> 'Exiting 2' + +// Removing an exit hook: +const unsubscribe = exitHook(() => {}); + +unsubscribe(); +``` +*/ +declare function exitHook(onExit: onExitCallback): unsubscribeCallback; + +declare namespace exitHook { /** Run some code when the process exits. - The `process.on('exit')` event doesn't catch all the ways a process can exit. + Please see https://github.com/sindresorhus/exit-hook/blob/main/readme.md#async-notes + for important considerations before using `exitHook.async`. - This package is useful for cleaning up before exiting. To exit safely, call - `exitHook.exit()` instead of `process.exit()`. + 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. - @param onExit - The callback function to execute when the process exits. If asynchronous, maxWait is required. - @param maxWait - An optional duration in ms to wait for onExit to complete. Required for asynchronous exit handlers. - @returns A function that removes the hook when called. + 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(() => { - console.log('Exiting'); + exitHook.async({ + async onExit() { + console.log('Exiting'); + }, + minWait: 100 }); - // You can add multiple hooks, even across files - // asynchronous hooks should include an amount of time to wait for - // their completion - exitHook(async () => { - console.log('Exiting 2'); - }, 100); - - throw new Error('🦄'); - - //=> 'Exiting' - //=> 'Exiting 2' - - // Removing an exit hook: - const unsubscribe = exitHook(() => {}); - - unsubscribe(); + // instead of process.exit + exitHook.exit(); ``` */ - (onExit: () => void | (() => Promise), maxWait?: number): () => void; + function exit(signal: number): void; +} - /** - Exit the process safely instead of calling process.exit() +/** The onExit callback */ +type onExitCallback = () => void; +/** The onExit callback */ +type onExitAsyncCallback = () => Promise; +/** An unsubscribe method that unregisters the hook */ +type unsubscribeCallback = () => void; - Because `process.exit()` skips asynchronous calls, it is recommended to call - `exitHook.exit()` instead. The exit hook will ensure asynchronous calls are - completed (within their maximum wait time) before exiting the process. - */ - exit: () => void; +/** Options for asynchronous hooks */ +type asyncHookOptions = { + /** 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 */ + minWait?: number; }; -declare const exitHook: ExitHook; - -export = exitHook; +export default exitHook; diff --git a/index.js b/index.js index c792003..646ad77 100644 --- a/index.js +++ b/index.js @@ -1,17 +1,20 @@ import process from 'node:process'; +const asyncCallbacks = new Set(); const callbacks = new Set(); -const callbacksSync = new Set(); let isCalled = false; let isRegistered = false; -function exit(shouldManuallyExit, isSynchronous, signal) { - if (callbacks.size > 0 && isSynchronous) { - console.error('SYNCHRONOUS TERMINATION NOTICE:'); - console.error('When explicitly exiting the process via process.exit, asynchronous'); - console.error('tasks in your exitHooks will not run. Either remove these tasks,'); - console.error('or use exitHook.exit() instead.'); +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) { @@ -26,7 +29,7 @@ function exit(shouldManuallyExit, isSynchronous, signal) { } }; - for (const callback of callbacksSync) { + for (const callback of callbacks) { callback(); } @@ -36,30 +39,29 @@ function exit(shouldManuallyExit, isSynchronous, signal) { } const promises = []; - let maxWait = 0; - for (const [callback, wait] of callbacks) { - maxWait = Math.max(maxWait, wait); + 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); - }, maxWait); + }, forceAfter); - Promise.all(promises).then(() => { // eslint-disable-line promise/prefer-await-to-then - clearTimeout(asyncTimer); - done(); - }); + await Promise.all(promises); + clearTimeout(asyncTimer); + done(); } -function exitHook(onExit, maxWait) { - const isSync = typeof maxWait === 'undefined'; - const asyncCallbackConfig = [onExit, maxWait]; - if (isSync) { - callbacksSync.add(onExit); +function addHook(options) { + const {onExit, minWait, isSynchronous} = options; + const asyncCallbackConfig = [onExit, minWait]; + if (isSynchronous) { + callbacks.add(onExit); } else { - callbacks.add(asyncCallbackConfig); + asyncCallbacks.add(asyncCallbackConfig); } if (!isRegistered) { @@ -87,14 +89,28 @@ function exitHook(onExit, maxWait) { } return () => { - if (isSync) { - callbacksSync.delete(onExit); + if (isSynchronous) { + callbacks.delete(onExit); } else { - callbacks.delete(asyncCallbackConfig); + 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); }; diff --git a/index.test-d.ts b/index.test-d.ts index faf3eaf..a26d38f 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -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, +}); + expectType<() => void>(unsubscribe); unsubscribe(); + +expectType<() => void>(asyncUnsubscribe); +asyncUnsubscribe(); diff --git a/readme.md b/readme.md index 8a28a45..7e6fb74 100644 --- a/readme.md +++ b/readme.md @@ -26,16 +26,10 @@ exitHook(() => { console.log('Exiting 2'); }); -// Hooks can be asynchronous by telling exitHooks to wait -exitHook(async () => { - console.log('Exiting 3, wait max 100ms'); -}, 100); - throw new Error('🦄'); //=> 'Exiting' //=> 'Exiting 2' -//=> 'Exiting 3, wait max 100ms' ``` Removing an exit hook: @@ -50,7 +44,7 @@ unsubscribe(); ## API -### exitHook(onExit, maxWait?) +### exitHook(onExit) Returns a function that removes the hook when called. @@ -60,11 +54,30 @@ Type: `Function` The callback function to execute when the process exits. -#### maxWait +### 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. + +#### 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 -Type: `Number` (optional) +`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 IO operations. For reliable execution of your asynchronous hooks, you must be confident the following statements are true: -If provided, process exit will be delayed by at least this amount of time in ms to allow the `onExit` to complete. +- **Your process is terminated via an unhandled exception, `SIGINT`, or `SIGTERM` signal and does _not_ use `process.exit`.** node.js does not offer a asynchronous shutdown API [#1](https://github.com/nodejs/node/discussions/29480#discussioncomment-99213) [#2](https://github.com/nodejs/node/discussions/29480#discussioncomment-99217), as doing so could create shutdown handlers that delay the termination of the node.js process indefinitely. +- **Your handlers are a "best effort" cleanup.** Because there are many ways a shutdown of a node process can be interrupted, and killed, asynchronous handlers should always adopt a "best effort" of cleanup. If an asynchronous handler does not run, it shouldn't leave your environment in a broken state. --- @@ -76,4 +89,4 @@ If provided, process exit will be delayed by at least this amount of time in ms Tidelift helps make open source sustainable for maintainers while giving companies
assurances about security, maintenance, and licensing for their dependencies.
-
+ \ No newline at end of file diff --git a/test.js b/test.js index b6e20ff..f8a48ec 100644 --- a/test.js +++ b/test.js @@ -34,7 +34,10 @@ test('listener count', t => { t.is(process.listenerCount('exit'), 1); // Add async style listener - const unsubscribe4 = exitHook(async () => {}, 100); + const unsubscribe4 = exitHook({ + async onExit() {}, + maxWait: 100, + }); t.is(process.listenerCount('exit'), 1); // Remove again From 4793be5b94f2ad25870ff1bfa9ec8c54d3868cea Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 28 Feb 2022 14:05:24 -0800 Subject: [PATCH 03/23] fixup: Improves Async Notes section of readme --- readme.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index 7e6fb74..81b5af3 100644 --- a/readme.md +++ b/readme.md @@ -74,10 +74,26 @@ The minimum amount of time to wait for this asynchronous hook to complete. Defau # 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 IO operations. For reliable execution of your asynchronous hooks, you must be confident the following statements are true: +`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. -- **Your process is terminated via an unhandled exception, `SIGINT`, or `SIGTERM` signal and does _not_ use `process.exit`.** node.js does not offer a asynchronous shutdown API [#1](https://github.com/nodejs/node/discussions/29480#discussioncomment-99213) [#2](https://github.com/nodejs/node/discussions/29480#discussioncomment-99217), as doing so could create shutdown handlers that delay the termination of the node.js process indefinitely. -- **Your handlers are a "best effort" cleanup.** Because there are many ways a shutdown of a node process can be interrupted, and killed, asynchronous handlers should always adopt a "best effort" of cleanup. If an asynchronous handler does not run, it shouldn't leave your environment in a broken state. +``` +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. +``` + +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. --- From 7b5779aa7737554bf18c0bcf5f5dcb035bbe3727 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 7 Mar 2022 20:19:28 -0800 Subject: [PATCH 04/23] fixup: Includes changes from Sindre's code review * `exitHook.async` renamed to `asyncExitHook` * `exitHook.exit` renamed to `gracefulExit` * `minWait` changed to `minimumWait` * Adds example of async hook to readme * Removes hard-wraps * Simplifies Asynchronous Notes, and adds a tl;dr --- fixture-async.js | 16 ++++--- index.d.ts | 111 +++++++++++++++++++++++++++-------------------- index.js | 38 ++++++++-------- index.test-d.ts | 11 ++--- readme.md | 63 +++++++++++++++++---------- 5 files changed, 139 insertions(+), 100 deletions(-) diff --git a/fixture-async.js b/fixture-async.js index 17b4316..55ace3b 100644 --- a/fixture-async.js +++ b/fixture-async.js @@ -1,4 +1,4 @@ -import exitHook from './index.js'; +import exitHook, {asyncExitHook, gracefulExit} from './index.js'; exitHook(() => { console.log('foo'); @@ -14,16 +14,18 @@ const unsubscribe = exitHook(() => { unsubscribe(); -exitHook.async({ - async onExit() { - await new Promise((resolve, _reject) => { +asyncExitHook( + async () => { + await new Promise(resolve => { setTimeout(() => { resolve(); }, 100); }); console.log('quux'); }, - minWait: 200, -}); + { + minimumWait: 200, + }, +); -exitHook.exit(); +gracefulExit(); diff --git a/index.d.ts b/index.d.ts index 82e037d..66a1165 100644 --- a/index.d.ts +++ b/index.d.ts @@ -34,64 +34,79 @@ unsubscribe(); */ 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; -} +/** +Run code asynchronously when the process exits. + +@see https://github.com/sindresorhus/exit-hook/blob/main/readme.md#asynchronous-exit-notes +@param options - The options, including a minimum wait time. +@param onExit - The callback function to execute when the process exits. +@returns A function that removes the hook when called. + +@example +``` +import { asyncExitHook } from 'exit-hook'; + +asyncExitHook({ + // wait this long before exiting + minimumWait: 500 +}, () => { + console.log('Exiting'); +}); + +throw new Error('🦄'); + +//=> 'Exiting' + +// Removing an exit hook: +const unsubscribe = asyncExitHook({}, () => {}); + +unsubscribe(); +``` +*/ +declare function asyncExitHook(onExit: onExitAsyncCallback, options?: asyncHookOptions): unsubscribeCallback; + +/** +Exit the process and complete all asynchronous hooks. + +If 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, identical to `process.exit` +@see https://github.com/sindresorhus/exit-hook/blob/main/readme.md#asynchronous-exit-notes +@returns void + +@example +``` +import { asyncExitHook, gracefulExit } from 'exit-hook'; + +asyncExitHook({ + // wait this long before exiting + minimumWait: 500 +}, () => { + console.log('Exiting'); +}); + +// instead of process.exit +gracefulExit(); +``` +*/ +declare function gracefulExit(signal?: number): void; /** The onExit callback */ type onExitCallback = () => void; + /** The onExit callback */ type onExitAsyncCallback = () => Promise; + /** An unsubscribe method that unregisters the hook */ type unsubscribeCallback = () => void; /** Options for asynchronous hooks */ type asyncHookOptions = { - /** 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 */ - minWait?: number; + /** The minimum amount of time to wait for this process to terminate. Defaults to 100ms */ + minimumWait?: number; }; export default exitHook; +export {asyncExitHook, gracefulExit}; diff --git a/index.js b/index.js index 646ad77..cbc3a2c 100644 --- a/index.js +++ b/index.js @@ -8,13 +8,13 @@ let isRegistered = false; 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. - `); + 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.', + ].join(' ')); } if (isCalled) { @@ -56,8 +56,8 @@ async function exit(shouldManuallyExit, isSynchronous, signal) { } function addHook(options) { - const {onExit, minWait, isSynchronous} = options; - const asyncCallbackConfig = [onExit, minWait]; + const {onExit, minimumWait, isSynchronous} = options; + const asyncCallbackConfig = [onExit, minimumWait]; if (isSynchronous) { callbacks.add(onExit); } else { @@ -100,19 +100,23 @@ function addHook(options) { function exitHook(onExit) { return addHook({ onExit, - minWait: null, + minimumWait: null, isSynchronous: true, }); } -exitHook.async = hookOptions => addHook({ - onExit: hookOptions.onExit, - minWait: hookOptions.minWait ?? 1000, - isSynchronous: false, -}); +function asyncExitHook(onExit, hookOptions) { + return addHook({ + onExit, + minimumWait: hookOptions.minimumWait ?? 100, + isSynchronous: false, + }); +} -exitHook.exit = (signal = 0) => { +function gracefulExit(signal = 0) { exit(true, false, -128 + signal); -}; +} export default exitHook; + +export {asyncExitHook, gracefulExit}; diff --git a/index.test-d.ts b/index.test-d.ts index a26d38f..882e8e9 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,12 +1,13 @@ 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 = exitHook.async({ - async onExit() {}, // eslint-disable-line @typescript-eslint/no-empty-function - minWait: 100, -}); +const asyncUnsubscribe = asyncExitHook(async () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + { + minimumWait: 100, + }, +); expectType<() => void>(unsubscribe); unsubscribe(); diff --git a/readme.md b/readme.md index 81b5af3..eb3eb82 100644 --- a/readme.md +++ b/readme.md @@ -54,46 +54,63 @@ Type: `Function` The callback function to execute when the process exits. -### exitHook.async(asyncHookOptions) +### asyncExitHook(onExit, asyncHookOptions) Returns a function that removes the hook when called. Please see [Async Notes](#async-notes) for considerations when using the asynchronous API. +```js +import {asyncExitHook} from 'exit-hook'; + +asyncExitHook(async () => { + console.log('Exiting'); +}, { + minimumWait: 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` returns `Promise` + +The callback function to execute when the process exits. + #### 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 -sends a SIGINT to the process running this code. -``` +# Asynchronous Exit Notes -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. +**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`. -## 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. +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. -## 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. +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. -## 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. +Asynchronous hooks should make a "best effort" to perform their tasks within the `minimumWait` option, but also be written to assume they may not complete their tasks before termination. --- From 77487f1e53c7bbdd7db5c700cb23141cb8fcac83 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 22 Mar 2022 22:06:02 -0700 Subject: [PATCH 05/23] fix: Makes minimumWait a required option --- index.d.ts | 6 +++--- index.js | 6 +++++- readme.md | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/index.d.ts b/index.d.ts index 66a1165..be1ae0b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -63,7 +63,7 @@ const unsubscribe = asyncExitHook({}, () => {}); unsubscribe(); ``` */ -declare function asyncExitHook(onExit: onExitAsyncCallback, options?: asyncHookOptions): unsubscribeCallback; +declare function asyncExitHook(onExit: onExitAsyncCallback, options: asyncHookOptions): unsubscribeCallback; /** Exit the process and complete all asynchronous hooks. @@ -104,8 +104,8 @@ type unsubscribeCallback = () => void; /** Options for asynchronous hooks */ type asyncHookOptions = { - /** The minimum amount of time to wait for this process to terminate. Defaults to 100ms */ - minimumWait?: number; + /** The minimum amount of time to wait for this process to terminate */ + minimumWait: number; }; export default exitHook; diff --git a/index.js b/index.js index cbc3a2c..128a54a 100644 --- a/index.js +++ b/index.js @@ -106,9 +106,13 @@ function exitHook(onExit) { } function asyncExitHook(onExit, hookOptions) { + if (typeof hookOptions?.minimumWait !== 'number') { + throw new TypeError('options.minimumWait must be set to a numeric value'); + } + return addHook({ onExit, - minimumWait: hookOptions.minimumWait ?? 100, + minimumWait: hookOptions.minimumWait, isSynchronous: false, }); } diff --git a/readme.md b/readme.md index eb3eb82..dd0ef00 100644 --- a/readme.md +++ b/readme.md @@ -100,7 +100,7 @@ A set of options for registering an asynchronous hook ##### asyncHookOptions.minWait -The minimum amount of time to wait for this asynchronous hook to complete. Defaults to `1000`ms. +The minimum amount of time to wait for this asynchronous hook to complete. # Asynchronous Exit Notes From d227278d4bc690245582b0dad295ba0d62d405cf Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 25 Jul 2022 01:59:17 -0700 Subject: [PATCH 06/23] fixup: Applies review feedback, applies sindre's ts styleguide --- fixture-async.js | 4 +--- index.d.ts | 55 ++++++++++++------------------------------------ index.js | 10 ++++----- index.test-d.ts | 4 +--- readme.md | 44 +++++++++++++++++++++++--------------- 5 files changed, 48 insertions(+), 69 deletions(-) diff --git a/fixture-async.js b/fixture-async.js index 55ace3b..fe70c54 100644 --- a/fixture-async.js +++ b/fixture-async.js @@ -23,9 +23,7 @@ asyncExitHook( }); console.log('quux'); }, - { - minimumWait: 200, - }, + 200, ); gracefulExit(); diff --git a/index.d.ts b/index.d.ts index be1ae0b..1727458 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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. @@ -32,26 +32,23 @@ const unsubscribe = exitHook(() => {}); unsubscribe(); ``` */ -declare function exitHook(onExit: onExitCallback): unsubscribeCallback; +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 options - The options, including a minimum wait time. -@param onExit - The callback function to execute when the process exits. +@param onExit - The callback function to execute when the process exits via `gracefulExit`, and will be wrapped in `Promise.resolve`. +@param minimumWait - The amount of time in ms that `onExit` is expected to take. @returns A function that removes the hook when called. @example ``` -import { asyncExitHook } from 'exit-hook'; +import {asyncExitHook} from 'exit-hook'; -asyncExitHook({ - // wait this long before exiting - minimumWait: 500 -}, () => { +asyncExitHook(() => { console.log('Exiting'); -}); +}, 500); throw new Error('🦄'); @@ -63,50 +60,26 @@ const unsubscribe = asyncExitHook({}, () => {}); unsubscribe(); ``` */ -declare function asyncExitHook(onExit: onExitAsyncCallback, options: asyncHookOptions): unsubscribeCallback; +export function asyncExitHook(onExit: () => (void | Promise), minimumWait: number): () => void; /** -Exit the process and complete all asynchronous hooks. +Exit the process and makes a best-effort to complete all asynchronous hooks. -If using asyncExitHook, consider using `gracefulExit` instead of -`process.exit()` to ensure all asynchronous tasks are given an opportunity to -run. +If 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, identical to `process.exit` @see https://github.com/sindresorhus/exit-hook/blob/main/readme.md#asynchronous-exit-notes -@returns void @example ``` -import { asyncExitHook, gracefulExit } from 'exit-hook'; +import {asyncExitHook, gracefulExit} from 'exit-hook'; -asyncExitHook({ - // wait this long before exiting - minimumWait: 500 -}, () => { +asyncExitHook(() => { console.log('Exiting'); -}); +}, 500); // instead of process.exit gracefulExit(); ``` */ -declare function gracefulExit(signal?: number): void; - -/** The onExit callback */ -type onExitCallback = () => void; - -/** The onExit callback */ -type onExitAsyncCallback = () => Promise; - -/** An unsubscribe method that unregisters the hook */ -type unsubscribeCallback = () => void; - -/** Options for asynchronous hooks */ -type asyncHookOptions = { - /** The minimum amount of time to wait for this process to terminate */ - minimumWait: number; -}; - -export default exitHook; -export {asyncExitHook, gracefulExit}; +export function gracefulExit(signal?: number): void; diff --git a/index.js b/index.js index 128a54a..abeced9 100644 --- a/index.js +++ b/index.js @@ -12,7 +12,7 @@ async function exit(shouldManuallyExit, isSynchronous, signal) { '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', + 'use gracefulExit() instead of process.exit(), or ensure your parent process', 'sends a SIGINT to the process running this code.', ].join(' ')); } @@ -105,14 +105,14 @@ function exitHook(onExit) { }); } -function asyncExitHook(onExit, hookOptions) { - if (typeof hookOptions?.minimumWait !== 'number') { - throw new TypeError('options.minimumWait must be set to a numeric value'); +function asyncExitHook(onExit, minimumWait) { + if (typeof minimumWait !== 'number') { + throw new TypeError('minimumWait must be set to a numeric value'); } return addHook({ onExit, - minimumWait: hookOptions.minimumWait, + minimumWait, isSynchronous: false, }); } diff --git a/index.test-d.ts b/index.test-d.ts index 882e8e9..1626d93 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -4,9 +4,7 @@ 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: 100, - }, + 100, ); expectType<() => void>(unsubscribe); diff --git a/readme.md b/readme.md index dd0ef00..55e6829 100644 --- a/readme.md +++ b/readme.md @@ -46,26 +46,26 @@ 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` -The callback function to execute when the process exits. +Describes a callback to run on `process.exit` -### asyncExitHook(onExit, asyncHookOptions) +### asyncExitHook(onExit, minimumWait) -Returns a function that removes the hook when called. Please see [Async Notes](#async-notes) for considerations when using the asynchronous API. +Register a function to run during `gracefulExit`. Returns a function that removes the hook when called. + +Please see [Async Notes](#async-notes) for considerations when using the asynchronous API. ```js import {asyncExitHook} from 'exit-hook'; asyncExitHook(async () => { console.log('Exiting'); -}, { - minimumWait: 300 -}); +}, 300); throw new Error('🦄'); @@ -79,9 +79,7 @@ import {asyncExitHook} from 'exit-hook'; const unsubscribe = asyncExitHook(async () => { console.log('Exiting'); -}, { - minimumWait: 300 -}); +}, 300); unsubscribe(); ``` @@ -90,17 +88,29 @@ unsubscribe(); Type: `Function` returns `Promise` -The callback function to execute when the process exits. +The callback function to execute when the process exits via `gracefulExit`, and will be wrapped in `Promise.resolve`. + +#### minimumWait + +Type: `Number` -#### asyncHookOptions +The amount of time to wait for this asynchronous hook to complete. -Type: `Object` +### gracefulExit(signal?: number): void + +Exit the process and makes a best-effort to complete all asynchronous hooks. + +```js +import {gracefulExit} from 'exit-hook'; + +gracefulExit(); +``` -A set of options for registering an asynchronous hook +#### signal -##### asyncHookOptions.minWait +Type: `Number` default `0` -The minimum amount of time to wait for this asynchronous hook to complete. +The exit code to use, identical to `process.exit` # Asynchronous Exit Notes @@ -110,7 +120,7 @@ node.js does not offer an asynchronous shutdown API by default [#1](https://gith 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` option, but also be written to assume they may not complete their tasks before termination. +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. --- From 4f0f64fb75147c3b1e29da8883a6216024f0ab0f Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 25 Jul 2022 16:49:31 -0700 Subject: [PATCH 07/23] fixup: Applies option recommendations from sindre --- fixture-async.js | 4 +++- index.d.ts | 16 ++++++++++++---- index.js | 8 ++++---- index.test-d.ts | 2 +- readme.md | 8 ++++++-- 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/fixture-async.js b/fixture-async.js index fe70c54..55ace3b 100644 --- a/fixture-async.js +++ b/fixture-async.js @@ -23,7 +23,9 @@ asyncExitHook( }); console.log('quux'); }, - 200, + { + minimumWait: 200, + }, ); gracefulExit(); diff --git a/index.d.ts b/index.d.ts index 1727458..e578de4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -39,7 +39,6 @@ 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`. -@param minimumWait - The amount of time in ms that `onExit` is expected to take. @returns A function that removes the hook when called. @example @@ -48,19 +47,21 @@ import {asyncExitHook} from 'exit-hook'; asyncExitHook(() => { console.log('Exiting'); -}, 500); +}, { + minimumWait: 500 +}); throw new Error('🦄'); //=> 'Exiting' // Removing an exit hook: -const unsubscribe = asyncExitHook({}, () => {}); +const unsubscribe = asyncExitHook(() => {}, {}); unsubscribe(); ``` */ -export function asyncExitHook(onExit: () => (void | Promise), minimumWait: number): () => void; +export function asyncExitHook(onExit: () => (void | Promise), options: Options): () => void; /** Exit the process and makes a best-effort to complete all asynchronous hooks. @@ -83,3 +84,10 @@ gracefulExit(); ``` */ export function gracefulExit(signal?: number): void; + +export interface Options { + /** + The amount of time in ms that the `onExit` function is expected to take. + */ + minimumWait: number; +} diff --git a/index.js b/index.js index abeced9..d3d1340 100644 --- a/index.js +++ b/index.js @@ -105,14 +105,14 @@ function exitHook(onExit) { }); } -function asyncExitHook(onExit, minimumWait) { - if (typeof minimumWait !== 'number') { - throw new TypeError('minimumWait must be set to a numeric value'); +function asyncExitHook(onExit, options) { + if (typeof options?.minimumWait !== 'number' || options.minimumWait <= 0) { + throw new TypeError('minimumWait must be set to a positive numeric value'); } return addHook({ onExit, - minimumWait, + minimumWait: options.minimumWait, isSynchronous: false, }); } diff --git a/index.test-d.ts b/index.test-d.ts index 1626d93..01eb8b3 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -4,7 +4,7 @@ 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 - 100, + {minimumWait: 300}, ); expectType<() => void>(unsubscribe); diff --git a/readme.md b/readme.md index 55e6829..ffa9095 100644 --- a/readme.md +++ b/readme.md @@ -79,7 +79,9 @@ import {asyncExitHook} from 'exit-hook'; const unsubscribe = asyncExitHook(async () => { console.log('Exiting'); -}, 300); +}, { + minimumWait: 300 +}); unsubscribe(); ``` @@ -90,7 +92,9 @@ Type: `Function` returns `Promise` The callback function to execute when the process exits via `gracefulExit`, and will be wrapped in `Promise.resolve`. -#### minimumWait +#### options + +##### minimumWait Type: `Number` From b0673c8a1b8acf65d41d75c1955116a50cebe6a9 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 25 Jul 2022 16:54:44 -0700 Subject: [PATCH 08/23] fixup: Fixes test that should be failing --- test.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test.js b/test.js index f8a48ec..97240c4 100644 --- a/test.js +++ b/test.js @@ -34,10 +34,12 @@ test('listener count', t => { t.is(process.listenerCount('exit'), 1); // Add async style listener - const unsubscribe4 = exitHook({ - async onExit() {}, - maxWait: 100, - }); + const unsubscribe4 = exitHook( + async () => {}, + { + maxWait: 100, + }, + ); t.is(process.listenerCount('exit'), 1); // Remove again From 6c7584f01da0012624aab476be4ed7186bb6b30d Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 26 Jul 2022 15:52:53 -0700 Subject: [PATCH 09/23] test: Adds tests for types in exitHook and asyncExitHook --- index.js | 8 ++++++++ test.js | 19 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index d3d1340..c27b985 100644 --- a/index.js +++ b/index.js @@ -98,6 +98,10 @@ function addHook(options) { } function exitHook(onExit) { + if (typeof onExit !== 'function') { + throw new TypeError('onExit must be a function'); + } + return addHook({ onExit, minimumWait: null, @@ -106,6 +110,10 @@ function exitHook(onExit) { } 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'); } diff --git a/test.js b/test.js index 97240c4..d3a91d9 100644 --- a/test.js +++ b/test.js @@ -1,7 +1,7 @@ import process from 'node:process'; import test from 'ava'; import execa from 'execa'; -import exitHook from './index.js'; +import exitHook, {asyncExitHook} from './index.js'; test('main', async t => { const {stdout} = await execa(process.execPath, ['fixture.js']); @@ -34,10 +34,10 @@ test('listener count', t => { t.is(process.listenerCount('exit'), 1); // Add async style listener - const unsubscribe4 = exitHook( + const unsubscribe4 = asyncExitHook( async () => {}, { - maxWait: 100, + minimumWait: 100, }, ); t.is(process.listenerCount('exit'), 1); @@ -46,3 +46,16 @@ test('listener count', t => { unsubscribe4(); t.is(process.listenerCount('exit'), 1); }); + +test('type enforcing', t => { + // Non-function passed to exitHook + t.throws(() => exitHook(null), {instanceOf: TypeError}); + + // Non-function passed to asyncExitHook + t.throws(() => asyncExitHook(null, { + minimumWait: 100, + }), {instanceOf: TypeError}); + + // Non-numeric passed to Options.minimumWait + t.throws(() => asyncExitHook(async () => Promise.resolve(true), {})); +}); From 6c69ff02f30ba2e6ee380d526befb34479ec2eeb Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 26 Jul 2022 16:07:13 -0700 Subject: [PATCH 10/23] fixup: Fixes wording on forceAfter --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index c27b985..c887c5e 100644 --- a/index.js +++ b/index.js @@ -45,7 +45,7 @@ async function exit(shouldManuallyExit, isSynchronous, signal) { promises.push(Promise.resolve(callback())); } - // Force exit if we exceeded our maxWait value + // Force exit if we exceeded our wait value const asyncTimer = setTimeout(() => { done(true); }, forceAfter); From 4ec258e8fac28a084657abda4f5cee2ef6ab3892 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 19 Aug 2022 13:39:07 +0200 Subject: [PATCH 11/23] Update readme.md --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index ffa9095..7f991c1 100644 --- a/readme.md +++ b/readme.md @@ -136,4 +136,4 @@ Asynchronous hooks should make a "best effort" to perform their tasks within the Tidelift helps make open source sustainable for maintainers while giving companies
assurances about security, maintenance, and licensing for their dependencies.
- \ No newline at end of file + From d256a8f636a7a281e79d297bae6372ef5bb60ffd Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 19 Aug 2022 13:39:46 +0200 Subject: [PATCH 12/23] Update fixture-async.js --- fixture-async.js | 1 + 1 file changed, 1 insertion(+) diff --git a/fixture-async.js b/fixture-async.js index 55ace3b..872e4d0 100644 --- a/fixture-async.js +++ b/fixture-async.js @@ -21,6 +21,7 @@ asyncExitHook( resolve(); }, 100); }); + console.log('quux'); }, { From 2ad64b275bcaaa8405329b4cfe89e10e830cfd3c Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 19 Aug 2022 13:42:12 +0200 Subject: [PATCH 13/23] Update index.d.ts --- index.d.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/index.d.ts b/index.d.ts index e578de4..e104688 100644 --- a/index.d.ts +++ b/index.d.ts @@ -64,11 +64,11 @@ unsubscribe(); export function asyncExitHook(onExit: () => (void | Promise), options: Options): () => void; /** -Exit the process and makes a best-effort to complete all asynchronous hooks. +Exit the process and make a best-effort to complete all asynchronous hooks. -If using asyncExitHook, consider using `gracefulExit` instead of `process.exit()` to ensure all asynchronous tasks are given an opportunity to run. +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, identical to `process.exit` +@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 @@ -79,7 +79,7 @@ asyncExitHook(() => { console.log('Exiting'); }, 500); -// instead of process.exit +// Instead of `process.exit()` gracefulExit(); ``` */ @@ -87,7 +87,7 @@ export function gracefulExit(signal?: number): void; export interface Options { /** - The amount of time in ms that the `onExit` function is expected to take. + The amount of time in milliseconds that the `onExit` function is expected to take. */ minimumWait: number; } From f026a52d9d9969e4a550a525df6dc91ffbd2eb9f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 19 Aug 2022 13:43:39 +0200 Subject: [PATCH 14/23] Update readme.md --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 7f991c1..5088dea 100644 --- a/readme.md +++ b/readme.md @@ -52,7 +52,7 @@ Register a function to run during `process.exit`. Returns a function that remove Type: `Function` -Describes a callback to run on `process.exit` +Describes a callback to run on `process.exit`. ### asyncExitHook(onExit, minimumWait) From a42c989cbab8d249fc0529eb7221c1302a5abce8 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 19 Aug 2022 13:44:09 +0200 Subject: [PATCH 15/23] Update readme.md --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 5088dea..50535cd 100644 --- a/readme.md +++ b/readme.md @@ -102,7 +102,7 @@ The amount of time to wait for this asynchronous hook to complete. ### gracefulExit(signal?: number): void -Exit the process and makes a best-effort to complete all asynchronous hooks. +Exit the process and make a best-effort to complete all asynchronous hooks. ```js import {gracefulExit} from 'exit-hook'; From 8bf98abff8515252ae9bf5ae8bb31649da0a8a22 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 19 Aug 2022 11:23:47 -0700 Subject: [PATCH 16/23] pr: Accepts Suggestion - readme formatting Co-authored-by: Sindre Sorhus --- readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 50535cd..7455da4 100644 --- a/readme.md +++ b/readme.md @@ -112,7 +112,8 @@ gracefulExit(); #### signal -Type: `Number` default `0` +Type: `number`\ +Default: `0` The exit code to use, identical to `process.exit` From d923846750ba89e8a2b6d88dbc9c46e55eea4555 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 19 Aug 2022 11:23:57 -0700 Subject: [PATCH 17/23] pr: Accepts Suggestion - readme formatting Co-authored-by: Sindre Sorhus --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 7455da4..53b3cc0 100644 --- a/readme.md +++ b/readme.md @@ -117,7 +117,7 @@ Default: `0` The exit code to use, identical to `process.exit` -# Asynchronous Exit Notes +## 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`. From 811d3e5f5d3ace809b16264509cbfc196bbf441e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 19 Aug 2022 11:24:11 -0700 Subject: [PATCH 18/23] pr: Accepts Suggestion - readme formatting Co-authored-by: Sindre Sorhus --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 53b3cc0..ae6c19d 100644 --- a/readme.md +++ b/readme.md @@ -121,7 +121,7 @@ The exit code to use, identical to `process.exit` **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. +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. From a56f1bb636f4610907d7bc784777d3a03d051518 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 19 Aug 2022 11:09:25 -0700 Subject: [PATCH 19/23] docs: Syncs readme.md to TS docs --- readme.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index ae6c19d..c120b55 100644 --- a/readme.md +++ b/readme.md @@ -98,12 +98,14 @@ The callback function to execute when the process exits via `gracefulExit`, and Type: `Number` -The amount of time to wait for this asynchronous hook to complete. +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'; @@ -115,7 +117,7 @@ gracefulExit(); Type: `number`\ Default: `0` -The exit code to use, identical to `process.exit` +The exit code to use. Same as the argument to `process.exit()`. ## Asynchronous Exit Notes From 913e0c0fbcc09656241c1fbf1f6863eddca8c311 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 19 Aug 2022 11:14:32 -0700 Subject: [PATCH 20/23] docs: Uses function signatures in readme --- readme.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index c120b55..dabf175 100644 --- a/readme.md +++ b/readme.md @@ -50,7 +50,7 @@ Register a function to run during `process.exit`. Returns a function that remove #### onExit -Type: `Function` +Type: `function(): void` Describes a callback to run on `process.exit`. @@ -88,7 +88,7 @@ unsubscribe(); #### onExit -Type: `Function` returns `Promise` +Type: `function(): void | Promise` The callback function to execute when the process exits via `gracefulExit`, and will be wrapped in `Promise.resolve`. @@ -96,7 +96,7 @@ The callback function to execute when the process exits via `gracefulExit`, and ##### minimumWait -Type: `Number` +Type: `number` The amount of time in milliseconds that the `onExit` function is expected to take. From 84fff551fb2e32c7d470f5a059d4fc45e8945fde Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 19 Aug 2022 11:19:11 -0700 Subject: [PATCH 21/23] fix: Removes null of minimumWait --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index c887c5e..c3359c2 100644 --- a/index.js +++ b/index.js @@ -58,6 +58,7 @@ async function exit(shouldManuallyExit, isSynchronous, signal) { function addHook(options) { const {onExit, minimumWait, isSynchronous} = options; const asyncCallbackConfig = [onExit, minimumWait]; + if (isSynchronous) { callbacks.add(onExit); } else { @@ -104,7 +105,6 @@ function exitHook(onExit) { return addHook({ onExit, - minimumWait: null, isSynchronous: true, }); } From 530eb3e098d8e1a684b51e3f2da40cbd0d0bbc02 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 19 Aug 2022 11:20:09 -0700 Subject: [PATCH 22/23] fix: Exports gracefulExit and asyncExitHook directly --- index.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index c3359c2..a8ee406 100644 --- a/index.js +++ b/index.js @@ -109,7 +109,7 @@ function exitHook(onExit) { }); } -function asyncExitHook(onExit, options) { +export function asyncExitHook(onExit, options) { if (typeof onExit !== 'function') { throw new TypeError('onExit must be a function'); } @@ -125,10 +125,8 @@ function asyncExitHook(onExit, options) { }); } -function gracefulExit(signal = 0) { +export function gracefulExit(signal = 0) { exit(true, false, -128 + signal); } export default exitHook; - -export {asyncExitHook, gracefulExit}; From c7a1ae39c82a6a487414ab75801f953b03261838 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 19 Aug 2022 11:30:23 -0700 Subject: [PATCH 23/23] docs: Updates exitHook readme to match TS docs --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index dabf175..35dfde2 100644 --- a/readme.md +++ b/readme.md @@ -52,7 +52,7 @@ Register a function to run during `process.exit`. Returns a function that remove Type: `function(): void` -Describes a callback to run on `process.exit`. +The callback function to execute when the process exits. ### asyncExitHook(onExit, minimumWait)