openvibers/openvibe.network Commits
Move Network's graceful shutdown onto openvibe-sdk/service (#21)
Changes
@@@ -22,7 +22,7 @@"jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1","pg": "^8.23.0","uuid": "^11.1.0",@@@ -1572,9 +1572,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.25.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",-- "integrity": "sha512-rbBj0NXUFNEOTDEtXYS5mD2uO5VJw6ZGC9zME1nbcEqrATMWbKeOt2cO02T/7QDP2iR+lJP97QhYTEF8rlyMog==",++ "version": "0.26.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",++ "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==","license": "MIT","engines": {"node": ">=20"@@@ -31,7 +31,7 @@"jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1","pg": "^8.23.0","uuid": "^11.1.0",ddeleted file mode 100644@@@ -1,117 +0,0 @@--'use strict';--// ═══════════════════════════════════════════════════════════════--// Graceful stop for OpenVibe.Network (roadmap WS-P lifecycle; openvibe-contracts--// manifests/services/network.json → lifecycle.shutdown). systemd sends SIGTERM on a restart or a deploy;--// SIGINT does the same by hand. On the first signal:--//--// 1. `stop` steps run: timers, pollers and relays stop taking new work (nothing new starts);--// 2. the HTTP server stops taking connections, idle keep-alive connections are closed, requests in--// flight are answered with `Connection: close`, and open event streams (text/event-stream) are--// closed — EventSource reconnects on its own;--// 3. the requests in flight finish, for at most `drainMs`; whatever is still open then is cut;--// 4. `close` steps run: the outbox relay settles, analytics flush, the database closes;--// 5. the process exits 0.--//--// A hard `deadlineMs` bounds the whole stop (the manifest's deadlineSeconds): past it the process--// exits 1. A second signal while stopping changes nothing.--// ═══════════════════════════════════════════════════════════════----const DRAIN_MS = 4000;--const DEADLINE_MS = 5000;----/**-- * @param {object} o-- * @param {string} o.name log prefix, e.g. 'Network'-- * @param {import('http').Server} o.server-- * @param {Array<Function>} [o.stop] run first, in order (sync or async; a failure is logged)-- * @param {Array<Function>} [o.close] run after the HTTP drain, in order-- * @param {number} [o.drainMs] how long requests in flight may take (default 4000)-- * @param {number} [o.deadlineMs] the whole stop, else exit 1 (default 5000)-- * @param {boolean} [o.signals] false: do not install SIGTERM/SIGINT handlers (tests)-- * @param {(code:number) => void} [o.exit] default process.exit-- * @returns {{ stop(signal?: string): Promise<number>, stopping(): boolean }}-- */--function gracefulStop(o) {-- const { name, server } = o;-- const drainMs = o.drainMs == null ? DRAIN_MS : o.drainMs;-- const deadlineMs = o.deadlineMs == null ? DEADLINE_MS : o.deadlineMs;-- const log = o.log || console;-- const exit = o.exit || ((code) => process.exit(code));-- let started = null;---- // Every response in flight, so the stop can mark them Connection: close and close event streams.-- const inflight = new Set();-- server.prependListener('request', (req, res) => {-- if (started && !res.headersSent) res.setHeader('Connection', 'close');-- inflight.add(res);-- res.on('close', () => inflight.delete(res));-- });---- async function step(fn, phase) {-- try { await fn(); } catch (err) { log.warn(`[${name}] stop: ${phase} step failed: ${err && err.message}`); }-- }---- function isEventStream(res) {-- const ct = String(res.getHeader('content-type') || '');-- return /text\/event-stream/i.test(ct) || (typeof res._header === 'string' && /content-type:\s*text\/event-stream/i.test(res._header));-- }---- function drain() {-- return new Promise((resolve) => {-- let done = false;-- const finish = (cut) => {-- if (done) return;-- done = true;-- clearInterval(sweep);-- clearTimeout(timer);-- resolve(cut);-- };-- server.close(() => finish(0));-- for (const res of inflight) {-- if (isEventStream(res)) res.destroy();-- else if (!res.headersSent) res.setHeader('Connection', 'close');-- }-- // A connection whose response just finished goes idle: close it now rather than after keepAliveTimeout.-- const idle = () => { if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections(); };-- idle();-- const sweep = setInterval(idle, 50);-- const timer = setTimeout(() => {-- const cut = inflight.size;-- if (typeof server.closeAllConnections === 'function') server.closeAllConnections();-- finish(cut || 1);-- }, drainMs);-- });-- }---- function stop(signal = 'stop') {-- if (started) return started;-- started = (async () => {-- const t0 = Date.now();-- log.log(`[${name}] ${signal}: stopping (requests in flight get ${drainMs} ms)`);-- const hard = setTimeout(() => { log.error(`[${name}] stop took longer than ${deadlineMs} ms: exiting 1`); exit(1); }, deadlineMs);-- if (hard.unref) hard.unref();-- for (const fn of o.stop || []) await step(fn, 'stop');-- const cut = await drain();-- if (cut) log.warn(`[${name}] ${cut} request(s) still open after ${drainMs} ms were cut`);-- for (const fn of o.close || []) await step(fn, 'close');-- clearTimeout(hard);-- log.log(`[${name}] stopped in ${Date.now() - t0} ms`);-- exit(0);-- return 0;-- })();-- return started;-- }---- if (o.signals !== false) {-- process.on('SIGTERM', () => { stop('SIGTERM'); });-- process.on('SIGINT', () => { stop('SIGINT'); });-- }-- return { stop, stopping: () => !!started };--}----/** Await `promise`, but no longer than `ms` (a best-effort step inside the deadline). */--function within(ms, promise) {-- return Promise.race([Promise.resolve(promise).catch(() => {}), new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); })]);--}----module.exports = { gracefulStop, within, DRAIN_MS, DEADLINE_MS };@@@ -946,7 +946,7 @@ const server = app.listen(config.port, config.host, () => {}, 24 * 60 * 60 * 1000));});--// ── Stop (roadmap WS-P lifecycle; server/graceful.js) ─────────++// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ───────// systemd sends SIGTERM on a restart or deploy. The maintenance timers, the registry, drift and library// pollers, the frame refreshes, the profile-event and grant-expiry timers stop (nothing new starts); the// server stops taking connections, closes idle keep-alive ones and lets requests in flight finish (8 s at@@@ -954,9 +954,9 @@ const server = app.listen(config.port, config.host, () => {// (unsent rows stay in the outbox), analytics flush and close, the database and the Valkey connection// close, and the process exits 0, within 10 s (well inside the unit's stop timeout). The email queue// resumes on the next start.--const { within } = require('./graceful');--require('./graceful').gracefulStop({-- name: 'Network', server, drainMs: 8000, deadlineMs: 10000,++const { gracefulStop, within } = require('openvibe-sdk/service');++gracefulStop({++ name: 'Network', server, drainMs: 8000, deadlineMs: 10000, deadlineExitCode: 1,stop: [() => { for (const t of timers) { clearTimeout(t); clearInterval(t); } },() => ecosystem.stop(),nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++// Network's graceful shutdown is the shared service kit (roadmap WS-P lifecycle; openvibe-sdk/service):++// the local shutdown copy is gone and server/index.js wires gracefulStop/within from the SDK with++// Network's own numbers. The behavioural check below runs a stop and proves Network's stop and close steps++// run in order and a clean stop exits 0; shutdown.test.js proves the same wiring on the real server.++// node test/service-kit.test.js++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++const { gracefulStop, within } = require('openvibe-sdk/service');++++const root = path.join(__dirname, '..');++const index = fs.readFileSync(path.join(root, 'server', 'index.js'), 'utf8');++++// Static: the wiring is the SDK's, with today's Network values.++assert.ok(/const \{ gracefulStop, within \} = require\('openvibe-sdk\/service'\)/.test(index), "server/index.js imports gracefulStop and within from openvibe-sdk/service");++assert.ok(!/require\(['"]\.\/graceful['"]\)/.test(index), 'server/index.js no longer requires ./graceful');++assert.ok(!fs.existsSync(path.join(root, 'server', 'graceful' + '.js')), 'the local shutdown copy is deleted');++assert.ok(/name: 'Network', server, drainMs: 8000, deadlineMs: 10000, deadlineExitCode: 1/.test(index), 'drainMs 8000, deadlineMs 10000, deadlineExitCode 1');++// The stop and close step lists are still Network's, in today's order.++const stopAt = index.indexOf('stop: [');++const closeAt = index.indexOf('close: [');++assert.ok(stopAt > 0 && closeAt > stopAt, 'the stop list precedes the close list');++for (const marker of ['ecosystem.stop()', 'deployDrift.stop()', 'libraryTags.stop()', 'frameService.stop()']) {++ assert.ok(index.indexOf(marker) > stopAt && index.indexOf(marker) < closeAt, `stop step ${marker} is in the stop list`);++}++for (const marker of ["require('./developer/event-relay').stopRelay(db)", 'analytics.destroy()', 'db.close()', 'valkey && valkey.close()']) {++ assert.ok(index.indexOf(marker) > closeAt, `close step ${marker} is in the close list`);++}++++(async () => {++ const server = http.createServer((req, res) => { res.end('ok'); });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const order = [];++ let code = null;++ const { stop, stopping } = gracefulStop({++ name: 'ServiceKit', server, drainMs: 8000, deadlineMs: 10000, deadlineExitCode: 1, signals: false,++ exit: (c) => { code = c; },++ stop: [() => order.push('stop:one'), async () => order.push('stop:two')],++ close: [() => order.push('close:one'), async () => order.push('close:two')],++ });++ assert.strictEqual(stopping(), false, 'not stopping before the signal');++ const stopped = await stop('SIGTERM');++ assert.deepStrictEqual(order, ['stop:one', 'stop:two', 'close:one', 'close:two'], 'the stop then close steps run in order');++ assert.strictEqual(code, 0, 'a clean stop exits 0');++ assert.strictEqual(stopped, 0, 'stop() resolves with the exit code');++ assert.strictEqual(stopping(), true, 'stopping() is true once stop() has run');++++ // within() bounds a step and swallows its rejection (a best-effort stop step is never a reason to fail the stop).++ const t0 = Date.now();++ await within(50, new Promise(() => {}));++ assert.ok(Date.now() - t0 < 2000, 'within returns at its bound');++ await within(50, Promise.reject(new Error('a slow step rejected')));++++ console.log('service-kit: all checks passed');++})().catch((err) => { console.error(err); process.exitCode = 1; });@@@ -1,5 +1,5 @@'use strict';--// A restart drains instead of cutting (roadmap WS-P lifecycle; server/graceful.js): on SIGTERM the server++// A restart drains instead of cutting (roadmap WS-P lifecycle; openvibe-sdk/service): on SIGTERM the server// stops taking connections, closes idle keep-alive ones, answers the request in flight (Connection: close),// stops its timers, pollers and relay, closes the databases and exits 0 within the manifest's 10 s, promptly,// even with a keep-alive client connected.
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1572,9 +1572,9 @@
@@ -1572,9 +1572,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.25.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
- "integrity": "sha512-rbBj0NXUFNEOTDEtXYS5mD2uO5VJw6ZGC9zME1nbcEqrATMWbKeOt2cO02T/7QDP2iR+lJP97QhYTEF8rlyMog==",
+ "version": "0.26.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
+ "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
deleted file mode 100644
deleted file mode 100644
@@ -1,117 +0,0 @@
@@ -1,117 +0,0 @@
-'use strict';
-// ═══════════════════════════════════════════════════════════════
-// Graceful stop for OpenVibe.Network (roadmap WS-P lifecycle; openvibe-contracts
-// manifests/services/network.json → lifecycle.shutdown). systemd sends SIGTERM on a restart or a deploy;
-// SIGINT does the same by hand. On the first signal:
-//
-// 1. `stop` steps run: timers, pollers and relays stop taking new work (nothing new starts);
-// 2. the HTTP server stops taking connections, idle keep-alive connections are closed, requests in
-// flight are answered with `Connection: close`, and open event streams (text/event-stream) are
-// closed — EventSource reconnects on its own;
-// 3. the requests in flight finish, for at most `drainMs`; whatever is still open then is cut;
-// 4. `close` steps run: the outbox relay settles, analytics flush, the database closes;
-// 5. the process exits 0.
-//
-// A hard `deadlineMs` bounds the whole stop (the manifest's deadlineSeconds): past it the process
-// exits 1. A second signal while stopping changes nothing.
-// ═══════════════════════════════════════════════════════════════
-
-const DRAIN_MS = 4000;
-const DEADLINE_MS = 5000;
-
-/**
- * @param {object} o
- * @param {string} o.name log prefix, e.g. 'Network'
- * @param {import('http').Server} o.server
- * @param {Array<Function>} [o.stop] run first, in order (sync or async; a failure is logged)
- * @param {Array<Function>} [o.close] run after the HTTP drain, in order
- * @param {number} [o.drainMs] how long requests in flight may take (default 4000)
- * @param {number} [o.deadlineMs] the whole stop, else exit 1 (default 5000)
- * @param {boolean} [o.signals] false: do not install SIGTERM/SIGINT handlers (tests)
- * @param {(code:number) => void} [o.exit] default process.exit
- * @returns {{ stop(signal?: string): Promise<number>, stopping(): boolean }}
- */
-function gracefulStop(o) {
- const { name, server } = o;
- const drainMs = o.drainMs == null ? DRAIN_MS : o.drainMs;
- const deadlineMs = o.deadlineMs == null ? DEADLINE_MS : o.deadlineMs;
- const log = o.log || console;
- const exit = o.exit || ((code) => process.exit(code));
- let started = null;
-
- // Every response in flight, so the stop can mark them Connection: close and close event streams.
- const inflight = new Set();
- server.prependListener('request', (req, res) => {
- if (started && !res.headersSent) res.setHeader('Connection', 'close');
- inflight.add(res);
- res.on('close', () => inflight.delete(res));
- });
-
- async function step(fn, phase) {
- try { await fn(); } catch (err) { log.warn(`[${name}] stop: ${phase} step failed: ${err && err.message}`); }
- }
-
- function isEventStream(res) {
- const ct = String(res.getHeader('content-type') || '');
- return /text\/event-stream/i.test(ct) || (typeof res._header === 'string' && /content-type:\s*text\/event-stream/i.test(res._header));
- }
-
- function drain() {
- return new Promise((resolve) => {
- let done = false;
- const finish = (cut) => {
- if (done) return;
- done = true;
- clearInterval(sweep);
- clearTimeout(timer);
- resolve(cut);
- };
- server.close(() => finish(0));
- for (const res of inflight) {
- if (isEventStream(res)) res.destroy();
- else if (!res.headersSent) res.setHeader('Connection', 'close');
- }
- // A connection whose response just finished goes idle: close it now rather than after keepAliveTimeout.
- const idle = () => { if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections(); };
- idle();
- const sweep = setInterval(idle, 50);
- const timer = setTimeout(() => {
- const cut = inflight.size;
- if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
- finish(cut || 1);
- }, drainMs);
- });
- }
-
- function stop(signal = 'stop') {
- if (started) return started;
- started = (async () => {
- const t0 = Date.now();
- log.log(`[${name}] ${signal}: stopping (requests in flight get ${drainMs} ms)`);
- const hard = setTimeout(() => { log.error(`[${name}] stop took longer than ${deadlineMs} ms: exiting 1`); exit(1); }, deadlineMs);
- if (hard.unref) hard.unref();
- for (const fn of o.stop || []) await step(fn, 'stop');
- const cut = await drain();
- if (cut) log.warn(`[${name}] ${cut} request(s) still open after ${drainMs} ms were cut`);
- for (const fn of o.close || []) await step(fn, 'close');
- clearTimeout(hard);
- log.log(`[${name}] stopped in ${Date.now() - t0} ms`);
- exit(0);
- return 0;
- })();
- return started;
- }
-
- if (o.signals !== false) {
- process.on('SIGTERM', () => { stop('SIGTERM'); });
- process.on('SIGINT', () => { stop('SIGINT'); });
- }
- return { stop, stopping: () => !!started };
-}
-
-/** Await `promise`, but no longer than `ms` (a best-effort step inside the deadline). */
-function within(ms, promise) {
- return Promise.race([Promise.resolve(promise).catch(() => {}), new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); })]);
-}
-
-module.exports = { gracefulStop, within, DRAIN_MS, DEADLINE_MS };
@@ -946,7 +946,7 @@ const server = app.listen(config.port, config.host, () => {
@@ -946,7 +946,7 @@ const server = app.listen(config.port, config.host, () => {
}, 24 * 60 * 60 * 1000));
}, 24 * 60 * 60 * 1000));
});
});
-// ── Stop (roadmap WS-P lifecycle; server/graceful.js) ─────────
+// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ───────
// systemd sends SIGTERM on a restart or deploy. The maintenance timers, the registry, drift and library
// systemd sends SIGTERM on a restart or deploy. The maintenance timers, the registry, drift and library
// pollers, the frame refreshes, the profile-event and grant-expiry timers stop (nothing new starts); the
// pollers, the frame refreshes, the profile-event and grant-expiry timers stop (nothing new starts); the
// server stops taking connections, closes idle keep-alive ones and lets requests in flight finish (8 s at
// server stops taking connections, closes idle keep-alive ones and lets requests in flight finish (8 s at
@@ -954,9 +954,9 @@ const server = app.listen(config.port, config.host, () => {
@@ -954,9 +954,9 @@ const server = app.listen(config.port, config.host, () => {
// (unsent rows stay in the outbox), analytics flush and close, the database and the Valkey connection
// (unsent rows stay in the outbox), analytics flush and close, the database and the Valkey connection
// close, and the process exits 0, within 10 s (well inside the unit's stop timeout). The email queue
// close, and the process exits 0, within 10 s (well inside the unit's stop timeout). The email queue
// resumes on the next start.
// resumes on the next start.
-const { within } = require('./graceful');
-require('./graceful').gracefulStop({
- name: 'Network', server, drainMs: 8000, deadlineMs: 10000,
+const { gracefulStop, within } = require('openvibe-sdk/service');
+gracefulStop({
+ name: 'Network', server, drainMs: 8000, deadlineMs: 10000, deadlineExitCode: 1,
stop: [
stop: [
() => { for (const t of timers) { clearTimeout(t); clearInterval(t); } },
() => { for (const t of timers) { clearTimeout(t); clearInterval(t); } },
() => ecosystem.stop(),
() => ecosystem.stop(),
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+// Network's graceful shutdown is the shared service kit (roadmap WS-P lifecycle; openvibe-sdk/service):
+// the local shutdown copy is gone and server/index.js wires gracefulStop/within from the SDK with
+// Network's own numbers. The behavioural check below runs a stop and proves Network's stop and close steps
+// run in order and a clean stop exits 0; shutdown.test.js proves the same wiring on the real server.
+// node test/service-kit.test.js
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const { gracefulStop, within } = require('openvibe-sdk/service');
+
+const root = path.join(__dirname, '..');
+const index = fs.readFileSync(path.join(root, 'server', 'index.js'), 'utf8');
+
+// Static: the wiring is the SDK's, with today's Network values.
+assert.ok(/const \{ gracefulStop, within \} = require\('openvibe-sdk\/service'\)/.test(index), "server/index.js imports gracefulStop and within from openvibe-sdk/service");
+assert.ok(!/require\(['"]\.\/graceful['"]\)/.test(index), 'server/index.js no longer requires ./graceful');
+assert.ok(!fs.existsSync(path.join(root, 'server', 'graceful' + '.js')), 'the local shutdown copy is deleted');
+assert.ok(/name: 'Network', server, drainMs: 8000, deadlineMs: 10000, deadlineExitCode: 1/.test(index), 'drainMs 8000, deadlineMs 10000, deadlineExitCode 1');
+// The stop and close step lists are still Network's, in today's order.
+const stopAt = index.indexOf('stop: [');
+const closeAt = index.indexOf('close: [');
+assert.ok(stopAt > 0 && closeAt > stopAt, 'the stop list precedes the close list');
+for (const marker of ['ecosystem.stop()', 'deployDrift.stop()', 'libraryTags.stop()', 'frameService.stop()']) {
+ assert.ok(index.indexOf(marker) > stopAt && index.indexOf(marker) < closeAt, `stop step ${marker} is in the stop list`);
+}
+for (const marker of ["require('./developer/event-relay').stopRelay(db)", 'analytics.destroy()', 'db.close()', 'valkey && valkey.close()']) {
+ assert.ok(index.indexOf(marker) > closeAt, `close step ${marker} is in the close list`);
+}
+
+(async () => {
+ const server = http.createServer((req, res) => { res.end('ok'); });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const order = [];
+ let code = null;
+ const { stop, stopping } = gracefulStop({
+ name: 'ServiceKit', server, drainMs: 8000, deadlineMs: 10000, deadlineExitCode: 1, signals: false,
+ exit: (c) => { code = c; },
+ stop: [() => order.push('stop:one'), async () => order.push('stop:two')],
+ close: [() => order.push('close:one'), async () => order.push('close:two')],
+ });
+ assert.strictEqual(stopping(), false, 'not stopping before the signal');
+ const stopped = await stop('SIGTERM');
+ assert.deepStrictEqual(order, ['stop:one', 'stop:two', 'close:one', 'close:two'], 'the stop then close steps run in order');
+ assert.strictEqual(code, 0, 'a clean stop exits 0');
+ assert.strictEqual(stopped, 0, 'stop() resolves with the exit code');
+ assert.strictEqual(stopping(), true, 'stopping() is true once stop() has run');
+
+ // within() bounds a step and swallows its rejection (a best-effort stop step is never a reason to fail the stop).
+ const t0 = Date.now();
+ await within(50, new Promise(() => {}));
+ assert.ok(Date.now() - t0 < 2000, 'within returns at its bound');
+ await within(50, Promise.reject(new Error('a slow step rejected')));
+
+ console.log('service-kit: all checks passed');
+})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'use strict';
-// A restart drains instead of cutting (roadmap WS-P lifecycle; server/graceful.js): on SIGTERM the server
+// A restart drains instead of cutting (roadmap WS-P lifecycle; openvibe-sdk/service): on SIGTERM the server
// stops taking connections, closes idle keep-alive ones, answers the request in flight (Connection: close),
// stops taking connections, closes idle keep-alive ones, answers the request in flight (Connection: close),
// stops its timers, pollers and relay, closes the databases and exits 0 within the manifest's 10 s, promptly,
// stops its timers, pollers and relay, closes the databases and exits 0 within the manifest's 10 s, promptly,
// even with a keep-alive client connected.
// even with a keep-alive client connected.