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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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');
Expand All @@ -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

Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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. |
Expand Down
15 changes: 10 additions & 5 deletions lib/exit.js
Original file line number Diff line number Diff line change
@@ -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 };
1 change: 1 addition & 0 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export interface System<C = Components> extends EventEmitter<SystemEvents> {
start(): Promise<C>;
stop(): Promise<void>;
restart(): Promise<C>;
exit(code?: number): Promise<never>;
stopOn(...signals: string[]): () => void;
exitOn(...signals: string[]): () => void;
}
Expand Down
10 changes: 8 additions & 2 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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) {
Expand All @@ -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() {}
Expand Down
6 changes: 6 additions & 0 deletions lib/validate-exit-code.js
Original file line number Diff line number Diff line change
@@ -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 };
88 changes: 81 additions & 7 deletions test/features/exiting.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 |
4 changes: 2 additions & 2 deletions test/features/readme-conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
44 changes: 36 additions & 8 deletions test/lib/exit-on-program.js → test/lib/exiting-program.js
Original file line number Diff line number Diff line change
@@ -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');
},
};

Expand All @@ -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();
Expand Down
Loading
Loading