From 0ebef05d3850d312ec8098584525b22ec1b9a0de Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 15 Sep 2026 11:56:52 -0700 Subject: Chat keeps its relay subscriptions alive while the app is on screen. New messages rang in Vector but sat unseen in v3 until a reload. The resume hook only rebuilds subscriptions after an absence it was told about; a socket that errors while the page stays visible (a wifi hop, a laptop lid, a phone freezing the tab without the away timer noticing, another tab clearing that timer first) is dropped from the nostr-tools pool with every subscription it carried, and nothing dials it again. A watchdog now runs whenever the app is on screen: on focus, on becoming visible, and every ten seconds between. It asks the pool whether each relay the inbox and the rooms listen on still holds a live socket (a dial hung past 20s counts as dead, and a relay that closed a subscription on us counts too) and rebuilds the lot when one is missing, dropping a stuck relay first so the rebuild dials afresh. Rebuilds retry on a growing backoff while a relay keeps refusing. tools/chat-watchdog-test.js errors every socket under an open room and checks the recovery. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z --- src/nostr.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) (limited to 'src/nostr.js') 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 -- cgit v1.2.3