openvibers/openvibe.bot Commits

A restart waits for each client's close in reply to its 1012, bounded at 600 ms (#426)

OP OpenVibers committed 10 h ago · 8c1f655709 · 2 files, +62 −3

Bot closed every socket with 1012 on a deploy and went straight on to exit. A process that exits with the client's frames unread resets the connection, and the reset could lose the close frame, so LOOI's Link logged a drop with no close code (Maticus, 11-12). hub.close now waits up to 600 ms for every client's close in reply, then drops the ones that did not answer; the graceful stop awaits it before the drain. Test: an answering device reads 1012 and has closed when hub.close resolves; a device that stops reading holds the close for the bound only and is then dropped.

Changes

ddiff --git a/server/realtime.js b/server/realtime.js
iindex 3d1d6ba..cffb33c 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -144,6 +144,9 @@ function simView(sim) {
};
}
++/** How long a restart waits for every client's close in reply to its 1012 before it drops the rest (11-12). */
++const CLOSE_HANDSHAKE_MS = 600;
++
function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now(), networkSend = null }) {
let domain = null;
const tickets = createControlTickets({ now }); // the embedded panel's Drive (server/control-tickets.js)
@@@ -1482,10 +1485,27 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
// 1012 Service Restart (RFC 6455's registry), not 1001 Going Away: Bot stops only to start again (a deploy takes
// about a second), so every client should reconnect at once and then back off. LOOI's node read the old 1001
// as link_lost and stayed down after a deploy on 2026-11-10 at 12:24:05 UTC.
-- for (const conn of [...deviceConns.values()]) { try { conn.ws.close(1012, 'restarting: reconnect now'); } catch { /* gone */ } }
-- for (const set of subsByRobot.values()) for (const conn of set) { try { conn.ws.close(1012, 'restarting: reconnect now'); } catch { /* gone */ } }
-- for (const conn of watchConns) { try { conn.ws.close(1012, 'restarting: reconnect now'); } catch { /* gone */ } }
++ // Then wait (at most CLOSE_HANDSHAKE_MS) for each client's close in reply before the process goes on to exit:
++ // a process that exits with the client's frames unread resets the connection, and a reset can lose the
++ // close frame on the way, so the device saw "no close frame" instead of 1012 (Maticus, 11-12). A client that
++ // does not answer in time is dropped as before.
++ const sockets = [...new Set([
++ ...[...deviceConns.values()].map((c) => c.ws),
++ ...[...subsByRobot.values()].flatMap((set) => [...set].map((c) => c.ws)),
++ ...[...watchConns].map((c) => c.ws),
++ ])].filter(Boolean);
++ const answered = sockets.map((ws) => new Promise((resolve) => {
++ if (ws.readyState === 3 || typeof ws.once !== 'function') resolve();
++ else ws.once('close', () => resolve());
++ try { ws.close(1012, 'restarting: reconnect now'); } catch { resolve(); }
++ }));
deviceConns.clear(); subsByRobot.clear(); watchConns.clear(); watchersByAddr.clear();
++ if (answered.length) {
++ let timer = null;
++ await Promise.race([Promise.all(answered), new Promise((r) => { timer = setTimeout(r, CLOSE_HANDSHAKE_MS); })]);
++ clearTimeout(timer);
++ for (const ws of sockets) { if (ws.readyState !== 3 && typeof ws.terminate === 'function') { try { ws.terminate(); } catch { /* gone */ } } }
++ }
await Promise.all([
new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)), new Promise((r) => watchWss.close(r)),
]).catch(() => {});
ddiff --git a/test/restart-close.test.js b/test/restart-close.test.js
nnew file mode 100644
iindex 0000000..6d66016
---- /dev/null
++++ b/test/restart-close.test.js
@@@ -0,0 +1,39 @@
++'use strict';
++// A restart (a deploy) closes every socket with 1012 and waits for each client's close in reply before the process
++// goes on to exit, so the device reads the code instead of a reset with no close frame (Maticus, 11-12). A client that
++// never answers is dropped after the bound, and the stop goes on.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++
++ async function device() {
++ const { robot, pairing } = await t.robot(alex);
++ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
++ assert.strictEqual(paired.status, 201, paired.text);
++ const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${paired.json.credential}` } });
++ await dev.waitFor((m) => m.type === 'config');
++ return dev;
++ }
++
++ await check('a restart closes devices with 1012 and waits for their answer, bounded', async () => {
++ const answering = await device();
++ const silent = await device();
++ // The silent one stops reading its socket, so it never sees the close frame and never answers.
++ silent.ws._socket.pause();
++ const started = Date.now();
++ await t.hub.close();
++ const took = Date.now() - started;
++ assert.strictEqual(answering.ended, true, 'the answering device had closed by the time hub.close resolved');
++ assert.strictEqual(answering.closeCode, 1012, `the device read 1012, not ${answering.closeCode}`);
++ assert.ok(took >= 500 && took < 3000, `the silent device held the close for the bound only (${took} ms)`);
++ silent.ws._socket.resume();
++ const code = await silent.waitForClose(5000);
++ assert.ok(code !== null, 'the silent device was dropped');
++ });
++
++ await t.close();
++ done();
++})();