openvibers/openvibe.tools Commits

Gateway zero-downtime restarts: systemd owns the listener (openvibe-tools.socket); the process listens on fd 3 when handed it, as Live and Network (#76)

OP OpenVibers committed 27 days ago · 4b97024e94 · 5 files, +73 −1

Changes

ddiff --git a/apps/gateway/README.md b/apps/gateway/README.md
iindex 6c0eacd..9f417cc 100644
---- a/apps/gateway/README.md
++++ b/apps/gateway/README.md
@@@ -64,5 +64,7 @@ See `.env.example`. Key variables: `PORT` (4001), `OV_NETWORK_URL`,
- Path: `/opt/openvibe.tools/apps/gateway`
- Env: `/etc/openvibe/tools.env`
- Unit: `deploy/systemd/openvibe-tools.service`
++- Socket: `deploy/systemd/openvibe-tools.socket` (127.0.0.1:4001) with the `openvibe-tools.service.d/socket.conf`
++ drop-in: requests that arrive during a restart wait for the new process instead of getting 502s.
- Nginx: `deploy/nginx/openvibe.tools.conf` (apex + wildcard; satellites have
their own more-specific server blocks)
ddiff --git a/apps/gateway/deploy/systemd/openvibe-tools.service.d/socket.conf b/apps/gateway/deploy/systemd/openvibe-tools.service.d/socket.conf
nnew file mode 100644
iindex 0000000..5c38f88
---- /dev/null
++++ b/apps/gateway/deploy/systemd/openvibe-tools.service.d/socket.conf
@@@ -0,0 +1,9 @@
++# Bind the service to the socket unit that owns its listener. Installed as a drop-in so the main
++# unit stays usable on a box that has not been switched over to socket activation.
++[Unit]
++Requires=openvibe-tools.socket
++After=openvibe-tools.socket
++
++[Service]
++# Node's libuv expects a non-blocking descriptor. (A [Service] option: in [Socket] systemd ignores it.)
++NonBlocking=true
ddiff --git a/apps/gateway/deploy/systemd/openvibe-tools.socket b/apps/gateway/deploy/systemd/openvibe-tools.socket
nnew file mode 100644
iindex 0000000..f8728f3
---- /dev/null
++++ b/apps/gateway/deploy/systemd/openvibe-tools.socket
@@@ -0,0 +1,21 @@
++# OpenVibe.Tools gateway — listening socket, owned by systemd rather than by the process (as OpenVibe.Live's and Network's).
++#
++# nginx proxies straight to 127.0.0.1:4001. Without this, each restart closed that listener for the whole boot and
++# nginx got ECONNREFUSED: 11 refused connections on 2026-10-17 on openvibe.tools. With the socket held here, connections that arrive mid-restart wait in the
++# kernel's accept queue and are served as soon as the new process calls accept().
++#
++# It does not preserve established connections (WebSockets, streams): those reconnect.
++[Unit]
++Description=OpenVibe.Tools gateway HTTP socket
++# No PartOf=openvibe-tools.service: PartOf propagates the service's stop/restart to this unit, and the
++# listener would close and reopen on every deploy. The socket must outlive every restart.
++
++[Socket]
++# Loopback only: nginx is the only client (the host firewall is off, so 0.0.0.0 would be public).
++ListenStream=127.0.0.1:4001
++NoDelay=true
++# Match the listen backlog the process would have used itself.
++Backlog=511
++
++[Install]
++WantedBy=sockets.target
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex d31363e..52de450 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -500,7 +500,13 @@ const satellitePolling = toolRegistry.startSatellitePolling(SATELLITES);
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}`);
--const server = app.listen(config.port, config.host, () => {
++// Zero-downtime restarts (as OpenVibe.Live): when systemd owns the listener (deploy/systemd/openvibe-tools.socket) it
++// hands it over as file descriptor 3, and connections that arrive during a restart wait in the kernel's accept queue
++// instead of being refused (11 refused connections on 2026-10-17 on openvibe.tools). Without the socket unit this is the
++// usual listen.
++const socketActivated = process.env.LISTEN_FDS === '1' && Number(process.env.LISTEN_PID) === process.pid;
++if (socketActivated) console.log('[Server] socket-activated: listening on the socket systemd handed over (fd 3)');
++const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [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);
ddiff --git a/apps/gateway/test/systemd-units.test.js b/apps/gateway/test/systemd-units.test.js
nnew file mode 100644
iindex 0000000..9c992b4
---- /dev/null
++++ b/apps/gateway/test/systemd-units.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++/**
++ * Tools gateway's systemd units (deploy/systemd): the listening socket is systemd's, so a restart queues connections in the
++ * kernel instead of refusing them (11 refused connections on 2026-10-17 on openvibe.tools). The rules OpenVibe.Live learned (2026-09-26):
++ * - the socket listens on loopback only (the host firewall is off: 0.0.0.0 would be public);
++ * - NonBlocking= is a [Service] option (in the drop-in); in [Socket] systemd ignores it;
++ * - no PartOf= on the socket, or every restart closes and reopens the listener;
++ * - the process listens on fd 3 only when systemd handed it over (LISTEN_FDS=1 and its own pid), else on the port.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const root = path.join(__dirname, '..', 'deploy', 'systemd');
++const read = (...p) => fs.readFileSync(path.join(root, ...p), 'utf8');
++const section = (text, name) => (text.split(/^\[/m).find((s) => s.startsWith(`${name}]`)) || '');
++
++const socket = read('openvibe-tools.socket');
++const listens = [...section(socket, 'Socket').matchAll(/^ListenStream=(.+)$/gm)].map((m) => m[1].trim());
++assert.deepStrictEqual(listens, ['127.0.0.1:4001'], 'the socket listens on loopback only, on the service port');
++assert.ok(!/^NonBlocking=/m.test(section(socket, 'Socket')), 'NonBlocking= is a [Service] option, not a [Socket] one');
++assert.ok(!/^PartOf=/m.test(section(socket, 'Unit')), 'no PartOf=: the socket outlives every restart');
++
++const dropIn = read('openvibe-tools.service.d', 'socket.conf');
++assert.match(section(dropIn, 'Unit'), /^Requires=openvibe-tools\.socket$/m);
++assert.match(section(dropIn, 'Unit'), /^After=openvibe-tools\.socket$/m);
++assert.match(section(dropIn, 'Service'), /^NonBlocking=true$/m);
++assert.match(section(read('openvibe-tools.service'), 'Service'), /^ExecStart=.*server\/index\.js$/m, 'the main unit stays usable without the socket');
++
++const index = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');
++assert.match(index, /process\.env\.LISTEN_FDS === '1' && Number\(process\.env\.LISTEN_PID\) === process\.pid/, 'fd 3 only when handed to this process');
++assert.ok(index.includes('const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, config.host]), () => {'.trim()), 'otherwise the configured port');
++
++console.log('systemd units: all checks passed');