openvibers/openvibe.bot Commits

Move Bot's shutdown onto openvibe-sdk/service gracefulStop (#7)

OP OpenVibers committed 2 Oct 2026 · 7ca0a20958 · 4 files, +142 −26

local checks ✓ 4/4 · no CI checks · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Bot/pull/7

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 7d4f737..368151d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.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.2.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
@@@ -860,9 +860,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.25.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
-- "integrity": "sha512-8eurZLyFUtB8XN16hLUuYkah+qwS3tkvdzul5qdZkJTi0aZ4q8dq2QGWSq6pN5mtVPdJyuJ8Mo53r36lixmP1g==",
++ "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 d627c55..2c1d7d2 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.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.2.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
ddiff --git a/server/index.js b/server/index.js
iindex d1ffee7..58d6fb4 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -17,6 +17,7 @@ const { openDb, migrate } = require('./db');
const { seedProfiles } = require('./profiles');
const { createApp } = require('./app');
const { createValkey } = require('openvibe-sdk/valkey');
++const { gracefulStop } = require('openvibe-sdk/service');
const { createRegistry } = require('openvibe-shared/metrics');
async function main() {
@@@ -48,26 +49,37 @@ async function main() {
server.keepAliveTimeout = 65_000;
server.on('upgrade', (req, socket, head) => hub.handleUpgrade(req, socket, head));
-- let closing = false;
-- async function shutdown(signal) {
-- if (closing) return;
-- closing = true;
-- console.log(`[Bot] ${signal} — closing`);
-- setTimeout(() => process.exit(0), 5000).unref();
-- timers.forEach(clearInterval);
-- keys.stop();
-- await hub.close().catch(() => {});
-- await outbox.stop();
-- await new Promise((resolve) => server.close(resolve));
-- await db.close().catch(() => {});
-- if (valkey) await valkey.close().catch(() => {});
-- process.exit(0);
-- }
-- process.on('SIGTERM', async () => { await shutdown('SIGTERM'); });
-- process.on('SIGINT', async () => { await shutdown('SIGINT'); });
++ // systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the stop
++ // steps in order (nothing new starts), drains the HTTP server, runs the close steps, then exits. The stop steps
++ // keep the old hand-written shutdown's exact order, and hub.close stays a stop step BEFORE the drain: upgraded
++ // WebSocket sockets are not tracked by server.close. drainMs bounds the drain; deadlineMs 5000 keeps the old 5 s
++ // hard timer and deadlineExitCode 0 its exit 0. Bot's manifest declares no lifecycle.shutdown deadline, so the
++ // kit's 5000 ms default is the right value.
++ const { stop: shutdown } = gracefulStop({
++ name: 'Bot',
++ server,
++ drainMs: 4000,
++ deadlineMs: 5000,
++ deadlineExitCode: 0,
++ stop: [
++ () => timers.forEach(clearInterval),
++ () => keys.stop(),
++ () => hub.close().catch(() => {}),
++ () => outbox.stop(),
++ ],
++ close: [
++ () => db.close().catch(() => {}),
++ () => valkey && valkey.close().catch(() => {}),
++ ],
++ });
++ return { app, server, shutdown };
++}
++
++if (require.main === module) {
++ main().catch((e) => {
++ console.error(`[Bot] could not start: ${e.message}`);
++ process.exit(1);
++ });
}
--main().catch((e) => {
-- console.error(`[Bot] could not start: ${e.message}`);
-- process.exit(1);
--});
++module.exports = { main };
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 0000000..9ce2cbe
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -0,0 +1,104 @@
++'use strict';
++/**
++ * Bot's shutdown moved onto openvibe-sdk/service (plan T1 lane A): main() hands the signal to gracefulStop,
++ * whose stop steps keep the old hand-written order and whose close steps close the database. Bot's manifest
++ * declares no lifecycle.shutdown deadline (OpenVibe.Contracts has no manifests/services/bot.json), so deadlineMs
++ * is the kit's 5000 default — today's hard timer — and deadlineExitCode 0 keeps its exit 0.
++ *
++ * The first four checks read server/index.js itself (the entry point cannot be inspected through its exports
++ * alone); the rest boot the real entry point with the same stubs the harness uses and drive the returned
++ * shutdown with process.exit stubbed, so the stop steps can be observed in order and the database close seen.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { check, done } = require('./helpers/app');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');
++
++(async () => {
++ await check('server/index.js imports gracefulStop from openvibe-sdk/service', () => {
++ const m = src.match(/const\s*\{([^}]*)\}\s*=\s*require\(['"]openvibe-sdk\/service['"]\)/);
++ assert.ok(m, "require('openvibe-sdk/service')'s exports are not destructured");
++ assert.ok(/\bgracefulStop\b/.test(m[1]), 'gracefulStop is not imported');
++ });
++
++ await check('server/index.js leaves the signal handlers to the kit', () => {
++ assert.ok(!/process\.on\(\s*['"]SIGTERM['"]/.test(src), 'server/index.js still installs its own SIGTERM handler');
++ assert.ok(!/process\.on\(\s*['"]SIGINT['"]/.test(src), 'server/index.js still installs its own SIGINT handler');
++ assert.ok(/gracefulStop\(\{/.test(src), 'gracefulStop is not called in main()');
++ });
++
++ await check("gracefulStop is named 'Bot' and keeps the old 5 s deadline and exit 0", () => {
++ assert.ok(/name:\s*['"]Bot['"]/.test(src), "gracefulStop is not named 'Bot'");
++ assert.ok(/drainMs:\s*4000/.test(src), 'drainMs is not 4000');
++ assert.ok(/deadlineMs:\s*5000/.test(src), 'deadlineMs is not 5000');
++ assert.ok(/deadlineExitCode:\s*0/.test(src), 'deadlineExitCode is not 0');
++ });
++
++ await check('hub.close stays a stop step before the HTTP drain; the database closes after it', () => {
++ const stop = src.indexOf('() => hub.close()');
++ const close = src.indexOf('() => db.close()');
++ assert.ok(stop !== -1 && close !== -1, 'hub.close / db.close steps not found');
++ assert.ok(stop < close, 'hub.close must be a stop step (before the drain and the close steps)');
++ });
++
++ // Boot the real entry point against the harness stubs; PORT=0 picks a free port and jobs are off. main()
++ // reads process.env through loadConfig(), and with no DATABASE_URL it opens the development PGlite handle.
++ const { startNetwork, startEvents } = require('./helpers/stubs');
++ const network = await startNetwork();
++ const events = await startEvents();
++ Object.assign(process.env, {
++ NODE_ENV: 'test',
++ PORT: '0',
++ HOST: '127.0.0.1',
++ BASE_URL: 'http://bot.test',
++ BOT_JOBS: 'off',
++ OV_NETWORK_URL: network.url,
++ OV_NETWORK_INTERNAL_URL: network.url,
++ OV_NETWORK_ISSUER: network.url,
++ OV_OAUTH_CLIENT_ID: 'bot',
++ OV_OAUTH_CLIENT_SECRET: 'shh',
++ EVENTS_URL: events.url,
++ });
++ delete process.env.DATABASE_URL;
++ delete process.env.DATABASE_DIRECT_URL;
++ delete process.env.VALKEY_URL;
++
++ let h = null;
++ await check('main() boots and returns a shutdown handle', async () => {
++ const { main } = require('../server/index');
++ h = await main();
++ assert.strictEqual(typeof h.shutdown, 'function', 'main() returned a shutdown function');
++ });
++
++ await check('the returned shutdown runs the stop steps in order and closes the database', async () => {
++ if (!h) return;
++ const order = [];
++ const wrap = (obj, name, tag) => { const orig = obj[name].bind(obj); obj[name] = (...a) => { order.push(tag); return orig(...a); }; };
++ wrap(h.app.locals.keys, 'stop', 'keys.stop');
++ wrap(h.app.locals.hub, 'close', 'hub.close');
++ wrap(h.app.locals.outbox, 'stop', 'outbox.stop');
++ wrap(h.app.locals.db, 'close', 'db.close');
++
++ const realExit = process.exit;
++ let exitCode = null;
++ process.exit = (code) => { exitCode = code; };
++ let code;
++ try { code = await h.shutdown(); } finally { process.exit = realExit; }
++
++ assert.strictEqual(exitCode, 0, 'exit() is called with 0');
++ assert.strictEqual(code, 0, 'the shutdown resolves with exit code 0');
++ assert.strictEqual(h.server.listening, false, 'the HTTP server stops listening');
++ assert.deepStrictEqual(order, ['keys.stop', 'hub.close', 'outbox.stop', 'db.close'],
++ `the stop steps ran in the old order (saw ${order.join(', ')})`);
++
++ // The kit starts the stop once: a second call is the same promise, no step runs again.
++ const again = await h.shutdown();
++ assert.strictEqual(again, 0, 'a second stop resolves with the same code');
++ assert.strictEqual(order.length, 4, 'a second stop runs no step again');
++ });
++
++ await Promise.all([network.close(), events.close()]);
++ done();
++})();