aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js91
-rw-r--r--src/nostr.js32
-rw-r--r--tools/chat-watchdog-test.js96
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);