roster-server 2.4.13 → 2.4.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/index.js +17 -0
- package/package.json +1 -1
- package/skills/roster-server/SKILL.md +1 -1
- package/test/signals.test.js +183 -0
package/README.md
CHANGED
|
@@ -149,7 +149,18 @@ Register `virtualServer.onClose(fn)` in a factory for timers, databases, queues,
|
|
|
149
149
|
|
|
150
150
|
`closeTimeoutMs` (default `30000`) bounds the whole operation. At the deadline Roster destroys remaining owned connections and active routed responses, attempts cleanup hooks, and rejects with a timeout. It cannot forcibly interrupt an application Promise or an ACME operation already in progress.
|
|
151
151
|
|
|
152
|
-
Closing is **idempotent and terminal**: repeated calls return the same Promise. Create a new instance after closure or failed initialization/startup.
|
|
152
|
+
Closing is **idempotent and terminal**: repeated calls return the same Promise. Create a new instance after closure or failed initialization/startup.
|
|
153
|
+
|
|
154
|
+
For automatic shutdown on `SIGINT` or `SIGTERM`, explicitly enable `handleSignals`:
|
|
155
|
+
|
|
156
|
+
```javascript
|
|
157
|
+
const roster = new Roster({ handleSignals: true });
|
|
158
|
+
await roster.start();
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The option defaults to `false`. Listeners are installed once when `init()` or `start()` begins and removed when closure completes, including failed startup or cleanup. Signals call `roster.close()`; repeated signals during closure do not repeat cleanup. Existing application signal listeners remain installed. Shutdown failures are logged and set `process.exitCode = 1`.
|
|
162
|
+
|
|
163
|
+
Roster does not force process termination or close caller-owned servers. The process exits naturally when its remaining work finishes; other resources or cluster workers still require application coordination. If the application already coordinates shutdown, leave `handleSignals` disabled and connect Roster to that flow:
|
|
153
164
|
|
|
154
165
|
```javascript
|
|
155
166
|
async function shutdown() {
|
|
@@ -233,6 +244,7 @@ Pass operational settings through the constructor, using the consuming applicati
|
|
|
233
244
|
| `autoCertificates` | `true` | Certificate runtime during `init()`; disable for serving-only workers. |
|
|
234
245
|
| `certificateRenewIntervalMs` | `43200000` (12h) | Roster renewal check interval; minimum `60000`. |
|
|
235
246
|
| `closeTimeoutMs` | `30000` | Positive finite total shutdown deadline. |
|
|
247
|
+
| `handleSignals` | `false` | Close this instance on `SIGINT`/`SIGTERM`; does not force process exit. |
|
|
236
248
|
| `tlsMinVersion`, `tlsMaxVersion` | `TLSv1.2`, `TLSv1.3` | Protocol limits for created HTTPS servers. |
|
|
237
249
|
| `skipLocalCheck` | `true` | Skips Greenlock dry-run/local challenge checks. |
|
|
238
250
|
| `dnsChallenge` | CLI wrapper | DNS-01 options or `false`; see [certificates](#certificates-and-dns). |
|
package/index.js
CHANGED
|
@@ -9,6 +9,7 @@ const Greenlock = require('./vendor/greenlock-express/greenlock-express.js');
|
|
|
9
9
|
const GreenlockShim = require('./vendor/greenlock-express/greenlock-shim.js');
|
|
10
10
|
const { resolveSiteApp } = require('./lib/resolve-site-app.js');
|
|
11
11
|
const log = require('lemonlog')('roster');
|
|
12
|
+
const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM'];
|
|
12
13
|
|
|
13
14
|
function requestError(error, res) {
|
|
14
15
|
log.error('Request handler failed:', error?.message || error);
|
|
@@ -287,6 +288,8 @@ class Roster {
|
|
|
287
288
|
this._initPromise = null;
|
|
288
289
|
this._startPromise = null;
|
|
289
290
|
this._closePromise = null;
|
|
291
|
+
this.handleSignals = parseBooleanFlag(options.handleSignals, false);
|
|
292
|
+
this._signalHandler = null;
|
|
290
293
|
this._ownedServers = new Set();
|
|
291
294
|
this._sockets = new Set();
|
|
292
295
|
this._upgradedSockets = new Set();
|
|
@@ -1097,6 +1100,16 @@ class Roster {
|
|
|
1097
1100
|
init() {
|
|
1098
1101
|
if (this._closing) return Promise.reject(new Error('Roster is closing or closed'));
|
|
1099
1102
|
if (this._initPromise) return this._initPromise;
|
|
1103
|
+
if (this.handleSignals) {
|
|
1104
|
+
this._signalHandler = () => {
|
|
1105
|
+
if (this._closing) return;
|
|
1106
|
+
this.close().catch(error => {
|
|
1107
|
+
log.error('Signal shutdown failed:', error.message);
|
|
1108
|
+
process.exitCode = 1;
|
|
1109
|
+
});
|
|
1110
|
+
};
|
|
1111
|
+
for (const signal of SHUTDOWN_SIGNALS) process.on(signal, this._signalHandler);
|
|
1112
|
+
}
|
|
1100
1113
|
this._initTask = this._initialize();
|
|
1101
1114
|
this._initPromise = this._initTask.catch(async error => {
|
|
1102
1115
|
if (!this._closing) {
|
|
@@ -1260,6 +1273,10 @@ class Roster {
|
|
|
1260
1273
|
}));
|
|
1261
1274
|
} finally {
|
|
1262
1275
|
clearTimeout(timer);
|
|
1276
|
+
if (this._signalHandler) {
|
|
1277
|
+
for (const signal of SHUTDOWN_SIGNALS) process.removeListener(signal, this._signalHandler);
|
|
1278
|
+
this._signalHandler = null;
|
|
1279
|
+
}
|
|
1263
1280
|
for (const [server, handlers] of this._attachments) {
|
|
1264
1281
|
server.removeListener('request', handlers.request);
|
|
1265
1282
|
server.removeListener('upgrade', handlers.upgrade);
|
package/package.json
CHANGED
|
@@ -56,7 +56,7 @@ A virtual request listener owns its request even before it ends the response. Ro
|
|
|
56
56
|
|
|
57
57
|
## Shutdown
|
|
58
58
|
|
|
59
|
-
- Connect `await roster.close()` to the application's existing shutdown flow.
|
|
59
|
+
- Connect `await roster.close()` to the application's existing shutdown flow, or explicitly set `handleSignals: true` for automatic `SIGINT`/`SIGTERM` cleanup. It defaults to `false`; listeners are installed during `init()`/`start()` and removed after closure, including failure. Repeated signals during closure do not repeat hooks. Existing signal listeners are preserved; shutdown errors are logged and set `process.exitCode = 1`. Roster never forces process termination.
|
|
60
60
|
- Register `virtualServer.onClose(fn)` for each site's timers, databases, and other resources. Return/await its cleanup Promise; wrap callback APIs when needed.
|
|
61
61
|
- Roster signals virtual `close` at shutdown start to release integrations such as long polling, closes routed WebSockets, drains HTTP, then runs cleanup hooks concurrently. Put dependent cleanup steps in one hook.
|
|
62
62
|
- Hooks run once. Failures are aggregated after other hooks are attempted. `closeTimeoutMs` bounds the complete operation; timeout cannot cancel arbitrary user Promises or in-flight ACME work.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { spawn } = require('node:child_process');
|
|
6
|
+
const { once } = require('node:events');
|
|
7
|
+
|
|
8
|
+
async function runChild(t, body, onMessage = () => {}) {
|
|
9
|
+
const script = `
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const http = require('node:http');
|
|
12
|
+
const Roster = require(${JSON.stringify(require.resolve('../index.js'))});
|
|
13
|
+
const options = { local: true, wwwPath: '/tmp/roster-signals-' + process.pid };
|
|
14
|
+
const signals = ['SIGINT', 'SIGTERM'];
|
|
15
|
+
(async () => { ${body} })().catch(error => {
|
|
16
|
+
console.error(error);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
}).finally(() => process.disconnect());
|
|
19
|
+
`;
|
|
20
|
+
const child = spawn(process.execPath, ['-e', script], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] });
|
|
21
|
+
const messages = [];
|
|
22
|
+
let stderr = '';
|
|
23
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
24
|
+
child.on('message', message => {
|
|
25
|
+
messages.push(message);
|
|
26
|
+
onMessage(message, child);
|
|
27
|
+
});
|
|
28
|
+
t.after(() => {
|
|
29
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
30
|
+
});
|
|
31
|
+
const [code, signal] = await once(child, 'close');
|
|
32
|
+
return { code, signal, messages, stderr };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertSuccess(result) {
|
|
36
|
+
assert.equal(result.signal, null, result.stderr);
|
|
37
|
+
assert.equal(result.code, 0, result.stderr);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it('does not install signal listeners unless explicitly enabled', { timeout: 10000 }, async t => {
|
|
41
|
+
assertSuccess(await runChild(t, `
|
|
42
|
+
const before = signals.map(signal => process.listeners(signal));
|
|
43
|
+
for (const handleSignals of [undefined, false]) {
|
|
44
|
+
const roster = new Roster({ ...options, handleSignals });
|
|
45
|
+
await roster.init();
|
|
46
|
+
signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
|
|
47
|
+
await roster.close();
|
|
48
|
+
signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
|
|
49
|
+
}
|
|
50
|
+
`));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('installs listeners once at initialization and removes only the closing instance listeners', { timeout: 10000 }, async t => {
|
|
54
|
+
assertSuccess(await runChild(t, `
|
|
55
|
+
const existing = () => {};
|
|
56
|
+
signals.forEach(signal => process.on(signal, existing));
|
|
57
|
+
const before = signals.map(signal => process.listeners(signal));
|
|
58
|
+
const first = new Roster({ ...options, handleSignals: true });
|
|
59
|
+
const second = new Roster({ ...options, handleSignals: true });
|
|
60
|
+
signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
|
|
61
|
+
await Promise.all([first.init(), first.init(), second.init()]);
|
|
62
|
+
signals.forEach((signal, i) => assert.equal(process.listenerCount(signal), before[i].length + 2));
|
|
63
|
+
await Promise.all([first.close(), first.close()]);
|
|
64
|
+
signals.forEach((signal, i) => {
|
|
65
|
+
assert.equal(process.listenerCount(signal), before[i].length + 1);
|
|
66
|
+
assert.ok(process.listeners(signal).includes(existing));
|
|
67
|
+
});
|
|
68
|
+
await second.close();
|
|
69
|
+
signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
|
|
70
|
+
`));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('removes signal listeners after initialization and cleanup both fail', { timeout: 10000 }, async t => {
|
|
74
|
+
assertSuccess(await runChild(t, `
|
|
75
|
+
const before = signals.map(signal => process.listeners(signal));
|
|
76
|
+
const roster = new Roster({ ...options, handleSignals: true });
|
|
77
|
+
roster.register('example.com', virtual => {
|
|
78
|
+
virtual.onClose(() => { throw new Error('cleanup failed'); });
|
|
79
|
+
throw new Error('factory failed');
|
|
80
|
+
});
|
|
81
|
+
await assert.rejects(roster.init(), AggregateError);
|
|
82
|
+
signals.forEach((signal, i) => assert.deepEqual(process.listeners(signal), before[i]));
|
|
83
|
+
`));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
87
|
+
it(`drains HTTP and runs hooks once on real ${signal}, including repeated signals`, { timeout: 10000 }, async t => {
|
|
88
|
+
const result = await runChild(t, `
|
|
89
|
+
const roster = new Roster({ ...options, handleSignals: true });
|
|
90
|
+
roster.assignPortToDomain = () => 0;
|
|
91
|
+
let cleaned = 0;
|
|
92
|
+
let finished = false;
|
|
93
|
+
let response;
|
|
94
|
+
const repeated = new Promise(resolve => process.once(${JSON.stringify(signal === 'SIGINT' ? 'SIGTERM' : 'SIGINT')}, resolve));
|
|
95
|
+
roster.register('example.com', virtual => {
|
|
96
|
+
virtual.on('close', () => {
|
|
97
|
+
process.send('closing');
|
|
98
|
+
setImmediate(() => { finished = true; response.end(' drained'); });
|
|
99
|
+
});
|
|
100
|
+
virtual.onClose(async () => {
|
|
101
|
+
assert.equal(finished, true);
|
|
102
|
+
await repeated;
|
|
103
|
+
cleaned++;
|
|
104
|
+
});
|
|
105
|
+
return (req, res) => {
|
|
106
|
+
response = res;
|
|
107
|
+
res.write('active');
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
await Promise.all([roster.start(), roster.start()]);
|
|
111
|
+
const server = roster.portServers[0];
|
|
112
|
+
const body = await new Promise((resolve, reject) => {
|
|
113
|
+
http.get({ hostname: 'localhost', port: server.address().port, agent: false }, res => {
|
|
114
|
+
let body = '';
|
|
115
|
+
res.once('data', () => process.send('ready'));
|
|
116
|
+
res.on('data', chunk => { body += chunk; });
|
|
117
|
+
res.on('end', () => resolve(body));
|
|
118
|
+
res.on('error', reject);
|
|
119
|
+
}).on('error', reject);
|
|
120
|
+
});
|
|
121
|
+
await roster.close();
|
|
122
|
+
assert.equal(body, 'active drained');
|
|
123
|
+
assert.equal(cleaned, 1);
|
|
124
|
+
assert.equal(server.listening, false);
|
|
125
|
+
signals.forEach(signal => assert.equal(process.listenerCount(signal), 0));
|
|
126
|
+
process.send('complete');
|
|
127
|
+
`, (message, child) => {
|
|
128
|
+
if (message === 'ready') child.kill(signal);
|
|
129
|
+
if (message === 'closing') child.kill(signal === 'SIGINT' ? 'SIGTERM' : 'SIGINT');
|
|
130
|
+
});
|
|
131
|
+
assertSuccess(result);
|
|
132
|
+
assert.deepEqual(result.messages, ['ready', 'closing', 'complete']);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
it('reports signal-triggered cleanup failure with exit code 1 and removes listeners', { timeout: 10000 }, async t => {
|
|
137
|
+
const result = await runChild(t, `
|
|
138
|
+
const roster = new Roster({ ...options, handleSignals: true });
|
|
139
|
+
roster.assignPortToDomain = () => 0;
|
|
140
|
+
let entered;
|
|
141
|
+
const closing = new Promise(resolve => { entered = resolve; });
|
|
142
|
+
roster.register('example.com', virtual => {
|
|
143
|
+
virtual.on('close', entered);
|
|
144
|
+
virtual.onClose(() => { throw new Error('cleanup failed'); });
|
|
145
|
+
return (req, res) => res.end();
|
|
146
|
+
});
|
|
147
|
+
await roster.start();
|
|
148
|
+
process.send('ready');
|
|
149
|
+
await closing;
|
|
150
|
+
await assert.rejects(roster.close(), AggregateError);
|
|
151
|
+
signals.forEach(signal => assert.equal(process.listenerCount(signal), 0));
|
|
152
|
+
assert.equal(process.exitCode, 1);
|
|
153
|
+
process.send('complete');
|
|
154
|
+
`, (message, child) => { if (message === 'ready') child.kill('SIGTERM'); });
|
|
155
|
+
assert.equal(result.signal, null, result.stderr);
|
|
156
|
+
assert.equal(result.code, 1, result.stderr);
|
|
157
|
+
assert.deepEqual(result.messages, ['ready', 'complete']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('handles signals during initialization and cleans up its listeners', { timeout: 10000 }, async t => {
|
|
161
|
+
const result = await runChild(t, `
|
|
162
|
+
const roster = new Roster({ ...options, handleSignals: true });
|
|
163
|
+
let release;
|
|
164
|
+
roster.loadSites = () => new Promise(resolve => { release = resolve; });
|
|
165
|
+
let called = false;
|
|
166
|
+
roster.register('example.com', () => { called = true; });
|
|
167
|
+
const initializing = assert.rejects(roster.init(), /closing or closed/);
|
|
168
|
+
process.once('message', () => release());
|
|
169
|
+
const close = roster.close.bind(roster);
|
|
170
|
+
roster.close = () => { const result = close(); process.send('closing'); return result; };
|
|
171
|
+
process.send('ready');
|
|
172
|
+
await initializing;
|
|
173
|
+
await close();
|
|
174
|
+
assert.equal(called, false);
|
|
175
|
+
signals.forEach(signal => assert.equal(process.listenerCount(signal), 0));
|
|
176
|
+
process.send('complete');
|
|
177
|
+
`, (message, child) => {
|
|
178
|
+
if (message === 'ready') child.kill('SIGTERM');
|
|
179
|
+
if (message === 'closing') child.send('release');
|
|
180
|
+
});
|
|
181
|
+
assertSuccess(result);
|
|
182
|
+
assert.deepEqual(result.messages, ['ready', 'closing', 'complete']);
|
|
183
|
+
});
|