openvibers/openvibe.chat Commits

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

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

Changes

ddiff --git a/README.md b/README.md
iindex 27f3e85..fed55e2 100644
---- a/README.md
++++ b/README.md
@@@ -461,7 +461,9 @@ the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a
its own interval by that much. It also reports whether events are relayed and the Events consumer's
counters.
Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-chat.locations.conf`,
--`/etc/openvibe/chat.env`.
++`/etc/openvibe/chat.env`. The listener on 127.0.0.1:4400 belongs to `openvibe-chat.socket` (with the
++`openvibe-chat.service.d/socket.conf` drop-in): HTTP requests that arrive during a restart wait for the new process
++instead of getting 502s (open WebSockets still reconnect); ovhost restarts the service, never the socket.
Request telemetry: counted by route template, method and status class, not raw URL; `/shared` files,
static assets, health, readiness and `/metrics` are not counted. One `http.request` sample per key a
ddiff --git a/deploy/systemd/openvibe-chat.service.d/socket.conf b/deploy/systemd/openvibe-chat.service.d/socket.conf
nnew file mode 100644
iindex 0000000..6daf992
---- /dev/null
++++ b/deploy/systemd/openvibe-chat.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-chat.socket
++After=openvibe-chat.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-chat.socket b/deploy/systemd/openvibe-chat.socket
nnew file mode 100644
iindex 0000000..736ba66
---- /dev/null
++++ b/deploy/systemd/openvibe-chat.socket
@@@ -0,0 +1,21 @@
++# OpenVibe.Chat — 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:4400. Without this, each restart closed that listener for the whole boot and
++# nginx got ECONNREFUSED: chat history and the chat API of openvibe.live answered 502 through each boot. 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.Chat HTTP socket
++# No PartOf=openvibe-chat.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:4400
++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 b4f737c..e2ea159 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -65,7 +65,13 @@ async function start() {
// writes Chat's own chat_ai_summaries/chat_timeline_events rows directly (C-04 done).
chatAi.start();
-- await new Promise((resolve) => server.listen(config.port, config.host, resolve));
++ // Zero-downtime restarts (as OpenVibe.Live): when systemd owns the listener (deploy/systemd/openvibe-chat.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 (chat history and the chat API of openvibe.live answered 502 through each boot). 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)');
++ await new Promise((resolve) => server.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, config.host]), resolve));
console.log(`[Chat] listening on http://${config.host}:${server.address().port} (ws /ws/chat${config.calls.enabled ? ', /ws/call' : ''})`);
const subs = subscriptions.startAtBoot({ config, port: server.address().port });
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
ddiff --git a/test/systemd-units.test.js b/test/systemd-units.test.js
nnew file mode 100644
iindex 0000000..5f8fe92
---- /dev/null
++++ b/test/systemd-units.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++/**
++ * Chat's systemd units (deploy/systemd): the listening socket is systemd's, so a restart queues connections in the
++ * kernel instead of refusing them (chat history and the chat API of openvibe.live answered 502 through each boot). 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-chat.socket');
++const listens = [...section(socket, 'Socket').matchAll(/^ListenStream=(.+)$/gm)].map((m) => m[1].trim());
++assert.deepStrictEqual(listens, ['127.0.0.1:4400'], '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-chat.service.d', 'socket.conf');
++assert.match(section(dropIn, 'Unit'), /^Requires=openvibe-chat\.socket$/m);
++assert.match(section(dropIn, 'Unit'), /^After=openvibe-chat\.socket$/m);
++assert.match(section(dropIn, 'Service'), /^NonBlocking=true$/m);
++assert.match(section(read('openvibe-chat.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(' await new Promise((resolve) => server.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, config.host]), resolve));'.trim()), 'otherwise the configured port');
++
++console.log('systemd units: all checks passed');