From 8f39b2c4793fa457af04c9879a3bfb0370e18a11 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:18:46 -0400 Subject: [PATCH] feat(signer): surface an actionable notice when the signer goes unresponsive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A raw window.nostr probe in the user's browser (getPublicKey → nip04.encrypt → nip04.decrypt, bypassing ALL of bitvid) HUNG forever, with the extension's own console showing `ObjectMultiplex - orphaned data for "background-liveness"` + `malformed chunk` + listener-leak warnings. That is the NIP-07 extension's content-script ↔ background-service-worker channel being dead (classic MV3 worker death) — an extension/environment failure, not a bitvid bug. No client change can force an unresponsive signer to answer; the fix is to reload/unlock the extension. What bitvid CAN do is stop failing silently. Add js/utils/signerHealthNotice.js: after a few consecutive list-decrypt timeouts while the signer reports itself present, emit ONE rate-limited, actionable user notice ("your signer isn't responding to decryption — unlock/reload the extension and refresh") instead of the silent 15-minute retry loop. Reset on any decrypt success so a transient blip doesn't warn. Wired into blocks, hashtags, and subscriptions. Also documents the definitive root-cause + all resilience increments in KNOWN_BUGS #0. Test: tests/signer-health-notice.test.mjs (threshold, rate-limit, success-reset). Full unit suite green. Co-Authored-By: Claude Opus 4.8 --- docs/KNOWN_BUGS.md | 35 ++++++++++-- js/services/hashtagPreferencesService.js | 12 +++- js/subscriptions.js | 11 +++- js/userBlocks.js | 11 ++++ js/utils/signerHealthNotice.js | 68 +++++++++++++++++++++++ tests/signer-health-notice.test.mjs | 71 ++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 js/utils/signerHealthNotice.js create mode 100644 tests/signer-health-notice.test.mjs diff --git a/docs/KNOWN_BUGS.md b/docs/KNOWN_BUGS.md index 50cbf126..844e0ca3 100644 --- a/docs/KNOWN_BUGS.md +++ b/docs/KNOWN_BUGS.md @@ -60,10 +60,37 @@ refactor work in that doc — these are defects. CPU. A single periodic probe detects recovery; one success closes the circuit (no refresh). Responsive errors don't open it; interactive permission prompts bypass it. Test: `tests/nostr/nip07-channel-breaker.test.mjs`. -- **Still open:** further burst reduction (collapse each list's multi-variant - fan-out into one multi-filter REQ per relay; investigate the ~67s signer gate); - liveness-ranked relay health (see #4); DM decrypt resilience (don't hard-fail - DMs on a transient permission miss — retry). +- **Resilience shipped (increments 3-6):** user-relays-first relay cap with + reserved defaults; transient-decrypt → retry (channel-death/timeout no longer + swallowed as "empty"); fetch/decrypt decouple + ProfileModalController + ensureLoaded (cut the login REQ burst); subscription refresh-loop guard; + generous decrypt budget (30s) + 60s backoff cap so a slow-but-responsive + signer completes; permission-handshake variant timeout 3s→20s so slow signers + aren't killed mid-grant. +- **DEFINITIVE root cause for "lists never load" (2026-06-16):** confirmed with a + raw `window.nostr` probe in the user's browser — a single clean + `getPublicKey → nip04.encrypt → nip04.decrypt` that bypasses ALL of bitvid + **hung forever**, and the extension's own console showed + `ObjectMultiplex - orphaned data for stream "background-liveness"` + + `malformed chunk` + `MaxListenersExceededWarning`. i.e. the NIP-07 extension's + content-script ↔ background-service-worker channel was dead/orphaned (classic + MV3 worker death). This is an EXTENSION/environment failure, not a bitvid bug — + no client change can force an unresponsive signer to answer. Fix is user-side: + reload/unlock the extension + refresh. +- **Client mitigation (SHIPPED):** `js/utils/signerHealthNotice.js` — after a few + consecutive list-decrypt timeouts with `signerStatus: "present"`, emit ONE + rate-limited, actionable user notice ("signer isn't responding to decryption — + unlock/reload your extension and refresh") instead of a silent forever-retry. + Wired into blocks/hashtags/subscriptions; reset on any decrypt success. + Test: `tests/signer-health-notice.test.mjs`. +- **Harness (`scripts/perf/nip07-channel-sim.mjs`):** with a LATENCY override it + proves the client now loads all lists for signers answering up to the decrypt + budget; lists only fail once per-call latency exceeds the budget (i.e. the + signer is the wall, not the client). +- **Still open:** liveness-ranked relay health (see #4); DM decrypt resilience + (don't hard-fail DMs on a transient permission miss — retry); optional nip-07 + queue concurrency bump for slow signers (risks channel drops on some + providers — opt-in). ### 1. Logged-out: video grid stays empty until a manual refresh - **Severity:** medium (first-load UX, logged-out). diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 9073120d..202336b7 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -15,6 +15,10 @@ import { assertAnyRelayAccepted, } from "../nostrPublish.js"; import { userLogger, devLogger } from "../utils/logger.js"; +import { + noteSignerDecryptTimeout, + noteSignerDecryptSuccess, +} from "../utils/signerHealthNotice.js"; import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { profileCache } from "../state/profileCache.js"; @@ -950,7 +954,12 @@ class HashtagPreferencesService { if (isTransientDecryptError(decryptResult.error)) { // Channel/timeout failures recover shortly — keep any cached prefs and - // retry instead of giving up until a page refresh. + // retry instead of giving up until a page refresh. If the signer is + // present but simply not answering, surface the shared unresponsive + // notice so the user knows to reload/unlock their extension. + if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") { + noteSignerDecryptTimeout("hashtags"); + } this.scheduleDecryptRetry(normalized, decryptResult.error, { allowPermissionPrompt, signerReadinessGate, @@ -1023,6 +1032,7 @@ class HashtagPreferencesService { this.loadedFromCache = false; this.lastSuccessfulSyncAt = Date.now(); this.decryptRetryAttempt = 0; + noteSignerDecryptSuccess(); if (this.backgroundLoading) { this.backgroundLoading = false; } diff --git a/js/subscriptions.js b/js/subscriptions.js index 70341ffc..b4f1b4a6 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -27,6 +27,10 @@ import { getApplication } from "./applicationContext.js"; import { VideoListView } from "./ui/views/VideoListView.js"; import { ALLOW_NSFW_CONTENT } from "./config.js"; import { devLogger, userLogger } from "./utils/logger.js"; +import { + noteSignerDecryptTimeout, + noteSignerDecryptSuccess, +} from "./utils/signerHealthNotice.js"; import moderationService from "./services/moderationService.js"; import nostrService from "./services/nostrService.js"; import { profileCache } from "./state/profileCache.js"; @@ -761,7 +765,11 @@ class SubscriptionsManager { this.uiReady = true; if (isTransientDecryptError(decryptResult.error)) { // Channel/timeout failures recover shortly — retry instead of - // abandoning the subscription list until a page refresh. + // abandoning the subscription list until a page refresh. A present + // signer that won't answer feeds the shared unresponsive notice. + if (decryptResult.error?.code === "subscriptions-decrypt-timeout") { + noteSignerDecryptTimeout("subscriptions"); + } this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { allowPermissionPrompt, signerReadinessGate, @@ -837,6 +845,7 @@ class SubscriptionsManager { } this.decryptRetryAttempt = 0; + noteSignerDecryptSuccess(); const decryptedStr = decryptResult.plaintext; const normalized = parseSubscriptionPlaintext(decryptedStr); diff --git a/js/userBlocks.js b/js/userBlocks.js index 58006348..80dc11f1 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -12,6 +12,10 @@ import { KIND_MUTE_LIST, } from "./nostrEventSchemas.js"; import { devLogger, userLogger } from "./utils/logger.js"; +import { + noteSignerDecryptTimeout, + noteSignerDecryptSuccess, +} from "./utils/signerHealthNotice.js"; import { STANDARD_TIMEOUT_MS, MAX_BLOCKLIST_ENTRIES } from "./constants.js"; import { publishEventToRelays, @@ -1651,6 +1655,12 @@ class UserBlockListManager { events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }, ); + // Signer reported present but didn't answer — feed the shared + // "signer unresponsive" notice so the user gets told to reload/unlock + // their extension instead of silently retrying forever. + if (signerStatus === "present") { + noteSignerDecryptTimeout("blocks"); + } this.scheduleDecryptRetry(normalized, { error: decryptionError, signerStatus, @@ -1692,6 +1702,7 @@ class UserBlockListManager { events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }); this.decryptRetryAttempt = 0; + noteSignerDecryptSuccess(); if (effectiveReason === "empty-events") { emitStatus({ status: "applied-empty", event: newestOverall, source }); diff --git a/js/utils/signerHealthNotice.js b/js/utils/signerHealthNotice.js new file mode 100644 index 00000000..1f63be39 --- /dev/null +++ b/js/utils/signerHealthNotice.js @@ -0,0 +1,68 @@ +// Shared "is the signer actually answering?" notice. +// +// When encrypted lists (blocks, subscriptions, hashtags, DMs) repeatedly time +// out DESPITE a resolved signer (signerStatus "present"), the cause is almost +// always the NIP-07 extension's background/service-worker channel being +// unresponsive — not bitvid (confirmed by a raw window.nostr probe hanging; +// see KNOWN_BUGS #0). Silently retrying forever leaves the user with no idea +// why their lists never load. This surfaces ONE clear, actionable message so +// they can unlock/reload the extension, then stays quiet so it never spams. + +import { userLogger } from "./logger.js"; + +const CONSECUTIVE_TIMEOUT_THRESHOLD = 3; +const NOTICE_COOLDOWN_MS = 120_000; + +const NOTICE_MESSAGE = + "Your Nostr signer extension isn't responding to decryption requests, so " + + "encrypted data (hashtags, subscriptions, block list, DMs) can't load. This " + + "is usually the extension's background worker going to sleep or getting " + + "stuck. Try unlocking it and/or reloading the extension (toggle it off and " + + "on from your browser's extensions page), then refresh bitvid."; + +let consecutiveTimeouts = 0; +let lastNoticeAt = 0; +let lastDetail = null; + +/** + * Record that a list decrypt timed out while the signer reported itself present. + * After a few consecutive such timeouts, emit ONE user-facing notice (rate + * limited) telling the user their signer is unresponsive. + */ +export function noteSignerDecryptTimeout(context = "list") { + consecutiveTimeouts += 1; + lastDetail = { context, consecutiveTimeouts, at: Date.now() }; + const now = Date.now(); + if ( + consecutiveTimeouts >= CONSECUTIVE_TIMEOUT_THRESHOLD && + now - lastNoticeAt > NOTICE_COOLDOWN_MS + ) { + lastNoticeAt = now; + userLogger.error(`[signer-unresponsive] ${NOTICE_MESSAGE}`, { + context, + consecutiveTimeouts, + }); + } +} + +/** + * Record that a signer call succeeded — the channel is alive, so reset the + * streak (a single later timeout shouldn't immediately re-warn). + */ +export function noteSignerDecryptSuccess() { + consecutiveTimeouts = 0; +} + +export function getSignerHealthState() { + return { consecutiveTimeouts, lastNoticeAt, lastDetail }; +} + +export const __testExports = { + CONSECUTIVE_TIMEOUT_THRESHOLD, + NOTICE_COOLDOWN_MS, + reset() { + consecutiveTimeouts = 0; + lastNoticeAt = 0; + lastDetail = null; + }, +}; diff --git a/tests/signer-health-notice.test.mjs b/tests/signer-health-notice.test.mjs new file mode 100644 index 00000000..8f3db91e --- /dev/null +++ b/tests/signer-health-notice.test.mjs @@ -0,0 +1,71 @@ +// Scenario (SCN-signer-unresponsive-notice): +// Given a NIP-07 signer that reports itself present but never answers decrypt +// calls (the real-env dead-background-worker case — KNOWN_BUGS #0), +// When list decrypts time out repeatedly, +// Then after a small threshold of consecutive timeouts the user is shown ONE +// actionable notice (rate limited via lastNoticeAt, not spammed per retry), +// And a successful decrypt resets the streak so a single later blip doesn't +// immediately re-warn. +// +// Observed at the boundary via getSignerHealthState() (consecutiveTimeouts + +// lastNoticeAt), so we don't depend on the read-only logger internals. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { + noteSignerDecryptTimeout, + noteSignerDecryptSuccess, + getSignerHealthState, + __testExports, +} = await import("../js/utils/signerHealthNotice.js"); + +test("notice fires once after consecutive timeouts, then rate-limits", () => { + __testExports.reset(); + const threshold = __testExports.CONSECUTIVE_TIMEOUT_THRESHOLD; + + for (let i = 0; i < threshold - 1; i++) { + noteSignerDecryptTimeout("hashtags"); + } + assert.equal( + getSignerHealthState().lastNoticeAt, + 0, + "no notice should fire below the threshold", + ); + + noteSignerDecryptTimeout("hashtags"); + const firedAt = getSignerHealthState().lastNoticeAt; + assert.ok(firedAt > 0, "notice fires when the streak crosses the threshold"); + + // Further timeouts within the cooldown must NOT bump the notice timestamp. + for (let i = 0; i < 10; i++) { + noteSignerDecryptTimeout("blocks"); + } + assert.equal( + getSignerHealthState().lastNoticeAt, + firedAt, + "must not re-warn within the cooldown window", + ); +}); + +test("a successful decrypt resets the streak", () => { + __testExports.reset(); + const threshold = __testExports.CONSECUTIVE_TIMEOUT_THRESHOLD; + + for (let i = 0; i < threshold - 1; i++) { + noteSignerDecryptTimeout("subscriptions"); + } + noteSignerDecryptSuccess(); + assert.equal( + getSignerHealthState().consecutiveTimeouts, + 0, + "success clears the timeout streak", + ); + + noteSignerDecryptTimeout("subscriptions"); + assert.equal( + getSignerHealthState().lastNoticeAt, + 0, + "a success between timeouts prevents the notice from firing", + ); +});