diff options
Diffstat (limited to 'packages/meshbay-hub/tests/harness/search_pool_harness.mjs')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/search_pool_harness.mjs | 221 |
1 files changed, 221 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/search_pool_harness.mjs b/packages/meshbay-hub/tests/harness/search_pool_harness.mjs new file mode 100644 index 0000000..521a97e --- /dev/null +++ b/packages/meshbay-hub/tests/harness/search_pool_harness.mjs @@ -0,0 +1,221 @@ +// Run the SHIPPED `ConnectionPool`, `fetchGroupIndex` and `fetchAllIndexes` +// against a fake clock and fake nodes, and count offers. +// +// The question this answers is how many connections Search negotiates, how many +// at once, and whether one it is reading from can be closed under it — the +// things that decided whether a phone ran into the hub's per-account ceilings. +// The pool, the index fetch and the sweep are lifted out of search-page.js as +// text; what is modelled is the clock and the nodes (`connectToGroup`, which +// is where an offer is made). +// +// Usage: node search_pool_harness.mjs <path to search-page.js> <json config> +import { readFileSync } from 'fs'; + +const src = readFileSync(process.argv[2], 'utf8'); +const cfg = JSON.parse(process.argv[3] || '{}'); + +const { + scenario = 'sweep', + groups = 5, + // Group indexes whose node never answers; `connectToGroup` fails after deadMs. + dead = [], + connectMs = 2000, // a phone on 4G + deadMs = 10000, + indexMs = 300, + runUntil = 600000, +} = cfg; + +// ── 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.clearTimeout = (id) => { timers = timers.filter((t) => t.id !== id); }; +// The pool orders its connections by `Date.now()`, so that is the fake clock too. +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; + timers = timers.filter((t) => t !== due); + now = due.at; + due.fn(); + await flush(); + } + now = until; +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ── The nodes ──────────────────────────────────────────────────────────────── + +let offers = 0; +let negotiating = 0; +let peakNegotiating = 0; +let readsOnClosed = 0; +let pings = 0; +const transports = []; +// Groups whose held connection has died quietly, as a phone's does in its sleep. +const silentlyDead = new Set(); + +class FakeTransport { + constructor(groupId) { + this.groupId = groupId; + this.closedFlag = false; + transports.push(this); + } + get connected() { return !this.closedFlag; } + close() { this.closedFlag = true; } + async ping(timeoutMs) { + pings += 1; + if (silentlyDead.has(this.groupId)) { + await sleep(timeoutMs); + throw new Error('Response timeout'); + } + await sleep(100); + } + async fetchIndex() { + // Indexes differ in size, so reads end at different times and overlap + // other groups' negotiations — which uniform timings never do. + const n = Number(String(this.groupId).split('-')[1]); + await sleep(indexMs * (1 + (n % 4))); + if (this.closedFlag) { readsOnClosed += 1; throw new Error('Transport closed'); } + return { entries: [{ id: `e-${this.groupId}` }] }; + } +} + +async function connectToGroup(_hub, groupId) { + offers += 1; + negotiating += 1; + peakNegotiating = Math.max(peakNegotiating, negotiating); + try { + const n = Number(String(groupId).split('-')[1]); + if (dead.includes(n)) { + await sleep(deadMs); + throw new Error('Connection timeout'); + } + await sleep(connectMs); + return { transport: new FakeTransport(groupId), ack: {} }; + } finally { + negotiating -= 1; + } +} + +globalThis.window = {}; +let remembered = null; +globalThis.localStorage = { getItem: () => null, setItem: (_k, v) => { remembered = v; } }; +const session = { bundleKey: 'k' }; +const _loadBundleKey = async () => 'k'; +const cacheGroupIndex = () => {}; + +// ── The code under test, as text ───────────────────────────────────────────── + +const constant = (name) => { + const m = new RegExp(`^const ${name} = (\\d+);`, 'm').exec(src); + if (!m) throw new Error(`${name} is gone from search-page.js`); + return `const ${name} = ${m[1]};`; +}; +const lift = (signature) => { + const start = src.indexOf(signature); + if (start < 0) throw new Error(`${signature} is gone from search-page.js`); + return src.slice(start, src.indexOf('\n}\n', start) + 3); +}; + +const make = new Function( + 'connectToGroup', 'window', 'session', '_loadBundleKey', 'cacheGroupIndex', + 'localStorage', + `${constant('MAX_IN_FLIGHT')} + ${constant('MAX_POOL_SIZE')} + ${constant('REUSE_PING_MS')} + const DOWN_KEY = 'harness'; + ${lift('class ConnectionPool {')} + ${lift('async function fetchGroupIndex(')} + ${lift('function lastKnownDown(')} + ${lift('function rememberDown(')} + ${lift('async function inFlight(')} + ${lift('async function fetchAllIndexes(')} + return { ConnectionPool, fetchAllIndexes, MAX_IN_FLIGHT, MAX_POOL_SIZE };`, +); +const code = make(connectToGroup, globalThis.window, session, _loadBundleKey, + cacheGroupIndex, globalThis.localStorage); + +// ── The scenarios ──────────────────────────────────────────────────────────── + +const list = Array.from({ length: groups }, (_, i) => ({ id: `g-${i}`, name: `G${i}` })); +let evicted = 0; +const pool = new code.ConnectionPool('https://hub.example', () => { evicted += 1; }); +const out = { maxInFlight: code.MAX_IN_FLIGHT, maxPool: code.MAX_POOL_SIZE }; + +// Groups whose index has landed — the only ones with tiles on screen. +const shown = new Set(); +const sweep = async () => { + const r = await code.fetchAllIndexes(pool, list, 'tok', 'alice', 'u1', () => {}, + (results) => { for (const id of results.keys()) shown.add(id); }); + return { found: r.results.size, unreachable: r.unreachable.length, at: now }; +}; + +if (scenario === 'sweep') { + sweep().then((r) => { Object.assign(out, r); }); + await run(runUntil); +} else if (scenario === 'refresh') { + // A sweep, then the Search page's own refresh button: the second must reuse. + sweep().then(async () => { + out.offersFirst = offers; + const again = await sweep(); + out.offersSecond = offers - out.offersFirst; + Object.assign(out, again); + }); + await run(runUntil); +} else if (scenario === 'slept') { + // A sweep, then the phone sleeps and two connections die without saying so. + sweep().then(async () => { + out.offersFirst = offers; + silentlyDead.add('g-0'); + silentlyDead.add('g-1'); + const again = await sweep(); + out.offersSecond = offers - out.offersFirst; + Object.assign(out, again); + }); + await run(runUntil); +} else if (scenario === 'tiles') { + // A tile of every group asks for its connection while the sweep is on. + sweep().then((r) => { Object.assign(out, r); }); + for (const g of list) pool.connect(g.id, 'tok', 'k', 'alice', 'u1').catch(() => {}); + await run(runUntil); +} else if (scenario === 'scroll') { + // Tiles of groups already on screen keep using their connections while the + // sweep is still reading other groups' indexes — which makes a connection an + // index is being read from the least recently used one in the pool. + let sweeping = true; + sweep().then((r) => { sweeping = false; Object.assign(out, r); }); + const touch = async () => { + while (sweeping) { + await sleep(250); + for (const id of shown) { + if (pool.has(id)) pool.connect(id, 'tok', 'k', 'alice', 'u1').catch(() => {}); + } + } + }; + touch(); + await run(runUntil); +} else if (scenario === 'unmount') { + // The page goes away while connections are still being negotiated. + sweep().catch(() => {}); + await run(connectMs / 2); + pool.closeAll(); + await run(runUntil); + out.openAfter = transports.filter((t) => !t.closedFlag).length; +} + +Object.assign(out, { + offers, peakNegotiating, readsOnClosed, pings, evicted, poolSize: pool.size, + remembered: remembered === null ? null : JSON.parse(remembered), + openTransports: transports.filter((t) => !t.closedFlag).length, +}); +console.log(JSON.stringify(out)); |