openvibers/openvibe.network Commits

Move Network's graceful shutdown onto openvibe-sdk/service (#21)

OP OpenVibers committed 3 Oct 2026 · 2b7dcfc9c2 · 6 files, +67 −127

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Network/pull/21

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 61f653e8..aa1b21f5 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 6b99d297..47338181 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/graceful.js b/server/graceful.js
ddeleted file mode 100644
iindex 51c724be..00000000
---- a/server/graceful.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex 8d5d66b1..c1a82054 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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(),
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 00000000..4e2bf9c5
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -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; });
ddiff --git a/test/shutdown.test.js b/test/shutdown.test.js
iindex b9f2cfa5..9f82191d 100644
---- a/test/shutdown.test.js
++++ b/test/shutdown.test.js
@@@ -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.