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)

OP OpenVibers committed 27 days ago · 352fb810e1 · 5 files, +78 −3

Changes

ddiff --git a/README.md b/README.md
iindex b1bc7683..57921e9d 100644
---- a/README.md
++++ b/README.md
@@@ -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, and
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
--`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`
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).
ddiff --git a/deploy/systemd/openvibe-network.service.d/socket.conf b/deploy/systemd/openvibe-network.service.d/socket.conf
nnew file mode 100644
iindex 00000000..73212c5c
---- /dev/null
++++ b/deploy/systemd/openvibe-network.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-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
ddiff --git a/deploy/systemd/openvibe-network.socket b/deploy/systemd/openvibe-network.socket
nnew file mode 100644
iindex 00000000..bd67ae86
---- /dev/null
++++ b/deploy/systemd/openvibe-network.socket
@@@ -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
ddiff --git a/server/index.js b/server/index.js
iindex 79d53681..383c69f5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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╔═══════════════════════════════════════╗`);
ddiff --git a/test/systemd-units.test.js b/test/systemd-units.test.js
nnew file mode 100644
iindex 00000000..66070457
---- /dev/null
++++ b/test/systemd-units.test.js
@@@ -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');