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)
Changes
@@@ -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 havetheir own more-specific server blocks)nnew file mode 100644@@@ -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=truennew file mode 100644@@@ -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@@@ -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);nnew file mode 100644@@@ -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');
@@ -64,5 +64,7 @@ See `.env.example`. Key variables: `PORT` (4001), `OV_NETWORK_URL`,
@@ -64,5 +64,7 @@ See `.env.example`. Key variables: `PORT` (4001), `OV_NETWORK_URL`,
- Path: `/opt/openvibe.tools/apps/gateway`
- Path: `/opt/openvibe.tools/apps/gateway`
- Env: `/etc/openvibe/tools.env`
- Env: `/etc/openvibe/tools.env`
- Unit: `deploy/systemd/openvibe-tools.service`
- 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
- Nginx: `deploy/nginx/openvibe.tools.conf` (apex + wildcard; satellites have
their own more-specific server blocks)
their own more-specific server blocks)
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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
@@ -500,7 +500,13 @@ const satellitePolling = toolRegistry.startSatellitePolling(SATELLITES);
@@ -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') });
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}`);
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).
// 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) => {
const subscribe = (n) => require('./revocation-events').ensureSubscription({ port: config.port }).catch((e) => {
console.warn('[Events] token cutoff subscription not ready:', e.message);
console.warn('[Events] token cutoff subscription not ready:', e.message);
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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');