openvibers/openvibe.live Commits

Stop Live through openvibe-sdk gracefulStop and pin the SDK at v0.26.0

OP OpenVibers committed 4 Oct 2026 · 37c2907ad4 · 5 files, +147 −63

Asked: T1 step 8 (kit lane A, the last consumer) — OpenVibe.Live: move the hand-written shutdown onto openvibe-sdk/service gracefulStop. ONE pull request from current origin/main (git fetch first; the local checkout may be stale). No other change in this PR.
Harness job live-t1-step-8-kit-lane-223709 (plan T1).

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 04378765..29016f38 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package-lock.json b/package-lock.json
iindex 2beb7298..2d74adde 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex f21dbdec..d313d716 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/index.js b/server/index.js
iindex bfa05190..334db31f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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);
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 00000000..e6688769
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -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); });