openvibers/openvibe.live Commits
Stop Live through openvibe-sdk gracefulStop and pin the SDK at v0.26.0
Changes
@@@ -5,8 +5,8 @@"runtime": "node 22, express, better-sqlite3, node-media-server, mediasoup; no build step","port": 3000,"domain": "openvibe.live (www, ingest, whip)",-- "contracts": "openvibe-contracts v0.79.0",-- "sdk": "openvibe-sdk v0.12.0",++ "contracts": "openvibe-contracts v0.83.0",++ "sdk": "openvibe-sdk v0.26.0","capabilities": ["live.chat_context.read","live.chat_effects.write",@@@ -26,7 +26,7 @@"node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",++ "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.5.0","pg": "^8.23.1","sanitize-html": "^2.17.7",@@@ -2831,9 +2831,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.16.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",-- "integrity": "sha512-fp5ePkxVf+/rVz3Ed1PDICYx5bxb/LNWmTauzR1KNhRiijE0e30CT0vh2rFMjH21yAUhOex5ku7WztJjKBVlmQ==",++ "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"@@@ -47,7 +47,7 @@"node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",++ "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.5.0","pg": "^8.23.1","sanitize-html": "^2.17.7",@@@ -146,6 +146,74 @@ const whipHandler = require('./streaming/whip-handler');const app = express();const server = http.createServer(app);++// ── Graceful stop (openvibe-sdk/service) ─────────────────────++// SIGTERM/SIGINT (non-drill) run this once: stop steps, beforeDrain (sockets and child processes stop while the++// HTTP server still takes connections), a 4 s drain, then the close steps; past 5 s the process exits 1.++// signals: false: shutdown() below installs the handlers, because a drill stops its own way.++const { gracefulStop } = require('openvibe-sdk/service');++const stopper = gracefulStop({++ name: 'Live',++ server,++ signals: false,++ drainMs: 4000,++ deadlineMs: 5000,++ deadlineExitCode: 1,++ stop: [++ () => {++ console.log('\n[Server] Shutting down...');++ // Stop advertising readiness immediately: from here on this process is draining, and anything++ // gating on /api/ready should see that before the socket actually closes.++ _bootComplete = false;++ },++ () => {++ // Notify all chat clients before closing connections++ try {++ require('./chat/chat-delivery').event({ kind: 'all' }, {++ type: 'server_restart',++ message: '⚙️ Chat server restarting — you will be reconnected automatically.',++ timestamp: new Date().toISOString(),++ });++ } catch { /* non-critical */ }++ },++ // Kill any in-flight whisper/ffmpeg transcription children so they don't orphan++ // (and so their temp files get cleaned by the close handlers). The interrupted VOD++ // is left in 'processing' → re-queued to 'pending' on next boot (crash recovery).++ () => { const n = require('./ai/transcribe').killActive(); if (n) console.log(`[Server] Killed ${n} transcription child(ren)`); },++ () => { const n = require('./ai/timeline-job').stopAll(); if (n) console.log(`[Server] Stopped ${n} continuous audio capture(s)`); },++ () => { require('./ai/media-analysis').killActive(); },++ // Cleanly END RobotStreamer passthrough streams FIRST (before the exit races the reconnect).++ // This closes our protoo peers so RS's SFU closes the producers now and tells its viewers,++ // instead of leaving stale producers that black out RS video (audio still playing) on the next++ // go-live until viewers refresh. Done up-front so RS has the whole shutdown window to propagate.++ () => { const n = require('./integrations/rs-passthrough-relay').stopAll(); if (n) console.log(`[Server] Closed ${n} RobotStreamer passthrough(s)`); },++ // No new background runs from here on (the jobs helper's loops); and end SSE streams so++ // the drain can actually complete instead of always hitting the deadline.++ () => { require('./utils/jobs').stopAll(); },++ () => { require('./streaming/live-events').closeAll(); },++ ],++ beforeDrain: async () => {++ // Small delay to let the message reach clients before closing sockets++ await new Promise((resolve) => setTimeout(resolve, 300));++ try { restreamManager.stopViewerCountPolling(); } catch { /* */ }++ try { restreamManager.stopAll(); } catch { /* */ }++ try { recorder.stopAll(); } catch { /* */ }++ try { callServer.close(); } catch { /* */ }++ try { chatServer.close(); } catch { /* */ }++ try { controlServer.close(); } catch { /* */ }++ try { broadcastServer.close(); } catch { /* */ }++ try { jsmpegRelay.closeAll(); } catch { /* */ }++ try { webrtcSFU.closeAll(); } catch { /* */ }++ try { rtmpServer.stop(); } catch { /* */ }++ },++ close: [++ async () => { try { await analyticsStore.close(); } catch (err) { console.error('[Analytics] Shutdown:', err); } },++ () => {++ db.close();++ console.log('[Server] Goodbye — keep the vibe alive.');++ },++ ],++});++// What this server runs (ADR-016): GET /release.json below, and release_info in /metrics.// In the release layout (/opt/openvibe.live/releases/<time>-<sha8>, a root-owned git worktree) the// service user cannot run git there, so the release id also comes from the directory's name.@@@ -1600,7 +1668,7 @@ function startDrill() {}// ── Graceful Shutdown ────────────────────────────────────────--function shutdown() {++function shutdown(signal) {if (drill.enabled) {// Nothing was started but the HTTP server and the two databases.console.log('[Drill] Shutting down');@@@ -1611,61 +1679,7 @@ function shutdown() {setTimeout(() => process.exit(0), 3000).unref();return;}-- console.log('\n[Server] Shutting down...');---- // Notify all chat clients before closing connections-- try {-- require('./chat/chat-delivery').event({ kind: 'all' }, {-- type: 'server_restart',-- message: '⚙️ Chat server restarting — you will be reconnected automatically.',-- timestamp: new Date().toISOString(),-- });-- } catch { /* non-critical */ }---- // Kill any in-flight whisper/ffmpeg transcription children so they don't orphan-- // (and so their temp files get cleaned by the close handlers). The interrupted VOD-- // is left in 'processing' → re-queued to 'pending' on next boot (crash recovery).-- try { const n = require('./ai/transcribe').killActive(); if (n) console.log(`[Server] Killed ${n} transcription child(ren)`); } catch { /* */ }-- try { const n = require('./ai/timeline-job').stopAll(); if (n) console.log(`[Server] Stopped ${n} continuous audio capture(s)`); } catch { /* */ }-- try { require('./ai/media-analysis').killActive(); } catch { /* */ }---- // Cleanly END RobotStreamer passthrough streams FIRST (before the exit races the reconnect).-- // This closes our protoo peers so RS's SFU closes the producers now and tells its viewers,-- // instead of leaving stale producers that black out RS video (audio still playing) on the next-- // go-live until viewers refresh. Done up-front so RS has the whole shutdown window to propagate.-- try { const n = require('./integrations/rs-passthrough-relay').stopAll(); if (n) console.log(`[Server] Closed ${n} RobotStreamer passthrough(s)`); } catch { /* */ }---- // Stop advertising readiness immediately: from here on this process is draining, and anything-- // gating on /api/ready should see that before the socket actually closes.-- _bootComplete = false;-- // No new background runs from here on (the jobs helper's loops); and end SSE streams so-- // server.close() can actually complete instead of always hitting the forced exit.-- try { require('./utils/jobs').stopAll(); } catch { /* */ }-- try { require('./streaming/live-events').closeAll(); } catch { /* */ }---- // Small delay to let the message reach clients before closing sockets-- setTimeout(async () => {-- restreamManager.stopViewerCountPolling();-- restreamManager.stopAll();-- try { recorder.stopAll(); } catch {}-- callServer.close();-- chatServer.close();-- controlServer.close();-- broadcastServer.close();-- jsmpegRelay.closeAll();-- webrtcSFU.closeAll();-- rtmpServer.stop();-- try { await analyticsStore.close(); } catch (err) { console.error('[Analytics] Shutdown:', err); }-- db.close();---- server.close(() => {-- console.log('[Server] Goodbye — keep the vibe alive.');-- process.exit(0);-- });---- // Force exit after 5s-- setTimeout(() => process.exit(1), 5000);-- }, 300);++ stopper.stop(signal); // floating-ok: stop() never rejects}process.on('SIGTERM', shutdown);nnew file mode 100644@@@ -0,0 +1,70 @@++/**++ * openvibe-sdk/service in Live (plan T1, the last kit consumer): the non-drill SIGTERM/SIGINT shutdown is the++ * kit's gracefulStop, not a hand-written setTimeout chain. The stop steps flip readiness and stop the jobs,++ * beforeDrain closes sockets and child processes while the server still listens, the HTTP server drains for++ * 4 s, then analytics and the database close; past 5 s the process exits 1. The drill branch stays its own.++ * The static half reads server/index.js; the behavioural half drives gracefulStop with Live's options.++ *++ * node test/service-kit.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const http = require('http');++const { gracefulStop } = require('openvibe-sdk/service');++++const source = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');++const quiet = { log() {}, warn() {}, error() {} };++++// ── Static: server/index.js ──────────────────────────────────────────────────────++assert.match(source, /const \{ gracefulStop \} = require\('openvibe-sdk\/service'\)/);++assert.strictEqual(source.split('gracefulStop(').length - 1, 1, 'exactly one gracefulStop( call');++const at = source.indexOf('gracefulStop(');++const call = source.slice(at, source.indexOf('\n});', at));++assert.match(call, /name: 'Live'/);++assert.match(call, /signals: false/);++assert.match(call, /drainMs: 4000/);++assert.match(call, /deadlineMs: 5000/);++assert.match(call, /deadlineExitCode: 1/);++assert.match(call, /beforeDrain:/);++assert.ok(at > source.indexOf('const server = http.createServer(app);'), 'the stopper is created after the server exists');++const stopAt = call.indexOf('stop: [');++assert.ok(stopAt > 0 && call.indexOf('_bootComplete = false', stopAt) < call.indexOf("require('./chat/chat-delivery')", stopAt),++ 'readiness flips before anything else stops');++++const fn = source.slice(source.indexOf('function shutdown('), source.indexOf("process.on('SIGTERM', shutdown)"));++assert.match(fn, /if \(drill\.enabled\) \{/, 'the drill branch is still there');++const nonDrill = fn.slice(fn.indexOf(' return;\n }\n'));++assert.match(nonDrill, /stopper\.stop\(signal\)/);++assert.doesNotMatch(nonDrill, /process\.exit\(1\), 5000/, 'the old 5 s exit-1 timer is gone');++assert.doesNotMatch(nonDrill, /server\.close\(/);++assert.match(source, /process\.on\('SIGTERM', shutdown\)/);++assert.match(source, /process\.on\('SIGINT', shutdown\)/);++++// ── Behaviour: Live's options, exits stubbed ─────────────────────────────────────++(async () => {++ const server = http.createServer((_req, res) => res.end('ok'));++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const order = [];++ const mark = (what) => () => { order.push(`${what}${server.listening ? '' : ' (closed)'}`); };++ let exited = null;++ let exits = 0;++ const kit = gracefulStop({++ name: 'Live', server, signals: false, drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,++ stop: [mark('ready off'), () => { throw new Error('a failing stop step does not stop the stop'); }, mark('jobs')],++ beforeDrain: mark('sockets'),++ close: [mark('analytics'), mark('db')],++ exit: (c) => { exited = c; exits++; },++ log: quiet,++ });++ const [code, again] = await Promise.all([kit.stop('SIGTERM'), kit.stop('SIGINT')]);++ assert.deepStrictEqual(order, ['ready off', 'jobs', 'sockets', 'analytics (closed)', 'db (closed)'],++ 'stop steps and beforeDrain run while listening; close steps after the drain');++ assert.strictEqual(code, 0);++ assert.strictEqual(again, 0, 'a second signal changes nothing');++ assert.strictEqual(exited, 0);++ assert.strictEqual(exits, 1, 'exit is called once');++ assert.strictEqual(server.listening, false);++ console.log('service-kit: ok');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
"runtime": "node 22, express, better-sqlite3, node-media-server, mediasoup; no build step",
"runtime": "node 22, express, better-sqlite3, node-media-server, mediasoup; no build step",
"port": 3000,
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
- "contracts": "openvibe-contracts v0.79.0",
- "sdk": "openvibe-sdk v0.12.0",
+ "contracts": "openvibe-contracts v0.83.0",
+ "sdk": "openvibe-sdk v0.26.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
+ "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.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -2831,9 +2831,9 @@
@@ -2831,9 +2831,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.16.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
- "integrity": "sha512-fp5ePkxVf+/rVz3Ed1PDICYx5bxb/LNWmTauzR1KNhRiijE0e30CT0vh2rFMjH21yAUhOex5ku7WztJjKBVlmQ==",
+ "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"
@@ -47,7 +47,7 @@
@@ -47,7 +47,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
+ "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.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
@@ -146,6 +146,74 @@ const whipHandler = require('./streaming/whip-handler');
@@ -146,6 +146,74 @@ const whipHandler = require('./streaming/whip-handler');
const app = express();
const app = express();
const server = http.createServer(app);
const server = http.createServer(app);
+// ── Graceful stop (openvibe-sdk/service) ─────────────────────
+// SIGTERM/SIGINT (non-drill) run this once: stop steps, beforeDrain (sockets and child processes stop while the
+// HTTP server still takes connections), a 4 s drain, then the close steps; past 5 s the process exits 1.
+// signals: false: shutdown() below installs the handlers, because a drill stops its own way.
+const { gracefulStop } = require('openvibe-sdk/service');
+const stopper = gracefulStop({
+ name: 'Live',
+ server,
+ signals: false,
+ drainMs: 4000,
+ deadlineMs: 5000,
+ deadlineExitCode: 1,
+ stop: [
+ () => {
+ console.log('\n[Server] Shutting down...');
+ // Stop advertising readiness immediately: from here on this process is draining, and anything
+ // gating on /api/ready should see that before the socket actually closes.
+ _bootComplete = false;
+ },
+ () => {
+ // Notify all chat clients before closing connections
+ try {
+ require('./chat/chat-delivery').event({ kind: 'all' }, {
+ type: 'server_restart',
+ message: '⚙️ Chat server restarting — you will be reconnected automatically.',
+ timestamp: new Date().toISOString(),
+ });
+ } catch { /* non-critical */ }
+ },
+ // Kill any in-flight whisper/ffmpeg transcription children so they don't orphan
+ // (and so their temp files get cleaned by the close handlers). The interrupted VOD
+ // is left in 'processing' → re-queued to 'pending' on next boot (crash recovery).
+ () => { const n = require('./ai/transcribe').killActive(); if (n) console.log(`[Server] Killed ${n} transcription child(ren)`); },
+ () => { const n = require('./ai/timeline-job').stopAll(); if (n) console.log(`[Server] Stopped ${n} continuous audio capture(s)`); },
+ () => { require('./ai/media-analysis').killActive(); },
+ // Cleanly END RobotStreamer passthrough streams FIRST (before the exit races the reconnect).
+ // This closes our protoo peers so RS's SFU closes the producers now and tells its viewers,
+ // instead of leaving stale producers that black out RS video (audio still playing) on the next
+ // go-live until viewers refresh. Done up-front so RS has the whole shutdown window to propagate.
+ () => { const n = require('./integrations/rs-passthrough-relay').stopAll(); if (n) console.log(`[Server] Closed ${n} RobotStreamer passthrough(s)`); },
+ // No new background runs from here on (the jobs helper's loops); and end SSE streams so
+ // the drain can actually complete instead of always hitting the deadline.
+ () => { require('./utils/jobs').stopAll(); },
+ () => { require('./streaming/live-events').closeAll(); },
+ ],
+ beforeDrain: async () => {
+ // Small delay to let the message reach clients before closing sockets
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ try { restreamManager.stopViewerCountPolling(); } catch { /* */ }
+ try { restreamManager.stopAll(); } catch { /* */ }
+ try { recorder.stopAll(); } catch { /* */ }
+ try { callServer.close(); } catch { /* */ }
+ try { chatServer.close(); } catch { /* */ }
+ try { controlServer.close(); } catch { /* */ }
+ try { broadcastServer.close(); } catch { /* */ }
+ try { jsmpegRelay.closeAll(); } catch { /* */ }
+ try { webrtcSFU.closeAll(); } catch { /* */ }
+ try { rtmpServer.stop(); } catch { /* */ }
+ },
+ close: [
+ async () => { try { await analyticsStore.close(); } catch (err) { console.error('[Analytics] Shutdown:', err); } },
+ () => {
+ db.close();
+ console.log('[Server] Goodbye — keep the vibe alive.');
+ },
+ ],
+});
+
// What this server runs (ADR-016): GET /release.json below, and release_info in /metrics.
// What this server runs (ADR-016): GET /release.json below, and release_info in /metrics.
// In the release layout (/opt/openvibe.live/releases/<time>-<sha8>, a root-owned git worktree) the
// In the release layout (/opt/openvibe.live/releases/<time>-<sha8>, a root-owned git worktree) the
// service user cannot run git there, so the release id also comes from the directory's name.
// service user cannot run git there, so the release id also comes from the directory's name.
@@ -1600,7 +1668,7 @@ function startDrill() {
@@ -1600,7 +1668,7 @@ function startDrill() {
}
}
// ── Graceful Shutdown ────────────────────────────────────────
// ── Graceful Shutdown ────────────────────────────────────────
-function shutdown() {
+function shutdown(signal) {
if (drill.enabled) {
if (drill.enabled) {
// Nothing was started but the HTTP server and the two databases.
// Nothing was started but the HTTP server and the two databases.
console.log('[Drill] Shutting down');
console.log('[Drill] Shutting down');
@@ -1611,61 +1679,7 @@ function shutdown() {
@@ -1611,61 +1679,7 @@ function shutdown() {
setTimeout(() => process.exit(0), 3000).unref();
setTimeout(() => process.exit(0), 3000).unref();
return;
return;
}
}
- console.log('\n[Server] Shutting down...');
-
- // Notify all chat clients before closing connections
- try {
- require('./chat/chat-delivery').event({ kind: 'all' }, {
- type: 'server_restart',
- message: '⚙️ Chat server restarting — you will be reconnected automatically.',
- timestamp: new Date().toISOString(),
- });
- } catch { /* non-critical */ }
-
- // Kill any in-flight whisper/ffmpeg transcription children so they don't orphan
- // (and so their temp files get cleaned by the close handlers). The interrupted VOD
- // is left in 'processing' → re-queued to 'pending' on next boot (crash recovery).
- try { const n = require('./ai/transcribe').killActive(); if (n) console.log(`[Server] Killed ${n} transcription child(ren)`); } catch { /* */ }
- try { const n = require('./ai/timeline-job').stopAll(); if (n) console.log(`[Server] Stopped ${n} continuous audio capture(s)`); } catch { /* */ }
- try { require('./ai/media-analysis').killActive(); } catch { /* */ }
-
- // Cleanly END RobotStreamer passthrough streams FIRST (before the exit races the reconnect).
- // This closes our protoo peers so RS's SFU closes the producers now and tells its viewers,
- // instead of leaving stale producers that black out RS video (audio still playing) on the next
- // go-live until viewers refresh. Done up-front so RS has the whole shutdown window to propagate.
- try { const n = require('./integrations/rs-passthrough-relay').stopAll(); if (n) console.log(`[Server] Closed ${n} RobotStreamer passthrough(s)`); } catch { /* */ }
-
- // Stop advertising readiness immediately: from here on this process is draining, and anything
- // gating on /api/ready should see that before the socket actually closes.
- _bootComplete = false;
- // No new background runs from here on (the jobs helper's loops); and end SSE streams so
- // server.close() can actually complete instead of always hitting the forced exit.
- try { require('./utils/jobs').stopAll(); } catch { /* */ }
- try { require('./streaming/live-events').closeAll(); } catch { /* */ }
-
- // Small delay to let the message reach clients before closing sockets
- setTimeout(async () => {
- restreamManager.stopViewerCountPolling();
- restreamManager.stopAll();
- try { recorder.stopAll(); } catch {}
- callServer.close();
- chatServer.close();
- controlServer.close();
- broadcastServer.close();
- jsmpegRelay.closeAll();
- webrtcSFU.closeAll();
- rtmpServer.stop();
- try { await analyticsStore.close(); } catch (err) { console.error('[Analytics] Shutdown:', err); }
- db.close();
-
- server.close(() => {
- console.log('[Server] Goodbye — keep the vibe alive.');
- process.exit(0);
- });
-
- // Force exit after 5s
- setTimeout(() => process.exit(1), 5000);
- }, 300);
+ stopper.stop(signal); // floating-ok: stop() never rejects
}
}
process.on('SIGTERM', shutdown);
process.on('SIGTERM', shutdown);
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+/**
+ * openvibe-sdk/service in Live (plan T1, the last kit consumer): the non-drill SIGTERM/SIGINT shutdown is the
+ * kit's gracefulStop, not a hand-written setTimeout chain. The stop steps flip readiness and stop the jobs,
+ * beforeDrain closes sockets and child processes while the server still listens, the HTTP server drains for
+ * 4 s, then analytics and the database close; past 5 s the process exits 1. The drill branch stays its own.
+ * The static half reads server/index.js; the behavioural half drives gracefulStop with Live's options.
+ *
+ * node test/service-kit.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const http = require('http');
+const { gracefulStop } = require('openvibe-sdk/service');
+
+const source = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
+const quiet = { log() {}, warn() {}, error() {} };
+
+// ── Static: server/index.js ──────────────────────────────────────────────────────
+assert.match(source, /const \{ gracefulStop \} = require\('openvibe-sdk\/service'\)/);
+assert.strictEqual(source.split('gracefulStop(').length - 1, 1, 'exactly one gracefulStop( call');
+const at = source.indexOf('gracefulStop(');
+const call = source.slice(at, source.indexOf('\n});', at));
+assert.match(call, /name: 'Live'/);
+assert.match(call, /signals: false/);
+assert.match(call, /drainMs: 4000/);
+assert.match(call, /deadlineMs: 5000/);
+assert.match(call, /deadlineExitCode: 1/);
+assert.match(call, /beforeDrain:/);
+assert.ok(at > source.indexOf('const server = http.createServer(app);'), 'the stopper is created after the server exists');
+const stopAt = call.indexOf('stop: [');
+assert.ok(stopAt > 0 && call.indexOf('_bootComplete = false', stopAt) < call.indexOf("require('./chat/chat-delivery')", stopAt),
+ 'readiness flips before anything else stops');
+
+const fn = source.slice(source.indexOf('function shutdown('), source.indexOf("process.on('SIGTERM', shutdown)"));
+assert.match(fn, /if \(drill\.enabled\) \{/, 'the drill branch is still there');
+const nonDrill = fn.slice(fn.indexOf(' return;\n }\n'));
+assert.match(nonDrill, /stopper\.stop\(signal\)/);
+assert.doesNotMatch(nonDrill, /process\.exit\(1\), 5000/, 'the old 5 s exit-1 timer is gone');
+assert.doesNotMatch(nonDrill, /server\.close\(/);
+assert.match(source, /process\.on\('SIGTERM', shutdown\)/);
+assert.match(source, /process\.on\('SIGINT', shutdown\)/);
+
+// ── Behaviour: Live's options, exits stubbed ─────────────────────────────────────
+(async () => {
+ const server = http.createServer((_req, res) => res.end('ok'));
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const order = [];
+ const mark = (what) => () => { order.push(`${what}${server.listening ? '' : ' (closed)'}`); };
+ let exited = null;
+ let exits = 0;
+ const kit = gracefulStop({
+ name: 'Live', server, signals: false, drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
+ stop: [mark('ready off'), () => { throw new Error('a failing stop step does not stop the stop'); }, mark('jobs')],
+ beforeDrain: mark('sockets'),
+ close: [mark('analytics'), mark('db')],
+ exit: (c) => { exited = c; exits++; },
+ log: quiet,
+ });
+ const [code, again] = await Promise.all([kit.stop('SIGTERM'), kit.stop('SIGINT')]);
+ assert.deepStrictEqual(order, ['ready off', 'jobs', 'sockets', 'analytics (closed)', 'db (closed)'],
+ 'stop steps and beforeDrain run while listening; close steps after the drain');
+ assert.strictEqual(code, 0);
+ assert.strictEqual(again, 0, 'a second signal changes nothing');
+ assert.strictEqual(exited, 0);
+ assert.strictEqual(exits, 1, 'exit is called once');
+ assert.strictEqual(server.listening, false);
+ console.log('service-kit: ok');
+})().catch((err) => { console.error(err); process.exit(1); });