diff options
| -rw-r--r-- | src/features/messages.js | 91 | ||||
| -rw-r--r-- | src/nostr.js | 32 | ||||
| -rw-r--r-- | tools/chat-watchdog-test.js | 96 |
3 files changed, 203 insertions, 16 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 4b7be5e..e812520 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -13,7 +13,7 @@ // listens for both identities and decrypts with whichever keys are present. import { - subscribeOn, publishOn, queryOn, fetchInboxRelays, + subscribeOn, publishOn, queryOn, fetchInboxRelays, relayAlive, liveRelayList, resetRelay, npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, } from '../nostr.js'; @@ -985,7 +985,7 @@ export function messagesFeature(ctx) { room.controlEntries.push(opened); scheduleFold(); }); - }), + }, { onclose: noteSubClosed }), subscribeOn(room.relays, { kinds: [1059], authors: [room.guestbook.pk], limit: 500 }, (wrap) => { if (seenWraps.has(wrap.id)) return; seenWraps.add(wrap.id); @@ -994,7 +994,7 @@ export function messagesFeature(ctx) { room.guestEntries.push(opened); scheduleFold(); }); - }) + }, { onclose: noteSubClosed }) ); for (const c of room.jm.channels || []) subChannel(room, c.id); } @@ -1019,7 +1019,7 @@ export function messagesFeature(ctx) { if (!opened) return; onChat(room, id, opened); }); - }); + }, { onclose: noteSubClosed }); allUnsubs.push(u); (room.unsubs = room.unsubs || []).push(u); } @@ -1032,17 +1032,75 @@ export function messagesFeature(ctx) { // seenWraps drops the repeats before any decryption, so this costs a few // round trips and nothing else. function resubscribeStreams() { - if (dmStarted) { - for (const u of dmUnsubs) { try { u(); } catch {} } - dmUnsubs = []; dmStarted = false; - startDMs(); - } - for (const room of rooms.values()) { - if (!room.subscribed) continue; - for (const u of room.unsubs || []) { try { u(); } catch {} } - room.unsubs = []; room.subscribed = false; room.subbed.clear(); - subscribeRoom(room); - } + const wasDm = dmStarted; + const wanted = [...rooms.values()].filter((r) => r.subscribed); + tearingDown = true; // our own closes are not a relay giving up on us + try { + if (wasDm) { + for (const u of dmUnsubs) { try { u(); } catch {} } + dmUnsubs = []; dmStarted = false; + } + for (const room of wanted) { + for (const u of room.unsubs || []) { try { u(); } catch {} } + room.unsubs = []; room.subscribed = false; room.subbed.clear(); + } + } finally { tearingDown = false; } + if (wasDm) startDMs(); + for (const room of wanted) subscribeRoom(room); + } + + // ---- subscription watchdog ------------------------------------------------ + // The resume hook above rebuilds after an absence it was TOLD about. It + // isn't told when a socket errors while the page stays on screen (a wifi + // hop, a laptop lid, a phone freezing the tab without the away timer + // noticing, another tab clearing that timer first) — and an errored + // socket is dropped from the pool with every subscription it carried. + // The room then sits quietly stale until a reload, while another client + // is ringing about new messages. So whenever the app is on screen — on + // focus, on becoming visible, and every ten seconds in between — the pool + // is asked whether each relay the inbox and the rooms listen on still + // holds a live socket; a relay that closed a subscription on us counts + // too. Anything missing rebuilds the lot. A rebuild is a few round trips + // (seenWraps drops the replay before any decryption), but a relay that + // keeps refusing is retried on a growing backoff, not every tick. + let tearingDown = false; + let subsDead = false; + let lastRebuild = 0; + let rebuildBackoff = 15_000; + let watchdogArmed = false; + function noteSubClosed() { + if (tearingDown || !allUnsubs.length) return; + subsDead = true; + setTimeout(ensureLive, 500); + } + function ensureLive() { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + if (!allUnsubs.length) return; // nothing is meant to be live yet + const urls = new Set(); + if (dmStarted) for (const u of liveRelayList(DM_RELAYS)) urls.add(u); + for (const room of rooms.values()) if (room.subscribed) for (const u of liveRelayList(room.relays)) urls.add(u); + const dead = [...urls].filter((u) => !relayAlive(u)); + if (!dead.length && !subsDead) { rebuildBackoff = 15_000; return; } + const now = Date.now(); + if (now - lastRebuild < rebuildBackoff) return; + lastRebuild = now; + rebuildBackoff = Math.min(rebuildBackoff * 2, 5 * 60_000); + subsDead = false; + console.warn('chat: relay socket gone (' + (dead.join(', ') || 'subscription closed') + ') — resubscribing'); + // a relay the pool is stuck dialing would just hand the rebuild the same + // hung promise — drop it so the rebuild dials afresh + tearingDown = true; + try { for (const u of dead) resetRelay(u); } finally { tearingDown = false; } + resubscribeStreams(); + } + function armWatchdog() { + if (watchdogArmed || typeof window === 'undefined') return; + watchdogArmed = true; + const soon = () => setTimeout(ensureLive, 300); + window.addEventListener('focus', soon); + window.addEventListener('pageshow', soon); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') soon(); }); + setInterval(ensureLive, 10_000); } function onChat(room, channelId, { rumor, author }) { @@ -2454,7 +2512,7 @@ export function messagesFeature(ctx) { const dmSub = (relays) => { const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { handleInboxWrap(wrap).catch(() => {}); - }); + }, { onclose: noteSubClosed }); allUnsubs.push(u); dmUnsubs.push(u); }; dmSub(DM_RELAYS); @@ -6569,6 +6627,7 @@ export function messagesFeature(ctx) { if (OPEN_VIEW === 'notifs') setTimeout(openNotifs, 0); // a connection that came back is a connection whose subs may have died window.addEventListener('online', () => setTimeout(resubscribeStreams, 1500)); + armWatchdog(); // and, on screen, keep checking the sockets are really there try { navigator.serviceWorker?.addEventListener('message', (ev) => { if (ev.data && ev.data.type === 'open' && ev.data.view === 'notifs') openNotifs(); diff --git a/src/nostr.js b/src/nostr.js index 74bf0f2..0794386 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -14,6 +14,7 @@ import * as nip44 from 'nostr-tools/nip44'; import * as nip04 from 'nostr-tools/nip04'; import { getPublicKey, finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure'; import { decode as nip19decode, npubEncode, nsecEncode, neventEncode } from 'nostr-tools/nip19'; +import { normalizeURL } from 'nostr-tools/utils'; import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17'; import { SimplePool } from 'nostr-tools/pool'; import { randomBytes, bytesToHex } from '@noble/hashes/utils'; @@ -135,6 +136,37 @@ function probeRelays(list) { } } +// Whether the pool still holds a live socket to a relay: connected, or +// dialing / waiting out a reconnect backoff for less than DIAL_MAX. A socket +// that ERRORED is dropped from the pool outright, its subscriptions with it, +// and nothing dials it again on its own; a dial that never answers (the +// network went away mid-handshake) hangs the pool's reconnect forever with +// no error at all. Both are the silence a watchdog has to catch. +const DIAL_MAX = 20_000; +const dialingSince = new Map(); // normalized url -> when we first saw it not-yet-connected +export function relayAlive(url) { + const n = normalizeURL(url); // the pool keys by the normalized form (trailing slash) + const r = pool.relays.get(n); + if (!r) { dialingSince.delete(n); return false; } + if (r.connected) { dialingSince.delete(n); return true; } + if (!r.connectionPromise && !r.reconnectTimeoutHandle) { dialingSince.delete(n); return false; } + const since = dialingSince.get(n) || (dialingSince.set(n, Date.now()), Date.now()); + return Date.now() - since < DIAL_MAX; +} +// Forget a relay the pool is stuck on, so the next subscribe dials afresh: +// its (hung or half-dead) socket is closed and the entry dropped. Its +// subscriptions close too — the caller is about to rebuild them. +export function resetRelay(url) { + const n = normalizeURL(url); + dialingSince.delete(n); + const r = pool.relays.get(n); + if (!r) return; + try { r.close(); } catch {} + pool.relays.delete(n); +} +// the relays a subscription on this list actually dials (sick ones skipped) +export const liveRelayList = (list) => liveRelays(list); + export function subscribeOn(relays, filter, onEvent, extra = {}) { try { // nostr-tools ≥2.23 takes a single filter object here; wrapping it in an diff --git a/tools/chat-watchdog-test.js b/tools/chat-watchdog-test.js new file mode 100644 index 0000000..ad9820b --- /dev/null +++ b/tools/chat-watchdog-test.js @@ -0,0 +1,96 @@ +// A relay socket that dies while the app is on screen must not leave the +// room deaf. Every relay socket under an open coinos room is made to ERROR +// (what a wifi hop or a phone freezing the tab does): nostr-tools drops an +// errored relay from its pool with every subscription it carried and never +// dials it again, so it's the watchdog's job to notice and rebuild — with no +// 'online' event and no resume hook to lean on. Afterwards the rebuilt +// subscriptions must stay quiet (no further rebuilds) and the sockets open. +// +// Chrome's offline emulation leaves established sockets alone, so the +// sockets are reached through a registry shimmed onto window.WebSocket. +// +// Run: bun tools/chat-watchdog-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +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 server = Bun.serve({ port: 5232, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +// every socket the page opens, so the test can kill them the way the OS does +await page.evaluateOnNewDocument(() => { + const Real = window.WebSocket; + const live = new Set(); + window.__sockets = live; + window.WebSocket = new Proxy(Real, { + construct(target, args) { + const ws = new target(...args); + live.add(ws); + ws.addEventListener('close', () => live.delete(ws)); + return ws; + }, + }); +}); +const warns = []; +page.on('console', (m) => { if (m.type() === 'warn' || m.type() === 'warning') warns.push({ t: Date.now(), text: m.text() }); }); +page.on('pageerror', (e) => console.log(' [pageerror]', e.message)); +const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, 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.toLowerCase())) return true; await sleep(250); } return false; }; +const rebuilds = (since = 0) => warns.filter((w) => w.t >= since && /resubscribing/.test(w.text)); +const waitRebuild = async (since, ms) => { for (let i = 0; i < ms / 500; i++) { if (rebuilds(since).length) return true; await sleep(500); } return false; }; + +try { + await page.setViewport({ width: 420, height: 860 }); + await page.goto('http://localhost:5232/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('button', 'Create a new wallet'); await sleep(300); + await click('button', 'Import existing'); await sleep(300); + await page.waitForSelector('textarea'); + await page.type('textarea', generateMnemonic(wordlist)); + await click('button', 'Open wallet'); + await waitText('receive', 15000); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click()); + await sleep(800); + await page.evaluate(() => { const e = [...document.querySelectorAll('.chat-thread-row')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'coinos'); if (e) e.click(); }); + check('the coinos room opens with messages', await waitText('GM Coinos community', 30000)); + await sleep(12000); // a full watchdog tick with everything healthy + check('a healthy room triggers no rebuild', rebuilds().length === 0, rebuilds().map((w) => w.text).join(' | ').slice(0, 200) || 'none'); + + console.log('\n[every relay socket errors under the open room]'); + const before = await page.evaluate(() => [...window.__sockets].filter((w) => w.readyState === 1).map((w) => w.url)); + check('relay sockets are open to begin with', before.length >= 2, before.join(' ')); + const cut = Date.now(); + await page.evaluate(() => { + for (const ws of [...window.__sockets]) { + // the handler nostr-tools installed: an error drops the relay from + // the pool and closes its subscriptions for good + try { ws.onerror && ws.onerror(new Event('error')); } catch {} + try { ws.close(); } catch {} + } + }); + check('the watchdog notices and rebuilds within seconds', await waitRebuild(cut, 15000), + rebuilds(cut).map((w) => w.text).join(' | ').slice(0, 200)); + await sleep(6000); + const after = await page.evaluate(() => [...window.__sockets].filter((w) => w.readyState === 1).map((w) => w.url)); + check('the relays are dialed again', after.length >= 2, after.join(' ')); + + console.log('\n[and then it stays quiet]'); + const settled = Date.now(); + await sleep(25000); + const late = rebuilds(settled); + check('the rebuilt subscriptions stay up (no further rebuilds)', late.length === 0, late.map((w) => w.text).join(' | ').slice(0, 200)); + const fresh = await page.evaluate(() => document.querySelectorAll('.chat-bubble').length); + check('the room still shows its messages', fresh > 5, `${fresh} bubbles`); +} catch (e) { console.log('ERROR', e); ok = false; } +await browser.close(); +server.stop(true); +console.log(ok ? '\nALL GOOD' : '\nFAILURES'); +process.exit(ok ? 0 : 1); |
