diff options
Diffstat (limited to 'packages/meshbay-hub')
4 files changed, 471 insertions, 7 deletions
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] |