openvibers/openvibe.host Commits
Move Host's shutdown onto openvibe-sdk/service gracefulStop (#10)
Changes
@@@ -18,7 +18,7 @@"express-rate-limit": "^7.5.0","iovalkey": "^0.4.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.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"},@@@ -1517,9 +1517,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"@@@ -34,7 +34,7 @@"express-rate-limit": "^7.5.0","iovalkey": "^0.4.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.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"},@@@ -8,8 +8,9 @@* object sweep).*/const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');--(async () => {++async function main() {const { app, ctx } = await createApp();const { config } = ctx;@@@ -22,17 +23,27 @@ server.requestTimeout = 10 * 60_000; // large uploads on slow linksctx.outbox.start();ctx.worker.start();--function shutdown(signal) {-- console.log(`[Host] ${signal}: closing`);-- ctx.worker.stop();-- server.close(async () => {-- try { await ctx.outbox.stop(); } catch { /* best effort */ }-- try { ctx.stopMetrics(); } catch { /* best effort */ }-- try { await ctx.store.close(); } catch { /* already closed */ }-- process.exit(0);-- });-- setTimeout(() => process.exit(0), 5000).unref();++// Signals belong to the kit: the worker stops taking new work, requests in flight drain for at most++// 4000 ms (a shorter drain than the 10 min upload timeout cuts an upload exactly as the old 5 s++// exit-0 timer did; Host's manifest declares lifecycle.shutdown.deadlineSeconds 5), then the outbox,++// metrics and database close, each best effort, and the process exits 0.++const { stop } = gracefulStop({++ name: 'Host',++ server,++ stop: [() => ctx.worker.stop()],++ close: [++ () => ctx.outbox.stop(),++ () => ctx.stopMetrics(),++ () => ctx.store.close(),++ ],++ drainMs: 4000,++ deadlineMs: 5000,++ deadlineExitCode: 0,++});++return { app, server, shutdown: stop };++}++++if (require.main === module) {++ main().catch((err) => { console.error('[Host] failed to start:', err); process.exit(1); });}--process.on('SIGTERM', () => shutdown('SIGTERM'));--process.on('SIGINT', () => shutdown('SIGINT'));--})().catch((err) => { console.error('[Host] failed to start:', err); process.exit(1); });++module.exports = { main };nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * Host's shutdown moved onto openvibe-sdk/service (plan T1 lane A): main() hands the signal to gracefulStop,++ * whose stop step keeps the old order (worker stops first), whose close steps keep the old order (outbox,++ * metrics, then the database, each best effort) and whose exit 0 on a blown deadline matches the old 5 s++ * exit-0 timer. Host's manifest (OpenVibe.Contracts manifests/services/host.json lifecycle.shutdown) declares++ * deadlineSeconds 5, and the 10 min upload requestTimeout stays: drainMs 4000 is what cuts an in-flight upload++ * 5 s after SIGTERM today.++ *++ * The first checks read server/index.js itself (the entry point cannot be inspected through its exports alone);++ * the last boots the real main() with a temp PGlite database and drives the returned shutdown with process.exit++ * stubbed, so the stop steps can be observed in order.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { check, done } = require('./stageb/boot');++++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 'Host' and keeps the 5 s deadline and exit 0", () => {++ assert.ok(/name:\s*['"]Host['"]/.test(src), "gracefulStop is not named 'Host'");++ 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('the worker stop is a stop step and the database closes after the drain; the 10 min upload timeout is kept', () => {++ const stop = src.indexOf('() => ctx.worker.stop()');++ const close = src.indexOf('() => ctx.store.close()');++ assert.ok(stop !== -1 && close !== -1, 'worker.stop / store.close steps not found');++ assert.ok(stop < close, 'worker.stop must be a stop step (before the drain and the close steps)');++ assert.ok(/server\.requestTimeout\s*=\s*10\s*\*\s*60_000/.test(src), 'server.requestTimeout is no longer 10 * 60_000');++ });++++ // Boot the real entry point on a temp PGlite database, worker and limits off; main() reads process.env.++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-host-kit-'));++ Object.assign(process.env, {++ NODE_ENV: 'test',++ PORT: '0',++ HOST: '127.0.0.1',++ BASE_URL: 'http://host.test',++ HOST_ACTOR_LIMITS: 'off',++ HOST_WORKER: 'off',++ HOST_MIN_FREE_BYTES: '0',++ HOST_FORM_SECRET: 'test-form-secret',++ HOST_PGLITE_DIR: path.join(dir, 'pglite'),++ HOST_STORAGE_DIR: path.join(dir, 'objects'),++ COOKIE_SECURE: 'false',++ OV_NETWORK_URL: 'http://127.0.0.1:1',++ OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:1',++ OV_OAUTH_CLIENT_ID: 'host',++ OV_OAUTH_CLIENT_SECRET: 'shh',++ });++ delete process.env.DATABASE_URL;++ delete process.env.DATABASE_DIRECT_URL;++ delete process.env.VALKEY_URL;++ delete process.env.EVENTS_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 stops the worker then the outbox, metrics and store, and exits 0', async () => {++ if (!h) return;++ const ctx = h.app.locals.ctx;++ const order = [];++ const wrap = (obj, name, tag) => { const orig = obj[name].bind(obj); obj[name] = (...a) => { order.push(tag); return orig(...a); }; };++ wrap(ctx.worker, 'stop', 'worker.stop');++ wrap(ctx.outbox, 'stop', 'outbox.stop');++ wrap(ctx, 'stopMetrics', 'stopMetrics');++ wrap(ctx.store, 'close', 'store.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, ['worker.stop', 'outbox.stop', 'stopMetrics', 'store.close'],++ `the 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');++ });++++ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* the PGlite handle may hold it open */ }++ done();++})();
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.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.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1517,9 +1517,9 @@
@@ -1517,9 +1517,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -34,7 +34,7 @@
@@ -34,7 +34,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.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.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -8,8 +8,9 @@
@@ -8,8 +8,9 @@
* object sweep).
* object sweep).
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
-(async () => {
+async function main() {
const { app, ctx } = await createApp();
const { app, ctx } = await createApp();
const { config } = ctx;
const { config } = ctx;
@@ -22,17 +23,27 @@ server.requestTimeout = 10 * 60_000; // large uploads on slow links
@@ -22,17 +23,27 @@ server.requestTimeout = 10 * 60_000; // large uploads on slow links
ctx.outbox.start();
ctx.outbox.start();
ctx.worker.start();
ctx.worker.start();
-function shutdown(signal) {
- console.log(`[Host] ${signal}: closing`);
- ctx.worker.stop();
- server.close(async () => {
- try { await ctx.outbox.stop(); } catch { /* best effort */ }
- try { ctx.stopMetrics(); } catch { /* best effort */ }
- try { await ctx.store.close(); } catch { /* already closed */ }
- process.exit(0);
- });
- setTimeout(() => process.exit(0), 5000).unref();
+// Signals belong to the kit: the worker stops taking new work, requests in flight drain for at most
+// 4000 ms (a shorter drain than the 10 min upload timeout cuts an upload exactly as the old 5 s
+// exit-0 timer did; Host's manifest declares lifecycle.shutdown.deadlineSeconds 5), then the outbox,
+// metrics and database close, each best effort, and the process exits 0.
+const { stop } = gracefulStop({
+ name: 'Host',
+ server,
+ stop: [() => ctx.worker.stop()],
+ close: [
+ () => ctx.outbox.stop(),
+ () => ctx.stopMetrics(),
+ () => ctx.store.close(),
+ ],
+ drainMs: 4000,
+ deadlineMs: 5000,
+ deadlineExitCode: 0,
+});
+return { app, server, shutdown: stop };
+}
+
+if (require.main === module) {
+ main().catch((err) => { console.error('[Host] failed to start:', err); process.exit(1); });
}
}
-process.on('SIGTERM', () => shutdown('SIGTERM'));
-process.on('SIGINT', () => shutdown('SIGINT'));
-})().catch((err) => { console.error('[Host] failed to start:', err); process.exit(1); });
+module.exports = { main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * Host's shutdown moved onto openvibe-sdk/service (plan T1 lane A): main() hands the signal to gracefulStop,
+ * whose stop step keeps the old order (worker stops first), whose close steps keep the old order (outbox,
+ * metrics, then the database, each best effort) and whose exit 0 on a blown deadline matches the old 5 s
+ * exit-0 timer. Host's manifest (OpenVibe.Contracts manifests/services/host.json lifecycle.shutdown) declares
+ * deadlineSeconds 5, and the 10 min upload requestTimeout stays: drainMs 4000 is what cuts an in-flight upload
+ * 5 s after SIGTERM today.
+ *
+ * The first checks read server/index.js itself (the entry point cannot be inspected through its exports alone);
+ * the last boots the real main() with a temp PGlite database and drives the returned shutdown with process.exit
+ * stubbed, so the stop steps can be observed in order.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { check, done } = require('./stageb/boot');
+
+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 'Host' and keeps the 5 s deadline and exit 0", () => {
+ assert.ok(/name:\s*['"]Host['"]/.test(src), "gracefulStop is not named 'Host'");
+ 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('the worker stop is a stop step and the database closes after the drain; the 10 min upload timeout is kept', () => {
+ const stop = src.indexOf('() => ctx.worker.stop()');
+ const close = src.indexOf('() => ctx.store.close()');
+ assert.ok(stop !== -1 && close !== -1, 'worker.stop / store.close steps not found');
+ assert.ok(stop < close, 'worker.stop must be a stop step (before the drain and the close steps)');
+ assert.ok(/server\.requestTimeout\s*=\s*10\s*\*\s*60_000/.test(src), 'server.requestTimeout is no longer 10 * 60_000');
+ });
+
+ // Boot the real entry point on a temp PGlite database, worker and limits off; main() reads process.env.
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-host-kit-'));
+ Object.assign(process.env, {
+ NODE_ENV: 'test',
+ PORT: '0',
+ HOST: '127.0.0.1',
+ BASE_URL: 'http://host.test',
+ HOST_ACTOR_LIMITS: 'off',
+ HOST_WORKER: 'off',
+ HOST_MIN_FREE_BYTES: '0',
+ HOST_FORM_SECRET: 'test-form-secret',
+ HOST_PGLITE_DIR: path.join(dir, 'pglite'),
+ HOST_STORAGE_DIR: path.join(dir, 'objects'),
+ COOKIE_SECURE: 'false',
+ OV_NETWORK_URL: 'http://127.0.0.1:1',
+ OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:1',
+ OV_OAUTH_CLIENT_ID: 'host',
+ OV_OAUTH_CLIENT_SECRET: 'shh',
+ });
+ delete process.env.DATABASE_URL;
+ delete process.env.DATABASE_DIRECT_URL;
+ delete process.env.VALKEY_URL;
+ delete process.env.EVENTS_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 stops the worker then the outbox, metrics and store, and exits 0', async () => {
+ if (!h) return;
+ const ctx = h.app.locals.ctx;
+ const order = [];
+ const wrap = (obj, name, tag) => { const orig = obj[name].bind(obj); obj[name] = (...a) => { order.push(tag); return orig(...a); }; };
+ wrap(ctx.worker, 'stop', 'worker.stop');
+ wrap(ctx.outbox, 'stop', 'outbox.stop');
+ wrap(ctx, 'stopMetrics', 'stopMetrics');
+ wrap(ctx.store, 'close', 'store.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, ['worker.stop', 'outbox.stop', 'stopMetrics', 'store.close'],
+ `the 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');
+ });
+
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* the PGlite handle may hold it open */ }
+ done();
+})();