├── .gitignore ├── .npmignore ├── example └── server.js ├── .github └── workflows │ └── ci.yaml ├── test ├── types.ts └── exiting.js ├── LICENSE ├── package.json ├── lib ├── index.d.ts └── index.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /coverage.html -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib/** 3 | !.npmignore 4 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Exiting = require('exiting'); 4 | const Hapi = require('@hapi/hapi'); 5 | 6 | const server = Hapi.Server(); 7 | const manager = Exiting.createManager(server); 8 | 9 | server.events.on('stop', () => { 10 | 11 | console.log('Server stopped.'); 12 | }); 13 | 14 | const provision = async () => { 15 | 16 | server.route({ 17 | method: 'GET', 18 | path: '/', 19 | handler() { 20 | 21 | return 'Hello'; 22 | } 23 | }); 24 | 25 | await manager.start(); 26 | 27 | console.log('Server started at:', server.info.uri); 28 | }; 29 | 30 | provision(); 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node-version: [18, 20, latest] 15 | hapi: [21] 16 | 17 | runs-on: ubuntu-latest 18 | name: Test node@${{ matrix.node-version }} hapi@${{ matrix.hapi }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | check-latest: ${{ matrix.node-version == 'latest' }} 26 | - run: npm ci 27 | - run: npm install @hapi/hapi@${{ matrix.hapi }} 28 | - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /test/types.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from '@hapi/hapi'; 2 | import * as Lab from '@hapi/lab'; 3 | import * as Exiting from '..'; 4 | 5 | const { expect } = Lab.types; 6 | 7 | const manager = Exiting.createManager(new Hapi.Server(), { 8 | exitTimeout: 30 * 1000 9 | }); 10 | 11 | expect.type>(manager); 12 | 13 | expect.type(new Exiting.ProcessExitError()); 14 | 15 | await manager.start(); 16 | await manager.stop(); 17 | 18 | manager.deactivate(); 19 | 20 | const options: Exiting.ManagerOptions = { 21 | exitTimeout: 30 * 1000 22 | }; 23 | 24 | new Exiting.Manager(new Hapi.Server(), options); 25 | Exiting.reset(); 26 | 27 | expect.error(new Exiting.Manager(new Hapi.Server(), { unknown: true })); 28 | Exiting.reset(); 29 | 30 | new Exiting.Manager(new Hapi.Server()); 31 | Exiting.reset(); 32 | 33 | expect.error(new Exiting.Manager()); 34 | Exiting.reset(); 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 - 2024, Gil Pedersen 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exiting", 3 | "version": "7.0.0", 4 | "description": "Gracefully stop hapi.js servers", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "lab -a @hapi/code -t 100 -L -Y", 8 | "test-cov-html": "lab -a @hapi/code -r html -o coverage.html" 9 | }, 10 | "types": "lib/index.d.ts", 11 | "author": "Gil Pedersen ", 12 | "license": "BSD-2-Clause", 13 | "devDependencies": { 14 | "@hapi/code": "^9.0.3", 15 | "@hapi/hapi": "^21.3.6", 16 | "@hapi/lab": "^25.2.0", 17 | "@types/node": "^18.19.24", 18 | "joi": "^17.12.2", 19 | "typescript": "~5.4.2" 20 | }, 21 | "dependencies": { 22 | "@hapi/bounce": "^3.0.1", 23 | "@hapi/hoek": "^11.0.4" 24 | }, 25 | "peerDependencies": { 26 | "@hapi/hapi": ">=21.0.0" 27 | }, 28 | "engines": { 29 | "node": ">=18.12.0" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/kanongil/exiting.git" 34 | }, 35 | "keywords": [ 36 | "hapi", 37 | "manager", 38 | "stop", 39 | "start", 40 | "process", 41 | "exit", 42 | "signal", 43 | "exception", 44 | "shutdown" 45 | ], 46 | "bugs": { 47 | "url": "https://github.com/kanongil/exiting/issues" 48 | }, 49 | "homepage": "https://github.com/kanongil/exiting#readme" 50 | } 51 | -------------------------------------------------------------------------------- /lib/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | 3 | // We can't rely on the @hapi/hapi typings, so declare something that matches what we use 4 | 5 | interface HapiServerInterface { 6 | 7 | start(...args: any[]): Promise; 8 | 9 | stop(options?: {}, ...args: any[]): Promise; 10 | 11 | ext(...args: any[]): any; 12 | 13 | listener: http.Server; 14 | } 15 | 16 | export interface ManagerOptions { 17 | 18 | /** 19 | * In milliseconds. Default 5000. 20 | */ 21 | exitTimeout: number; 22 | } 23 | 24 | export class Manager { 25 | 26 | readonly servers: readonly Omit[]; 27 | readonly state?: 'starting' | 'started' | 'stopping' | 'prestopped' | 'stopped' | 'startAborted' | 'errored' | 'timeout'; 28 | 29 | constructor(servers: S | Iterable, options?: ManagerOptions); 30 | 31 | /** 32 | * Starts the Hapi servers. 33 | * 34 | * Returns manager if the server starts succcessfully. 35 | */ 36 | start(): Promise; 37 | 38 | /** 39 | * Stops the Hapi servers. 40 | * 41 | * Rejects if any server fails to stop. 42 | */ 43 | stop(): Promise; 44 | 45 | /** 46 | * Removes process listeners and resets process exit. 47 | */ 48 | deactivate(): void; 49 | } 50 | 51 | /** 52 | * Creates a new manager for given servers. 53 | * 54 | * @param servers 55 | * @param options 56 | */ 57 | export function createManager( 58 | servers: S | Iterable, 59 | options?: ManagerOptions 60 | ): Manager; 61 | 62 | /** 63 | * Console.error helper. 64 | * 65 | * @param args log arguments 66 | */ 67 | export function log(...args: any[]): void; 68 | 69 | /** 70 | * Deactivates the existing manager. 71 | */ 72 | export function reset(): void; 73 | 74 | /** 75 | * Custom exiting error thrown when process.exit() is called. 76 | */ 77 | export class ProcessExitError extends TypeError { 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exiting 2 | 3 | Safely shutdown [hapi.js](http://hapijs.com/) servers whenever the process exits. 4 | 5 | [![Node.js CI](https://github.com/kanongil/exiting/actions/workflows/ci.yaml/badge.svg?branch=master)](https://github.com/kanongil/exiting/actions/workflows/ci.yaml) 6 | 7 | ## Details 8 | 9 | While it is simple to start and stop a server, ensuring proper shutdown on external, or internal, 10 | triggers can be cumbersome to handle properly. 11 | **exiting** makes this easy by managing your Hapi servers, taking care of starting and stopping 12 | them as appropriate. 13 | 14 | Depending on the exit trigger, the hapi servers will either be gracefully stopped or aborted (by only 15 | triggering `onPreStop` hooks). 16 | The exit triggers are handled as detailed: 17 | 18 | * Graceful exit with code `0`: 19 | * `process.exit()` with exit code `0`. 20 | * Unhandled `SIGINT` kill signal, through eg. `ctrl-c`. 21 | * Unhandled `SIGTERM` kill signal. 22 | * Unhandled `SIGQUIT` kill signal. 23 | * Aborted exit: 24 | * `process.exit()` with non-zero exit code. 25 | * Unhandled `SIGHUP` kill signal (code `1`). 26 | * Any uncaught exception (code `1`). 27 | * Any unhandled rejection (code `1`). 28 | * Any closed connection listeners, eg. on worker disconnect (code `255`). 29 | 30 | If shutting down one of the servers is too slow, a timeout will eventually trigger an exit (exit code `255`). 31 | 32 | The shutdown logic is programmed to handle almost any conceivable exit condition, and provides 33 | 100% test coverage. 34 | The only instances that `onPreHook` code is not called, are uncatchable signals, like `SIGKILL`, 35 | and fatal errors that trigger during shutdown. 36 | 37 | ## Example 38 | 39 | Basic server example: 40 | 41 | ```js 42 | const Hapi = require('hapi'); 43 | const Exiting = require('exiting'); 44 | 45 | const server = Hapi.Server(); 46 | const manager = Exiting.createManager(server); 47 | 48 | server.events.on('stop', () => { 49 | 50 | console.log('Server stopped.'); 51 | }); 52 | 53 | const provision = async () => { 54 | 55 | server.route({ 56 | method: 'GET', 57 | path: '/', 58 | handler: () => 'Hello' 59 | }); 60 | 61 | await manager.start(); 62 | 63 | console.log('Server started at:', server.info.uri); 64 | }; 65 | 66 | provision(); 67 | ``` 68 | 69 | The server and process life-cycle will now be managed by **exiting**. 70 | 71 | If you need to delay the shutdown for processing, you can install an extention function on the 72 | `onPreStop` or `onPostStop` extension points, eg: 73 | 74 | ```js 75 | server.ext('onPreStop', () => { 76 | 77 | return new Promise((resolve) => { 78 | 79 | setTimeout(resolve, 1000); 80 | }); 81 | }); 82 | ``` 83 | 84 | Multiple servers example: 85 | 86 | ```js 87 | const Hapi = require('hapi'); 88 | const Exiting = require('exiting'); 89 | 90 | const publicServer = Hapi.Server(); 91 | const adminServer = Hapi.Server(); 92 | const manager = Exiting.createManager([publicServer, adminServer]); 93 | 94 | const provision = async () => { 95 | 96 | publicServer.route({ 97 | method: 'GET', 98 | path: '/', 99 | handler: () => 'Hello' 100 | }); 101 | 102 | adminServer.route({ 103 | method: 'GET', 104 | path: '/', 105 | handler: () => 'Hello Admin' 106 | }); 107 | 108 | await manager.start(); 109 | 110 | console.log('Public server started at:', publicServer.info.uri); 111 | console.log('Admin server started at:', adminServer.info.uri); 112 | }; 113 | 114 | provision(); 115 | ``` 116 | 117 | ## Installation 118 | 119 | Install using npm: `npm install exiting`. 120 | 121 | ## Usage 122 | 123 | To enable **exiting** for you server, replace the call to `server.start()` with 124 | `Exiting.createManager(server).start()`. 125 | 126 | ### Exiting.createManager(servers, [options]) 127 | 128 | Create a new exit manager for one or more hapi.js servers. The `options` object supports: 129 | 130 | * `exitTimeout` - When exiting, force process exit after this amount of ms has elapsed. Default: `5000`. 131 | 132 | ### await manager.start() 133 | 134 | Starts the manager and all the managed servers, as if `server.start()` is called on each server. 135 | If any server fails to start, all will be stopped with `server.stop()` before the error is re-thrown. 136 | 137 | Note that `process.exit()` is monkey patched to intercept such calls. 138 | Starting also installs the signal handlers and an `uncaughtException` handler. 139 | 140 | ### await manager.stop([options]) 141 | 142 | Stops the manager and all the servers, as if `server.stop()` is called on each server. 143 | 144 | ## Notes on process.exit() 145 | 146 | The `process.exit()` method is handled in a special manner that allows the asyncronous stop 147 | logic to resolve before actually exiting. Since this can be called from anywhere in the code, 148 | and subsequent code is never expected to be executed, the manager will throw an 149 | `Exiting.ProcessExitError` to attempt to escape the current execution context. This allows 150 | something like the following to still exit: 151 | 152 | ```js 153 | while (true) { 154 | process.exit(1); 155 | } 156 | ``` 157 | 158 | This might not always work, and can potentially cause a lock up instead of exiting. 159 | Eg. with this code: 160 | 161 | ```js 162 | try { 163 | process.exit(1); 164 | } 165 | catch (err) { 166 | /* do nothing */ 167 | } 168 | while (true) {} 169 | ``` 170 | 171 | You should avoid using `process.exit()` in your own code, and call `manager.stop()` instead. 172 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Bounce = require('@hapi/bounce'); 4 | const Hoek = require('@hapi/hoek'); 5 | 6 | 7 | const internals = { 8 | manager: null, 9 | signals: new Map([ 10 | ['SIGINT', true], 11 | ['SIGQUIT', true], 12 | ['SIGTERM', true], 13 | ['SIGHUP', false] 14 | ]), 15 | listeners: new Map(), 16 | processExit: null 17 | }; 18 | 19 | 20 | internals.addExitHook = function (event, handler, prepend = false) { 21 | 22 | prepend ? process.prependListener(event, handler) : process.on(event, handler); 23 | internals.listeners.set(event, handler); 24 | }; 25 | 26 | 27 | internals.teardownExitHooks = function () { 28 | 29 | process.exit = internals.processExit; 30 | 31 | for (const listener of internals.listeners) { 32 | process.removeListener(...listener); 33 | } 34 | 35 | internals.listeners.clear(); 36 | internals.processExit = null; 37 | }; 38 | 39 | 40 | exports.Manager = class { 41 | 42 | exitTimeout = 5000; 43 | servers; 44 | state; // 'starting', 'started', 'stopping', 'prestopped', 'stopped', 'startAborted', 'errored', 'timeout' 45 | exitTimer; 46 | exitCode = 0; 47 | active = true; 48 | 49 | constructor(servers, options = {}) { 50 | 51 | Hoek.assert(!internals.manager, 'Only one manager can be created'); 52 | 53 | this.exitTimeout = options.exitTimeout || this.exitTimeout; 54 | this.servers = typeof servers[Symbol.iterator] === 'function' ? [...servers] : [servers]; 55 | 56 | internals.manager = this; 57 | } 58 | 59 | async start() { 60 | 61 | if (!this.state) { 62 | this._setupExitHooks(); 63 | } 64 | 65 | this.state = 'starting'; 66 | 67 | let startError = null; 68 | let active = []; 69 | 70 | const safeStop = async (server) => { 71 | 72 | try { 73 | await server.stop(); 74 | } 75 | catch (err) { 76 | Bounce.rethrow(err, 'system'); 77 | } 78 | }; 79 | 80 | const safeStart = async (server) => { 81 | 82 | // "atomic" start, which immediately stops servers on errors 83 | try { 84 | await server.start(); 85 | if (startError) { 86 | throw new Error('Start aborted'); 87 | } 88 | 89 | active.push(server); 90 | } 91 | catch (err) { 92 | Bounce.rethrow(err, 'system'); 93 | 94 | if (!startError) { 95 | startError = err; 96 | } 97 | 98 | const stopping = active.concat(server); 99 | active = []; 100 | await Promise.all(stopping.map(safeStop)); 101 | } 102 | }; 103 | 104 | try { 105 | await Promise.all(this.servers.map(safeStart)); 106 | } 107 | finally { 108 | const aborted = (this.state === 'startAborted'); 109 | this.state = startError ? 'errored' : 'started'; 110 | 111 | if (aborted) { // Note that throw is not returned when aborted 112 | return this._exit(); // eslint-disable-line no-unsafe-finally 113 | } 114 | } 115 | 116 | if (startError) { 117 | throw startError; 118 | } 119 | 120 | // Attach close listeners to catch spurious closes 121 | 122 | for (const server of this.servers) { 123 | server.listener.once('close', this._listenerClosedHandler.bind(this)); 124 | } 125 | 126 | return this; 127 | } 128 | 129 | stop(options = {}) { 130 | 131 | Hoek.assert(this.state === 'started', 'Stop requires that server is started'); 132 | 133 | return this._stop(options); 134 | } 135 | 136 | deactivate() { 137 | 138 | if (this.active) { 139 | if (internals.processExit) { 140 | internals.teardownExitHooks(); 141 | } 142 | 143 | clearTimeout(this.exitTimer); 144 | internals.manager = undefined; 145 | 146 | this.active = false; 147 | } 148 | } 149 | 150 | // Private 151 | 152 | async _exit(code) { 153 | 154 | if (!this.active) { 155 | return; 156 | } 157 | 158 | if (typeof code === 'number' && code > this.exitCode) { 159 | this.exitCode = code; 160 | } 161 | 162 | if (!this.exitTimer) { 163 | this.exitTimer = setTimeout(() => { 164 | 165 | this.state = 'timeout'; 166 | return this._exit(255); 167 | }, this.exitTimeout); 168 | } 169 | 170 | if (this.state === 'starting') { 171 | this.state = 'startAborted'; 172 | return; 173 | } 174 | 175 | if (this.state === 'startAborted') { // wait until started 176 | return; 177 | } 178 | 179 | if (this.state === 'started') { 180 | 181 | // change state to prestopped as soon as the first server is stopping 182 | for (const server of this.servers) { 183 | server.ext('onPreStop', this._listenerStopHandler.bind(this)); 184 | } 185 | 186 | try { 187 | await this._stop({ timeout: this.exitTimeout - 500 }); 188 | } 189 | catch (err) { 190 | this._log('Server stop failed:', err.stack); 191 | } 192 | 193 | return this._exit(); 194 | } 195 | 196 | if (this.state === 'stopping') { // wait until stopped 197 | return; 198 | } 199 | 200 | if (this.state === 'prestopped') { 201 | if (this.exitCode === 0) { 202 | return; // defer to prestop logic 203 | } 204 | 205 | this.state = 'errored'; 206 | } 207 | 208 | // Perform actual exit 209 | 210 | internals.processExit(this.exitCode); 211 | } 212 | 213 | _abortHandler(event) { 214 | 215 | if (process.listenerCount(event) === 1) { 216 | return this._exit(1); 217 | } 218 | } 219 | 220 | _gracefulHandler(event) { 221 | 222 | if (process.listenerCount(event) === 1) { 223 | return this._exit(0); 224 | } 225 | } 226 | 227 | _unhandledError(type, err) { 228 | 229 | if (err instanceof exports.ProcessExitError) { // Ignore ProcessExitError, since we are already handling it 230 | return; 231 | } 232 | 233 | this._log(`Fatal ${type}:`, (err || {}).stack || err); 234 | 235 | if (this.state === 'stopping') { // Exceptions while stopping advance to error state immediately 236 | this.state = 'errored'; 237 | } 238 | 239 | return this._exit(1); 240 | } 241 | 242 | _uncaughtExceptionHandler(err) { 243 | 244 | return this._unhandledError('exception', err); 245 | } 246 | 247 | _unhandledRejectionHandler(err) { 248 | 249 | return this._unhandledError('rejection', err); 250 | } 251 | 252 | _listenerClosedHandler() { 253 | 254 | // If server is closed without stopping, exit with error 255 | 256 | if (this.state === 'started') { 257 | return this._exit(255); 258 | } 259 | } 260 | 261 | _listenerStopHandler(/*server*/) { 262 | 263 | this.state = 'prestopped'; 264 | 265 | if (this.exitCode !== 0) { 266 | throw new Error('Process aborted'); 267 | } 268 | } 269 | 270 | async _stop(options) { 271 | 272 | try { 273 | this.state = 'stopping'; 274 | await Promise.all(this.servers.map((server) => server.stop(options))); 275 | this.state = 'stopped'; 276 | } 277 | catch (err) { 278 | this.state = 'errored'; 279 | throw err; 280 | } 281 | } 282 | 283 | _badExitCheck() { 284 | 285 | if (this.state !== 'stopped' && this.state !== 'errored' && this.state !== 'timeout') { 286 | this._log('Process exiting without stopping server (state == ' + this.state + ')'); 287 | } 288 | } 289 | 290 | _setupExitHooks() { 291 | 292 | internals.addExitHook('uncaughtException', this._uncaughtExceptionHandler.bind(this)); 293 | internals.addExitHook('unhandledRejection', this._unhandledRejectionHandler.bind(this)); 294 | 295 | for (const [event, graceful] of internals.signals) { 296 | const handler = graceful ? this._gracefulHandler.bind(this, event) : this._abortHandler.bind(this, event); 297 | internals.addExitHook(event, handler, true); 298 | } 299 | 300 | internals.addExitHook('beforeExit', this._exit.bind(this)); 301 | internals.addExitHook('exit', this._badExitCheck.bind(this)); 302 | 303 | // Monkey patch process.exit() 304 | 305 | internals.processExit = process.exit; 306 | process.exit = (code) => { 307 | 308 | this._exit(code); 309 | 310 | // Since we didn't actually exit, throw an error to escape the current scope 311 | 312 | throw new exports.ProcessExitError(); 313 | }; 314 | } 315 | 316 | _log(...args) { 317 | 318 | try { 319 | return exports.log(...args); 320 | } 321 | catch {} 322 | } 323 | }; 324 | 325 | 326 | exports.createManager = function (servers, options) { 327 | 328 | return new exports.Manager(servers, options); 329 | }; 330 | 331 | 332 | exports.log = function (...args) { 333 | 334 | console.error('[exiting]', ...args); 335 | }; 336 | 337 | 338 | exports.reset = function () { 339 | 340 | if (internals.manager) { 341 | internals.manager.deactivate(); 342 | } 343 | }; 344 | 345 | 346 | exports.ProcessExitError = class extends TypeError { 347 | 348 | constructor() { 349 | 350 | super('process.exit() was called'); 351 | } 352 | }; 353 | -------------------------------------------------------------------------------- /test/exiting.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Events = require('events'); 6 | 7 | const Code = require('@hapi/code'); 8 | const Exiting = require('..'); 9 | const Hapi = require('@hapi/hapi'); 10 | const Hoek = require('@hapi/hoek'); 11 | const Lab = require('@hapi/lab'); 12 | 13 | 14 | // Test shortcuts 15 | 16 | const lab = exports.lab = Lab.script(); 17 | const { describe, it, before, beforeEach, after, afterEach } = lab; 18 | const { expect } = Code; 19 | 20 | 21 | describe('Manager', () => { 22 | 23 | const processExit = process.exit; 24 | 25 | const grabExit = (manager, emit) => { 26 | 27 | const promise = new Promise((resolve) => { 28 | 29 | process.exit = (code) => { 30 | 31 | if (emit) { 32 | process.emit('exit', code); 33 | } 34 | 35 | resolve({ code, state: manager.state }); 36 | }; 37 | }); 38 | 39 | promise.exit = (code) => { 40 | 41 | try { 42 | process.exit(code); 43 | } 44 | catch (err) { 45 | if (!(err instanceof Exiting.ProcessExitError)) { 46 | throw err; 47 | } 48 | } 49 | 50 | return promise; 51 | }; 52 | 53 | return promise; 54 | }; 55 | 56 | const ignoreProcessExitError = (err) => { 57 | 58 | if (err instanceof Exiting.ProcessExitError) { 59 | return; 60 | } 61 | 62 | throw err; 63 | }; 64 | 65 | before(() => { 66 | 67 | // Silence log messages 68 | 69 | const log = Exiting.log; 70 | Exiting.log = function (...args) { 71 | 72 | const consoleError = console.error; 73 | console.error = Hoek.ignore; 74 | log.apply(Exiting, args); 75 | console.error = consoleError; 76 | }; 77 | }); 78 | 79 | beforeEach(() => { 80 | 81 | Exiting.reset(); 82 | }); 83 | 84 | after(() => { 85 | 86 | Exiting.reset(); 87 | process.exit = processExit; 88 | }); 89 | 90 | afterEach(() => { 91 | 92 | process.exit = processExit; 93 | }); 94 | 95 | it('creates new object', () => { 96 | 97 | const manager = Exiting.createManager({}); 98 | expect(manager).to.exist(); 99 | expect(manager).to.be.an.instanceof(Exiting.Manager); 100 | }); 101 | 102 | it('can start and stop without exiting', async () => { 103 | 104 | const manager = Exiting.createManager(Hapi.Server()); 105 | 106 | await manager.start(); 107 | 108 | expect(manager.state).to.equal('started'); 109 | 110 | await Hoek.wait(0); 111 | await manager.stop(); 112 | 113 | expect(manager.state).to.equal('stopped'); 114 | }); 115 | 116 | it('can start and stop with multiple servers', async () => { 117 | 118 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 119 | 120 | await manager.start(); 121 | 122 | expect(manager.state).to.equal('started'); 123 | 124 | await Hoek.wait(0); 125 | await manager.stop(); 126 | 127 | expect(manager.state).to.equal('stopped'); 128 | }); 129 | 130 | it('can restart servers', async () => { 131 | 132 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 133 | const exited = grabExit(manager); 134 | 135 | await manager.start(); 136 | await Hoek.wait(0); 137 | await manager.stop(); 138 | 139 | expect(manager.state).to.equal('stopped'); 140 | 141 | await manager.start(); 142 | 143 | expect(manager.state).to.equal('started'); 144 | 145 | const { code, state } = await exited.exit(0); 146 | expect(state).to.equal('stopped'); 147 | expect(code).to.equal(0); 148 | }); 149 | 150 | it('supports stop options', async () => { 151 | 152 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 153 | 154 | await manager.start(); 155 | 156 | expect(manager.state).to.equal('started'); 157 | 158 | await manager.stop({ timeout: 5 }); 159 | 160 | expect(manager.state).to.equal('stopped'); 161 | }); 162 | 163 | it('alerts on unknown exit', async () => { 164 | 165 | const manager = Exiting.createManager(Hapi.Server()); 166 | 167 | await manager.start(); 168 | 169 | const logged = new Promise((resolve) => { 170 | 171 | const log = Exiting.log; 172 | Exiting.log = (message) => { 173 | 174 | Exiting.log = log; 175 | resolve(message); 176 | }; 177 | }); 178 | 179 | // Fake a spurious process "exit" event 180 | 181 | process.emit('exit', 0); 182 | 183 | expect(await logged).to.equal('Process exiting without stopping server (state == started)'); 184 | await manager.stop(); 185 | }); 186 | 187 | it('forwards start rejections', async () => { 188 | 189 | const servers = [Hapi.Server(), Hapi.Server(), Hapi.Server()]; 190 | const manager = new Exiting.Manager(servers); 191 | 192 | let stops = 0; 193 | servers.forEach((server) => { 194 | 195 | server.events.on('stop', () => ++stops); 196 | }); 197 | 198 | servers[1].ext('onPreStart', () => { 199 | 200 | throw new Error('start fail'); 201 | }); 202 | 203 | servers[2].ext('onPostStop', () => { 204 | 205 | throw new Error('stop fail'); 206 | }); 207 | 208 | await expect(manager.start()).to.reject(Error, 'start fail'); 209 | 210 | expect(manager.state).to.equal('errored'); 211 | expect(stops).to.equal(3); 212 | }); 213 | 214 | it('cancels exit when reset', async () => { 215 | 216 | const server = new Hapi.Server(); 217 | const manager = new Exiting.Manager([Hapi.Server(), server, Hapi.Server()]); 218 | 219 | server.ext('onPreStop', () => { 220 | 221 | Exiting.reset(); 222 | }); 223 | 224 | await manager.start(); 225 | await manager.stop(); 226 | }); 227 | 228 | it('cancels exit when reset after close', async () => { 229 | 230 | const server = new Hapi.Server(); 231 | const manager = new Exiting.Manager([Hapi.Server(), server, Hapi.Server()]); 232 | const exited = grabExit(manager); 233 | 234 | server.ext('onPostStop', () => { 235 | 236 | Exiting.reset(); 237 | }); 238 | 239 | await manager.start(); 240 | exited.exit(0); 241 | }); 242 | 243 | it('uncaughtException handler ignores ProcessExitErrors', async (flags) => { 244 | 245 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 246 | const exited = grabExit(manager, true); 247 | 248 | await manager.start(); 249 | 250 | // Immitate a throw by faking an uncaughtException 251 | 252 | flags.onUncaughtException = ignoreProcessExitError; 253 | process.emit('uncaughtException', new Exiting.ProcessExitError()); 254 | 255 | const { code, state } = await exited.exit(0); 256 | expect(state).to.equal('stopped'); 257 | expect(code).to.equal(0); 258 | }); 259 | 260 | it('unhandledRejection handler ignores ProcessExitErrors', async (flags) => { 261 | 262 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 263 | const exited = grabExit(manager, true); 264 | 265 | await manager.start(); 266 | 267 | await new Promise((resolve, reject) => { 268 | 269 | flags.onUnhandledRejection = (err) => { 270 | 271 | (err instanceof Exiting.ProcessExitError) ? resolve() : reject(err); 272 | }; 273 | 274 | Promise.reject(new Exiting.ProcessExitError()); 275 | }); 276 | 277 | const { code, state } = await exited.exit(0); 278 | expect(state).to.equal('stopped'); 279 | expect(code).to.equal(0); 280 | }); 281 | 282 | it('does not exit for registered signal handlers', async () => { 283 | 284 | const sigint = Events.once(process, 'SIGINT'); 285 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 286 | 287 | await manager.start(); 288 | 289 | setImmediate(() => { 290 | 291 | process.kill(process.pid, 'SIGINT'); 292 | }); 293 | 294 | await sigint; 295 | await Hoek.wait(1); 296 | 297 | expect(manager.state).to.equal('started'); 298 | }); 299 | 300 | it('does not exit for registered aborting signal handlers', async () => { 301 | 302 | const sighub = Events.once(process, 'SIGHUP'); 303 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 304 | 305 | await manager.start(); 306 | 307 | setImmediate(() => { 308 | 309 | process.kill(process.pid, 'SIGHUP'); 310 | }); 311 | 312 | await sighub; 313 | await Hoek.wait(1); 314 | 315 | expect(manager.state).to.equal('started'); 316 | }); 317 | 318 | it('can deactivate', async () => { 319 | 320 | const manager = Exiting.createManager(Hapi.Server()); 321 | await manager.start(); 322 | 323 | expect(process.listenerCount('exit')).to.equal(1); 324 | expect(manager.active).to.be.true(); 325 | 326 | manager.deactivate(); 327 | expect(process.listenerCount('exit')).to.equal(0); 328 | expect(manager.active).to.be.false(); 329 | }); 330 | 331 | it('deactivate does nothing after reset', async () => { 332 | 333 | const manager = Exiting.createManager(Hapi.Server()); 334 | await manager.start(); 335 | 336 | expect(process.listenerCount('exit')).to.equal(1); 337 | expect(manager.active).to.be.true(); 338 | 339 | Exiting.reset(); 340 | expect(process.listenerCount('exit')).to.equal(0); 341 | expect(manager.active).to.be.false(); 342 | 343 | manager.deactivate(); 344 | expect(process.listenerCount('exit')).to.equal(0); 345 | expect(manager.active).to.be.false(); 346 | }); 347 | 348 | describe('exits gracefully', () => { 349 | 350 | it('on process.exit with code 0', async () => { 351 | 352 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 353 | const exited = grabExit(manager, true); 354 | 355 | await manager.start(); 356 | await Hoek.wait(0); 357 | 358 | const { code, state } = await exited.exit(0); 359 | expect(state).to.equal('stopped'); 360 | expect(code).to.equal(0); 361 | }); 362 | 363 | it('while starting', async () => { 364 | 365 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 366 | const exited = grabExit(manager); 367 | 368 | manager.start(); // No await here 369 | 370 | exited.exit(0); 371 | exited.exit(0); 372 | 373 | const { code, state } = await exited; 374 | expect(state).to.equal('stopped'); 375 | expect(code).to.equal(0); 376 | }); 377 | 378 | it('on double exit', async () => { 379 | 380 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 381 | const exited = grabExit(manager); 382 | 383 | await manager.start(); 384 | await Hoek.wait(0); 385 | exited.exit(0); 386 | exited.exit(0); 387 | 388 | const { code, state } = await exited; 389 | expect(state).to.equal('stopped'); 390 | expect(code).to.equal(0); 391 | }); 392 | 393 | it('on double exit with preStop delay', async () => { 394 | 395 | const server = new Hapi.Server(); 396 | const manager = new Exiting.Manager(server); 397 | const exited = grabExit(manager); 398 | 399 | server.ext('onPreStop', async () => { 400 | 401 | await Hoek.wait(0); 402 | }); 403 | 404 | await manager.start(); 405 | await Hoek.wait(0); 406 | exited.exit(0); 407 | exited.exit(0); 408 | 409 | const { code, state } = await exited; 410 | expect(state).to.equal('stopped'); 411 | expect(code).to.equal(0); 412 | }); 413 | 414 | it('on SIGINT', async () => { 415 | 416 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 417 | const exited = grabExit(manager); 418 | 419 | await manager.start(); 420 | process.kill(process.pid, 'SIGINT'); 421 | 422 | const { code, state } = await exited; 423 | expect(state).to.equal('stopped'); 424 | expect(code).to.equal(0); 425 | }); 426 | 427 | it('on SIGQUIT', async () => { 428 | 429 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 430 | const exited = grabExit(manager); 431 | 432 | await manager.start(); 433 | process.kill(process.pid, 'SIGQUIT'); 434 | 435 | const { code, state } = await exited; 436 | expect(state).to.equal('stopped'); 437 | expect(code).to.equal(0); 438 | }); 439 | 440 | it('on SIGTERM', async () => { 441 | 442 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 443 | const exited = grabExit(manager); 444 | 445 | await manager.start(); 446 | process.kill(process.pid, 'SIGTERM'); 447 | 448 | const { code, state } = await exited; 449 | expect(state).to.equal('stopped'); 450 | expect(code).to.equal(0); 451 | }); 452 | }); 453 | 454 | describe('aborts', () => { 455 | 456 | it('on process.exit with non-zero exit code', async () => { 457 | 458 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 459 | const exited = grabExit(manager, true); 460 | 461 | await manager.start(); 462 | await Hoek.wait(0); 463 | 464 | const { code, state } = await exited.exit(10); 465 | expect(state).to.equal('errored'); 466 | expect(code).to.equal(10); 467 | }); 468 | 469 | it('on thrown errors', async () => { 470 | 471 | process.removeAllListeners('uncaughtException'); // Disable lab integration 472 | 473 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 474 | const exited = grabExit(manager, true); 475 | 476 | await manager.start(); 477 | 478 | // Immitate a throw by faking an uncaughtException 479 | 480 | process.emit('uncaughtException', new Error('fail')); 481 | 482 | const { code, state } = await exited; 483 | expect(state).to.equal('errored'); 484 | expect(code).to.equal(1); 485 | }); 486 | 487 | it('on non-error throw', async () => { 488 | 489 | process.removeAllListeners('uncaughtException'); // Disable lab integration 490 | 491 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 492 | const exited = grabExit(manager, true); 493 | 494 | await manager.start(); 495 | 496 | // Immitate a throw by faking an uncaughtException 497 | 498 | process.emit('uncaughtException', 10); 499 | 500 | const { code, state } = await exited; 501 | expect(state).to.equal('errored'); 502 | expect(code).to.equal(1); 503 | }); 504 | 505 | it('on "undefined" throw', async () => { 506 | 507 | process.removeAllListeners('uncaughtException'); // Disable lab integration 508 | 509 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 510 | const exited = grabExit(manager, true); 511 | 512 | await manager.start(); 513 | 514 | // Immitate a throw by faking an uncaughtException 515 | 516 | process.emit('uncaughtException', undefined); 517 | 518 | const { code, state } = await exited; 519 | expect(state).to.equal('errored'); 520 | expect(code).to.equal(1); 521 | }); 522 | 523 | it('on unhandled rejections', async () => { 524 | 525 | process.removeAllListeners('unhandledRejection'); // Disable lab integration 526 | 527 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 528 | const exited = grabExit(manager, true); 529 | 530 | await manager.start(); 531 | 532 | new Promise((resolve, reject) => reject(new Error('unhandled'))); 533 | 534 | const { code, state } = await exited; 535 | expect(state).to.equal('errored'); 536 | expect(code).to.equal(1); 537 | }); 538 | 539 | it('on thrown errors while prestopping', async () => { 540 | 541 | process.removeAllListeners('uncaughtException'); // Disable lab integration 542 | 543 | const server = new Hapi.Server(); 544 | const manager = new Exiting.Manager([Hapi.Server(), server, Hapi.Server()]); 545 | const exited = grabExit(manager, true); 546 | 547 | server.ext('onPreStop', () => { 548 | 549 | process.emit('uncaughtException', new Error('fail')); 550 | }); 551 | 552 | await manager.start(); 553 | manager.stop(); // No await 554 | 555 | const { code, state } = await exited; 556 | expect(state).to.equal('errored'); 557 | expect(code).to.equal(1); 558 | }); 559 | 560 | it('on thrown errors while poststopping', async () => { 561 | 562 | process.removeAllListeners('uncaughtException'); // Disable lab integration 563 | 564 | const server = new Hapi.Server(); 565 | const manager = new Exiting.Manager([Hapi.Server(), server, Hapi.Server()]); 566 | const exited = grabExit(manager, true); 567 | 568 | server.ext('onPostStop', () => { 569 | 570 | process.emit('uncaughtException', new Error('fail')); 571 | }); 572 | 573 | await manager.start(); 574 | manager.stop(); // No await 575 | 576 | const { code, state } = await exited; 577 | expect(state).to.equal('errored'); 578 | expect(code).to.equal(1); 579 | }); 580 | 581 | it('on SIGHUP', async () => { 582 | 583 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 584 | const exited = grabExit(manager, true); 585 | 586 | await manager.start(); 587 | process.kill(process.pid, 'SIGHUP'); 588 | 589 | const { code, state } = await exited; 590 | expect(state).to.equal('errored'); 591 | expect(code).to.equal(1); 592 | }); 593 | 594 | it('on server "close"', async () => { 595 | 596 | const server = new Hapi.Server(); 597 | const manager = new Exiting.Manager([Hapi.Server(), server, Hapi.Server()]); 598 | const exited = grabExit(manager, true); 599 | 600 | await manager.start(); 601 | server.listener.close(); 602 | 603 | const { code, state } = await exited; 604 | expect(state).to.equal('errored'); 605 | expect(code).to.equal(255); 606 | }); 607 | 608 | it('on exit timeout', async () => { 609 | 610 | const server = new Hapi.Server(); 611 | const manager = new Exiting.Manager([Hapi.Server(), server, Hapi.Server()], { exitTimeout: 1 }); 612 | const exited = grabExit(manager, true); 613 | 614 | const preStopped = new Promise((resolve) => { 615 | 616 | server.ext('onPreStop', async () => { 617 | 618 | await Hoek.wait(100); 619 | resolve(manager.state); 620 | expect(manager.state).to.equal('timeout'); 621 | }); 622 | }); 623 | 624 | await manager.start(); 625 | 626 | const { code, state } = await exited.exit(0); 627 | expect(state).to.equal('timeout'); 628 | expect(code).to.equal(255); 629 | 630 | expect(await preStopped).to.equal('timeout'); 631 | }); 632 | 633 | it('on double exit with error', async () => { 634 | 635 | const manager = Exiting.createManager([Hapi.Server(), Hapi.Server(), Hapi.Server()]); 636 | const exited = grabExit(manager); 637 | 638 | await manager.start(); 639 | await Hoek.wait(0); 640 | exited.exit(0); 641 | exited.exit(1); 642 | 643 | const { code, state } = await exited; 644 | expect(state).to.equal('errored'); 645 | expect(code).to.equal(1); 646 | }); 647 | }); 648 | }); 649 | --------------------------------------------------------------------------------