diff options
| -rw-r--r-- | src/features/messages.js | 67 | ||||
| -rw-r--r-- | src/nostr.js | 94 | ||||
| -rw-r--r-- | tools/relay-budget-test.js | 154 |
3 files changed, 275 insertions, 40 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index e1468ce..836966f 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4923,25 +4923,38 @@ export function messagesFeature(ctx) { const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] } // NIP-65: where this author actually writes. Their notes and threads live // there first — our default relays are just the common ground. + // Asked one at a time — a quoted note's author, a face tapped, a reply's + // target — these went out as one REQ per author per relay: a feed with + // six quotes on screen sent eighteen. Asks now wait a beat and go out + // together, one REQ with every author wanted, through the feed's own + // batched fetch, which also remembers who has no list so nobody is asked + // twice. const relayListCache = new Map(); // pk -> Promise<string[]> + const RELAY_ASK_MS = 80; + let relayAskQ = new Map(), relayAskTimer = null; // pk -> [resolve] function relaysOf(pk) { let p = relayListCache.get(pk); if (p) return p; // the feed's batched fetch has probably already asked for this one const known = relayListsNow().get(pk); if (known) { p = Promise.resolve(known.r || []); relayListCache.set(pk, p); return p; } - p = queryOn([...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [10002], authors: [pk] }, 3500) - .then((evs) => { - const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; - if (!newest) return []; - return [...new Set(newest.tags - .filter((x) => x[0] === 'r' && x[1] && x[2] !== 'read' && /^wss:\/\//.test(x[1])) - .map((x) => x[1].replace(/\/$/, '')))].slice(0, 4); - }) - .catch(() => []); + p = new Promise((resolve) => { + if (!relayAskQ.has(pk)) relayAskQ.set(pk, []); + relayAskQ.get(pk).push(resolve); + if (!relayAskTimer) relayAskTimer = setTimeout(flushRelayAsks, RELAY_ASK_MS); + }); relayListCache.set(pk, p); return p; } + async function flushRelayAsks() { + relayAskTimer = null; + const batch = relayAskQ; relayAskQ = new Map(); + try { await fetchRelayLists([...batch.keys()], [...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])]); } catch {} + for (const [pk, waiters] of batch) { + const r = ((relayListsNow().get(pk) || {}).r || []).slice(0, 4); + for (const w of waiters) w(r); + } + } // Plus relays that keep history: the big ones prune within days, and an // author without a NIP-65 list (Joe Nakamoto, 2026-10-01) otherwise shows // only whatever primal still holds — 2 posts of 22. @@ -5385,12 +5398,12 @@ export function messagesFeature(ctx) { // small, widely mirrored, and worth having before anything else is asked. // Authors with no list at all are remembered as such, so we don't ask again // every time the feed refreshes. - async function fetchRelayLists(pks) { + async function fetchRelayLists(pks, relays = NOTE_RELAYS) { const want = [...new Set(pks)].filter((pk) => !relayListsNow().has(pk)); if (!want.length) return; for (let i = 0; i < want.length; i += 100) { const batch = want.slice(i, i + 100); - const evs = await queryOn(NOTE_RELAYS, { kinds: [10002], authors: batch }, 4500).catch(() => []); + const evs = await queryOn(relays, { kinds: [10002], authors: batch }, 4500).catch(() => []); const newest = new Map(); for (const ev of evs || []) { const cur = newest.get(ev.pubkey); @@ -7770,22 +7783,33 @@ export function messagesFeature(ctx) { } // While the feed is what's on screen, new posts arrive by themselves — on // the same relays the pass above reads, one subscription each. + // The live subscriptions stay up across refreshes. Every refresh pass + // used to end by closing and reopening all of them — seventeen REQs over + // eight relays, twice per opening of Following — for subscriptions whose + // relays and authors hadn't changed; only the head they count fresh posts + // against moves, and that is read at each event. + let feedWatchKey = '', feedWatchHead = { t: Infinity }; function watchFeed() { - stopFeedWatch(); const c = feedNow(); const def = feedDef(c.id); // the Conversations view has its own - if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return; + if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') { stopFeedWatch(); return; } const newerThan = c.notes.length ? c.notes[0].created_at : Infinity; const since = Math.floor(Date.now() / 1000) - 60; const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {}; const kinds = feedKinds(def); - const on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; const authors = feedAuthors(def); + const orT = orTopicsOf(def); + const plan = def.curated || !authors.length ? [] : feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); + const key = [c.id, !!def.curated, !!def.all, !!def.replies, kinds.join(), topics.join(), orT.join(), (feedRelays(def) || []).join(), + ...plan.map(({ relays, authors: a }) => relays.join() + '>' + a.join())].join('\n'); + if (feedUnsubs.length && key === feedWatchKey) { feedWatchHead.t = newerThan; return; } + stopFeedWatch(); + feedWatchKey = key; + const head = feedWatchHead = { t: newerThan }; + const on = (ev) => { mergeFeed([ev], { live: true, newerThan: head.t }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); }; if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; } if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, since }, on)); return; } - const orT = orTopicsOf(def); if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, since }, on)); - const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors); for (const { relays, authors: a } of plan) for (let i = 0; i < a.length; i += REQ_AUTHORS) feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); @@ -7793,6 +7817,7 @@ export function messagesFeature(ctx) { function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } feedUnsubs = []; + feedWatchKey = ''; } // Prepare older pages several screens ahead of the reader. A scroll while // preparation is underway shares the same work instead of exposing it early. @@ -11270,10 +11295,16 @@ export function messagesFeature(ctx) { } } const REPORTS_MS = 30 * 60_000; + // The half hour counts from the last sync the device SAVED, so a reload + // (every open of the installed app is one) doesn't ask the outbox relays + // again — some forty REQs with every follow's reports — and a sync after + // one asks only for what came since. (The index is kept.) async function syncReports({ force = false } = {}) { if (!modOn('reports') || !myPubkeys().length) return; - if (!force && Date.now() - reportsAt < REPORTS_MS) return; + const last = Math.max(reportsAt, reportsNow().at || 0); + if (!force && Date.now() - last < REPORTS_MS) return; reportsAt = Date.now(); + const since = last ? { since: Math.floor(last / 1000) - 3600 } : {}; const authors = [...new Set([...myPubkeys(), ...followsNow().set])].slice(0, FEED_AUTHORS_MAX); const from = new Set(authors); try { @@ -11282,7 +11313,7 @@ export function messagesFeature(ctx) { const got = await Promise.all(plan.flatMap(({ relays, authors: a }) => { const chunks = []; for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS)); - return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500 }, 5000).catch(() => [])); + return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500, ...since }, 5000).catch(() => [])); })); for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) noteReport(ev); reportsNow().at = Date.now(); diff --git a/src/nostr.js b/src/nostr.js index f0b47d1..731c076 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -407,12 +407,77 @@ async function authRelay(relay, onauth) { } return relay.auth(onauth); } +// ---- one-shot reads: per relay, closed on its own EOSE, a few at a time -- +// The pool's own querySync keeps every relay's REQ open until the SLOWEST +// relay has answered or the wait runs out: asked beside a relay still +// dialing, our own relay — done in fifty milliseconds — carried each REQ +// for five seconds, and a feed's opening asks stacked up on it by the +// dozen. Each relay's REQ now closes on that relay's EOSE. And no relay +// carries more than REQ_SLOTS of them at once: the rest wait their turn, +// which is a beat on a relay that answers and no longer than the wait on +// one that doesn't. (A Following feed opened from the laptop sent some 560 +// REQs in twenty seconds across 57 relays, over a hundred each to damus +// and primal; damus answered the home IP "banned: too many rate-limit +// violations" on 2026-11-02, and primal notices past twenty at once.) +const REQ_SLOTS = 10; +const slots = new Map(); // normalized url -> { n, q: [resolve] } +function takeSlot(url) { + const k = relayKey(url); + let s = slots.get(k); + if (!s) slots.set(k, (s = { n: 0, q: [] })); + if (s.n < REQ_SLOTS) { s.n++; return Promise.resolve(false); } + const at = Date.now(); + return new Promise((resolve) => s.q.push(() => resolve(Date.now() - at))); // how long it waited +} +function freeSlot(url) { + const s = slots.get(relayKey(url)); + if (!s) return; + const next = s.q.shift(); + if (next) next(); // the slot passes straight to the next in line + else s.n--; +} +export const reqSlotsOf = (url) => { const s = slots.get(relayKey(url)); return s ? { busy: s.n, waiting: s.q.length } : { busy: 0, waiting: 0 }; }; +// One REQ carrying `filters` on one relay. Settles at the relay's EOSE, its +// CLOSED, a failed dial, or maxWait — whichever is first — and sends CLOSE. +function askRelay(url, filters, maxWait, onEvent) { + return new Promise((resolve) => { + let finished = false, sub = null, timer = 0; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + if (sub) { try { sub.close('done'); } catch {} } + freeSlot(url); + resolve(); + }; + takeSlot(url).then((waited) => { + // a turn that came only after the ask's own wait had run out: the + // caller has moved on, and a storm of late REQs helps nobody + if (waited > maxWait) { finish(); return; } + timer = setTimeout(finish, maxWait + 1000); + try { + sub = pool.subscribeMap(filters.map((filter) => ({ url, filter })), { maxWait, onevent: onEvent, oneose: finish, onclose: finish }); + if (finished) { try { sub.close('done'); } catch {} } + } catch { finish(); } + }); + }); +} +// All relays asked at once; an event is counted once however many hold it. +async function askRelays(relays, filters, maxWait, onEvent) { + const seen = new Set(); + await Promise.all(relays.map((url) => askRelay(url, filters, maxWait, (ev) => { + if (!ev || seen.has(ev.id)) return; + seen.add(ev.id); + onEvent(ev); + }))); +} export async function queryOn(relays, filter, maxWait = 1500) { probeRelays(relays); try { const live = readRelays(relays); if (!live.length) return []; - const evs = await pool.querySync(live, filter, { maxWait }); + const evs = []; + await askRelays(live, [filter], maxWait, (ev) => evs.push(ev)); const oks = await verifyEventsAsync(evs); return evs.filter((_, i) => oks[i]); } catch { return []; } @@ -517,17 +582,8 @@ export async function queryManyOn(relays, filters, maxWait = 1500) { try { const live = readRelays(relays); if (!live.length || !filters.length) return []; - const evs = await new Promise((resolve) => { - const got = []; - let sub = null, done = false; - const finish = () => { if (!done) { done = true; resolve(got); } }; - sub = pool.subscribeMap(live.flatMap((url) => filters.map((filter) => ({ url, filter }))), { - maxWait, - onevent: (e) => got.push(e), - oneose: () => { try { sub?.close('closed automatically on eose'); } catch {} finish(); }, - onclose: finish, - }); - }); + const evs = []; + await askRelays(live, filters, maxWait, (ev) => evs.push(ev)); const oks = await verifyEventsAsync(evs); return evs.filter((_, i) => oks[i]); } catch { return []; } @@ -540,14 +596,8 @@ export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) { try { const live = readRelays(relays); if (!live.length) return; - await new Promise((resolve) => { - pool.subscribeManyEose(live, filter, { - maxWait, - onevent(event) { - pending.push(verifyOneAsync(event).then((ok) => ok && onEvent(event)).catch(() => {})); - }, - onclose: resolve, - }); + await askRelays(live, [filter], maxWait, (event) => { + pending.push(verifyOneAsync(event).then((ok) => ok && onEvent(event)).catch(() => {})); }); } catch {} finally { await Promise.all(pending); } } @@ -657,7 +707,7 @@ export const PROFILE_RELAYS = HOME_RELAYS; // Fetch a recipient's profile (name + picture) for a pubkey, newest across relays. export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { let events; - try { events = await pool.querySync(relays, { kinds: [0], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return null; } + try { events = await queryOn(relays, { kinds: [0], authors: [pubkeyHex] }, 5000); } catch { return null; } const best = events.sort((a, b) => b.created_at - a.created_at)[0]; if (!best) return null; try { @@ -679,7 +729,7 @@ export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { // 10050), else their NIP-65 read relays (kind 10002), else [] (caller falls back). export async function fetchInboxRelays(pubkeyHex, relays = PROFILE_RELAYS) { let events; - try { events = await pool.querySync(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, { maxWait: 5000 }); } catch { return []; } + try { events = await queryOn(relays, { kinds: [10002, 10050], authors: [pubkeyHex] }, 5000); } catch { return []; } const newest = (kind) => events.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0]; const dm = newest(10050); if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r; } diff --git a/tools/relay-budget-test.js b/tools/relay-budget-test.js new file mode 100644 index 0000000..600f60b --- /dev/null +++ b/tools/relay-budget-test.js @@ -0,0 +1,154 @@ +// What a Following feed asks of a relay, and how. Measured from the laptop +// (2026-11-02) an open of Following sent some 560 REQs in twenty seconds: +// a relay list asked per quoted note's author (one REQ per author per +// relay), the live subscriptions closed and reopened at the end of every +// refresh pass, the follows' reports fetched afresh at every reload, and +// every one-shot REQ held open on our fast relay until the slowest relay +// in the ask had answered. damus answered the home IP "banned: too many +// rate-limit violations". Now: relay lists go out in one REQ per batch, +// unchanged live subscriptions stay up, the reports sync honours the saved +// clock, each relay's REQ closes on its own EOSE, and no relay carries more +// than ten one-shot REQs at once. Run: bun tools/relay-budget-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { neventEncode } from 'nostr-tools/nip19'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const html = await buildHtml({ minify: true, pwa: false }); +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = () => Math.floor(Date.now() / 1000); +const post = (sk, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now() - ago, tags, content }, sk); +// six people the follow quotes, each with one note on the relay +const quoters = Array.from({ length: 6 }, () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }); +const quotedNotes = quoters.map((q, i) => post(q.sk, `quoted thought ${i}`, 7200 + i)); +const stored = [...quotedNotes, post(SK, 'morning one', 3600), post(SK, 'morning two', 3000)]; +const quotePosts = quotedNotes.map((n, i) => post(SK, `look at this ${i} nostr:${neventEncode({ id: n.id, author: n.pubkey })}`, 1200 + i * 10, [['q', n.id, '', n.pubkey]])); +stored.push(...quotePosts); +const cached = [...quotePosts, ...stored.filter((e) => e.pubkey === AUTHOR && !quotePosts.includes(e))].sort((a, b) => b.created_at - a.created_at); +const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since); + +// two relays on one server: /fast answers at once, /slow holds its EOSE +const SLOW_MS = 3000; +const log = []; // { t, relay, kind: 'REQ'|'CLOSE'|'EOSE', id, filters } +const sockets = new Set(); +let maxOpen = { fast: 0, slow: 0 }; +const server = Bun.serve({ port: 0, fetch: (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/fast' || path === '/slow') return srv.upgrade(req, { data: { relay: path.slice(1), subs: new Map() } }) ? undefined : new Response('ws', { status: 426 }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +}, websocket: { + open(ws) { sockets.add(ws); }, + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + const relay = ws.data.relay; + if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; } + if (m[0] === 'REQ') { + const filters = m.slice(2); + ws.data.subs.set(m[1], filters); + maxOpen[relay] = Math.max(maxOpen[relay], ws.data.subs.size); + log.push({ t: Date.now(), relay, kind: 'REQ', id: m[1], filters }); + const answer = () => { + if (!ws.data.subs.has(m[1])) return; + for (const e of stored) if (filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + log.push({ t: Date.now(), relay, kind: 'EOSE', id: m[1] }); + }; + if (relay === 'slow') setTimeout(answer, SLOW_MS); else answer(); + } else if (m[0] === 'CLOSE') { ws.data.subs.delete(m[1]); log.push({ t: Date.now(), relay, kind: 'CLOSE', id: m[1] }); } + }, + close(ws) { sockets.delete(ws); }, +} }); +const PORT = server.port; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 390, height: 844 }); +await page.evaluateOnNewDocument((port) => { + const Real = window.WebSocket; + window.WebSocket = function (url, p) { + if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/fast`; + else if (/relay\.damus\.io/.test(String(url))) url = `ws://localhost:${port}/slow`; + return p ? new Real(url, p) : new Real(url); + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}, PORT); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const reqs = (relay, pred) => log.filter((x) => x.kind === 'REQ' && x.relay === relay && pred(x.filters)); +const isLive = (fs) => fs.some((f) => f.authors && f.since && !f.limit && f.kinds && f.kinds.includes(1)); +// a subscription meant to stay up: since with no limit, or limit 0 (the DM inbox) +const isSub = (fs) => fs.some((f) => (f.since && !f.limit) || f.limit === 0); + +try { + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Early Bird', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, cached]); + log.length = 0; maxOpen = { fast: 0, slow: 0 }; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + const t0 = Date.now(); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the quote posts are on screen', await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 6, null, 20000)); + await sleep(9000); // the refresh pass, the quotes' lookups, the reports sync + + console.log('\n[relay lists for the quoted notes\' authors]'); + const qpks = new Set(quoters.map((q) => q.pk)); + const listAsks = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds.length === 1 && f.kinds[0] === 10002 && (f.authors || []).some((a) => qpks.has(a)))); + const covered = new Set(listAsks.flatMap((x) => x.filters.flatMap((f) => f.authors || [])).filter((a) => qpks.has(a))); + check('asked for in one REQ, not one per author', listAsks.length === 1 && covered.size === 6, listAsks.length + ' REQ(s) covering ' + covered.size + ' of 6 authors'); + + console.log('\n[the live subscriptions]'); + const live = reqs('fast', isLive); + const liveClosed = live.filter((x) => log.some((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === x.id)); + check('opened once and kept across the refresh', live.length === 1 && liveClosed.length === 0, live.length + ' live REQs, ' + liveClosed.length + ' closed'); + + console.log('\n[one-shot REQs]'); + const fastReqs = reqs('fast', (fs) => !isSub(fs)); + const lag = []; + for (const r of fastReqs) { + const eose = log.find((y) => y.kind === 'EOSE' && y.relay === 'fast' && y.id === r.id); + const close = log.find((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === r.id && y.t >= r.t); + if (eose && close) { lag.push(close.t - eose.t); if (close.t - eose.t > 1500) console.log(' late close ' + (close.t - eose.t) + ' ms: ' + JSON.stringify(r.filters).slice(0, 160)); } + } + const slowLag = lag.filter((x) => x > 1500).length; + check('closed on the fast relay\'s own EOSE, not the slow relay\'s', lag.length >= 10 && slowLag === 0, lag.length + ' closed, ' + slowLag + ' later than 1.5 s (slowest ' + Math.max(0, ...lag) + ' ms; the slow relay holds EOSE ' + SLOW_MS + ' ms)'); + const slowReqs = reqs('slow', () => true).length; + check('no more than ten one-shot REQs open on a relay at once', maxOpen.slow <= 10 + 5, slowReqs + ' REQs to the slow relay, at most ' + maxOpen.slow + ' open at once (fast: ' + maxOpen.fast + ')'); + const totalFast = reqs('fast', () => true).length; + console.log(' ' + totalFast + ' REQs to the fast relay in ' + ((Date.now() - t0) / 1000).toFixed(0) + ' s'); + + console.log('\n[a reload within the half hour]'); + const reportsBefore = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length; + log.length = 0; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await sleep(6000); + const reportsAfter = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length; + check('the follows\' reports are not fetched again', reportsBefore >= 1 && reportsAfter === 0, reportsBefore + ' REQ(s) on the first open, ' + reportsAfter + ' after the reload'); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the feed asks the relays for less, and asks it in fewer REQs' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
