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)
Changes
@@@ -461,7 +461,9 @@ the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or aits own interval by that much. It also reports whether events are relayed and the Events consumer'scounters.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 annew 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-chat.socket++After=openvibe-chat.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.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@@@ -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 statusnnew file mode 100644@@@ -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');
@@ -461,7 +461,9 @@ the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a
@@ -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
its own interval by that much. It also reports whether events are relayed and the Events consumer's
counters.
counters.
Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-chat.locations.conf`,
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,
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
static assets, health, readiness and `/metrics` are not counted. One `http.request` sample per key a
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-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
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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
@@ -65,7 +65,13 @@ async function start() {
@@ -65,7 +65,13 @@ async function start() {
// writes Chat's own chat_ai_summaries/chat_timeline_events rows directly (C-04 done).
// writes Chat's own chat_ai_summaries/chat_timeline_events rows directly (C-04 done).
chatAi.start();
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' : ''})`);
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 });
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
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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');