openvibers/openvibe.network Commits
Zero-downtime restarts: systemd owns Network's listener (openvibe-network.socket); the process listens on fd 3 when handed it (each restart was ~7 s of 502s for every site's sign-in and navbar) (#267)
Changes
@@@ -98,7 +98,7 @@ openvibe.network (port 4000)├── deploy/│ ├── nginx/ # openvibe.network.conf; network-subdomains.conf (status/themes/admin/auth/api. redirects); parked-domains.conf│ ├── scripts/ # deploy.sh (runs `ovhost deploy network`)--│ └── systemd/ # openvibe-network.service (EnvironmentFile=/etc/openvibe/network.env)++│ └── systemd/ # openvibe-network.service (EnvironmentFile=/etc/openvibe/network.env), .socket + socket.conf drop-in└── .env.example```@@@ -151,7 +151,10 @@ restart, `/api/ready` polled, an automatic rollback if it does not come up, andprints `ovhost plan network`. If ovhost is missing, the wrapper exits with an error.The unit is `openvibe-network.service` (runs as `ubuntu` from `/opt/openvibe.network`) on--`127.0.0.1:4000`, the env file `/etc/openvibe/network.env`. Rollback: automatic when `/api/ready` does not++`127.0.0.1:4000`, the env file `/etc/openvibe/network.env`. The listener belongs to `openvibe-network.socket`++(with the `openvibe-network.service.d/socket.conf` drop-in), so connections that arrive during a restart wait++for the new process instead of getting 502s; ovhost restarts the service, never the socket. Check that systemd++holds it with `sudo ss -ltnp | grep :4000` (systemd listed, the process on fd 3). Rollback: automatic when `/api/ready` does notcome up; afterwards `sudo ovhost rollback network --to <sha>`. Two blockers: `chrome_cache`/`chrome_hits`became `frame_cache`/`frame_hits` on 2026-09-24 (an older release starts with empty caches), and`tool_domains` was rebuilt once for the mirror role (an older release does not know mirror rows).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-network.socket++After=openvibe-network.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,22 @@++# OpenVibe.Network — listening socket, owned by systemd rather than by the process (as OpenVibe.Live's).++#++# nginx proxies straight to 127.0.0.1:4000, and every site's sign-in, navbar session and token checks reach++# Network. Without this, each restart closed that listener for the whole boot (~7 s on 2026-10-17: seeding++# alone takes 5-6 s) and every site got 502s. 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 (SSE streams, long polls): those reconnect.++[Unit]++Description=OpenVibe.Network HTTP socket++# No PartOf=openvibe-network.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:4000++NoDelay=true++# Match the listen backlog the process would have used itself.++Backlog=511++++[Install]++WantedBy=sockets.target@@@ -942,7 +942,13 @@ app.use(require('./not-found').notFound);// ── Start ────────────────────────────────────────────────────const timers = []; // the periodic maintenance below, cleared on stop--const server = app.listen(config.port, config.host, () => {++// Zero-downtime restarts (as OpenVibe.Live): when systemd owns the listener (deploy/systemd/openvibe-network.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 (each restart was ~7 s of 502s for every site's sign-in and navbar, 2026-10-17).++// Without the socket unit this is the usual listen on the configured port.++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]), () => {// The home page renders the Tools catalog it already holds: fetch it now, not on the first visit.require('./domains/catalog').refresh().catch(() => {});console.log(`\n╔═══════════════════════════════════════╗`);nnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++/**++ * Network's systemd units (deploy/systemd): the listening socket is systemd's, so a restart queues connections in++ * the kernel instead of refusing them (each restart was ~7 s of 502s on every site's sign-in and navbar,++ * 2026-10-17). The rules OpenVibe.Live learned (its test/systemd-units.test.js, 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-network.socket');++const listens = [...section(socket, 'Socket').matchAll(/^ListenStream=(.+)$/gm)].map((m) => m[1].trim());++assert.deepStrictEqual(listens, ['127.0.0.1:4000'], '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-network.service.d', 'socket.conf');++assert.match(section(dropIn, 'Unit'), /^Requires=openvibe-network\.socket$/m);++assert.match(section(dropIn, 'Unit'), /^After=openvibe-network\.socket$/m);++assert.match(section(dropIn, 'Service'), /^NonBlocking=true$/m);++assert.match(section(read('openvibe-network.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.match(index, /app\.listen\(\.\.\.\(socketActivated \? \[\{ fd: 3 \}\] : \[config\.port, config\.host\]\)/, 'otherwise the configured port');++++console.log('systemd units: all checks passed');
@@ -98,7 +98,7 @@ openvibe.network (port 4000)
@@ -98,7 +98,7 @@ openvibe.network (port 4000)
├── deploy/
├── deploy/
│ ├── nginx/ # openvibe.network.conf; network-subdomains.conf (status/themes/admin/auth/api. redirects); parked-domains.conf
│ ├── nginx/ # openvibe.network.conf; network-subdomains.conf (status/themes/admin/auth/api. redirects); parked-domains.conf
│ ├── scripts/ # deploy.sh (runs `ovhost deploy network`)
│ ├── scripts/ # deploy.sh (runs `ovhost deploy network`)
-│ └── systemd/ # openvibe-network.service (EnvironmentFile=/etc/openvibe/network.env)
+│ └── systemd/ # openvibe-network.service (EnvironmentFile=/etc/openvibe/network.env), .socket + socket.conf drop-in
└── .env.example
└── .env.example
```
```
@@ -151,7 +151,10 @@ restart, `/api/ready` polled, an automatic rollback if it does not come up, and
@@ -151,7 +151,10 @@ restart, `/api/ready` polled, an automatic rollback if it does not come up, and
prints `ovhost plan network`. If ovhost is missing, the wrapper exits with an error.
prints `ovhost plan network`. If ovhost is missing, the wrapper exits with an error.
The unit is `openvibe-network.service` (runs as `ubuntu` from `/opt/openvibe.network`) on
The unit is `openvibe-network.service` (runs as `ubuntu` from `/opt/openvibe.network`) on
-`127.0.0.1:4000`, the env file `/etc/openvibe/network.env`. Rollback: automatic when `/api/ready` does not
+`127.0.0.1:4000`, the env file `/etc/openvibe/network.env`. The listener belongs to `openvibe-network.socket`
+(with the `openvibe-network.service.d/socket.conf` drop-in), so connections that arrive during a restart wait
+for the new process instead of getting 502s; ovhost restarts the service, never the socket. Check that systemd
+holds it with `sudo ss -ltnp | grep :4000` (systemd listed, the process on fd 3). Rollback: automatic when `/api/ready` does not
come up; afterwards `sudo ovhost rollback network --to <sha>`. Two blockers: `chrome_cache`/`chrome_hits`
come up; afterwards `sudo ovhost rollback network --to <sha>`. Two blockers: `chrome_cache`/`chrome_hits`
became `frame_cache`/`frame_hits` on 2026-09-24 (an older release starts with empty caches), and
became `frame_cache`/`frame_hits` on 2026-09-24 (an older release starts with empty caches), and
`tool_domains` was rebuilt once for the mirror role (an older release does not know mirror rows).
`tool_domains` was rebuilt once for the mirror role (an older release does not know mirror rows).
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-network.socket
+After=openvibe-network.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,22 @@
@@ -0,0 +1,22 @@
+# OpenVibe.Network — listening socket, owned by systemd rather than by the process (as OpenVibe.Live's).
+#
+# nginx proxies straight to 127.0.0.1:4000, and every site's sign-in, navbar session and token checks reach
+# Network. Without this, each restart closed that listener for the whole boot (~7 s on 2026-10-17: seeding
+# alone takes 5-6 s) and every site got 502s. 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 (SSE streams, long polls): those reconnect.
+[Unit]
+Description=OpenVibe.Network HTTP socket
+# No PartOf=openvibe-network.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:4000
+NoDelay=true
+# Match the listen backlog the process would have used itself.
+Backlog=511
+
+[Install]
+WantedBy=sockets.target
@@ -942,7 +942,13 @@ app.use(require('./not-found').notFound);
@@ -942,7 +942,13 @@ app.use(require('./not-found').notFound);
// ── Start ────────────────────────────────────────────────────
// ── Start ────────────────────────────────────────────────────
const timers = []; // the periodic maintenance below, cleared on stop
const timers = []; // the periodic maintenance below, cleared on stop
-const server = app.listen(config.port, config.host, () => {
+// Zero-downtime restarts (as OpenVibe.Live): when systemd owns the listener (deploy/systemd/openvibe-network.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 (each restart was ~7 s of 502s for every site's sign-in and navbar, 2026-10-17).
+// Without the socket unit this is the usual listen on the configured port.
+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]), () => {
// The home page renders the Tools catalog it already holds: fetch it now, not on the first visit.
// The home page renders the Tools catalog it already holds: fetch it now, not on the first visit.
require('./domains/catalog').refresh().catch(() => {});
require('./domains/catalog').refresh().catch(() => {});
console.log(`\n╔═══════════════════════════════════════╗`);
console.log(`\n╔═══════════════════════════════════════╗`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+/**
+ * Network's systemd units (deploy/systemd): the listening socket is systemd's, so a restart queues connections in
+ * the kernel instead of refusing them (each restart was ~7 s of 502s on every site's sign-in and navbar,
+ * 2026-10-17). The rules OpenVibe.Live learned (its test/systemd-units.test.js, 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-network.socket');
+const listens = [...section(socket, 'Socket').matchAll(/^ListenStream=(.+)$/gm)].map((m) => m[1].trim());
+assert.deepStrictEqual(listens, ['127.0.0.1:4000'], '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-network.service.d', 'socket.conf');
+assert.match(section(dropIn, 'Unit'), /^Requires=openvibe-network\.socket$/m);
+assert.match(section(dropIn, 'Unit'), /^After=openvibe-network\.socket$/m);
+assert.match(section(dropIn, 'Service'), /^NonBlocking=true$/m);
+assert.match(section(read('openvibe-network.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.match(index, /app\.listen\(\.\.\.\(socketActivated \? \[\{ fd: 3 \}\] : \[config\.port, config\.host\]\)/, 'otherwise the configured port');
+
+console.log('systemd units: all checks passed');