mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(signer): surface an actionable notice when the signer goes unresponsive
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 <[email protected]>
This commit is contained in:
1 parent
f3bac3066d
commit
8f39b2c479
6 files changed
+202
-6
No files matched your search
+31
-4
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+10
-1
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user