openvibers/openvibe.tools Commits

Graceful stop for every Tools process (WS-P lifecycle follow-up): apps/_shared/graceful.js on SIGTERM/SIGINT stops timers, pollers and the job worker first, then stops taking connections, closes idle keep-alive ones, answers requests in flight with Connection: close and closes event streams (EventSource reconnects), lets requests finish for 4 s at most, then closes databases and pools and exits 0 (exit 1 only past the 5 s deadline). The gateway had no handler at all (Node's default ended it at once, cutting requests); it now stops its catalog, services and satellite pollers and the search indexer, flushes recent-tool writes (1 s at most), closes the engine pool, the guard and the cutoff store. The seven satellites used to close their analytics, jobs and guard databases before the HTTP server finished, under requests still running, and force-exit 1 after 5 s; they now drain first and close after. test: _shared/test/graceful.test.js (the helper on a plain server; every one of the 8 processes, SIGTERM with a request in flight: answered, no new connections, exit 0 in < 5 s)

OP OpenVibers committed 26 Sept 2026 · 93f0f6c844 · 12 files, +445 −87


Changes

ddiff --git a/apps/_shared/graceful.js b/apps/_shared/graceful.js
nnew file mode 100644
iindex 0000000..76be6ff
---- /dev/null
++++ b/apps/_shared/graceful.js
@@@ -0,0 +1,117 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Graceful stop for every Tools process (roadmap WS-P lifecycle; openvibe-contracts
++// manifests/services/tools.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 job workers 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: databases, worker pools;
++// 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. No dependencies: required by relative path.
++// ═══════════════════════════════════════════════════════════════
++
++const DRAIN_MS = 4000;
++const DEADLINE_MS = 5000;
++
++/**
++ * @param {object} o
++ * @param {string} o.name log prefix, e.g. 'Img.OpenVibe'
++ * @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/apps/_shared/test/graceful.test.js b/apps/_shared/test/graceful.test.js
nnew file mode 100644
iindex 0000000..4146d71
---- /dev/null
++++ b/apps/_shared/test/graceful.test.js
@@@ -0,0 +1,172 @@
++'use strict';
++// Graceful stop (roadmap WS-P lifecycle; apps/_shared/graceful.js). The helper on a plain server: a
++// request in flight finishes and is told Connection: close, new connections are refused, idle
++// keep-alive ones and event streams close, the steps run in order, a request that outlives the drain
++// is cut, a stop that outlives the deadline exits 1. Then every Tools process (the gateway and the
++// seven satellites, as systemd runs them): SIGTERM while a request is in flight, the request is
++// answered, and the process exits 0 within the manifest's 5 s.
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { gracefulStop } = require('../graceful');
++const { startApp, freePort } = require('./spawn');
++
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++const quiet = { log() {}, warn() {}, error() {} };
++
++function listen(handler) {
++ return new Promise((resolve) => { const s = http.createServer(handler); s.listen(0, '127.0.0.1', () => resolve(s)); });
++}
++/** A request whose body is sent in two halves; → { req, response: Promise<{ status, headers, body, at }>, finish() } */
++function slowPost(port, p, headers = {}) {
++ const body = Buffer.from(JSON.stringify({ probe: 'graceful-stop', pad: 'x'.repeat(64) }));
++ const req = http.request({ host: '127.0.0.1', port, path: p, method: 'POST', agent: false, headers: { 'Content-Type': 'application/json', 'Content-Length': body.length, ...headers } });
++ const response = new Promise((resolve, reject) => {
++ req.on('response', (res) => { let b = ''; res.setEncoding('utf8'); res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b, at: Date.now() })); });
++ req.on('error', reject);
++ });
++ req.write(body.subarray(0, 10));
++ return { req, response, finish: () => req.end(body.subarray(10)) };
++}
++async function refused(port) {
++ try { await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1000) }); return false; } catch { return true; }
++}
++
++async function helperTests() {
++ // 1. In flight finishes, idle keep-alive closes, steps in order, exit 0.
++ {
++ const order = [];
++ let code = null;
++ const server = await listen((req, res) => {
++ if (req.url === '/slow') return setTimeout(() => { order.push('answered'); res.end('done'); }, 400);
++ res.end('ok');
++ });
++ const port = server.address().port;
++ const g = gracefulStop({ name: 't1', server, signals: false, log: quiet, exit: (c) => { code = c; }, stop: [() => order.push('stop')], close: [async () => { await sleep(10); order.push('close'); }] });
++ const agent = new http.Agent({ keepAlive: true });
++ await new Promise((r) => http.get({ port, path: '/', agent }, (res) => { res.resume(); res.on('end', r); })); // leaves an idle keep-alive connection
++ const slow = new Promise((resolve) => http.get({ port, path: '/slow', agent: false }, (res) => { let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ b, connection: res.headers.connection })); }));
++ await sleep(50);
++ const t0 = Date.now();
++ const stopped = g.stop('SIGTERM');
++ await sleep(50);
++ assert.ok(await refused(port), 'no new connections once stopping');
++ const r = await slow;
++ assert.strictEqual(r.b, 'done', 'the request in flight is answered');
++ assert.strictEqual(r.connection, 'close', 'and told the connection closes');
++ assert.strictEqual(await stopped, 0);
++ const ms = Date.now() - t0;
++ assert.ok(ms < 1500, `stopped once the request finished, not after the keep-alive timeout (${ms} ms)`);
++ assert.deepStrictEqual(order, ['stop', 'answered', 'close']);
++ assert.strictEqual(code, 0);
++ assert.strictEqual(g.stopping(), true);
++ agent.destroy();
++ }
++ // 2. An open event stream is closed at once (EventSource reconnects); the stop does not wait for it.
++ {
++ let code = null;
++ const server = await listen((req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write(': hi\n\n'); });
++ const port = server.address().port;
++ const g = gracefulStop({ name: 't2', server, signals: false, log: quiet, drainMs: 3000, exit: (c) => { code = c; } });
++ const ended = new Promise((resolve) => http.get({ port, path: '/events', agent: false }, (res) => { res.resume(); res.on('close', resolve); res.on('error', resolve); }));
++ await sleep(50);
++ const t0 = Date.now();
++ await g.stop();
++ await ended;
++ assert.ok(Date.now() - t0 < 1000, 'the event stream did not hold the stop');
++ assert.strictEqual(code, 0);
++ }
++ // 3. A request that outlives the drain is cut; the close steps still run; exit 0.
++ {
++ let code = null, closed = false;
++ const server = await listen(() => { /* never answers */ });
++ const port = server.address().port;
++ const g = gracefulStop({ name: 't3', server, signals: false, log: quiet, drainMs: 300, exit: (c) => { code = c; }, close: [() => { closed = true; }] });
++ const cut = new Promise((resolve) => { const q = http.get({ port, path: '/hang', agent: false }); q.on('error', resolve); q.on('response', resolve); });
++ await sleep(50);
++ const t0 = Date.now();
++ await g.stop();
++ await cut;
++ const ms = Date.now() - t0;
++ assert.ok(ms >= 280 && ms < 1500, `cut at the drain limit (${ms} ms)`);
++ assert.ok(closed, 'close steps ran');
++ assert.strictEqual(code, 0);
++ }
++ // 4. A step that hangs past the deadline: exit 1 at the deadline.
++ {
++ const server = await listen((req, res) => res.end());
++ const exited = new Promise((resolve) => {
++ gracefulStop({ name: 't4', server, signals: false, log: quiet, deadlineMs: 300, exit: resolve, stop: [() => new Promise(() => {})] }).stop();
++ });
++ const t0 = Date.now();
++ assert.strictEqual(await exited, 1);
++ assert.ok(Date.now() - t0 < 1000);
++ server.close();
++ }
++}
++
++async function processTests() {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-graceful-'));
++ // Food proxies its APIs to maps: a stand-in maps that answers slowly makes a request in flight.
++ const maps = await listen((req, res) => {
++ if (req.url.startsWith('/api/ready')) return res.end('{"status":"ready"}');
++ setTimeout(() => { res.setHeader('Content-Type', 'application/json'); res.end('[]'); }, 800);
++ });
++ const mapsUrl = `http://127.0.0.1:${maps.address().port}`;
++ const dirs = (a) => ({ DATA_DIR: path.join(tmp, a), UPLOADS_DIR: path.join(tmp, a, 'up'), OUTPUT_DIR: path.join(tmp, a, 'out') });
++ const apps = [
++ { app: 'gateway', env: { ...dirs('gateway'), TOOLS_REVOCATIONS_DB: path.join(tmp, 'revocations.db'), OV_DOMAINS_URL: 'http://127.0.0.1:9/api/domains', OV_REGISTRY_URL: 'http://127.0.0.1:9/registry', TOOLS_SATELLITE_PORTS: 'img=9,audio=9,docs=9,yt=9,text=9,maps=9,food=9' }, post: '/internal/events' },
++ { app: 'img', env: dirs('img'), post: '/api/graceful-probe' },
++ { app: 'audio', env: dirs('audio'), post: '/api/graceful-probe' },
++ { app: 'docs', env: dirs('docs'), post: '/api/graceful-probe' },
++ { app: 'yt', env: dirs('yt'), post: '/api/graceful-probe' },
++ { app: 'text', env: dirs('text'), post: '/api/graceful-probe' },
++ { app: 'maps', env: dirs('maps'), post: '/api/graceful-probe', readyPath: '/api/ready' },
++ { app: 'food', env: { ...dirs('food'), MAPS_API: mapsUrl }, get: '/api/foods?q=rice', readyPath: '/api/ready' },
++ ];
++ const results = [];
++ for (const a of apps) {
++ const port = await freePort();
++ const p = await startApp(a.app, a.env, port, a.readyPath ? { readyPath: a.readyPath } : undefined);
++ try {
++ let inflight;
++ if (a.post) {
++ inflight = slowPost(port, a.post);
++ } else {
++ const req = http.get({ host: '127.0.0.1', port, path: a.get, agent: false });
++ inflight = { response: new Promise((resolve, reject) => { req.on('response', (res) => { let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b, at: Date.now() })); }); req.on('error', reject); }), finish() {} };
++ }
++ await sleep(200); // the request has reached the handler (its body is still coming, or maps is slow)
++ const t0 = Date.now();
++ const exited = p.kill('SIGTERM');
++ await sleep(300);
++ assert.ok(await refused(port), `${a.app}: no new connections once stopping`);
++ inflight.finish();
++ const r = await inflight.response;
++ assert.ok(r.at > t0, `${a.app}: the request was still in flight at SIGTERM`);
++ assert.ok(r.status >= 200 && r.status < 600, `${a.app}: answered (${r.status})`);
++ assert.strictEqual(r.headers.connection, 'close', `${a.app}: told the connection closes`);
++ const code = await exited;
++ const ms = Date.now() - t0;
++ assert.strictEqual(code, 0, `${a.app}: exit code 0\n${p.output().slice(-2000)}`);
++ assert.ok(ms < 5000, `${a.app}: exited within the manifest's 5 s (${ms} ms)`);
++ assert.match(p.output(), /stopped in \d+ ms/, `${a.app}: logged its stop`);
++ results.push(`${a.app} ${ms} ms`);
++ } catch (err) {
++ p.kill('SIGKILL');
++ throw err;
++ }
++ }
++ maps.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ return results;
++}
++
++(async () => {
++ await helperTests();
++ const times = await processTests();
++ console.log(`graceful stop: helper ok; SIGTERM → exit 0 with the request in flight answered: ${times.join(', ')}`);
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/_shared/usage.js b/apps/_shared/usage.js
iindex 8ad3868..8321bd9 100644
---- a/apps/_shared/usage.js
++++ b/apps/_shared/usage.js
@@@ -206,6 +206,14 @@ async function mergeGuestFavorites(req, res, subject, host, rec = recorder()) {
let _recorder = null;
function recorder() { if (!_recorder) _recorder = createRecorder(); return _recorder; }
++/** Graceful stop (apps/_shared/graceful.js): stop the flush timer and send what this process gathered, at most `ms`. */
++async function stopRecorder(ms = 1000) {
++ if (!_recorder) return;
++ _recorder.stop();
++ if (!_recorder.enabled || !_recorder.stats().pending) return;
++ await require('./graceful').within(ms, _recorder.flush());
++ _recorder.stop();
++}
const MERGED_COOKIE = 'ov_recent_merged';
/**
@@@ -262,4 +270,4 @@ function usagePages({ snapshot, rec = null }) {
};
}
--module.exports = { createRecorder, recorder, usagePages, recentFromCookie, mergeGuestTools, favoritesFromCookie, setCookieFavorite, mergeGuestFavorites, NS, COOKIE, MERGED_COOKIE, FAV_COOKIE };
++module.exports = { createRecorder, recorder, stopRecorder, usagePages, recentFromCookie, mergeGuestTools, favoritesFromCookie, setCookieFavorite, mergeGuestFavorites, NS, COOKIE, MERGED_COOKIE, FAV_COOKIE };
ddiff --git a/apps/audio/server/index.js b/apps/audio/server/index.js
iindex fc7c211..7bdd8bf 100644
---- a/apps/audio/server/index.js
++++ b/apps/audio/server/index.js
@@@ -387,15 +387,22 @@ const server = app.listen(config.port, config.host, () => {
console.log(`╚═══════════════════════════════════════╝\n`);
});
--// ── Graceful Shutdown ────────────────────────────────────────
--function shutdown() {
-- console.log('[Audio.OpenVibe] Shutting down...');
-- analytics.destroy();
-- analyticsDb.close();
-- jobs.close(); // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
-- guard.close();
-- server.close(() => process.exit(0));
-- setTimeout(() => process.exit(1), 5000);
--}
--process.on('SIGTERM', shutdown);
--process.on('SIGINT', shutdown);
++// ── Graceful stop (roadmap WS-P lifecycle; apps/_shared/graceful.js) ──
++// SIGTERM: the retention timer and the job worker stop (nothing new starts; open job event streams
++// end); the server stops taking connections and lets requests in flight finish (4 s at most); then
++// recent-tool writes are flushed (1 s at most), the analytics, the jobs store and the guard close,
++// and the process exits 0, within the manifest's 5 s.
++require('../../_shared/graceful').gracefulStop({
++ name: 'Audio.OpenVibe', server,
++ stop: [
++ () => retention.stopCleanup(),
++ () => jobs.stop(),
++ ],
++ close: [
++ () => require('../../_shared/usage').stopRecorder(800),
++ () => analytics.destroy(),
++ () => analyticsDb.close(),
++ () => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
++ () => guard.close(),
++ ],
++});
ddiff --git a/apps/docs/server/index.js b/apps/docs/server/index.js
iindex 5afd650..173866a 100644
---- a/apps/docs/server/index.js
++++ b/apps/docs/server/index.js
@@@ -411,16 +411,23 @@ const server = app.listen(config.port, config.host, () => {
}
});
--// ── Graceful Shutdown ────────────────────────────────────────
--function shutdown() {
-- console.log('[Docs.OpenVibe] Shutting down...');
-- analytics.destroy();
-- analyticsDb.close();
-- jobs.close(); // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
-- pool.close();
-- guard.close();
-- server.close(() => process.exit(0));
-- setTimeout(() => process.exit(1), 5000);
--}
--process.on('SIGTERM', shutdown);
--process.on('SIGINT', shutdown);
++// ── Graceful stop (roadmap WS-P lifecycle; apps/_shared/graceful.js) ──
++// SIGTERM: the retention timer and the job worker stop (nothing new starts; open job event streams
++// end); the server stops taking connections and lets requests in flight finish (4 s at most); then
++// recent-tool writes are flushed (1 s at most), the analytics, the jobs store, the worker pool and the
++// guard close, and the process exits 0, within the manifest's 5 s.
++require('../../_shared/graceful').gracefulStop({
++ name: 'Docs.OpenVibe', server,
++ stop: [
++ () => retention.stopCleanup(),
++ () => jobs.stop(),
++ ],
++ close: [
++ () => require('../../_shared/usage').stopRecorder(800),
++ () => analytics.destroy(),
++ () => analyticsDb.close(),
++ () => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
++ () => pool.close(),
++ () => guard.close(),
++ ],
++});
ddiff --git a/apps/food/server/index.js b/apps/food/server/index.js
iindex eaf801b..3465e96 100644
---- a/apps/food/server/index.js
++++ b/apps/food/server/index.js
@@@ -158,14 +158,14 @@ const server = app.listen(PORT, '127.0.0.1', () => {
console.log(`[Food.OpenVibe] 🍽️ food.openvibe.tools listening on 127.0.0.1:${PORT}`);
});
--// ── Graceful Shutdown ────────────────────────────────────────
--function shutdown() {
-- console.log('[Food.OpenVibe] Shutting down...');
-- analytics.destroy();
-- analyticsDb.close();
-- guard.close();
-- server.close(() => process.exit(0));
-- setTimeout(() => process.exit(1), 5000);
--}
--process.on('SIGTERM', shutdown);
--process.on('SIGINT', shutdown);
++// ── Graceful stop (roadmap WS-P lifecycle; apps/_shared/graceful.js) ──
++// SIGTERM: the server stops taking connections and lets requests in flight finish (4 s at most);
++// then the analytics and the guard close, and the process exits 0, within the manifest's 5 s.
++require('../../_shared/graceful').gracefulStop({
++ name: 'Food.OpenVibe', server,
++ close: [
++ () => analytics.destroy(),
++ () => analyticsDb.close(),
++ () => guard.close(),
++ ],
++});
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 8baaa66..2c8a3c3 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -121,7 +121,7 @@ app.use(helmet({
}));
// ── Host roles: aliases redirect, unknown hosts leave, satellite hosts are proxied ──
const { hostRoles } = require('./registry/host-middleware');
--registry.start();
++const registryTimer = registry.start();
app.use(hostRoles({
hostOf: getRequestHost,
enforce: config.isProduction || process.env.OV_ENFORCE_HOSTS === '1',
@@@ -482,13 +482,13 @@ app.get('*', (req, res) => {
// ── Start ────────────────────────────────────────────────────
// What each satellite says about its own tools (a program missing on the host → unavailable here too).
--toolRegistry.startSatellitePolling(SATELLITES);
++const satellitePolling = toolRegistry.startSatellitePolling(SATELLITES);
// Every tool as an OpenVibe.Search document (owner tools), kept in step with the registry (S9).
const searchIndex = require('./search-index').searchIndexerFromEnv({ snapshot: toolRegistry.snapshot, dataDir: require('path').resolve(__dirname, '..', process.env.DATA_DIR || 'data') });
if (searchIndex.indexer) searchIndex.indexer.start(); else console.log(`[SearchIndex] off: ${searchIndex.reason}`);
--app.listen(config.port, config.host, () => {
++const server = app.listen(config.port, config.host, () => {
// Subscribe to Network's token cutoffs (retried a few times; off without EVENTS_URL / TOOLS_EVENTS_SECRET).
const subscribe = (n) => require('./revocation-events').ensureSubscription({ port: config.port }).catch((e) => {
console.warn('[Events] token cutoff subscription not ready:', e.message);
@@@ -504,3 +504,24 @@ app.listen(config.port, config.host, () => {
console.log(`║ Live: ${config.liveUrl.padEnd(32)}║`);
console.log(`╚════════════════════════════════════════════╝\n`);
});
++
++// ── Stop (roadmap WS-P lifecycle; apps/_shared/graceful.js) ──
++// SIGTERM: the catalog, services and satellite pollers and the search indexer stop; the server stops
++// taking connections and lets requests in flight finish (4 s at most; event streams are closed and
++// reconnect); then recent-tool writes are flushed (1 s at most), the engine pool, the guard and the
++// cutoff store close, and the process exits 0 — within the manifest's 5 s.
++require('../../_shared/graceful').gracefulStop({
++ name: 'Tools gateway', server,
++ stop: [
++ () => clearInterval(registryTimer),
++ () => registry.services.stop(),
++ () => satellitePolling.stop(),
++ () => { if (searchIndex.indexer) searchIndex.indexer.stop(); },
++ ],
++ close: [
++ () => require('../../_shared/usage').stopRecorder(800),
++ () => runApi.pool.close(),
++ () => guard.close(),
++ () => require('./revocation-events').close(),
++ ],
++});
ddiff --git a/apps/gateway/server/revocation-events.js b/apps/gateway/server/revocation-events.js
iindex 7025be0..f26d400 100644
---- a/apps/gateway/server/revocation-events.js
++++ b/apps/gateway/server/revocation-events.js
@@@ -14,12 +14,14 @@ const { revocationsFile } = require('../../_shared/guard/revocations');
const TOPIC = 'network.user.token_valid_after';
let store = null;
++let storeDb = null;
function cutoffs() {
if (store) return store;
const Database = require('better-sqlite3');
const file = revocationsFile();
fs.mkdirSync(path.dirname(file), { recursive: true });
const db = new Database(file);
++ storeDb = db;
// A rollback journal, not WAL: the other apps open the file read-only under ProtectSystem=strict and could not
// create a WAL's -shm file. Writes are one row per sign-out-everywhere.
db.pragma('journal_mode = DELETE');
@@@ -27,6 +29,13 @@ function cutoffs() {
store = require('openvibe-sdk/auth').createRevocationStore(db, { table: 'token_revocations' });
return store;
}
++/** Close the cutoff store (graceful stop); the next request opens it again. */
++function close() {
++ const db = storeDb;
++ store = null;
++ storeDb = null;
++ if (db) try { db.close(); } catch { /* already closed */ }
++}
const secrets = () => String(process.env.TOOLS_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter((s) => s.length >= 32);
const stats = { received: 0, revoked: 0, refused: 0 };
@@@ -71,4 +80,4 @@ async function ensureSubscription({ port, fetchImpl = globalThis.fetch, log = co
return 'created';
}
--module.exports = { handler, ensureSubscription, stats, TOPIC, _cutoffs: cutoffs };
++module.exports = { handler, ensureSubscription, close, stats, TOPIC, _cutoffs: cutoffs };
ddiff --git a/apps/img/server/index.js b/apps/img/server/index.js
iindex 8259890..9ea0367 100644
---- a/apps/img/server/index.js
++++ b/apps/img/server/index.js
@@@ -351,16 +351,23 @@ const server = app.listen(config.port, config.host, () => {
console.log(h.available ? `[Img.OpenVibe] HEIC decoder: ${h.path}` : `[Img.OpenVibe] HEIC uploads answer 503 until libheif's decoder is installed (${h.detail})`);
});
--// ── Graceful Shutdown ────────────────────────────────────────
--function shutdown() {
-- console.log('[Img.OpenVibe] Shutting down...');
-- analytics.destroy();
-- analyticsDb.close();
-- jobs.close(); // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
-- pool.close();
-- guard.close();
-- server.close(() => process.exit(0));
-- setTimeout(() => process.exit(1), 5000);
--}
--process.on('SIGTERM', shutdown);
--process.on('SIGINT', shutdown);
++// ── Graceful stop (roadmap WS-P lifecycle; apps/_shared/graceful.js) ──
++// SIGTERM: the retention timer and the job worker stop (nothing new starts; open job event streams
++// end); the server stops taking connections and lets requests in flight finish (4 s at most); then
++// recent-tool writes are flushed (1 s at most), the analytics, the jobs store, the worker pool and the
++// guard close, and the process exits 0, within the manifest's 5 s.
++require('../../_shared/graceful').gracefulStop({
++ name: 'Img.OpenVibe', server,
++ stop: [
++ () => retention.stopCleanup(),
++ () => jobs.stop(),
++ ],
++ close: [
++ () => require('../../_shared/usage').stopRecorder(800),
++ () => analytics.destroy(),
++ () => analyticsDb.close(),
++ () => jobs.close(), // running jobs stay 'running' in data/jobs.db; the next boot re-queues them
++ () => pool.close(),
++ () => guard.close(),
++ ],
++});
ddiff --git a/apps/maps/server/index.js b/apps/maps/server/index.js
iindex f955c84..683714a 100644
---- a/apps/maps/server/index.js
++++ b/apps/maps/server/index.js
@@@ -491,14 +491,16 @@ const server = app.listen(config.port, config.host, () => {
console.log(`[Maps.OpenVibe] 🗺️ maps.openvibe.tools listening on ${config.host}:${config.port}`);
});
--// ── Graceful Shutdown ────────────────────────────────────────
--function shutdown() {
-- console.log('[Maps.OpenVibe] Shutting down...');
-- analytics.destroy();
-- analyticsDb.close();
-- guard.close();
-- server.close(() => process.exit(0));
-- setTimeout(() => process.exit(1), 5000);
--}
--process.on('SIGTERM', shutdown);
--process.on('SIGINT', shutdown);
++// ── Graceful stop (roadmap WS-P lifecycle; apps/_shared/graceful.js) ──
++// SIGTERM: the server stops taking connections and lets requests in flight finish (4 s at most;
++// event streams are closed and reconnect); then recent-tool writes are flushed (1 s at most), the
++// analytics and the guard close, and the process exits 0, within the manifest's 5 s.
++require('../../_shared/graceful').gracefulStop({
++ name: 'Maps.OpenVibe', server,
++ close: [
++ () => require('../../_shared/usage').stopRecorder(800),
++ () => analytics.destroy(),
++ () => analyticsDb.close(),
++ () => guard.close(),
++ ],
++});
ddiff --git a/apps/text/server/index.js b/apps/text/server/index.js
iindex 26ad325..41351ad 100644
---- a/apps/text/server/index.js
++++ b/apps/text/server/index.js
@@@ -174,14 +174,16 @@ const server = app.listen(config.port, config.host, () => {
console.log(`╚═══════════════════════════════════════╝\n`);
});
--// ── Graceful Shutdown ────────────────────────────────────────
--function shutdown() {
-- console.log('[Text.OpenVibe] Shutting down...');
-- analytics.destroy();
-- analyticsDb.close();
-- guard.close();
-- server.close(() => process.exit(0));
-- setTimeout(() => process.exit(1), 5000);
--}
--process.on('SIGTERM', shutdown);
--process.on('SIGINT', shutdown);
++// ── Graceful stop (roadmap WS-P lifecycle; apps/_shared/graceful.js) ──
++// SIGTERM: the server stops taking connections and lets requests in flight finish (4 s at most;
++// event streams are closed and reconnect); then recent-tool writes are flushed (1 s at most), the
++// analytics and the guard close, and the process exits 0, within the manifest's 5 s.
++require('../../_shared/graceful').gracefulStop({
++ name: 'Text.OpenVibe', server,
++ close: [
++ () => require('../../_shared/usage').stopRecorder(800),
++ () => analytics.destroy(),
++ () => analyticsDb.close(),
++ () => guard.close(),
++ ],
++});
ddiff --git a/apps/yt/server/index.js b/apps/yt/server/index.js
iindex 489e53f..227f93d 100644
---- a/apps/yt/server/index.js
++++ b/apps/yt/server/index.js
@@@ -324,14 +324,20 @@ const server = app.listen(config.port, config.host, () => {
console.log(`╚═══════════════════════════════════════╝\n`);
});
--// ── Graceful Shutdown ────────────────────────────────────────
--function shutdown() {
-- console.log('[YT.OpenVibe] Shutting down...');
-- analytics.destroy();
-- analyticsDb.close();
-- guard.close();
-- server.close(() => process.exit(0));
-- setTimeout(() => process.exit(1), 5000);
--}
--process.on('SIGTERM', shutdown);
--process.on('SIGINT', shutdown);
++// ── Graceful stop (roadmap WS-P lifecycle; apps/_shared/graceful.js) ──
++// SIGTERM: the download cleanup timer stops; the server stops taking connections and lets requests
++// in flight finish (4 s at most; progress streams are closed and reconnect); then recent-tool writes
++// are flushed (1 s at most), the analytics and the guard close, and the process exits 0, within the
++// manifest's 5 s.
++require('../../_shared/graceful').gracefulStop({
++ name: 'YT.OpenVibe', server,
++ stop: [
++ () => downloader.stopCleanup(),
++ ],
++ close: [
++ () => require('../../_shared/usage').stopRecorder(800),
++ () => analytics.destroy(),
++ () => analyticsDb.close(),
++ () => guard.close(),
++ ],
++});