openvibers/openvibe.bot Commits
A restart waits for each client's close in reply to its 1012, bounded at 600 ms (#426)
Changes
@@@ -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(() => {});nnew file mode 100644@@@ -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();++})();
@@ -144,6 +144,9 @@ function simView(sim) {
@@ -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 }) {
function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now(), networkSend = null }) {
let domain = null;
let domain = null;
const tickets = createControlTickets({ now }); // the embedded panel's Drive (server/control-tickets.js)
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
@@ -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
// 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
// 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.
// 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();
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([
await Promise.all([
new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)), new Promise((r) => watchWss.close(r)),
new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)), new Promise((r) => watchWss.close(r)),
]).catch(() => {});
]).catch(() => {});
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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();
+})();