Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
75 changes: 74 additions & 1 deletion test/features/exiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions test/lib/exiting-program.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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');
Expand All @@ -58,14 +71,21 @@ function terminate() {
}

function exit() {
system.exit(exitCode);
return system.exit(exitCode);
}

function setExitCodeThenExit() {
process.exitCode = 5;
system.exit();
}

async function stopThenExit() {
await system.stop().catch(alreadyAnnounced);
system.exit(exitCode);
}

function alreadyAnnounced() {}

async function stopAndSurvive() {
process.emit('shutdown');
await system.stop();
Expand Down
23 changes: 22 additions & 1 deletion test/steps/exiting-library.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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', /"([^"]+)"/);

Expand All @@ -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';
})
Expand Down Expand Up @@ -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 });
}
Expand Down
Loading