summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-17 10:11:07 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-17 10:11:07 +0200
commit7e61d16f0ca6aeca6adb1dd909a7e7b0376719f9 (patch)
tree08466d6c6879efd1f2c21a118919cb99835fb31f
parent6329beaca34e31749ff0343d02e27db2826157e7 (diff)
downloadmeshbay-7e61d16f0ca6aeca6adb1dd909a7e7b0376719f9.tar.gz
fix(spa): follow the device to another network
Moving between mobile data and the node's Wi-Fi left the connection on the network it had left until ICE gave up, and a group that had failed was never tried again: the Android application had to be killed. A change of network or an ICE disconnection now pings the session and reconnects one that does not answer, wakes a reconnect backing off, and retries a group page left on an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js109
-rw-r--r--packages/meshbay-hub/tests/harness/network_change_harness.mjs267
-rw-r--r--packages/meshbay-hub/tests/test_network_change.py90
5 files changed, 480 insertions, 8 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 4db06c40..6fe0722e 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -1561,6 +1561,15 @@ refusal and not a dropped connection, so a chunk let through while a reconnect's
handshake runs ends the download the reconnect is for. The close event of a
channel a reconnect has replaced concerns nothing on the new one.
+**A connection follows the device to another network.** A phone moving between
+mobile data and the Wi-Fi the node is on keeps a candidate pair over the
+network it left, and ICE says so only once its consent checks lapse, about
+thirty seconds. So a change of network (`online`, the connection's `type`, the
+Android application's `meshbay-default-network`) and an ICE `disconnected` each
+ask the session for a ping, and a session that does not answer within five
+seconds is reconnected; a reconnect backing off tries again at once; a group
+page left on an error tries again (`transport.js` `onNetworkChange`).
+
The lease module is free of asyncio and of the transport: it decides, and the
caller does the I/O. A queue that reveals itself only through a DataChannel is a
queue nobody can prove things about.
@@ -4781,7 +4790,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| Metadata at the hub | Membership, and who posted in which group and when. A known leak, not a solved problem (§7.1) |
| **QUIC** | Off by default, and **not at parity**: it serves the index and file chunks with no transfer lease, no leaseless ceiling, no root-availability check and no content blocklist, does its file I/O on the event loop, and returns exception text to the peer (**L3**). No client speaks it. Either it comes to parity or it goes; until then §5.1's "chat is the only gap" is the one sentence here that overstates the code |
| **A very high bitrate wedges the player against a small buffer ceiling** | Where even the *floor* read-ahead does not fit — ninety seconds plus the minute kept behind, at the file's bitrate, above what the engine will hold — the film stalls: measured on the harness at 9.3 Mbit/s against a 100 MB ceiling, 100.8 s of film played in 900 s of wall clock. **Predates the byte budget and is unchanged by it**, to the tenth of a second; what the budget did change there is the refusal count, 1560 → 2. The fix is not a bound at all, it is a second stage of buffer outside the SourceBuffer, which means gating the append path — the riskiest change in this area and not one to make alongside another |
-| **The reconnect backoff only wakes on `visibilitychange`** | So a tab that stays visible through an outage — which is what a screen wake lock guarantees while a film is playing — waits out the full backoff, up to 30 s, after the network is already back. Nothing listens for `online` |
| **Per-device revocation has no CLI** | A device is revoked over MNP (`roster.revoke_device`), from a device the node has already pinned. On a headless node the operator's only lever is `member unpin`, which removes **every** device of that account — so the per-device control the roster is built around is reachable from an interface and from nowhere else. §6.7 listed a `meshbay-node member device list\|revoke` verb that was never written, and that listing is how this was found: `USERGUIDE.md` was the first document written by reading the CLI rather than this specification, and the verb it copied out did not run |
| **Migrations run on SQLite only** | The chain reaches head and agrees with the models there (§12), which is not where it ships. **The exposure is one revision deep, not the whole chain**: every revision behind the first packaged release was development that no installation ever ran, so nothing replays them on PostgreSQL. What is unguarded is the *next* migration — a default, an index type or a constraint PostgreSQL refuses reaches a deploy without the suite saying so |
| **The Create group wizard calls two hooks after an early return** | `CreateGroupWizard` (`create-group-page.js`) returns during node detection, before its `useRef`/`useEffect` for provisioning, so the hook count changes between renders. Preact tolerates a list that grows, and nothing is known to break; `test_hook_ordering.py` checks declaration order, not this. Found while tracing the frozen-fields report, which had another cause (`ask.js`) |
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 5972e000..728ffc37 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -789,6 +789,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// it; nothing in between should disturb a working connection.
}, [groupId, Boolean(token), retryKey]);
+ // A group that could not be reached is tried again when the device joins
+ // another network: from the Wi-Fi to mobile data, or back, is what decides
+ // whether the node can be reached, and in the Android application there is
+ // no reload to do it. Not while a code, a passphrase or a device link is
+ // asked for: those wait for the person, not for a network.
+ useEffect(() => {
+ if (status !== 'error' || needsPass || needsCode || needsDevice) return undefined;
+ const T = window.MeshBayTransport;
+ if (!T || !T.onNetworkChange) return undefined;
+ return T.onNetworkChange(() => setRetryKey((k) => k + 1));
+ }, [status, needsPass, needsCode, needsDevice]);
+
const downloadFileForModal = useCallback(async (entry) => {
// The video/preview modals' own download button — the table's row and
// toolbar actions call the same shared helper from files-app.js, since
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 0d987d9a..0b796cab 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -168,6 +168,53 @@ async function _localNetworkRefusedBy(pc, offerSdp, answerSdp) {
}
}
+// ── Another network ──────────────────────────────────────────────────────────
+//
+// A phone moving between mobile data and the Wi-Fi the node is on keeps a
+// connection whose candidate pair runs over the network it left. ICE says so
+// only once its consent checks lapse, about thirty seconds, and on Android not
+// before the old network has lingered; a connection that had failed was not
+// tried again at all, and the application had to be killed to reach the group.
+
+// How long after the change before anything is tried: the new network's route
+// and addresses take a moment, and a burst of events is one change.
+const NETWORK_SETTLE_MS = 1500;
+// How long a connection that may be on the old network has to answer a ping.
+const NETWORK_PROBE_MS = 5000;
+
+/**
+ * Calls `fn` once the device has moved to another network, and returns the way
+ * to stop. `online` is the end of no network at all; the connection's `type`
+ * (Chrome on Android, and its WebView) is Wi-Fi against mobile data, and its
+ * other changes, a bandwidth estimate, are not news; `meshbay-default-network`
+ * is the Android application's own word for it (MainActivity.watchNetwork).
+ */
+function onNetworkChange(fn) {
+ const connection = (typeof navigator !== 'undefined' && navigator.connection) || null;
+ let type = connection ? connection.type : undefined;
+ let timer = null;
+ const changed = () => {
+ clearTimeout(timer);
+ timer = setTimeout(fn, NETWORK_SETTLE_MS);
+ };
+ const onConnection = () => {
+ if (connection.type === undefined || connection.type === type) return;
+ type = connection.type;
+ changed();
+ };
+ window.addEventListener('online', changed);
+ window.addEventListener('meshbay-default-network', changed);
+ if (connection && connection.addEventListener) connection.addEventListener('change', onConnection);
+ return () => {
+ clearTimeout(timer);
+ window.removeEventListener('online', changed);
+ window.removeEventListener('meshbay-default-network', changed);
+ if (connection && connection.removeEventListener) {
+ connection.removeEventListener('change', onConnection);
+ }
+ };
+}
+
// Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a
// slow link and small enough that nothing accumulates.
// How long to collect ICE candidates before sending the offer anyway. Long
@@ -719,6 +766,52 @@ class MeshBayTransport {
if (document.visibilityState === 'visible') this._wakeReconnect();
};
document.addEventListener('visibilitychange', this._onVisibilityWake);
+ this._networkProbe = null;
+ this._offNetwork = onNetworkChange(() => this._networkChanged());
+ }
+
+ /**
+ * The device moved to another network. A reconnect that is backing off tries
+ * again at once, on the network just joined; an open session is asked
+ * whether it still answers.
+ */
+ _networkChanged() {
+ if (this._closed) return;
+ trace('network_change', {
+ pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState,
+ session: this._sessionUp, reconnecting: !!this._reconnectPromise,
+ });
+ if (this._reconnectPromise) { this._wakeReconnect(); return; }
+ this._probeSoon('network_change', 0);
+ }
+
+ /** A ping a moment from now; no answer is a lost connection, reconnected. */
+ _probeSoon(why, delayMs = NETWORK_SETTLE_MS) {
+ if (this._closed || !this._sessionUp) return;
+ clearTimeout(this._networkProbe);
+ this._networkProbe = setTimeout(async () => {
+ const pc = this._pc;
+ if (this._closed || !this._sessionUp || this._reconnectPromise) return;
+ try {
+ await this.ping(NETWORK_PROBE_MS);
+ } catch {
+ if (pc !== this._pc || this._closed || this._reconnectPromise) return;
+ trace('probe_lost', { why });
+ console.warn('[MeshBay] No answer after', why, '- reconnecting');
+ this._connectionLost();
+ }
+ }, delayMs);
+ }
+
+ /** The connection is gone: reconnect, and fail what was waiting on it. */
+ _connectionLost() {
+ this._connected = false;
+ this._sessionUp = false;
+ this._reconnect();
+ const err = new Error('WebRTC connection lost');
+ err.name = 'TransportLostError';
+ for (const [, p] of this._pending) p.reject(err);
+ this._pending.clear();
}
/** Cuts short a reconnect currently backing off (see _reconnectLoop). A
@@ -966,13 +1059,7 @@ class MeshBayTransport {
// staying "open" throughout, so every send from here on would simply
// sit out its own timeout instead of failing fast.
if (pc.connectionState === 'failed' && pc === this._pc && !this._closed) {
- this._connected = false;
- this._sessionUp = false;
- this._reconnect();
- const err = new Error('WebRTC connection lost');
- err.name = 'TransportLostError';
- for (const [, p] of this._pending) p.reject(err);
- this._pending.clear();
+ this._connectionLost();
}
};
pc.oniceconnectionstatechange = () => {
@@ -985,6 +1072,11 @@ class MeshBayTransport {
iceReached = true;
if (pc === this._pc) this._noteConnectProgress('ice_connected');
}
+ // Often a moment's loss that clears itself, and as often the network
+ // under the pair gone: a ping tells which long before "failed" does.
+ if (pc.iceConnectionState === 'disconnected' && pc === this._pc) {
+ this._probeSoon('ice_disconnected');
+ }
};
// Diagnostic-only: a periodic health ping and a resume-triggered one, so
@@ -1855,6 +1947,8 @@ class MeshBayTransport {
this._closed = true;
this._closeLanes();
document.removeEventListener('visibilitychange', this._onVisibilityWake);
+ this._offNetwork();
+ clearTimeout(this._networkProbe);
this._wakeReconnect();
if (this._diagCleanup) { this._diagCleanup(); this._diagCleanup = null; }
if (this._channel) this._channel.close();
@@ -2660,4 +2754,5 @@ function extendTransport(part) {
// Export
MeshBayTransport.localNetworkRefused = localNetworkRefused;
+MeshBayTransport.onNetworkChange = onNetworkChange;
window.MeshBayTransport = MeshBayTransport;
diff --git a/packages/meshbay-hub/tests/harness/network_change_harness.mjs b/packages/meshbay-hub/tests/harness/network_change_harness.mjs
new file mode 100644
index 00000000..e0201125
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/network_change_harness.mjs
@@ -0,0 +1,267 @@
+// Run the SHIPPED transport through a change of network, and see whether the
+// connection follows.
+//
+// A phone moving between mobile data and the Wi-Fi the node is on keeps a
+// connection whose candidate pair runs over the network it left. Reported on
+// the Android application: the group stopped answering and the application had
+// to be killed. transport.js is executed whole; what is modelled is the
+// environment: a clock, the window's events, `navigator.connection`, a peer
+// connection that opens its channel after the answer, a hub that answers the
+// offer and the token (or does not, while the device is offline), and a node
+// that answers the handshake and pings until it is cut off.
+//
+// Usage: node network_change_harness.mjs <transport sources> <json>
+// {"scenario": "change-dead" | "change-alive" | "backoff" | "ice-dead" | "ice-alive"
+// | "events" | "closed"}
+import { readFileSync } from 'fs';
+import { delimiter } from 'path';
+
+const transportSrc = process.argv[2].split(delimiter)
+ .map((p) => readFileSync(p, 'utf8')).join('\n');
+const { scenario } = JSON.parse(process.argv[3] || '{}');
+
+for (const level of ['log', 'warn', 'error', 'info', 'debug']) {
+ console[level] = (...args) => process.stderr.write(args.join(' ') + '\n');
+}
+process.on('unhandledRejection', () => {});
+
+// ── The clock ────────────────────────────────────────────────────────────────
+
+let now = 0;
+let nextId = 1;
+let timers = [];
+globalThis.setTimeout = (fn, ms) => {
+ const t = { at: now + (ms || 0), fn, id: nextId++ };
+ timers.push(t);
+ return t.id;
+};
+globalThis.setInterval = (fn, ms) => {
+ const t = { at: now + ms, fn, id: nextId++, every: ms };
+ timers.push(t);
+ return t.id;
+};
+globalThis.clearTimeout = (id) => { timers = timers.filter((t) => t.id !== id); };
+globalThis.clearInterval = globalThis.clearTimeout;
+Date.now = () => now;
+globalThis.performance = { now: () => now };
+const flush = async () => { for (let i = 0; i < 50; i++) await Promise.resolve(); };
+async function run(until) {
+ await flush();
+ while (timers.length) {
+ const due = timers.reduce((a, b) => (b.at < a.at ? b : a));
+ if (due.at > until) break;
+ if (due.every) due.at += due.every;
+ else timers = timers.filter((t) => t !== due);
+ now = Math.max(now, due.every ? due.at - due.every : due.at);
+ due.fn();
+ await flush();
+ }
+ now = until;
+}
+
+// ── The page around the transport ────────────────────────────────────────────
+
+function eventTarget(obj) {
+ const listeners = new Map();
+ obj.addEventListener = (type, fn) => {
+ if (!listeners.has(type)) listeners.set(type, new Set());
+ listeners.get(type).add(fn);
+ };
+ obj.removeEventListener = (type, fn) => { listeners.get(type)?.delete(fn); };
+ obj.dispatch = (type) => { for (const fn of [...(listeners.get(type) || [])]) fn({ type }); };
+ obj.listening = () => [...listeners.values()].reduce((n, s) => n + s.size, 0);
+ return obj;
+}
+
+globalThis.window = globalThis;
+eventTarget(globalThis);
+const connection = eventTarget({ type: 'cellular', effectiveType: '4g' });
+Object.defineProperty(globalThis, 'navigator',
+ { value: { userAgent: 'harness', connection }, configurable: true });
+globalThis.location = { hash: '' };
+globalThis.document = {
+ addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
+};
+globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
+
+const b64 = (u8) => Buffer.from(u8).toString('base64');
+window.MeshBayCrypto = {
+ b64encode: (u8) => b64(u8),
+ b64decode: (s) => new Uint8Array(Buffer.from(String(s), 'base64')),
+ verifyNodeSignature: async () => true,
+ challengeTranscript: () => new Uint8Array(1),
+ webrtcBinding: () => new Uint8Array(1),
+ handshakeProof: async () => new Uint8Array([7]),
+ handshakeTranscript: () => new Uint8Array(1),
+ constantTimeEqual: () => true,
+ openGroup: async () => window.__codec.msgpack_encode({ chat_epoch: 1 }),
+};
+
+const SDP = 'v=0\r\na=fingerprint:sha-256 AA:BB\r\n';
+let offline = false;
+globalThis.fetch = async (url) => {
+ if (offline) throw new TypeError('Failed to fetch');
+ return {
+ ok: true, status: 200, headers: { get: () => null },
+ json: async () => (url.endsWith('/mnp-token') ? { mnp_token: 't' } : { sdp: SDP }),
+ };
+};
+
+// ── The node ─────────────────────────────────────────────────────────────────
+
+let pings = 0;
+const pcs = [];
+
+class NodeSession {
+ constructor(channel) { this.channel = channel; this.cut = false; }
+ reply(obj, ms) {
+ setTimeout(() => {
+ if (this.cut || this.channel.readyState !== 'open') return;
+ const body = window.__codec.msgpack_encode(obj);
+ const frame = new Uint8Array(4 + body.byteLength);
+ new DataView(frame.buffer).setUint32(0, body.byteLength, false);
+ frame.set(body, 4);
+ this.channel.onmessage({ data: frame.buffer });
+ }, ms);
+ }
+ receive(frame) {
+ if (this.cut) return;
+ const msg = window.__codec.msgpack_decode(frame.slice(4));
+ const req_id = msg.req_id;
+ switch (msg.type) {
+ case 'handshake':
+ return this.reply({ type: 'handshake_challenge', v: '99.0', v_min: '0.0',
+ nonce: b64(new Uint8Array(32)), node_pk: 'pk', sig: 's', req_id }, 50);
+ case 'handshake_response':
+ return this.reply({ type: 'handshake_ack', proof: 'AA==', node_pk: 'pk', sig: 's',
+ nonce: 'n', ct: 'c', req_id }, 50);
+ case 'device_hello':
+ return this.reply({ type: 'device_hello_ack', req_id }, 50);
+ case 'ping':
+ pings += 1;
+ return this.reply({ type: 'pong', token: msg.token, req_id }, 30);
+ default:
+ return undefined;
+ }
+ }
+}
+
+class FakeChannel {
+ constructor() { this.readyState = 'connecting'; this.binaryType = 'arraybuffer'; }
+ send(frame) {
+ if (this.readyState !== 'open') throw new Error('InvalidStateError');
+ this.session.receive(frame);
+ }
+ close() {
+ if (this.readyState === 'closed') return;
+ this.readyState = 'closed';
+ if (this.onclose) this.onclose({});
+ }
+}
+
+class FakePC {
+ constructor() {
+ this.connectionState = 'new';
+ this.iceConnectionState = 'new';
+ this.iceGatheringState = 'complete';
+ pcs.push(this);
+ }
+ createDataChannel() { this.channel = new FakeChannel(); return this.channel; }
+ async createOffer() { return { type: 'offer', sdp: SDP }; }
+ async setLocalDescription(d) { this.localDescription = d; }
+ async getStats() { return new Map(); }
+ async setRemoteDescription() {
+ setTimeout(() => {
+ if (this.connectionState === 'closed') return;
+ this.channel.session = new NodeSession(this.channel);
+ this.channel.readyState = 'open';
+ this.connectionState = 'connected';
+ this.iceConnectionState = 'connected';
+ if (this.channel.onopen) this.channel.onopen();
+ }, 100);
+ }
+ close() {
+ this.connectionState = 'closed';
+ if (this.channel) this.channel.close();
+ }
+}
+globalThis.RTCPeerConnection = FakePC;
+
+// ── The shipped transport ────────────────────────────────────────────────────
+
+new Function(`${transportSrc}\nwindow.__codec = { msgpack_encode, msgpack_decode };`)();
+const Transport = window.MeshBayTransport;
+// What the transport's own module listens to, before any connection exists.
+const moduleListeners = window.listening() + connection.listening();
+const out = {};
+
+if (scenario === 'events') {
+ // The helper alone: which events count as another network, and how many.
+ let calls = 0;
+ const off = Transport.onNetworkChange(() => { calls += 1; });
+ const at = [];
+ const step = async (fire) => { fire(); await run(now + 5000); at.push(calls); };
+ await step(() => connection.dispatch('change')); // a bandwidth estimate
+ await step(() => { connection.type = 'wifi'; connection.dispatch('change'); });
+ await step(() => window.dispatch('online'));
+ await step(() => window.dispatch('meshbay-default-network'));
+ await step(() => { // one change, three events
+ connection.type = 'cellular'; connection.dispatch('change');
+ window.dispatch('online');
+ window.dispatch('meshbay-default-network');
+ });
+ off();
+ await step(() => window.dispatch('online'));
+ out.calls = at;
+ out.leftListening = window.listening() + connection.listening() - moduleListeners;
+} else {
+ const main = new Transport('https://hub.invalid', 'token');
+ let reconnectedAt = null;
+ main.addReconnectListener(() => { if (reconnectedAt === null) reconnectedAt = now; });
+ let connected = false;
+ const identity = { pkEdB64: 'ed', signAs: async () => 'sig', native: false };
+ main.connect('node', 'token', 'group', new Uint8Array(32), identity, null, 'u', 'uid',
+ undefined, undefined, '', 'pk')
+ .then(() => { connected = true; }, (err) => { out.connectError = err.message; });
+ await run(5000);
+ out.connected = connected;
+
+ const first = pcs[0];
+ if (scenario === 'change-dead' || scenario === 'change-alive') {
+ // Mobile data to Wi-Fi. The pair in use ran over mobile data; when it is
+ // gone, the node hears nothing more on it.
+ if (scenario === 'change-dead') first.channel.session.cut = true;
+ connection.type = 'wifi';
+ connection.dispatch('change');
+ await run(25000);
+ } else if (scenario === 'ice-dead' || scenario === 'ice-alive') {
+ if (scenario === 'ice-dead') first.channel.session.cut = true;
+ first.iceConnectionState = 'disconnected';
+ first.oniceconnectionstatechange();
+ await run(25000);
+ } else if (scenario === 'backoff') {
+ // The connection fails while the device has no network, and every attempt
+ // fails with it until the backoff has grown long. Then Wi-Fi comes back.
+ offline = true;
+ first.channel.session.cut = true;
+ first.connectionState = 'failed';
+ first.onconnectionstatechange();
+ await run(40000);
+ offline = false;
+ connection.type = 'wifi';
+ connection.dispatch('change');
+ out.backAt = now;
+ await run(80000);
+ } else if (scenario === 'closed') {
+ main.close();
+ connection.type = 'wifi';
+ connection.dispatch('change');
+ await run(25000);
+ out.leftListening = window.listening() + connection.listening() - moduleListeners;
+ }
+ out.reconnectedAt = reconnectedAt;
+ out.connections = pcs.length;
+ out.pings = pings;
+}
+
+process.stdout.write(JSON.stringify(out));
diff --git a/packages/meshbay-hub/tests/test_network_change.py b/packages/meshbay-hub/tests/test_network_change.py
new file mode 100644
index 00000000..2298e8e6
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_network_change.py
@@ -0,0 +1,90 @@
+"""
+A connection follows the device to another network.
+
+A phone moving between mobile data and the Wi-Fi the node is on keeps a
+connection whose candidate pair runs over the network it left. ICE says so only
+once its consent checks lapse, about thirty seconds, and on Android not before
+the old network has lingered. Reported on the Android application: the group
+stopped answering and the application had to be killed; in Chrome, a group that
+had failed on Wi-Fi opened on mobile data only after a pull-to-refresh.
+
+Now a change of network asks the open connection for a ping, and a connection
+that does not answer is reconnected; a reconnect that is backing off tries again
+at once; and a group page left on an error tries again (group-page.js).
+
+These run the shipped transport: harness/network_change_harness.mjs.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+from spa_source import transport_argv
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+HARNESS = Path(__file__).parent / "harness" / "network_change_harness.mjs"
+
+pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node is not available")
+
+# Connected at 5 s, the network changes at 5 s: settled 1.5 s later, a ping
+# unanswered for 5 s, the first reconnect attempt 1 s after that.
+CHANGED_AT_MS = 5000
+
+
+def _run(scenario: str) -> dict:
+ proc = subprocess.run(["node", str(HARNESS), transport_argv(),
+ json.dumps({"scenario": scenario})],
+ capture_output=True, text=True, encoding="utf-8", timeout=120)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def test_a_connection_left_on_the_old_network_is_reconnected():
+ out = _run("change-dead")
+ assert out["connected"]
+ assert out["connections"] == 2, out
+ # Long before ICE's own thirty seconds would have declared it failed.
+ assert out["reconnectedAt"] - CHANGED_AT_MS < 10000, out
+
+
+def test_a_connection_that_still_answers_is_kept():
+ out = _run("change-alive")
+ assert out["connections"] == 1 and out["reconnectedAt"] is None, out
+ assert out["pings"] == 1
+
+
+def test_an_ice_disconnection_is_checked_rather_than_waited_out():
+ dead = _run("ice-dead")
+ assert dead["connections"] == 2 and dead["reconnectedAt"] - CHANGED_AT_MS < 10000, dead
+ alive = _run("ice-alive")
+ assert alive["connections"] == 1 and alive["reconnectedAt"] is None, alive
+
+
+def test_a_reconnect_backing_off_tries_again_on_the_new_network():
+ """Without the wake-up, the attempt after 30 s of failures waits 16 s more."""
+ out = _run("backoff")
+ assert out["reconnectedAt"] is not None, out
+ assert out["reconnectedAt"] - out["backAt"] < 3000, out
+
+
+def test_only_a_change_of_network_counts_and_once():
+ """A bandwidth estimate is not news; Wi-Fi against mobile data, `online` and
+ the application's own event are, and a burst of them is one change."""
+ out = _run("events")
+ assert out["calls"] == [0, 1, 2, 3, 4, 4], out
+ assert out["leftListening"] == 0, out
+
+
+def test_a_closed_transport_stops_listening():
+ out = _run("closed")
+ assert out["connections"] == 1 and out["leftListening"] == 0, out
+
+
+def test_a_group_left_on_an_error_tries_again_when_the_network_changes():
+ source = (STATIC / "group-page.js").read_text(encoding="utf-8")
+ block = re.search(r"onNetworkChange\(.*?\)", source, re.S)
+ assert block, "the group page does not listen for another network"
+ assert "setRetryKey" in source[block.start():block.start() + 200]