diff options
| author | Adam Soltys <[email protected]> | 2026-11-01 21:29:27 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-01 21:29:27 -0700 |
| commit | 84460457619857ea6cb678bb0a7b2918c222014a (patch) | |
| tree | 9f3b2e66f719086b03b66650232c5ef5723252b6 | |
| parent | 89277914f2f67ddd9d2edd0adcdffc9d8727fc0f (diff) | |
| download | coinosv3-84460457619857ea6cb678bb0a7b2918c222014a.tar.gz coinosv3-84460457619857ea6cb678bb0a7b2918c222014a.zip | |
Back from the background, the app checks its relay sockets before it asks them anything: one tiny REQ to each; a socket that answers is kept, one that doesn't is closed and redialed at once, its subscriptions refired by the library with since set to the last event each saw, and only then do the DMs resubscribe and the feed refresh. "No new notes in Following since 11:35? It's 9 pm now … it loaded more posts, it just took a while" (Adam, 2026-11-01): after hours in the background a phone's tab is thawed with every socket reading OPEN and long dead on the network, so the catch-up sent on resume vanished, and the library found a dead socket out only after its 29 s ping and that ping's 20 s timeout, then waited 10 s to redial — a minute or more before the backlog arrived through the refired subscriptions. tools/resume-wake-test.js freezes a fake relay's sockets, posts while the app "sleeps", and has the post on screen 2.7 s after resume (never within 15 s before). Also: Primal's "too many concurrent REQs" notice no longer cools the relay off for 30 s at every boot — it serves every REQ past its twenty anyway (measured 60 at once: 60 EOSEs, 12 notices), so the cool-off only lost us its answers while the relay lists, profiles and the feed all asked at once; a CLOSED with that reason still countsHEADmaster
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6TFgisGKUDhdStLUvs
| -rw-r--r-- | src/features/messages.js | 14 | ||||
| -rw-r--r-- | src/nostr.js | 55 | ||||
| -rw-r--r-- | tools/resume-wake-test.js | 111 |
3 files changed, 174 insertions, 6 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 4e90646..e1468ce 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -15,7 +15,7 @@ import { mediaServers } from '../media-servers.js'; // listens for both identities and decrypts with whichever keys are present. import { - subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, + subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, wakeRelays, npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS, } from '../nostr.js'; @@ -18511,10 +18511,14 @@ export function messagesFeature(ctx) { resumed(awayMs) { // a blink between apps didn't kill anything if (awayMs && awayMs < 5_000) return; - resubscribeStreams(); // DMs and rooms, whether or not chat is on screen - if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return true; - if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {}); - else refreshFeed({ force: true }).catch(() => {}); + // the sockets first: after a freeze they read open and are dead, and + // a refresh sent on them finds nothing (wakeRelays) + wakeRelays().catch(() => {}).then(() => { + resubscribeStreams(); // DMs and rooms, whether or not chat is on screen + if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return; + if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {}); + else refreshFeed({ force: true }).catch(() => {}); + }); return true; }, // A zap flow reporting how its payment went, so the chip that appeared diff --git a/src/nostr.js b/src/nostr.js index c77fc27..f0b47d1 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -186,7 +186,12 @@ pool.ensureRelay = async (url, params) => { relay._coinosRL = true; const notice = relay.onnotice; relay.onnotice = (msg) => { - if (RATE_LIMITED.test(String(msg))) markRelayRateLimited(relay.url, String(msg)); + // Primal's "too many concurrent REQs" is advice, not a refusal: every + // REQ past its twenty is answered all the same (measured: 60 at once, + // 60 EOSEs, 12 notices). Cooling it off for 30 s at every boot — when + // the relay lists, profiles and the feed all ask at once — only lost + // us its answers. A CLOSED with that reason still counts (below). + if (RATE_LIMITED.test(String(msg)) && !/concurrent/i.test(String(msg))) markRelayRateLimited(relay.url, String(msg)); try { notice?.call(relay, msg); } catch {} }; const subscribe = relay.subscribe.bind(relay); @@ -284,6 +289,54 @@ export function resetRelay(url) { try { r.close(); } catch {} pool.relays.delete(n); } +// Back from a freeze (a phone's backgrounded tab: timers stopped, the +// network's idea of every socket long gone), the pool's sockets still read +// OPEN, and anything sent on them vanishes. Left to itself the library +// notices one in up to 29 s (its ping) + 20 s (the ping's timeout), then +// waits 10 s more before redialing — a minute of a feed that won't catch up +// ("no new notes since 11:35?", Adam, 2026-11-01). So on resume every open +// socket is asked one tiny REQ; one that answers within the deadline is +// fine, one that doesn't is closed and redialed now, its subscriptions +// refired by the library with `since` set to the last event each saw. A +// relay already waiting out a reconnect backoff is dialed now too. +const WAKE_PING_MS = 2500; +const DUMMY_ID = 'a'.repeat(64); +function pingRelay(r, ms) { + return new Promise((resolve) => { + let done = false; + const fin = (ok) => { if (done) return; done = true; clearTimeout(t); resolve(ok); }; + const t = setTimeout(() => fin(false), ms); + try { + const sub = r.subscribe([{ ids: [DUMMY_ID], limit: 0 }], { + label: '<wake-ping>', + oneose: () => { fin(true); try { sub.close(); } catch {} }, + onclose: () => fin(true), // a relay refusing the REQ is a relay that answers + eoseTimeout: ms + 1000, // ours fires first: the library's would count as an EOSE + }); + } catch { fin(false); } + }); +} +function redialRelay(r, why) { + console.warn('nostr: ' + r.url + ' ' + why + ' — redialing'); + // the dead socket's close would reach the library long after (a closing + // handshake nobody answers), and undo the new connection: detached first + const ws = r.ws; + try { if (ws) { ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null; ws.close(); } } catch {} + try { r.handleHardClose(why); } catch {} // the library's own path: subscriptions kept, a reconnect queued + if (r.reconnectTimeoutHandle) { clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined; } + r.connect().catch(() => {}); // now, not after the backoff +} +export async function wakeRelays(ms = WAKE_PING_MS) { + const checks = []; + for (const r of pool.relays.values()) { + if (r.connected && r.ws && r.ws.readyState === 1) checks.push(pingRelay(r, ms).then((ok) => { if (!ok) redialRelay(r, 'silent after resume'); })); + else if (!r.connectionPromise && r.reconnectTimeoutHandle) { + clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined; + r.connect().catch(() => {}); + } + } + await Promise.all(checks); +} // the relays a subscription on this list actually dials (sick ones skipped) export const liveRelayList = (list) => liveRelays(list); // ...and the ones a one-shot read goes to (rate-limited ones skipped too) diff --git a/tools/resume-wake-test.js b/tools/resume-wake-test.js new file mode 100644 index 0000000..3b992fb --- /dev/null +++ b/tools/resume-wake-test.js @@ -0,0 +1,111 @@ +// Back from the background on a phone, the app's relay sockets read OPEN +// and are dead (the tab was frozen; the network forgot them), and the +// catch-up it sends on them finds nothing. The library noticed a dead one +// only after its 29 s ping + 20 s timeout, then waited 10 s to redial — a +// Following feed stuck at the morning's posts for a minute or more after +// opening the app in the evening ("no new notes since 11:35?", Adam, +// 2026-11-01). On resume every open socket is now asked one tiny REQ; a +// silent one is closed and redialed at once, its subscriptions refired, and +// the feed refresh runs on the live sockets. Run: bun tools/resume-wake-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +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 = (content, ago) => finalizeEvent({ kind: 1, created_at: now() - ago, tags: [], content }, SK); +const stored = [post('morning one', 3600), post('morning two', 3000), post('morning three', 2400)]; +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); +// one relay; a socket marked dead keeps its connection and answers nothing, +// the way a phone's frozen sockets do once the network has forgotten them +const sockets = new Set(); let opened = 0, closed = 0; +const server = Bun.serve({ port: 0, fetch: (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/relay') return srv.upgrade(req) ? 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) { ws.data = { subs: new Map(), dead: false }; sockets.add(ws); opened++; }, + message(ws, raw) { + if (ws.data.dead) return; + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; } + if (m[0] === 'REQ') { + const s = { id: m[1], filters: m.slice(2) }; + ws.data.subs.set(m[1], s); + for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + } else if (m[0] === 'CLOSE') ws.data.subs.delete(m[1]); + }, + close(ws) { sockets.delete(ws); closed++; }, +} }); +const PORT = server.port; +const liveSubs = () => [...sockets].filter((s) => !s.data.dead).flatMap((s) => [...s.data.subs.values()]); + +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}/relay`; 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 warns = []; +page.on('console', (m) => { if (/redialing|silent after resume/.test(m.text())) warns.push(m.text()); }); +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) => { const t0 = Date.now(); for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return (Date.now() - t0) / 1000; await sleep(100); } return null; }; +const rowTexts = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 40))); + +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, stored]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the morning\'s posts are in the feed', (await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 3, null, 20000)) != null, (await rowTexts()).join(' | ')); + check('the feed watches the relay live', (await waitFor(() => true, null, 100), await (async () => { for (let i = 0; i < 100; i++) { if (liveSubs().some((s) => s.filters.some((f) => f.authors && f.since))) return true; await sleep(100); } return false; })()), liveSubs().length + ' subs on ' + sockets.size + ' sockets'); + const openedBefore = opened; + + console.log('\n[the phone sleeps: its sockets die quietly, a follow posts]'); + for (const s of sockets) s.data.dead = true; + const fresh = post('evening news', 0); + stored.push(fresh); + await page.evaluate((k) => { try { localStorage.setItem('btc-wallet-bg-at', String(Date.now() - 10 * 60_000)); } catch {} }, base); + await sleep(300); + + console.log('\n[back on screen]'); + const t0 = Date.now(); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + const redial = await (async () => { for (let i = 0; i < 150; i++) { if (opened > openedBefore) return (Date.now() - t0) / 1000; await sleep(100); } return null; })(); + check('the dead socket is found out and redialed within seconds', redial != null && redial < 6, redial != null ? redial.toFixed(1) + 's (' + warns[0] + ')' : 'no new socket in 15 s'); + const shown = await waitFor(() => { const p = document.querySelector('.feed-new-pill'); if (p) p.click(); return [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes('evening news')); }, null, 15000); + check('the post from while it slept is on screen within seconds', shown != null && (Date.now() - t0) / 1000 < 12, shown != null ? ((Date.now() - t0) / 1000).toFixed(1) + 's after resume' : (await rowTexts()).join(' | ')); + check('the dead sockets were let go', closed >= 1, closed + ' closed'); + check('…and the live subscriptions are back on the new socket', liveSubs().some((s) => s.filters.some((f) => f.authors && f.since)), liveSubs().length + ' subs'); + 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✅ back from the background, dead sockets are redialed at once and the feed catches up' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
