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
Changes
@@@ -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"},@@@ -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'));++nnew file mode 100644@@@ -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; });
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -755,7 +755,7 @@ app.get(['/admin', '/admin/*'], (req, res) => {
@@ -755,7 +755,7 @@ app.get(['/admin', '/admin/*'], (req, res) => {
app.use(require('./not-found').notFound);
app.use(require('./not-found').notFound);
// ── Start ────────────────────────────────────────────────────
// ── 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.
// The home page renders the Tools catalog it already holds: fetch it now, not on the first visit.
require('./domains/catalog').refresh().catch(() => {});
require('./domains/catalog').refresh().catch(() => {});
console.log(`\n╔═══════════════════════════════════════╗`);
console.log(`\n╔═══════════════════════════════════════╗`);
@@ -794,3 +794,19 @@ app.listen(config.port, config.host, () => {
@@ -794,3 +794,19 @@ app.listen(config.port, config.host, () => {
if (cleaned > 0) console.log(`[Sessions] Cleaned ${cleaned} expired sessions`);
if (cleaned > 0) console.log(`[Sessions] Cleaned ${cleaned} expired sessions`);
}, 24 * 60 * 60 * 1000);
}, 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'));
+
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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; });