From 6fe6c440a6585f09dc44bbd244b85f27986b940a Mon Sep 17 00:00:00 2001 From: Stephen Cresswell <229672+cressie176@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:01:09 +0100 Subject: [PATCH] Add exit(code): stop the system, then exit the process with the code An application which gives up, from a component_failed listener or a fatal error handler, had to write the stop-then-process.exit dance itself, although exitOn already performed it in lib/exit.js. That primitive is now public as system.exit(code), and exitOn is: on each signal, call exit() with no code. After a successful stop the process exits with the given code. After a failed stop the caller's non-zero code is kept and 0 or none becomes 1: the caller's reason for exiting outranks the stop's failure, which the events announce anyway. The alternative, always 1 as exitOn did, would lose a fatal handler's code on the way to the orchestrator. Three things found empirically shape the code. On Node 24 process.exit(undefined) is an explicit 0 and does not honour process.exitCode, so the no-code path calls a bare process.exit(); the existing restart scenario, which expects exitCode 3, caught the difference. Signal listeners are called with the signal's name and number, and process.exit('SIGTERM') throws, so exitOn's listener discards its arguments; a new scenario raising a real SIGTERM is the only one which catches that, since custom events carry no arguments. A signal listener does not keep the event loop alive, so the child program's postgres now holds a timer the way a server holds a socket. Closes #20. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 7 ++ CONTRIBUTING.md | 3 +- README.md | 17 +++- lib/exit.js | 15 ++-- lib/index.d.ts | 1 + lib/index.js | 10 ++- lib/validate-exit-code.js | 6 ++ test/features/exiting.md | 88 +++++++++++++++++-- test/features/readme-conformance.md | 4 +- ...{exit-on-program.js => exiting-program.js} | 44 ++++++++-- test/steps/exiting-library.js | 47 ++++++++-- test/steps/readme-library.js | 2 +- test/types/create-system.types.ts | 5 ++ 13 files changed, 211 insertions(+), 38 deletions(-) create mode 100644 lib/validate-exit-code.js rename test/lib/{exit-on-program.js => exiting-program.js} (52%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 537287a..78b941b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to cotillion are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- `exit(code)`: stops the system and exits the process with the code once the stop has finished. + A failed stop exits with 1 unless the code given was non-zero, which is kept. + ## [0.2.0] ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77a8c2b..d36fdac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,8 @@ a test, and the module layout stays as small as that specification allows. | lib/validate-options.js | the eager validation createSystem applies to its options | | lib/validate-timeout.js | the timeout shape check the two validations share | | lib/validate-events.js | the eager validation stopOn and exitOn apply to their signals | -| lib/exit.js | the exit exitOn performs once the stop a signal began has finished, the only place the library calls process.exit | +| lib/validate-exit-code.js | the eager validation exit applies to its code | +| lib/exit.js | the exit which exit, and exitOn on a signal, perform once the stop has finished, the only place the library calls process.exit | | lib/index.d.ts | the hand-written type definitions, importing only Node's EventEmitter type | The table grows as the implementation lands; the conventions below are binding from the first diff --git a/README.md b/README.md index 1836811..d707b44 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ A runnable web app, with postgres, redis and a Hono HTTP server on Docker, lives - [Events](#events) - [System timeouts](#system-timeouts) - [Component timeouts](#component-timeouts) +- [Exiting](#exiting) - [Signals](#signals) - [Component failures](#component-failures) - [Parallel groups](#parallel-groups) @@ -267,9 +268,17 @@ A number applies to all timeouts; the object form sets them separately. There ar A component which exceeds its own limit is treated as a failure. Its failed event carries a `TimeoutError` such as "The component emailListener timed out after 5000ms while starting", the operation continues as after any failure, and the function is left running. If the component is abortable, the AbortSignal passed to its start function fires as well, so the function can give up. +## Exiting + +`system.exit(code)` stops the system and exits the process once the stop has finished: with the given code if the stop succeeded, or with 1 if it failed, unless you gave a code other than 0, which is kept. The code you gave is your reason for exiting, and the stop's failure is announced through the events. With no code the process exits with `process.exitCode`, which is 0 unless you set it. + +```ts +system.exit(1); +``` + ## Signals -`system.exitOn(...signals)` stops the system when the process receives any of the named signals, and exits once that stop has finished: with code 0 if it succeeded, 1 if it failed. +`system.exitOn(...signals)` calls `exit()` when the process receives any of the named signals, so the system stops and the process exits with code 0 if the stop succeeded and 1 if it failed. ```ts system.exitOn('SIGTERM', 'SIGINT'); @@ -285,7 +294,7 @@ system.on(SystemEvent.StopSucceeded, () => process.exit()); system.on(SystemEvent.StopFailed, () => process.exit(1)); ``` -The first line matters: a failed start is followed by a stop, which usually succeeds. Any process event will do as a signal; further signals during a stop do nothing more; and the listeners stay for the life of the process, so a signal after a `restart()` stops the restarted system. Cotillion calls `process.exit` only from `exitOn`. +The first line matters: a failed start is followed by a stop, which usually succeeds. Any process event will do as a signal; further signals during a stop do nothing more; and the listeners stay for the life of the process, so a signal after a `restart()` stops the restarted system. Cotillion calls `process.exit` only from `exit` and `exitOn`. ## Component failures @@ -295,7 +304,7 @@ A component can fail after it has started: a database client loses its connectio client.on('error', fail); ``` -A call to `fail(error)` announces `component_failed` with the name and the error. Cotillion does nothing else: the application decides, and `system.restart()` is the usual answer. +A call to `fail(error)` announces `component_failed` with the name and the error. Cotillion does nothing else: the application decides. `system.restart()` is the usual answer, and `system.exit(1)` gives up. ```ts system.on(ComponentEvent.Failed, ({ name, error }) => { @@ -341,7 +350,7 @@ If a component in a group fails to start, the rest of the group is allowed to fi | Error | Thrown when | |----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Error | The definition or the options are invalid: a missing or duplicate name, a malformed entry, or a malformed timeout. Thrown by createSystem. Also thrown by stopOn and exitOn given no signals, or one which is not a string. | +| Error | The definition or the options are invalid: a missing or duplicate name, a malformed entry, or a malformed timeout. Thrown by createSystem. Also thrown by stopOn and exitOn given no signals, or one which is not a string, and by exit given a code which is not an integer. | | TimeoutError | The system's start or stop timeout expired, or a component exceeded its own timeout. The message names the component, or components, concerned. | | AbortError | A stop interrupted the start. Thrown by start() once the stop has finished, and carried as the reason of the AbortSignal passed to each abortable component's start function; the message names the components whose start was in flight. | | AggregateError | More than one entry of a parallel group failed. Contains every failure. | diff --git a/lib/exit.js b/lib/exit.js index 4d32f61..cf78978 100644 --- a/lib/exit.js +++ b/lib/exit.js @@ -1,13 +1,18 @@ -function exitWhenStopped(stopping) { - return stopping.then(exitSucceeded, exitFailed); +function exitWhenStopped(stopping, code) { + return stopping.then(exitWith(code), exitFailedWith(code)); } -function exitSucceeded() { +function exitWith(code) { + if (code === undefined) return exitWithProcessExitCode; + return () => process.exit(code); +} + +function exitWithProcessExitCode() { process.exit(); } -function exitFailed() { - process.exit(1); +function exitFailedWith(code) { + return () => process.exit(code || 1); } module.exports = { exitWhenStopped }; diff --git a/lib/index.d.ts b/lib/index.d.ts index 7806139..4d7aaca 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -102,6 +102,7 @@ export interface System extends EventEmitter { start(): Promise; stop(): Promise; restart(): Promise; + exit(code?: number): Promise; stopOn(...signals: string[]): () => void; exitOn(...signals: string[]): () => void; } diff --git a/lib/index.js b/lib/index.js index 82200ab..cfadbba 100644 --- a/lib/index.js +++ b/lib/index.js @@ -5,6 +5,7 @@ const { ComponentEvent, SkipReason, SystemEvent } = require('./events'); const { validateDefinition } = require('./validate-definition'); const { exitWhenStopped } = require('./exit'); const { validateEvents } = require('./validate-events'); +const { validateExitCode } = require('./validate-exit-code'); const { validateOptions } = require('./validate-options'); const { Outcome, createWaits } = require('./waits'); @@ -129,12 +130,17 @@ function createSystem(definition, options) { return start(); } + function exit(code) { + validateExitCode(code); + return exitWhenStopped(stop(), code); + } + function stopOn(...signals) { return stopWhenSignalled(signals, stopQuietly); } function exitOn(...signals) { - return stopWhenSignalled(signals, () => exitWhenStopped(stop())); + return stopWhenSignalled(signals, () => exit()); } function stopWhenSignalled(signals, listener) { @@ -145,7 +151,7 @@ function createSystem(definition, options) { }; } - return Object.assign(system, { start, stop, restart, stopOn, exitOn }); + return Object.assign(system, { start, stop, restart, exit, stopOn, exitOn }); } function alreadyAnnounced() {} diff --git a/lib/validate-exit-code.js b/lib/validate-exit-code.js new file mode 100644 index 0000000..2608bea --- /dev/null +++ b/lib/validate-exit-code.js @@ -0,0 +1,6 @@ +function validateExitCode(code) { + if (code === undefined || Number.isInteger(code)) return; + throw new Error('The exit code must be an integer'); +} + +module.exports = { validateExitCode }; diff --git a/test/features/exiting.md b/test/features/exiting.md index 516d505..355ba7f 100644 --- a/test/features/exiting.md +++ b/test/features/exiting.md @@ -1,12 +1,14 @@ # Feature: Exiting -system.exitOn(...signals) does what stopOn does and also ends the process once the stop a signal -began has finished: with code 0 if the stop succeeded, and 1 if it failed. Stops the application -begins itself, by stop() or restart(), do not exit, so an application can restart its system when -a component fails and keep running. A failed start is not exitOn's business either: start() -rejects, and left unhandled at the top level Node ends the process with code 1, as these programs -let it. An exit code can only be seen from outside the process, so these scenarios run a small -program in a child process and read the code it exited with. +system.exit(code) stops the system and ends the process once the stop has finished, with the given +code if the stop succeeded. If the stop failed the code is kept unless it was 0 or absent, which +become 1, so a failed stop is never reported as a success and a caller's reason for exiting is never +lost. With no code the process exits with process.exitCode, which is 0 unless the program set it. +system.exitOn(...signals) does what stopOn does and calls exit() with no code when a signal arrives. Stops the application begins itself, by stop() or restart(), do not exit, so an +application can restart its system when a component fails and keep running. A failed start is not +exitOn's business either: start() rejects, and left unhandled at the top level Node ends the process +with code 1, as these programs let it. An exit code can only be seen from outside the process, so +these scenarios run a small program in a child process and read the code it exited with. ## Rule: A signal stops the system and then exits the process @@ -17,6 +19,13 @@ program in a child process and read the code it exited with. - Then the program exits with code 0 - And the program announced system_stop_succeeded before exiting +### Scenario: A termination signal after the system has started + +- Given a program whose system exits on a termination signal +- When the program runs +- Then the program exits with code 0 +- And the program announced system_stop_succeeded before exiting + ### Scenario: A component which fails to stop - Given a program whose system exits on a process event @@ -54,3 +63,68 @@ program in a child process and read the code it exited with. - When the program runs - Then the program exits with code 7 - And the program announced system_stop_succeeded before exiting + +## Rule: exit() stops the system and then exits the process with the code + +### Scenario: An exit with [code description] after a successful stop + +- Given a program whose system exits itself with [code description] +- When the program runs +- Then the program exits with code [code] +- And the program announced system_stop_succeeded before exiting + +### Examples: + +| code description | code | +|------------------|------| +| no code | 0 | +| the code 3 | 3 | + +### Scenario: An exit with no code after the program set process.exitCode + +- Given a program whose system exits itself with no code +- And the program sets process.exitCode to 5 before exiting +- When the program runs +- Then the program exits with code 5 +- And the program announced system_stop_succeeded before exiting + +### Scenario: An exit while the system is starting + +- Given a program whose system exits itself with the code 3 while starting +- When the program runs +- Then the program exits with code 3 +- And the program announced system_stop_succeeded before exiting + +## Rule: A failed stop exits with the caller's non-zero code, or 1 + +### Scenario: An exit with [code description] after a failed stop + +- Given a program whose system exits itself with [code description] +- And the program's postgres fails to stop +- When the program runs +- Then the program exits with code [code] +- And the program announced system_stop_failed before exiting + +### Examples: + +| code description | code | +|------------------|------| +| no code | 1 | +| the code 0 | 1 | +| the code 3 | 3 | + +## Rule: The exit code is an integer + +### Scenario: The exit code [value] + +- Given the components postgres +- When the system is asked to exit with the code [value] +- Then the request is rejected with "The exit code must be an integer" + +### Examples: + +| value | +|--------| +| 3.5 | +| "3" | +| true | diff --git a/test/features/readme-conformance.md b/test/features/readme-conformance.md index 87dd539..197e7e1 100644 --- a/test/features/readme-conformance.md +++ b/test/features/readme-conformance.md @@ -38,7 +38,7 @@ package, and about who calls process.exit, must match the code. - Given the README - Then the package has no production dependencies -### Scenario: Cotillion calls process.exit only from exitOn +### Scenario: Cotillion calls process.exit only from exit and exitOn - Given the README -- Then the library calls process.exit only where exitOn is implemented +- Then the library calls process.exit only where exit is implemented diff --git a/test/lib/exit-on-program.js b/test/lib/exiting-program.js similarity index 52% rename from test/lib/exit-on-program.js rename to test/lib/exiting-program.js index 8d22348..a26084c 100644 --- a/test/lib/exit-on-program.js +++ b/test/lib/exiting-program.js @@ -1,18 +1,23 @@ const { ComponentEvent, SystemEvent, createSystem } = require('../../lib'); -const [behaviour] = process.argv.slice(2); +const [behaviour, failure, code] = process.argv.slice(2); + +const exitCode = code === 'none' ? undefined : Number(code); let reportFailure; +let connection; const postgres = { name: 'postgres', async start(components, { fail }) { reportFailure = fail; - if (behaviour === 'start-fails') throw new Error('connection refused'); - return 'a connection'; + if (failure === 'start-fails') throw new Error('connection refused'); + connection = setInterval(() => {}, 1000); + return connection; }, async stop() { - if (behaviour === 'stop-fails') throw new Error('could not disconnect'); + clearInterval(connection); + if (failure === 'stop-fails') throw new Error('could not disconnect'); }, }; @@ -21,23 +26,46 @@ const system = createSystem([postgres]); for (const event of [...Object.values(SystemEvent), ...Object.values(ComponentEvent)]) system.on(event, () => console.log(event)); -const unbind = system.exitOn('shutdown'); +const unbind = system.exitOn('shutdown', 'SIGTERM'); if (behaviour === 'unbound') unbind(); +const whileStarting = { + 'exit-while-starting': exit, +}; + const afterStart = { - clean: signal, - 'stop-fails': signal, + signal, + terminate, unbound: stopAndSurvive, 'component-fails': failThenRestart, + exit, + 'exit-after-setting-exit-code': setExitCodeThenExit, }; -system.start().then(afterStart[behaviour]); +const starting = system.start(); + +whileStarting[behaviour]?.(); + +starting.then(afterStart[behaviour]); function signal() { process.emit('shutdown'); } +function terminate() { + process.kill(process.pid, 'SIGTERM'); +} + +function exit() { + system.exit(exitCode); +} + +function setExitCodeThenExit() { + process.exitCode = 5; + system.exit(); +} + async function stopAndSurvive() { process.emit('shutdown'); await system.stop(); diff --git a/test/steps/exiting-library.js b/test/steps/exiting-library.js index 54bf869..b115e9b 100644 --- a/test/steps/exiting-library.js +++ b/test/steps/exiting-library.js @@ -3,6 +3,8 @@ const { execFile } = require('node:child_process'); const path = require('node:path'); const { promisify } = require('node:util'); const Yadda = require('yadda'); +const { createSystem } = require('../../lib'); +const { parseValue } = require('../lib/definition-notation'); const { Dictionary, @@ -12,31 +14,52 @@ const { const run = promisify(execFile); -const program = path.join(__dirname, '..', 'lib', 'exit-on-program.js'); +const program = path.join(__dirname, '..', 'lib', 'exiting-program.js'); -const dictionary = new Dictionary().define('code', /(\d+)/, async (digits) => Number(digits)).define('event', /(\w+)/); +const dictionary = new Dictionary() + .define('code', /(\d+)/, async (digits) => Number(digits)) + .define('codeDescription', /(no code|the code \d+)/, async (phrase) => phrase.replace(/\D/g, '') || 'none') + .define('event', /(\w+)/) + .define('value', /(-?\d+(?:\.\d+)?|"[^"]*"|true|false)/, async (token) => parseValue(token)) + .define('message', /"([^"]+)"/); module.exports = English.localise(new ContextParamLibrary(dictionary)) .given('a program whose system exits on a process event', ({ world }) => { - world.behaviour = 'clean'; + world.program = { behaviour: 'signal', failure: 'none', code: 'none' }; + }) + .given('a program whose system exits on a termination signal', ({ world }) => { + world.program = { behaviour: 'terminate', failure: 'none', code: 'none' }; + }) + .given('a program whose system exits itself with $codeDescription', ({ world }, code) => { + world.program = { behaviour: 'exit', failure: 'none', code }; + }) + .given('a program whose system exits itself with $codeDescription while starting', ({ world }, code) => { + world.program = { behaviour: 'exit-while-starting', failure: 'none', code }; + }) + .given('the program sets process.exitCode to 5 before exiting', ({ world }) => { + world.program.behaviour = 'exit-after-setting-exit-code'; }) .given("the program's postgres fails to start", ({ world }) => { - world.behaviour = 'start-fails'; + world.program.failure = 'start-fails'; }) .given("the program's postgres fails to stop", ({ world }) => { - world.behaviour = 'stop-fails'; + world.program.failure = 'stop-fails'; }) .given("the program's postgres fails after starting, and the program restarts the system", ({ world }) => { - world.behaviour = 'component-fails'; + world.program.behaviour = 'component-fails'; }) .given('the program unbinds the exit before stopping', ({ world }) => { - world.behaviour = 'unbound'; + world.program.behaviour = 'unbound'; }) .when('the program runs', async ({ world }) => { - world.exit = await run(process.execPath, [program, world.behaviour]).then(exited(0), (error) => + const { behaviour, failure, code } = world.program; + world.exit = await run(process.execPath, [program, behaviour, failure, code]).then(exited(0), (error) => exited(error.code)(error), ); }) + .when('the system is asked to exit with the code $value', ({ world }, code) => { + world.error = errorFrom(() => createSystem(world.definition).exit(code)); + }) .then('the program exits with code $code', ({ world }, code) => { eq(world.exit.code, code, `the program printed:\n${world.exit.stdout}${world.exit.stderr}`); }) @@ -47,3 +70,11 @@ module.exports = English.localise(new ContextParamLibrary(dictionary)) function exited(code) { return ({ stdout, stderr }) => ({ code, stdout, stderr }); } + +function errorFrom(fn) { + try { + fn(); + } catch (error) { + return error; + } +} diff --git a/test/steps/readme-library.js b/test/steps/readme-library.js index 12ebb11..1446984 100644 --- a/test/steps/readme-library.js +++ b/test/steps/readme-library.js @@ -34,7 +34,7 @@ module.exports = English.localise(new ContextParamLibrary(dictionary)) deq(packageJson().dependencies, undefined); deq(packageJson().peerDependencies, undefined); }) - .then('the library calls process.exit only where exitOn is implemented', () => { + .then('the library calls process.exit only where exit is implemented', () => { const callers = librarySources().filter((source) => source.text.includes('process.exit')); deq( callers.map((source) => source.file), diff --git a/test/types/create-system.types.ts b/test/types/create-system.types.ts index 4f999ea..a36a4ce 100644 --- a/test/types/create-system.types.ts +++ b/test/types/create-system.types.ts @@ -16,6 +16,8 @@ const system: System = createSystem([]); const components: Promise = system.start(); const stopped: Promise = system.stop(); const restarted: Promise = system.restart(); +const exited: Promise = system.exit(1); +const exitedQuietly: Promise = system.exit(); const unbind: () => void = system.stopOn('SIGTERM', 'SIGINT'); const unbindExit: () => void = system.exitOn('SIGTERM', 'SIGINT'); @@ -124,6 +126,9 @@ const startExpectingTheSignalFirst: System = createSystem([{ name: 'postgres', a // @ts-expect-error the events are given as arguments, not wrapped in options system.stopOn({ events: ['SIGTERM'] }); +// @ts-expect-error the exit code is a number, not a signal +system.exit('SIGTERM'); + // @ts-expect-error abortable is a flag, not a description const abortableWhichIsNotABoolean: System = createSystem([{ name: 'postgres', abortable: 'yes' }]);