openvibers/openvibe.network Commits

Graceful stop (WS-P lifecycle): on SIGTERM Network stops taking connections, closes idle keep-alive ones and lets requests in flight finish (10 s at most) before exiting 0; it was the one service without a handler, so every restart cut requests

OP OpenVibers committed 26 Sept 2026 · d1b7323aef · 3 files, +67 −2


Changes

ddiff --git a/package.json b/package.json
iindex 3a87d8d6..1bdd624c 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/index.js b/server/index.js
iindex 5b1fd448..2df61230 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -755,7 +755,7 @@ app.get(['/admin', '/admin/*'], (req, res) => {
app.use(require('./not-found').notFound);
// ── Start ────────────────────────────────────────────────────
--app.listen(config.port, config.host, () => {
++const server = app.listen(config.port, config.host, () => {
// The home page renders the Tools catalog it already holds: fetch it now, not on the first visit.
require('./domains/catalog').refresh().catch(() => {});
console.log(`\n╔═══════════════════════════════════════╗`);
@@@ -794,3 +794,19 @@ app.listen(config.port, config.host, () => {
if (cleaned > 0) console.log(`[Sessions] Cleaned ${cleaned} expired sessions`);
}, 24 * 60 * 60 * 1000);
});
++
++// ── Stop (roadmap WS-P lifecycle) ────────────────────────────
++// systemd sends SIGTERM on a restart or deploy: stop taking connections, close idle keep-alive ones,
++// let requests in flight finish (at most 10 s, well inside the unit's stop timeout), then exit. Work
++// kept in the database (outboxes, the email queue) resumes on the next start.
++function shutdown(signal) {
++ if (shutdown.started) return;
++ shutdown.started = true;
++ console.log(`[Network] ${signal}: closing`);
++ server.close(() => process.exit(0));
++ if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections();
++ setTimeout(() => process.exit(0), 10000).unref();
++}
++process.on('SIGTERM', () => shutdown('SIGTERM'));
++process.on('SIGINT', () => shutdown('SIGINT'));
++
ddiff --git a/test/shutdown.test.js b/test/shutdown.test.js
nnew file mode 100644
iindex 00000000..bd60d03a
---- /dev/null
++++ b/test/shutdown.test.js
@@@ -0,0 +1,49 @@
++'use strict';
++// A restart drains instead of cutting (roadmap WS-P lifecycle): on SIGTERM the server stops taking
++// connections, closes idle keep-alive ones and exits 0, promptly, even with a keep-alive client connected.
++// node test/shutdown.test.js
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const net = require('net');
++const http = require('http');
++const path = require('path');
++const { spawn } = require('child_process');
++
++const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
++
++(async () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-net-stop-'));
++ const port = await freePort();
++ const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
++ env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', DB_PATH: path.join(dir, 'network.db'), NODE_ENV: 'test' },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ let out = '';
++ child.stdout.on('data', (d) => { out += d; });
++ child.stderr.on('data', (d) => { out += d; });
++ const exited = new Promise((r) => child.on('exit', (code, signal) => r({ code, signal })));
++ try {
++ let up = false;
++ for (let i = 0; i < 100 && !up; i++) {
++ up = await fetch(`http://127.0.0.1:${port}/api/health`).then((r) => r.ok).catch(() => false);
++ if (!up) await new Promise((r) => setTimeout(r, 100));
++ }
++ assert.ok(up, `the server did not start:\n${out.slice(-2000)}`);
++ // A keep-alive client that finished its request and keeps the connection open.
++ const agent = new http.Agent({ keepAlive: true });
++ await new Promise((resolve, reject) => http.get({ host: '127.0.0.1', port, path: '/api/health', agent }, (res) => { res.resume(); res.on('end', resolve); }).on('error', reject));
++ const t0 = Date.now();
++ child.kill('SIGTERM');
++ const r = await Promise.race([exited, new Promise((res) => setTimeout(() => res({ timeout: true }), 8000))]);
++ assert.ok(!r.timeout, 'exits within 8 s with an idle keep-alive connection open');
++ assert.deepStrictEqual([r.code, r.signal], [0, null], `a clean exit, not a kill: ${JSON.stringify(r)}\n${out.slice(-800)}`);
++ assert.ok(/SIGTERM: closing/.test(out), 'says it is closing');
++ console.log(`shutdown: exited 0 in ${Date.now() - t0} ms`);
++ agent.destroy();
++ } finally {
++ if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++ console.log('shutdown: all checks passed');
++})().catch((err) => { console.error(err); process.exitCode = 1; });