openvibers/openvibe.live Commits
Socket activation that stays in effect, on loopback: the release layout's socket unit still listened on 0.0.0.0:3000 with NonBlocking= in [Socket] (ignored by systemd); installing it on 2026-09-25 dropped the socket's descriptor, so the service bound 0.0.0.0:3000 itself: Live answered on the public IP past Cloudflare and nginx, and each restart refused connections (four 502s in the WS-P task 3 drain probe). Both unit copies are now the loopback one, NonBlocking= moved to the service drop-in; deploy.sh checks that systemd really holds the listener (not just is-active) and rebinds a changed or dropped socket (stop, restart socket, start); without LISTEN_FDS the process binds 127.0.0.1 in production (LISTEN_HOST overrides; HOST still builds URLs). test/systemd-units.test.js. Production was repaired by hand first (systemd holds 127.0.0.1:3000, the public port is closed)
Changes
@@@ -55,6 +55,8 @@ BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"SERVICE="${SERVICE:-openvibe-live}"SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"SYSTEMCTL="${SYSTEMCTL:-systemctl}"++SS="${SS:-ss}"++SOCKET_CHANGED=false# Unit files land in /etc: when systemctl is being run through sudo, so is the install.SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""SITE_URL="${SITE_URL:-https://openvibe.live}"@@@ -169,7 +171,14 @@ install_units() {fi}install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"++ local socket_before=false++ [ -f "$SYSTEMD_DIR/${SERVICE}.socket" ] && [ -f "$unit_dir/${SERVICE}.socket" ] && ! cmp -s "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket" && socket_before=trueinstall_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"++ # A socket unit changed under a running socket: after daemon-reload systemd may drop its descriptor++ # ("no socket file descriptors are open ... not functional until restarted"); restart_and_verify then++ # restarts the socket itself. Left alone, the service binds the port on its own and every later++ # restart refuses connections (what happened from 2026-09-25 to 2026-09-26).++ [ "$socket_before" = true ] && SOCKET_CHANGED=trueinstall_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"if [ "$changed" = true ]; thenrun $SYSTEMCTL daemon-reload@@@ -205,18 +214,42 @@ backup_db() {fi}++# Does systemd (pid 1) hold the socket unit's listener? `is-active` is not enough: a socket unit whose++# descriptor was dropped still reads active while the service binds the port itself.++socket_held() {++ local listen port++ listen=$($SYSTEMCTL show "${SERVICE}.socket" -p Listen --value 2>/dev/null | head -1 | awk '{print $1}')++ port=${listen##*:}++ [ -n "$port" ] || return 1++ $SS -Hltnp "sport = :$port" 2>/dev/null | grep -q '"systemd",pid=1,'++}++restart_and_verify() {-- local on_fail="$1"++ local on_fail="$1" socketed=falseif $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then++ socketed=true$SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true-- say "socket unit active — new HTTP connections queue through the restart"fi-- say "restarting ${SERVICE}…"local start; start=$(date +%s)-- $SYSTEMCTL restart "$SERVICE" 9>&-++ if [ "$socketed" = true ] && { [ "$SOCKET_CHANGED" = true ] || ! socket_held; }; then++ # The socket must be rebound: stop the service (it may hold the port itself), restart the++ # socket, start the service on it. HTTP is refused for that moment, once.++ say "socket unit $([ "$SOCKET_CHANGED" = true ] && echo changed || echo 'is not holding its listener') — stopping ${SERVICE}, restarting the socket"++ $SYSTEMCTL stop "$SERVICE" 9>&- || true++ $SYSTEMCTL restart "${SERVICE}.socket" || say "✗ could not restart ${SERVICE}.socket"++ $SYSTEMCTL start "$SERVICE" 9>&-++ SOCKET_CHANGED=false++ else++ [ "$socketed" = true ] && say "socket unit holds the listener — new HTTP connections queue through the restart"++ say "restarting ${SERVICE}…"++ $SYSTEMCTL restart "$SERVICE" 9>&-++ fiprintf '[Deploy] waiting for readiness'if wait_ready; thenprintf '\n'; say "ready after $(( $(date +%s) - start ))s"++ if [ "$socketed" = true ] && ! socket_held; then++ say "✗ socket activation is not in effect: systemd does not hold the listener, so restarts refuse connections (check journalctl -u ${SERVICE}.socket)"++ fireturn 0fisay "✗ not ready within ${READY_TIMEOUT}s"@@@ -3,3 +3,7 @@[Unit]Requires=openvibe-live.socketAfter=openvibe-live.socket++++[Service]++# Node's libuv expects a non-blocking descriptor. (A [Service] option: in [Socket] systemd ignores it.)++NonBlocking=true@@@ -19,8 +19,6 @@ Description=OpenVibe.Live HTTP socket# Cloudflare and nginx (so past the client-address headers nginx sets), until 2026-09-23.ListenStream=127.0.0.1:3000NoDelay=true--# Node's libuv expects a non-blocking descriptor.--NonBlocking=true# Match the listen backlog the process would have used itself.Backlog=511@@@ -3,3 +3,7 @@[Unit]Requires=openvibe-live.socketAfter=openvibe-live.socket++++[Service]++# Node's libuv expects a non-blocking descriptor. (A [Service] option: in [Socket] systemd ignores it.)++NonBlocking=true@@@ -15,10 +15,10 @@ Description=OpenVibe.Live HTTP socket# and connections arriving in that gap were refused. The socket must outlive every restart.[Socket]--ListenStream=0.0.0.0:3000++# Loopback only: nginx is the only client. 0.0.0.0 left Live answering on the public IP, past++# Cloudflare and nginx (so past the client-address headers nginx sets), until 2026-09-23.++ListenStream=127.0.0.1:3000NoDelay=true--# Node's libuv expects a non-blocking descriptor.--NonBlocking=true# Match the listen backlog the process would have used itself.Backlog=511@@@ -154,6 +154,9 @@ function buildConfig(registryValues) {return {port: parseInt(process.env.PORT || '3000', 10),host: process.env.HOST || '0.0.0.0',++ // What the HTTP server binds when systemd did not hand it a socket (LISTEN_FDS). In production that is++ // loopback: nginx is the only client, and HOST (0.0.0.0) is for building URLs, not for binding.++ listenHost: process.env.LISTEN_HOST || (process.env.NODE_ENV === 'production' ? '127.0.0.1' : (process.env.HOST || '0.0.0.0')),baseUrl,openvibeToolsUrl, // public-facing URL of the SSO provider (e.g. https://openvibe.network)nodeEnv: process.env.NODE_ENV || 'development',@@@ -1307,7 +1307,7 @@ async function start() {// identical to run by hand, in dev, or on a box that has not been switched over.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 us (fd 3)');-- server.listen(socketActivated ? { fd: 3 } : { port: config.port, host: config.host }, () => {++ server.listen(socketActivated ? { fd: 3 } : { port: config.port, host: config.listenHost }, () => {console.log('');console.log(`[Server] HTTP server: http://${config.host}:${config.port}`);console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/chat`);@@@ -1606,7 +1606,7 @@ function startDrill() {// Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).server.once('error', (err) => { console.error(`[Drill] HTTP server: ${err.message}`); process.exit(1); });// Never fd 3: a drill does not serve on a socket systemd handed over (assertSafe refuses that too).-- server.listen({ port: config.port, host: config.host }, () => {++ server.listen({ port: config.port, host: config.listenHost }, () => {_bootComplete = true;console.log(`[Drill] Ready: http://${config.host}:${config.port} (reads only)`);});nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++/**++ * The systemd units Live runs under (deploy/systemd, and deploy/systemd/release for the release layout).++ *++ * From 2026-09-25 to 2026-09-26 the release layout's socket unit still listened on 0.0.0.0:3000 (the++ * 2026-09-23 loopback fix had reached only the in-place copy) and carried NonBlocking= in [Socket],++ * which systemd ignores. Installing it dropped the socket's descriptor, the service bound the port++ * itself on 0.0.0.0, Live answered on the public IP past Cloudflare and nginx, and every restart++ * refused connections for a moment. So:++ * - both copies are the same file (one socket, one drop-in);++ * - the socket listens on loopback only, and NonBlocking= lives in the service drop-in;++ * - the process binds loopback in production when systemd did not hand it a socket.++ */++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');++++for (const f of ['openvibe-live.socket', path.join('openvibe-live.service.d', 'socket.conf')]) {++ assert.strictEqual(read('release', f), read(f), `deploy/systemd/release/${f} is the same file as deploy/systemd/${f}`);++}++++const socket = read('openvibe-live.socket');++const section = (text, name) => (text.split(/^\[/m).find((s) => s.startsWith(`${name}]`)) || '');++const listens = [...section(socket, 'Socket').matchAll(/^ListenStream=(.+)$/gm)].map((m) => m[1].trim());++assert.deepStrictEqual(listens, ['127.0.0.1:3000'], 'the socket listens on loopback only');++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');++assert.match(section(read('openvibe-live.service.d', 'socket.conf'), 'Service'), /^NonBlocking=true$/m);++++// The fallback bind when there is no LISTEN_FDS.++const load = (env) => {++ const keep = { ...process.env };++ for (const k of ['NODE_ENV', 'HOST', 'LISTEN_HOST', 'BASE_URL']) delete process.env[k];++ Object.assign(process.env, env);++ for (const k of Object.keys(require.cache)) if (k.endsWith(`${path.sep}server${path.sep}config.js`)) delete require.cache[k];++ try { return require('../server/config'); } finally { process.env = keep; }++};++assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).listenHost, '127.0.0.1', 'production binds loopback whatever HOST says');++assert.strictEqual(load({ NODE_ENV: 'production', LISTEN_HOST: '10.0.0.5', BASE_URL: 'https://openvibe.live' }).listenHost, '10.0.0.5', 'LISTEN_HOST overrides');++assert.strictEqual(load({ NODE_ENV: 'development' }).listenHost, '0.0.0.0', 'development keeps HOST (default 0.0.0.0)');++assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).host, '0.0.0.0', 'HOST still builds URLs');++++// deploy.sh checks that systemd really holds the listener, and restarts a socket unit that changed.++const deploy = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');++assert.match(deploy, /socket_held\(\)/, 'deploy.sh has the listener check');++assert.match(deploy, /SOCKET_CHANGED=true/, 'a changed socket unit is noted');++assert.match(deploy, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/, 'and restarted');++++console.log('systemd units: all checks passed');
@@ -55,6 +55,8 @@ BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"
@@ -55,6 +55,8 @@ BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"
SERVICE="${SERVICE:-openvibe-live}"
SERVICE="${SERVICE:-openvibe-live}"
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
SYSTEMCTL="${SYSTEMCTL:-systemctl}"
SYSTEMCTL="${SYSTEMCTL:-systemctl}"
+SS="${SS:-ss}"
+SOCKET_CHANGED=false
# Unit files land in /etc: when systemctl is being run through sudo, so is the install.
# Unit files land in /etc: when systemctl is being run through sudo, so is the install.
SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""
SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""
SITE_URL="${SITE_URL:-https://openvibe.live}"
SITE_URL="${SITE_URL:-https://openvibe.live}"
@@ -169,7 +171,14 @@ install_units() {
@@ -169,7 +171,14 @@ install_units() {
fi
fi
}
}
install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"
install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"
+ local socket_before=false
+ [ -f "$SYSTEMD_DIR/${SERVICE}.socket" ] && [ -f "$unit_dir/${SERVICE}.socket" ] && ! cmp -s "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket" && socket_before=true
install_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"
install_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"
+ # A socket unit changed under a running socket: after daemon-reload systemd may drop its descriptor
+ # ("no socket file descriptors are open ... not functional until restarted"); restart_and_verify then
+ # restarts the socket itself. Left alone, the service binds the port on its own and every later
+ # restart refuses connections (what happened from 2026-09-25 to 2026-09-26).
+ [ "$socket_before" = true ] && SOCKET_CHANGED=true
install_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"
install_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"
if [ "$changed" = true ]; then
if [ "$changed" = true ]; then
run $SYSTEMCTL daemon-reload
run $SYSTEMCTL daemon-reload
@@ -205,18 +214,42 @@ backup_db() {
@@ -205,18 +214,42 @@ backup_db() {
fi
fi
}
}
+# Does systemd (pid 1) hold the socket unit's listener? `is-active` is not enough: a socket unit whose
+# descriptor was dropped still reads active while the service binds the port itself.
+socket_held() {
+ local listen port
+ listen=$($SYSTEMCTL show "${SERVICE}.socket" -p Listen --value 2>/dev/null | head -1 | awk '{print $1}')
+ port=${listen##*:}
+ [ -n "$port" ] || return 1
+ $SS -Hltnp "sport = :$port" 2>/dev/null | grep -q '"systemd",pid=1,'
+}
+
restart_and_verify() {
restart_and_verify() {
- local on_fail="$1"
+ local on_fail="$1" socketed=false
if $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then
if $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then
+ socketed=true
$SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true
$SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true
- say "socket unit active — new HTTP connections queue through the restart"
fi
fi
- say "restarting ${SERVICE}…"
local start; start=$(date +%s)
local start; start=$(date +%s)
- $SYSTEMCTL restart "$SERVICE" 9>&-
+ if [ "$socketed" = true ] && { [ "$SOCKET_CHANGED" = true ] || ! socket_held; }; then
+ # The socket must be rebound: stop the service (it may hold the port itself), restart the
+ # socket, start the service on it. HTTP is refused for that moment, once.
+ say "socket unit $([ "$SOCKET_CHANGED" = true ] && echo changed || echo 'is not holding its listener') — stopping ${SERVICE}, restarting the socket"
+ $SYSTEMCTL stop "$SERVICE" 9>&- || true
+ $SYSTEMCTL restart "${SERVICE}.socket" || say "✗ could not restart ${SERVICE}.socket"
+ $SYSTEMCTL start "$SERVICE" 9>&-
+ SOCKET_CHANGED=false
+ else
+ [ "$socketed" = true ] && say "socket unit holds the listener — new HTTP connections queue through the restart"
+ say "restarting ${SERVICE}…"
+ $SYSTEMCTL restart "$SERVICE" 9>&-
+ fi
printf '[Deploy] waiting for readiness'
printf '[Deploy] waiting for readiness'
if wait_ready; then
if wait_ready; then
printf '\n'; say "ready after $(( $(date +%s) - start ))s"
printf '\n'; say "ready after $(( $(date +%s) - start ))s"
+ if [ "$socketed" = true ] && ! socket_held; then
+ say "✗ socket activation is not in effect: systemd does not hold the listener, so restarts refuse connections (check journalctl -u ${SERVICE}.socket)"
+ fi
return 0
return 0
fi
fi
say "✗ not ready within ${READY_TIMEOUT}s"
say "✗ not ready within ${READY_TIMEOUT}s"
@@ -3,3 +3,7 @@
@@ -3,3 +3,7 @@
[Unit]
[Unit]
Requires=openvibe-live.socket
Requires=openvibe-live.socket
After=openvibe-live.socket
After=openvibe-live.socket
+
+[Service]
+# Node's libuv expects a non-blocking descriptor. (A [Service] option: in [Socket] systemd ignores it.)
+NonBlocking=true
@@ -19,8 +19,6 @@ Description=OpenVibe.Live HTTP socket
@@ -19,8 +19,6 @@ Description=OpenVibe.Live HTTP socket
# Cloudflare and nginx (so past the client-address headers nginx sets), until 2026-09-23.
# Cloudflare and nginx (so past the client-address headers nginx sets), until 2026-09-23.
ListenStream=127.0.0.1:3000
ListenStream=127.0.0.1:3000
NoDelay=true
NoDelay=true
-# Node's libuv expects a non-blocking descriptor.
-NonBlocking=true
# Match the listen backlog the process would have used itself.
# Match the listen backlog the process would have used itself.
Backlog=511
Backlog=511
@@ -3,3 +3,7 @@
@@ -3,3 +3,7 @@
[Unit]
[Unit]
Requires=openvibe-live.socket
Requires=openvibe-live.socket
After=openvibe-live.socket
After=openvibe-live.socket
+
+[Service]
+# Node's libuv expects a non-blocking descriptor. (A [Service] option: in [Socket] systemd ignores it.)
+NonBlocking=true
@@ -15,10 +15,10 @@ Description=OpenVibe.Live HTTP socket
@@ -15,10 +15,10 @@ Description=OpenVibe.Live HTTP socket
# and connections arriving in that gap were refused. The socket must outlive every restart.
# and connections arriving in that gap were refused. The socket must outlive every restart.
[Socket]
[Socket]
-ListenStream=0.0.0.0:3000
+# Loopback only: nginx is the only client. 0.0.0.0 left Live answering on the public IP, past
+# Cloudflare and nginx (so past the client-address headers nginx sets), until 2026-09-23.
+ListenStream=127.0.0.1:3000
NoDelay=true
NoDelay=true
-# Node's libuv expects a non-blocking descriptor.
-NonBlocking=true
# Match the listen backlog the process would have used itself.
# Match the listen backlog the process would have used itself.
Backlog=511
Backlog=511
@@ -154,6 +154,9 @@ function buildConfig(registryValues) {
@@ -154,6 +154,9 @@ function buildConfig(registryValues) {
return {
return {
port: parseInt(process.env.PORT || '3000', 10),
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || '0.0.0.0',
host: process.env.HOST || '0.0.0.0',
+ // What the HTTP server binds when systemd did not hand it a socket (LISTEN_FDS). In production that is
+ // loopback: nginx is the only client, and HOST (0.0.0.0) is for building URLs, not for binding.
+ listenHost: process.env.LISTEN_HOST || (process.env.NODE_ENV === 'production' ? '127.0.0.1' : (process.env.HOST || '0.0.0.0')),
baseUrl,
baseUrl,
openvibeToolsUrl, // public-facing URL of the SSO provider (e.g. https://openvibe.network)
openvibeToolsUrl, // public-facing URL of the SSO provider (e.g. https://openvibe.network)
nodeEnv: process.env.NODE_ENV || 'development',
nodeEnv: process.env.NODE_ENV || 'development',
@@ -1307,7 +1307,7 @@ async function start() {
@@ -1307,7 +1307,7 @@ async function start() {
// identical to run by hand, in dev, or on a box that has not been switched over.
// identical to run by hand, in dev, or on a box that has not been switched over.
const socketActivated = process.env.LISTEN_FDS === '1' && Number(process.env.LISTEN_PID) === process.pid;
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 us (fd 3)');
if (socketActivated) console.log('[Server] socket-activated: listening on the socket systemd handed us (fd 3)');
- server.listen(socketActivated ? { fd: 3 } : { port: config.port, host: config.host }, () => {
+ server.listen(socketActivated ? { fd: 3 } : { port: config.port, host: config.listenHost }, () => {
console.log('');
console.log('');
console.log(`[Server] HTTP server: http://${config.host}:${config.port}`);
console.log(`[Server] HTTP server: http://${config.host}:${config.port}`);
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/chat`);
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/chat`);
@@ -1606,7 +1606,7 @@ function startDrill() {
@@ -1606,7 +1606,7 @@ function startDrill() {
// Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).
// Its port taken: stop (the process-wide handler would log EADDRINUSE and keep running unready).
server.once('error', (err) => { console.error(`[Drill] HTTP server: ${err.message}`); process.exit(1); });
server.once('error', (err) => { console.error(`[Drill] HTTP server: ${err.message}`); process.exit(1); });
// Never fd 3: a drill does not serve on a socket systemd handed over (assertSafe refuses that too).
// Never fd 3: a drill does not serve on a socket systemd handed over (assertSafe refuses that too).
- server.listen({ port: config.port, host: config.host }, () => {
+ server.listen({ port: config.port, host: config.listenHost }, () => {
_bootComplete = true;
_bootComplete = true;
console.log(`[Drill] Ready: http://${config.host}:${config.port} (reads only)`);
console.log(`[Drill] Ready: http://${config.host}:${config.port} (reads only)`);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+/**
+ * The systemd units Live runs under (deploy/systemd, and deploy/systemd/release for the release layout).
+ *
+ * From 2026-09-25 to 2026-09-26 the release layout's socket unit still listened on 0.0.0.0:3000 (the
+ * 2026-09-23 loopback fix had reached only the in-place copy) and carried NonBlocking= in [Socket],
+ * which systemd ignores. Installing it dropped the socket's descriptor, the service bound the port
+ * itself on 0.0.0.0, Live answered on the public IP past Cloudflare and nginx, and every restart
+ * refused connections for a moment. So:
+ * - both copies are the same file (one socket, one drop-in);
+ * - the socket listens on loopback only, and NonBlocking= lives in the service drop-in;
+ * - the process binds loopback in production when systemd did not hand it a socket.
+ */
+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');
+
+for (const f of ['openvibe-live.socket', path.join('openvibe-live.service.d', 'socket.conf')]) {
+ assert.strictEqual(read('release', f), read(f), `deploy/systemd/release/${f} is the same file as deploy/systemd/${f}`);
+}
+
+const socket = read('openvibe-live.socket');
+const section = (text, name) => (text.split(/^\[/m).find((s) => s.startsWith(`${name}]`)) || '');
+const listens = [...section(socket, 'Socket').matchAll(/^ListenStream=(.+)$/gm)].map((m) => m[1].trim());
+assert.deepStrictEqual(listens, ['127.0.0.1:3000'], 'the socket listens on loopback only');
+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');
+assert.match(section(read('openvibe-live.service.d', 'socket.conf'), 'Service'), /^NonBlocking=true$/m);
+
+// The fallback bind when there is no LISTEN_FDS.
+const load = (env) => {
+ const keep = { ...process.env };
+ for (const k of ['NODE_ENV', 'HOST', 'LISTEN_HOST', 'BASE_URL']) delete process.env[k];
+ Object.assign(process.env, env);
+ for (const k of Object.keys(require.cache)) if (k.endsWith(`${path.sep}server${path.sep}config.js`)) delete require.cache[k];
+ try { return require('../server/config'); } finally { process.env = keep; }
+};
+assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).listenHost, '127.0.0.1', 'production binds loopback whatever HOST says');
+assert.strictEqual(load({ NODE_ENV: 'production', LISTEN_HOST: '10.0.0.5', BASE_URL: 'https://openvibe.live' }).listenHost, '10.0.0.5', 'LISTEN_HOST overrides');
+assert.strictEqual(load({ NODE_ENV: 'development' }).listenHost, '0.0.0.0', 'development keeps HOST (default 0.0.0.0)');
+assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).host, '0.0.0.0', 'HOST still builds URLs');
+
+// deploy.sh checks that systemd really holds the listener, and restarts a socket unit that changed.
+const deploy = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');
+assert.match(deploy, /socket_held\(\)/, 'deploy.sh has the listener check');
+assert.match(deploy, /SOCKET_CHANGED=true/, 'a changed socket unit is noted');
+assert.match(deploy, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/, 'and restarted');
+
+console.log('systemd units: all checks passed');