From 6a24a70f732ccbccde7af81a4c783b31a2e05a27 Mon Sep 17 00:00:00 2001 From: Stephen Cresswell <229672+cressie176@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:57:40 +0100 Subject: [PATCH] Exit without stopping when there is nothing to stop Calling exit() from the rejection of a failed start announced a second stop pair, so an application logging system_stop_succeeded reported one shutdown twice. The stop which follows a failed start had already reset the system, and exit() then ran a fresh stop which skipped every component and announced itself regardless. Both stops were doing what they were told, so one of them had to stay quiet. Making stop() silent on a stopped system would overrule the rule that an operation with nothing to do is announced like any other, which the system events feature asserts and which lets a listener on system_stop_succeeded see every stop however it was triggered. So exit() now checks whether there is anything to stop: no start in flight, no stop in flight or left failed, and no component started. When there is not, the process exits at once and no stop is announced. A stop which failed leaves components started, so an exit after it still retries the stop and reports its outcome in the code. The exiting scenarios gain a step which compares the whole event trace the program printed, since the existing step only checked that an event appeared and could not see a duplicate. Closes #22 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 ++++ README.md | 6 +++ lib/index.js | 11 ++++- test/features/exiting.md | 75 ++++++++++++++++++++++++++++++++++- test/lib/exiting-program.js | 26 ++++++++++-- test/steps/exiting-library.js | 23 ++++++++++- 6 files changed, 143 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f22f878..a28c175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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] + +### Fixed + +- `exit()` on a system with nothing to stop, because it never started, has already stopped, or its + start failed, no longer announces an empty stop before exiting. Exiting from the rejection of a + failed `start()` now reports the one stop the failure caused, rather than two. + ## [0.2.1] ### Added diff --git a/README.md b/README.md index d707b44..cd3980d 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,12 @@ A component which exceeds its own limit is treated as a failure. Its failed even system.exit(1); ``` +If there is nothing to stop, because the system never started, has already stopped, or its start failed and the stop which follows has finished, `exit` does not stop it again: the process exits without announcing a stop. So exiting from the rejection of `start()` reports the one stop the failure caused: + +```ts +await system.start().catch(() => system.exit(1)); +``` + ## Signals `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. diff --git a/lib/index.js b/lib/index.js index cfadbba..cf27d09 100644 --- a/lib/index.js +++ b/lib/index.js @@ -132,7 +132,16 @@ function createSystem(definition, options) { function exit(code) { validateExitCode(code); - return exitWhenStopped(stop(), code); + return exitWhenStopped(stopUnlessStopped(), code); + } + + function stopUnlessStopped() { + if (isStopped()) return Promise.resolve(); + return stop(); + } + + function isStopped() { + return !starting && !stopping && !hasStarted(states, definition); } function stopOn(...signals) { diff --git a/test/features/exiting.md b/test/features/exiting.md index 355ba7f..50ff865 100644 --- a/test/features/exiting.md +++ b/test/features/exiting.md @@ -7,7 +7,9 @@ lost. With no code the process exits with process.exitCode, which is 0 unless th 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 +with code 1, as these programs let it. A system with nothing to stop, because it never started, has +stopped, or its start failed and the stop which follows has finished, is not stopped again: exit() +ends the process without announcing a stop. 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 @@ -95,6 +97,77 @@ these scenarios run a small program in a child process and read the code it exit - Then the program exits with code 3 - And the program announced system_stop_succeeded before exiting +## Rule: An exit with nothing to stop does not stop again + +### Scenario: An exit from the rejection of a failed start + +- Given a program whose system exits itself with the code 1 when the start fails +- And the program's postgres fails to start +- When the program runs +- Then the program exits with code 1 +- And before exiting the program announced: + + | event | + |---------------------------| + | system_start_initiated | + | component_start_initiated | + | component_start_failed | + | system_start_failed | + | system_stop_initiated | + | component_stop_skipped | + | system_stop_succeeded | + +### Scenario: An exit after the system has stopped + +- Given a program whose system exits itself with the code 3 after stopping +- When the program runs +- Then the program exits with code 3 +- And before exiting the program announced: + + | event | + |---------------------------| + | system_start_initiated | + | component_start_initiated | + | component_start_succeeded | + | system_start_succeeded | + | system_stop_initiated | + | component_stop_initiated | + | component_stop_succeeded | + | system_stop_succeeded | + +### Scenario: An exit of a system which was never started + +- Given a program whose system exits itself with the code 3 without starting +- When the program runs +- Then the program exits with code 3 +- And before exiting the program announced: + + | event | + |-------| + +### Scenario: An exit after a stop which failed stops again + +- Given a program whose system exits itself with the code 3 after stopping +- And the program's postgres fails to stop +- When the program runs +- Then the program exits with code 3 +- And before exiting the program announced: + + | event | + |---------------------------| + | system_start_initiated | + | component_start_initiated | + | component_start_succeeded | + | system_start_succeeded | + | system_stop_initiated | + | component_stop_initiated | + | component_stop_failed | + | system_stop_failed | + | system_stop_initiated | + | component_stop_initiated | + | component_stop_failed | + | system_stop_failed | + ## Rule: A failed stop exits with the caller's non-zero code, or 1 ### Scenario: An exit with [code description] after a failed stop diff --git a/test/lib/exiting-program.js b/test/lib/exiting-program.js index a26084c..b9ef7ec 100644 --- a/test/lib/exiting-program.js +++ b/test/lib/exiting-program.js @@ -30,6 +30,10 @@ const unbind = system.exitOn('shutdown', 'SIGTERM'); if (behaviour === 'unbound') unbind(); +const insteadOfStarting = { + 'exit-without-starting': exit, +}; + const whileStarting = { 'exit-while-starting': exit, }; @@ -41,13 +45,22 @@ const afterStart = { 'component-fails': failThenRestart, exit, 'exit-after-setting-exit-code': setExitCodeThenExit, + 'exit-after-stopping': stopThenExit, +}; + +const afterFailedStart = { + 'exit-when-start-fails': exit, }; -const starting = system.start(); +const starting = (insteadOfStarting[behaviour] ?? startSystem)(); whileStarting[behaviour]?.(); -starting.then(afterStart[behaviour]); +starting.then(afterStart[behaviour], afterFailedStart[behaviour]); + +function startSystem() { + return system.start(); +} function signal() { process.emit('shutdown'); @@ -58,7 +71,7 @@ function terminate() { } function exit() { - system.exit(exitCode); + return system.exit(exitCode); } function setExitCodeThenExit() { @@ -66,6 +79,13 @@ function setExitCodeThenExit() { system.exit(); } +async function stopThenExit() { + await system.stop().catch(alreadyAnnounced); + system.exit(exitCode); +} + +function alreadyAnnounced() {} + async function stopAndSurvive() { process.emit('shutdown'); await system.stop(); diff --git a/test/steps/exiting-library.js b/test/steps/exiting-library.js index b115e9b..b6e471a 100644 --- a/test/steps/exiting-library.js +++ b/test/steps/exiting-library.js @@ -1,10 +1,11 @@ -const { equal: eq, ok } = require('node:assert/strict'); +const { deepEqual: deq, equal: eq, ok } = require('node:assert/strict'); 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 { parseStepDataTable } = require('../lib/step-data-table'); const { Dictionary, @@ -20,6 +21,7 @@ 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('events', /([\s\S]+)/, async (text) => parseStepDataTable(text)) .define('value', /(-?\d+(?:\.\d+)?|"[^"]*"|true|false)/, async (token) => parseValue(token)) .define('message', /"([^"]+)"/); @@ -36,6 +38,15 @@ module.exports = English.localise(new ContextParamLibrary(dictionary)) .given('a program whose system exits itself with $codeDescription while starting', ({ world }, code) => { world.program = { behaviour: 'exit-while-starting', failure: 'none', code }; }) + .given('a program whose system exits itself with $codeDescription when the start fails', ({ world }, code) => { + world.program = { behaviour: 'exit-when-start-fails', failure: 'none', code }; + }) + .given('a program whose system exits itself with $codeDescription after stopping', ({ world }, code) => { + world.program = { behaviour: 'exit-after-stopping', failure: 'none', code }; + }) + .given('a program whose system exits itself with $codeDescription without starting', ({ world }, code) => { + world.program = { behaviour: 'exit-without-starting', failure: 'none', code }; + }) .given('the program sets process.exitCode to 5 before exiting', ({ world }) => { world.program.behaviour = 'exit-after-setting-exit-code'; }) @@ -65,8 +76,18 @@ module.exports = English.localise(new ContextParamLibrary(dictionary)) }) .then('the program announced $event before exiting', ({ world }, event) => { ok(world.exit.stdout.split('\n').includes(event), `the program announced:\n${world.exit.stdout}`); + }) + .then('before exiting the program announced:\n$events', ({ world }, expected) => { + deq(traceOf(world.exit.stdout), expected); }); +function traceOf(stdout) { + return stdout + .split('\n') + .filter(Boolean) + .map((event) => ({ event })); +} + function exited(code) { return ({ stdout, stderr }) => ({ code, stdout, stderr }); }