diff options
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
13 files changed, 563 insertions, 13 deletions
diff --git a/packages/meshbay-hub/tests/harness/cast_session_probe.py b/packages/meshbay-hub/tests/harness/cast_session_probe.py index 573dd950..901d27cd 100644 --- a/packages/meshbay-hub/tests/harness/cast_session_probe.py +++ b/packages/meshbay-hub/tests/harness/cast_session_probe.py @@ -99,6 +99,7 @@ window.MeshBayTransport = function () { // One chunk per photo: every photo is the same picture. async fetchChunk() { return { nonce: 'n', ct: await photoBytes() }; }, addReconnectListener() { return () => {}; }, + addReconnectFailureListener() { return () => {}; }, close() {}, }; return new Proxy(self, { diff --git a/packages/meshbay-hub/tests/harness/group_tab_probe.py b/packages/meshbay-hub/tests/harness/group_tab_probe.py index e23a9462..a430c625 100644 --- a/packages/meshbay-hub/tests/harness/group_tab_probe.py +++ b/packages/meshbay-hub/tests/harness/group_tab_probe.py @@ -73,6 +73,7 @@ window.MeshBayTransport = class { // unmount; a stub without this throws inside connect() and the page // renders its error state instead of a tab bar. addReconnectListener() { return () => {}; } + addReconnectFailureListener() { return () => {}; } // What the node should hold of our identity, settled after connecting. async settleNodeBundle() {} close() {} diff --git a/packages/meshbay-hub/tests/harness/local_network_harness.mjs b/packages/meshbay-hub/tests/harness/local_network_harness.mjs new file mode 100644 index 00000000..9afdf59e --- /dev/null +++ b/packages/meshbay-hub/tests/harness/local_network_harness.mjs @@ -0,0 +1,156 @@ +// Run the SHIPPED transport against a device that refuses the local network. +// +// Android 17 makes the local network a permission of the application, and an +// application that targets it (Chrome from 155) has none until the person +// grants it. The browser then drops the node's addresses on the phone's own +// subnet: they never show in getStats, no check goes out to them, ICE fails and +// the channel closes. Measured on a Pixel, Android 17, Chrome 155, 2026-10-16. +// +// transport.js is executed whole. What is modelled is the environment: a clock, +// a peer connection whose ICE fails (or connects and then fails) after the +// answer, the remote candidates its getStats reports, and a hub that answers the +// offer and the token. +// +// Usage: node local_network_harness.mjs <transport sources> <json> +// {"classify": [[offerSdp, answerSdp, remoteAddresses], ...]} +// → the verdict of localNetworkRefused for each case +// {"offer": sdp, "answer": sdp, "remote": [...], "iceConnects": bool, "statsThrow": bool} +// → how connect() ends +import { readFileSync } from 'fs'; +import { delimiter } from 'path'; + +const transportSrc = process.argv[2].split(delimiter) + .map((p) => readFileSync(p, 'utf8')).join('\n'); +const cfg = 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; +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 ──────────────────────────────────────────── + +globalThis.window = globalThis; +globalThis.addEventListener = () => {}; +globalThis.removeEventListener = () => {}; +globalThis.location = { hash: '' }; +globalThis.document = { + addEventListener() {}, removeEventListener() {}, visibilityState: 'visible', +}; +globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; +window.MeshBayCrypto = {}; + +globalThis.fetch = async (url) => ({ + ok: true, status: 200, headers: { get: () => null }, + json: async () => (url.endsWith('/mnp-token') ? { mnp_token: 't' } : { sdp: cfg.answer }), +}); + +// ── The device ─────────────────────────────────────────────────────────────── + +class FakeChannel { + constructor() { this.readyState = 'connecting'; this.binaryType = 'arraybuffer'; } + send() { throw new Error('InvalidStateError'); } + close() { + if (this.readyState === 'closed') return; + this.readyState = 'closed'; + if (this.onclose) this.onclose({}); + } +} + +let statsCalls = 0; +class FakePC { + constructor() { + this.connectionState = 'new'; + this.iceConnectionState = 'new'; + this.iceGatheringState = 'complete'; + } + createDataChannel() { this.channel = new FakeChannel(); return this.channel; } + async createOffer() { return { type: 'offer', sdp: cfg.offer }; } + async setLocalDescription(d) { this.localDescription = d; } + async setRemoteDescription() { + this.iceConnectionState = 'checking'; + this.oniceconnectionstatechange(); + if (cfg.iceConnects) { + setTimeout(() => { + this.iceConnectionState = 'connected'; + this.oniceconnectionstatechange(); + }, 200); + } + // ICE gives up, and the channel goes with it, as Chrome reports it. + setTimeout(() => { + if (this.connectionState === 'closed') return; + this.iceConnectionState = 'failed'; + this.oniceconnectionstatechange(); + this.connectionState = 'failed'; + this.onconnectionstatechange(); + this.channel.close(); + }, 12000); + } + async getStats() { + statsCalls += 1; + if (this.connectionState === 'closed' || cfg.statsThrow) throw new Error('InvalidStateError'); + const reports = new Map(); + (cfg.remote || []).forEach((address, i) => { + reports.set(`R${i}`, { id: `R${i}`, type: 'remote-candidate', address }); + }); + return reports; + } + close() { + this.connectionState = 'closed'; + if (this.channel) this.channel.close(); + } +} +globalThis.RTCPeerConnection = FakePC; + +// ── The shipped transport ──────────────────────────────────────────────────── + +new Function(transportSrc)(); +const Transport = window.MeshBayTransport; + +if (cfg.classify) { + process.stdout.write(JSON.stringify( + cfg.classify.map(([offer, answer, remote]) => Transport.localNetworkRefused(offer, answer, remote)))); +} else { + const main = new Transport('https://hub.invalid', 'token'); + let outcome = 'pending'; + let reason = null; + main.connect('node', 'token', 'group', new Uint8Array(32), null, null, 'u', 'uid', + undefined, undefined, '', 'pk') + .then(() => { outcome = 'connected'; }, + (err) => { outcome = err.message; reason = err.reason || null; main.close(); }); + await run(60000); + process.stdout.write(JSON.stringify({ outcome, reason, statsCalls })); +} diff --git a/packages/meshbay-hub/tests/harness/music_grid_probe.py b/packages/meshbay-hub/tests/harness/music_grid_probe.py index 4f5b0ce7..1995b2a1 100644 --- a/packages/meshbay-hub/tests/harness/music_grid_probe.py +++ b/packages/meshbay-hub/tests/harness/music_grid_probe.py @@ -81,6 +81,7 @@ window.MeshBayTransport = function () { // Real, not left to the Proxy below: that would hand back a promise // where an unsubscribe belongs, and the page calls it on unmount. addReconnectListener() { return () => {}; }, + addReconnectFailureListener() { return () => {}; }, close() {}, }; return new Proxy(self, { diff --git a/packages/meshbay-hub/tests/harness/music_queue_probe.py b/packages/meshbay-hub/tests/harness/music_queue_probe.py index 9be440b3..83c2972e 100755 --- a/packages/meshbay-hub/tests/harness/music_queue_probe.py +++ b/packages/meshbay-hub/tests/harness/music_queue_probe.py @@ -80,6 +80,7 @@ window.MeshBayTransport = function () { // Real, not left to the Proxy below: that would hand back a promise // where an unsubscribe belongs, and the page calls it on unmount. addReconnectListener() { return () => {}; }, + addReconnectFailureListener() { return () => {}; }, close() {}, }; return new Proxy(self, { diff --git a/packages/meshbay-hub/tests/harness/music_untagged_probe.py b/packages/meshbay-hub/tests/harness/music_untagged_probe.py index f4c484c3..7af1efc7 100644 --- a/packages/meshbay-hub/tests/harness/music_untagged_probe.py +++ b/packages/meshbay-hub/tests/harness/music_untagged_probe.py @@ -110,6 +110,7 @@ window.MeshBayTransport = function () { async fetchChatHistory() { return { messages: [], hasMore: false }; }, async fetchLinkPreview() { return { ok: false }; }, addReconnectListener() { return () => {}; }, + addReconnectFailureListener() { return () => {}; }, close() {}, }; return new Proxy(self, { 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..a8bd0809 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/network_change_harness.mjs @@ -0,0 +1,329 @@ +// 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" | "refused" +// | "refused-during"} +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'; +const candidate = (address, kind) => `a=candidate:1 1 udp 2130706431 ${address} 50000 typ ${kind}\r\n`; +// On the node's Wi-Fi without the permission (test_local_network_refused.py): +// the phone's offer and the node's answer as measured, and the node's address +// on the phone's subnet missing from what the browser paired. +const WIFI_OFFER = SDP + candidate('192.168.1.13', 'host') + candidate('90.112.206.172', 'srflx'); +const NODE_ANSWER = SDP + candidate('192.168.1.22', 'host') + candidate('90.112.206.172', 'srflx'); +const KEPT_WHEN_REFUSED = ['90.112.206.172']; +let offline = false; +let lanRefused = 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: lanRefused ? NODE_ANSWER : 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: lanRefused ? WIFI_OFFER : SDP }; } + async setLocalDescription(d) { this.localDescription = d; } + async getStats() { + const reports = new Map(); + if (this.refused) { + KEPT_WHEN_REFUSED.forEach((address, i) => { + reports.set(`R${i}`, { id: `R${i}`, type: 'remote-candidate', address }); + }); + } + return reports; + } + async setRemoteDescription() { + if (lanRefused) { + // ICE gives up, and the channel goes with it, as Chrome reports it: + // there after thirty seconds, here after fifteen. + this.refused = true; + setTimeout(() => { + if (this.connectionState === 'closed') return; + this.iceConnectionState = 'failed'; + this.connectionState = 'failed'; + this.channel.close(); + }, 15000); + return; + } + 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; }); + const failures = []; + main.addReconnectFailureListener((err) => { failures.push(err.reason || null); }); + 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 === 'refused-during') { + // The same, with the permission granted while an attempt set up before it + // is still waiting on ICE: that attempt is given up, the next goes at once. + lanRefused = true; + first.channel.session.cut = true; + connection.type = 'wifi'; + connection.dispatch('change'); + await run(40000); + const attempts = pcs.length; + while (pcs.length === attempts) await run(now + 100); + await run(now + 2000); + lanRefused = false; + out.backAt = now; + main.retryNow(); + await run(now + 40000); + } else if (scenario === 'refused') { + // Connected on mobile data, then on the node's Wi-Fi without the local + // network permission: every attempt is refused, and says so. The + // permission is granted after a while, and the page asks to try now. + lanRefused = true; + first.channel.session.cut = true; + connection.type = 'wifi'; + connection.dispatch('change'); + await run(60000); + out.failures = [...failures]; + out.stillReconnecting = main.reconnecting; + lanRefused = false; + out.backAt = now; + main.retryNow(); + 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/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py index 6e3be1e2..0d6e4134 100644 --- a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py +++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py @@ -76,6 +76,7 @@ window.MeshBayTransport = function () { // Real, not left to the Proxy below: that would hand back a promise // where an unsubscribe belongs, and the page calls it on unmount. addReconnectListener() { return () => {}; }, + addReconnectFailureListener() { return () => {}; }, close() {}, }; return new Proxy(self, { diff --git a/packages/meshbay-hub/tests/harness/reconnect_race_harness.mjs b/packages/meshbay-hub/tests/harness/reconnect_race_harness.mjs index 0f1cd979..606598da 100644 --- a/packages/meshbay-hub/tests/harness/reconnect_race_harness.mjs +++ b/packages/meshbay-hub/tests/harness/reconnect_race_harness.mjs @@ -30,6 +30,7 @@ const { rttMs = 300, // one request → its reply, during the handshake lateCloseMs = 0, // >0: the replaced channel's close event fires this late runUntil = 300000, + stream = false, // a film playing instead of a download (see below) } = JSON.parse(process.argv[4] || '{}'); for (const level of ['log', 'warn', 'error', 'info', 'debug']) { @@ -106,6 +107,7 @@ globalThis.fetch = async (url) => ({ const OPENING = new Set(['handshake', 'handshake_response', 'keypair_bundle_fetch', 'gek_bundle_fetch', 'join_request', 'device_add_request', 'device_hello']); let refusedBeforeHandshake = 0; +const refusedTypes = new Set(); let served = 0; const pcs = []; @@ -127,6 +129,7 @@ class NodeSession { const req_id = msg.req_id; if (!this.authed && !OPENING.has(msg.type)) { refusedBeforeHandshake += 1; + refusedTypes.add(msg.type); return this.reply({ type: 'error', detail: 'Handshake required', req_id }, 1); } switch (msg.type) { @@ -210,10 +213,26 @@ main.addReconnectListener(() => { reconnected += 1; }); const written = []; const writable = { write: async (bytes) => { written.push(bytes[0]); } }; -main.connect('node', 'token', 'group', new Uint8Array(32), identity, null, 'u', 'uid', - undefined, undefined, '', 'pk') - .then(() => pipelinedDownload(main, 'gek', 'f'.repeat(64), chunks, null, writable, null, '')) - .then(() => { outcome = 'done'; }, (err) => { outcome = `failed: ${err.message}`; }); +// A film playing (`stream`): the player goes on granting the node room for +// segments as it plays from its buffer, and on telling it what it sees, through +// the connection loss and the reconnect, as video-player.js does. A refusal of +// either is a node error the player shows in place of the film. +const opened = main.connect('node', 'token', 'group', new Uint8Array(32), identity, null, + 'u', 'uid', undefined, undefined, '', 'pk'); +if (stream) { + opened.then(() => { + main.requestStream('f'.repeat(64)); + setInterval(() => { + main.grantStreamCredit(1); + main.sendStreamDiag({ buffered: 1 }); + }, 50); + outcome = 'done'; + }, (err) => { outcome = `failed: ${err.message}`; }); +} else { + opened + .then(() => pipelinedDownload(main, 'gek', 'f'.repeat(64), chunks, null, writable, null, '')) + .then(() => { outcome = 'done'; }, (err) => { outcome = `failed: ${err.message}`; }); +} await run(hangAt); pcs[0].channel.session.hung = true; @@ -223,7 +242,7 @@ pcs[0].onconnectionstatechange(); await run(runUntil); process.stdout.write(JSON.stringify({ - outcome, reconnected, refusedBeforeHandshake, served, + outcome, reconnected, refusedBeforeHandshake, refusedTypes: [...refusedTypes], served, written: written.length, inOrder: written.every((b, i) => b === (i & 255)), connections: pcs.length, diff --git a/packages/meshbay-hub/tests/harness/search_connect_harness.mjs b/packages/meshbay-hub/tests/harness/search_connect_harness.mjs index 60cea2ba..0c10a39d 100644 --- a/packages/meshbay-hub/tests/harness/search_connect_harness.mjs +++ b/packages/meshbay-hub/tests/harness/search_connect_harness.mjs @@ -26,6 +26,13 @@ const { resolveAt = null, // How many nodes the hub lists for the group. nodes = 1, + // Which of them answer (resolveAt applies to these). null: all. + answering = null, + // Which of them this device refuses to reach on the local network. As + // measured on a Pixel: no progress, and connect() names the refusal only + // when ICE gives up, `refusedAt` ms in. + lanRefused = [], + refusedAt = 30000, // How far to run the clock before giving up on the harness itself. runUntil = 300000, } = cfg; @@ -65,11 +72,15 @@ async function run(until) { // ── The environment ────────────────────────────────────────────────────────── let closed = 0; +const closedAt = []; +const lateRefusals = []; +let made = 0; class FakeTransport { constructor() { this.onConnectProgress = null; this.onNeedToken = null; + this.index = made++; } connect() { @@ -83,14 +94,25 @@ class FakeTransport { } }, at); } - return new Promise((resolve) => { - if (resolveAt !== null) { + return new Promise((resolve, reject) => { + if (lanRefused.includes(this.index)) { + setTimeout(() => { + const err = new Error('This device refused to reach the local network'); + err.reason = 'local_network_blocked'; + reject(err); + }, refusedAt); + return; + } + if (resolveAt !== null && (answering === null || answering.includes(this.index))) { setTimeout(() => resolve({ ok: true, at: now - started }), resolveAt); + } else { + // The transport's own deadline for the channel: every connect() ends. + setTimeout(() => reject(new Error('DataChannel open timeout')), 30000); } }); } - close() { closed += 1; } + close() { closed += 1; closedAt.push(now); } } globalThis.window = { MeshBayTransport: FakeTransport }; @@ -125,13 +147,18 @@ const connectToGroup = make(hubFetch, ensureFreshToken, globalThis.window); // ── The scenario ───────────────────────────────────────────────────────────── let outcome = null; -connectToGroup('https://hub.example', 'g1', 'tok', null, 'alice', 'u1') +connectToGroup('https://hub.example', 'g1', 'tok', null, 'alice', 'u1', + (groupId) => { lateRefusals.push({ groupId, at: now }); }) .then(() => { outcome = { result: 'connected', at: now }; }) - .catch((e) => { outcome = { result: 'failed', at: now, error: String(e.message) }; }); + .catch((e) => { + outcome = { result: 'failed', at: now, error: String(e.message), reason: e.reason || null }; + }); await run(runUntil); console.log(JSON.stringify({ ...(outcome || { result: 'still-waiting', at: now }), closed, + closedAt, + lateRefusals, })); diff --git a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs index f84d90bc..27ad8757 100644 --- a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs +++ b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs @@ -23,6 +23,8 @@ const { // Indexes (0-based, in the hub's listing order) of the groups whose node is // down. They take `deadMs`; every other group answers in `liveMs`. dead = [], + // Of those, the ones whose node this device refused on the local network. + lanRefused = [], liveMs = 200, deadMs = 10000, // Group indexes the browser remembers as silent from a previous sweep. @@ -87,8 +89,12 @@ const fetchGroupIndex = (_pool, groupId) => new Promise((resolve, reject) => { const n = Number(String(groupId).split('-')[1]); const isDead = dead.includes(n); setTimeout( - () => (isDead ? reject(new Error('Connection timeout')) - : resolve({ entries: [{ id: `e${n}` }], roots: {} })), + () => { + if (!isDead) return resolve({ entries: [{ id: `e${n}` }], roots: {} }); + const err = new Error('Connection timeout'); + if (lanRefused.includes(n)) err.reason = 'local_network_blocked'; + return reject(err); + }, isDead ? deadMs : liveMs, ); }); @@ -125,11 +131,15 @@ const fetchAllIndexes = make( const list = Array.from({ length: groups }, (_, i) => ({ id: `g-${i}`, name: `G${i}` })); let finishedAt = null; +let outcome = null; fetchAllIndexes( null, list, 'tok', 'alice', 'u1', () => {}, // progress counters only (results) => { shown.push({ at: now, groups: results.size }); }, -).then(() => { finishedAt = now; }); +).then((out) => { + finishedAt = now; + outcome = { unreachable: out.unreachable, lanBlocked: out.lanBlocked.map((g) => g.name) }; +}); await run(runUntil); @@ -140,6 +150,7 @@ console.log(JSON.stringify({ // Every render, so a test can see whether live groups waited on dead ones. renders: shown, finishedAt, + outcome, inFlight: Number(ceiling[1]), // What the browser will remember for next time. remembered: written === null ? null : JSON.parse(written), diff --git a/packages/meshbay-hub/tests/harness/sticky_header_probe.py b/packages/meshbay-hub/tests/harness/sticky_header_probe.py index 3e75494b..89fb2827 100755 --- a/packages/meshbay-hub/tests/harness/sticky_header_probe.py +++ b/packages/meshbay-hub/tests/harness/sticky_header_probe.py @@ -240,6 +240,7 @@ window.MeshBayTransport = function () { // Real, not left to the Proxy below: that would hand back a promise // where an unsubscribe belongs, and the page calls it on unmount. addReconnectListener() { return () => {}; }, + addReconnectFailureListener() { return () => {}; }, close() {}, }; return new Proxy(self, { diff --git a/packages/meshbay-hub/tests/harness/video_series_probe.py b/packages/meshbay-hub/tests/harness/video_series_probe.py index f161c305..2b9ade30 100644 --- a/packages/meshbay-hub/tests/harness/video_series_probe.py +++ b/packages/meshbay-hub/tests/harness/video_series_probe.py @@ -75,6 +75,7 @@ window.MeshBayTransport = function () { // what TMDB would have said. async fetchMediaMeta() { return { confidence: 0 }; }, addReconnectListener() { return () => {}; }, + addReconnectFailureListener() { return () => {}; }, close() {}, }; // Everything else the player asks for never answers: it sits on its |