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)
Changes
@@@ -743,7 +743,9 @@ fast-forward `/opt/openvibe.media`, install on a lockfile change, restart, waitovhost 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-1fixtures (`npm run n-1:record`).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-media.socket++After=openvibe-media.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.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@@@ -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) => {nnew file mode 100644@@@ -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');
@@ -743,7 +743,9 @@ fast-forward `/opt/openvibe.media`, install on a lockfile change, restart, wait
@@ -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`
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),
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
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
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
[deploy/nginx/openvibe.media.conf](deploy/nginx/openvibe.media.conf). After a deploy, record the N-1
fixtures (`npm run n-1:record`).
fixtures (`npm run n-1:record`).
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-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
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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
@@ -357,7 +357,13 @@ const ready = (async () => {
@@ -357,7 +357,13 @@ const ready = (async () => {
// ── Listen + graceful shutdown ───────────────────────────────
// ── 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).
// Subscribe to Network's sign-out-everywhere cutoffs (not on a restore drill; retried a few times).
if (!drill.enabled) {
if (!drill.enabled) {
const subscribe = (n) => require('./revocations').ensureSubscription().catch((e) => {
const subscribe = (n) => require('./revocations').ensureSubscription().catch((e) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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');