openvibers/openvibe.media Commits

Zero-downtime restarts: systemd owns Media's listener (openvibe-media.socket); the process listens on fd 3 when handed it, as Live and Network (#137)

OP OpenVibers committed 27 days ago · 76fbc8f11e · 5 files, +74 −2

Changes

ddiff --git a/README.md b/README.md
iindex 98c3cff..56d02a8 100644
---- a/README.md
++++ b/README.md
@@@ -743,7 +743,9 @@ fast-forward `/opt/openvibe.media`, install on a lockfile change, restart, wait
ovhost refuses the restart while a VOD is recording (`recordings_in_progress` on `/api/ready`); `--wait-idle`
holds it until none is. The unit is `openvibe-media.service` ([deploy/systemd/](deploy/systemd/openvibe-media.service),
runs as `ubuntu`, media under `/opt/openvibe.media/data`) on `127.0.0.1:4100`, the env file
--`/etc/openvibe/media.env`. The database is `ov_media` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh media`
++`/etc/openvibe/media.env`. The listener belongs to `openvibe-media.socket` (with the
++`openvibe-media.service.d/socket.conf` drop-in): requests that arrive during a restart wait for the new process
++instead of getting 502s; ovhost restarts the service, never the socket. The database is `ov_media` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh media`
writes its settings); the release migrates it at boot; nginx serves `openvibe.media` from
[deploy/nginx/openvibe.media.conf](deploy/nginx/openvibe.media.conf). After a deploy, record the N-1
fixtures (`npm run n-1:record`).
ddiff --git a/deploy/systemd/openvibe-media.service.d/socket.conf b/deploy/systemd/openvibe-media.service.d/socket.conf
nnew file mode 100644
iindex 0000000..d34a66b
---- /dev/null
++++ b/deploy/systemd/openvibe-media.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-media.socket
++After=openvibe-media.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-media.socket b/deploy/systemd/openvibe-media.socket
nnew file mode 100644
iindex 0000000..9668b52
---- /dev/null
++++ b/deploy/systemd/openvibe-media.socket
@@@ -0,0 +1,21 @@
++# OpenVibe.Media — 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:4100. Without this, each restart closed that listener for the whole boot and
++# nginx got ECONNREFUSED: 64 refused connections on 2026-10-17 alone: thumbnails, pictures and video for every site. 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.Media HTTP socket
++# No PartOf=openvibe-media.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:4100
++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 f4db897..19f3d3f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -357,7 +357,13 @@ const ready = (async () => {
// ── Listen + graceful shutdown ───────────────────────────────
-- const server = app.listen(config.port, config.host, () => {
++ // Zero-downtime restarts (as OpenVibe.Live): when systemd owns the listener (deploy/systemd/openvibe-media.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 (64 refused connections on 2026-10-17 alone: thumbnails, pictures and video for every
++ // site). 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 sign-out-everywhere cutoffs (not on a restore drill; retried a few times).
if (!drill.enabled) {
const subscribe = (n) => require('./revocations').ensureSubscription().catch((e) => {
ddiff --git a/test/systemd-units.test.js b/test/systemd-units.test.js
nnew file mode 100644
iindex 0000000..e305a0c
---- /dev/null
++++ b/test/systemd-units.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++/**
++ * Media's systemd units (deploy/systemd): the listening socket is systemd's, so a restart queues connections in the
++ * kernel instead of refusing them (64 refused connections on 2026-10-17 alone: thumbnails, pictures and video for every site). 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-media.socket');
++const listens = [...section(socket, 'Socket').matchAll(/^ListenStream=(.+)$/gm)].map((m) => m[1].trim());
++assert.deepStrictEqual(listens, ['127.0.0.1:4100'], '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-media.service.d', 'socket.conf');
++assert.match(section(dropIn, 'Unit'), /^Requires=openvibe-media\.socket$/m);
++assert.match(section(dropIn, 'Unit'), /^After=openvibe-media\.socket$/m);
++assert.match(section(dropIn, 'Service'), /^NonBlocking=true$/m);
++assert.match(section(read('openvibe-media.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');