diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index bfbf80f2..2fd72a70 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -52,6 +52,44 @@ const DECRYPT_TIMEOUT_MS = 15000; const DECRYPT_RETRY_DELAY_MS = NETWORK_RETRY_DELAY_MS; const MAX_DECRYPT_RETRY_DELAY_MS = 30000; +// Transient decrypt failures that must RETRY in the background rather than give +// up: our own timeout, a severed nip-07 message port ("message channel closed"), +// and the circuit breaker's fast-fail. Without this a brief channel drop during +// the login burst abandons hashtag prefs until a refresh (KNOWN_BUGS #0). +const TRANSIENT_DECRYPT_ERROR_CODES = new Set([ + "hashtag-preferences-decrypt-timeout", + "nip07-channel-unresponsive", +]); +const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [ + "message channel closed", + "could not establish connection", + "receiving end does not exist", + "extension context invalidated", + "connection lost", + "channel is unresponsive", +]; +function isTransientDecryptError(error) { + if (!error) return false; + if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) return true; + const message = + typeof error.message === "string" ? error.message.toLowerCase() : ""; + if (TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p))) { + return true; + } + // decryptEvent wraps the per-scheme Promise.any failures in a generic + // "decrypt-failed" error and nests the real causes under .errors/.cause — + // recurse so a channel-death sub-error still triggers a retry. + const nested = Array.isArray(error.errors) + ? error.errors + : Array.isArray(error.cause) + ? error.cause + : null; + if (nested) { + return nested.some((entry) => isTransientDecryptError(entry?.error || entry)); + } + return false; +} + function computeRetryDelay(baseDelayMs, attempt) { const normalizedAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0; @@ -907,7 +945,9 @@ class HashtagPreferencesService { decryptResult.error, ); - if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") { + if (isTransientDecryptError(decryptResult.error)) { + // Channel/timeout failures recover shortly — keep any cached prefs and + // retry instead of giving up until a page refresh. this.scheduleDecryptRetry(normalized, decryptResult.error, { allowPermissionPrompt, signerReadinessGate, diff --git a/js/subscriptions.js b/js/subscriptions.js index 09d8c877..47ff7521 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -60,6 +60,41 @@ const DECRYPT_TIMEOUT_MS = 15000; const DECRYPT_RETRY_DELAY_MS = 1500; const MAX_DECRYPT_RETRY_DELAY_MS = 30000; +// Transient decrypt failures that must RETRY rather than give up: our own +// timeout, a severed nip-07 message port ("message channel closed"), and the +// circuit breaker's fast-fail. Without this a brief channel drop during the +// login burst abandons the subscription list until a refresh (KNOWN_BUGS #0). +const TRANSIENT_DECRYPT_ERROR_CODES = new Set([ + "subscriptions-decrypt-timeout", + "nip07-channel-unresponsive", +]); +const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [ + "message channel closed", + "could not establish connection", + "receiving end does not exist", + "extension context invalidated", + "connection lost", + "channel is unresponsive", +]; +function isTransientDecryptError(error) { + if (!error) return false; + if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) return true; + const message = + typeof error.message === "string" ? error.message.toLowerCase() : ""; + if (TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p))) { + return true; + } + const nested = Array.isArray(error.errors) + ? error.errors + : Array.isArray(error.cause) + ? error.cause + : null; + if (nested) { + return nested.some((entry) => isTransientDecryptError(entry?.error || entry)); + } + return false; +} + function computeRetryDelay(baseDelayMs, attempt) { const normalizedAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0; @@ -690,7 +725,9 @@ class SubscriptionsManager { if (!decryptResult.ok) { this.lastLoadError = decryptResult.error || null; this.uiReady = true; - if (decryptResult.error?.code === "subscriptions-decrypt-timeout") { + if (isTransientDecryptError(decryptResult.error)) { + // Channel/timeout failures recover shortly — retry instead of + // abandoning the subscription list until a page refresh. this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { allowPermissionPrompt, signerReadinessGate, diff --git a/js/testHarness.js b/js/testHarness.js index 5ec5188c..b8813068 100644 --- a/js/testHarness.js +++ b/js/testHarness.js @@ -472,6 +472,15 @@ function getAppState() { } : null, decryptBehavior: { ...activeDecryptBehavior }, + lists: { + // Observable list-decrypt outcomes for harnesses validating the cold-login + // decrypt path (see scripts/perf/nip07-channel-sim.mjs / KNOWN_BUGS #0). + blocksLoaded: Boolean(userBlocks?.loaded), + blockedPubkeys: + typeof userBlocks?.getBlockedPubkeys === "function" + ? userBlocks.getBlockedPubkeys() + : [], + }, }; } diff --git a/js/userBlocks.js b/js/userBlocks.js index cc97e42b..0f854b89 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -95,6 +95,35 @@ const BACKGROUND_DECRYPT_TIMEOUT_MS = 8000; // PERF: Reduced from 10s to 3s for faster recovery during login. const DECRYPT_RETRY_DELAY_MS = 3000; const MAX_DECRYPT_RETRY_DELAY_MS = 30000; + +// Transient decrypt failures that must KEEP the stale list and RETRY in the +// background (the channel/extension recovers shortly), rather than giving up +// permanently. Besides our own timeout, this covers a severed nip-07 message +// port ("message channel closed" — KNOWN_BUGS #0) and the circuit breaker's +// fast-fail while it probes for recovery. Without this, a brief channel drop +// during the login burst abandons the block list until a page refresh, even +// though the channel comes back seconds later. +const TRANSIENT_DECRYPT_ERROR_CODES = new Set([ + "user-blocklist-decrypt-timeout", + "nip07-channel-unresponsive", +]); +const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [ + "message channel closed", + "could not establish connection", + "receiving end does not exist", + "extension context invalidated", + "connection lost", + "channel is unresponsive", +]; +function isTransientDecryptError(error) { + if (!error) return false; + if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) { + return true; + } + const message = + typeof error.message === "string" ? error.message.toLowerCase() : ""; + return TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p)); +} function computeRetryDelay(baseDelayMs, attempt) { const normalizedAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0; const multiplier = 2 ** normalizedAttempt; @@ -1453,8 +1482,29 @@ class UserBlockListManager { decryptedText = result.plaintext; setLastSuccessfulScheme(normalized, result.scheme); } - } catch { - // All schemes failed — decryptedText stays empty + } catch (aggregateError) { + // Promise.any rejects with an AggregateError once EVERY scheme + // failed. If any failure was a transient channel/extension error + // (a severed message port, breaker fast-fail, etc.) we must NOT + // swallow it and return [] — that silently misreads a brief + // channel drop as "the user has no blocks" and never retries + // (KNOWN_BUGS #0). Re-throw so applyEvents keeps the stale list + // and schedules a background retry; once the channel recovers the + // retry succeeds. Genuine decrypt mismatches (wrong key/content) + // still fall through to the empty result. + const subErrors = + aggregateError && Array.isArray(aggregateError.errors) + ? aggregateError.errors + : [aggregateError]; + const transient = subErrors.find((e) => isTransientDecryptError(e)); + if (transient) { + if (!transient.signerStatus) { + transient.signerStatus = signerStatus; + } + throw transient; + } + // All schemes failed for non-transient reasons — decryptedText + // stays empty (treated as "no blocks"). } } @@ -1552,7 +1602,10 @@ class UserBlockListManager { emitStatus({ status: "settled" }); return; } - if (decryptionError.code === "user-blocklist-decrypt-timeout") { + if (isTransientDecryptError(decryptionError)) { + const isTimeout = + decryptionError.code === "user-blocklist-decrypt-timeout"; + const reason = isTimeout ? "decrypt-timeout" : "channel-unavailable"; const signerStatus = decryptionError.signerStatus || resolveSignerStatus(); const recoveredPrivateBlocks = new Set(previousState.privateBlocks); const recoveredPublicMutes = new Set([ @@ -1566,14 +1619,14 @@ class UserBlockListManager { Number.isFinite(newestCreatedAt) ? newestCreatedAt : previousState.blockEventCreatedAt; applySets(recoveredPrivateBlocks, recoveredPublicMutes, { source, - reason: "decrypt-timeout-partial", + reason: `${reason}-partial`, events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }); this.loaded = true; emitStatus({ status: "stale", - reason: "decrypt-timeout", + reason, signerStatus, source, blockedPubkeys: Array.from(this.blockedPubkeys), @@ -1581,9 +1634,12 @@ class UserBlockListManager { emitStatus({ status: "settled" }); userLogger.warn( - "[UserBlockList] Decryption timed out; keeping stale list and retrying in background.", + isTimeout + ? "[UserBlockList] Decryption timed out; keeping stale list and retrying in background." + : "[UserBlockList] Signer channel unavailable; keeping stale list and retrying in background.", { signerStatus, + reason, events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }, ); diff --git a/scripts/perf/nip07-channel-sim.mjs b/scripts/perf/nip07-channel-sim.mjs new file mode 100644 index 00000000..cd456b99 --- /dev/null +++ b/scripts/perf/nip07-channel-sim.mjs @@ -0,0 +1,276 @@ +// Deterministic NIP-07 CHANNEL simulation harness. +// +// Reproduces the real-env cold-login failure (KNOWN_BUGS #0) WITHOUT a real +// browser extension, so fixes can be validated locally instead of round-tripping +// through a user's wallet. A fake window.nostr (backed by real nostr-tools +// crypto) models a configurable message-channel: +// +// MODE=healthy fast, never drops (baseline — lists should decrypt) +// MODE=slow every call delayed (overwhelmed but alive) +// MODE=overload drops the channel under concurrent load ("message channel +// closed"), recovers after a cooldown — the real failure +// MODE=dead drops on first overload and never recovers (refresh-only) +// +// It seeds the user's encrypted lists (block list + watch history) so login +// triggers genuine decrypt contention against the signer-readiness handshake, +// then reports the things the real console showed: time-to-signer-ready, whether +// the circuit breaker opened/recovered, decrypt-timeout count, and which lists +// actually loaded. +// +// Usage: +// MODE=overload APP=http://localhost:3000 node scripts/perf/nip07-channel-sim.mjs +// for m in healthy slow overload dead; do MODE=$m node scripts/perf/nip07-channel-sim.mjs; done + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { finalizeEvent, getPublicKey, nip04 } from "nostr-tools"; +import * as nip44 from "nostr-tools/nip44"; + +const APP = process.env.APP || "http://localhost:3000"; +const MODE = (process.env.MODE || "overload").toLowerCase(); +const WS_PORT = Number(process.env.WS_PORT ?? 8974); +const VIDEOS = Number(process.env.VIDEOS ?? 12); +const OBSERVE_MS = Number(process.env.OBSERVE_MS ?? 40000); + +// Channel models per mode. LATENCY_MS = per-call delay; MAX_INFLIGHT = how many +// concurrent extension calls before the port "drops"; DROP_COOLDOWN_MS = how long +// the dropped channel stays dead before recovering. +const MODES = { + healthy: { latency: 80, maxInflight: 99, dropCooldown: 0 }, + slow: { latency: 2500, maxInflight: 99, dropCooldown: 0 }, + overload: { latency: 400, maxInflight: 1, dropCooldown: 4000 }, + dead: { latency: 400, maxInflight: 1, dropCooldown: 10 ** 9 }, +}; +const channel = MODES[MODE] || MODES.overload; + +const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); +const USER_PK = getPublicKey(USER_SK); +const CHANNEL_CLOSED_MESSAGE = + "A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received"; + +// --- channel model state (Node side) --- +let pending = 0; +let droppedUntil = 0; +let extCalls = {}; +let drops = 0; + +function viaChannel(method, fn) { + extCalls[method] = (extCalls[method] || 0) + 1; + const now = Date.now(); + // Already-dropped channel: reject fast (the severed port). + if (now < droppedUntil) { + drops += 1; + return Promise.reject(new Error(CHANNEL_CLOSED_MESSAGE)); + } + // Concurrency overload trips the drop. + if (pending >= channel.maxInflight && channel.dropCooldown > 0) { + droppedUntil = now + channel.dropCooldown; + drops += 1; + return Promise.reject(new Error(CHANNEL_CLOSED_MESSAGE)); + } + pending += 1; + return (async () => { + try { + if (channel.latency > 0) { + await new Promise((r) => setTimeout(r, channel.latency)); + } + return await fn(); + } finally { + pending -= 1; + } + })(); +} + +const convKey = (peerPk) => nip44.v2.utils.getConversationKey(USER_SK, peerPk); + +function seedEncryptedBlockList(relay, createdAt) { + // A blocked pubkey the app must reveal by decrypting the kind-10000 mute list. + const blocked = "b".repeat(64); + const plaintext = JSON.stringify({ blockedPubkeys: [blocked] }); + const content = nip04.encrypt(USER_SK, USER_PK, plaintext); + relay.seedEvent( + finalizeEvent( + { + kind: 10000, + created_at: createdAt, + tags: [["encrypted", "nip04"]], + content, + }, + USER_SK, + ), + ); + return blocked; +} + +async function main() { + const relay = startRelay(WS_PORT, { httpPort: false }); + const relayUrl = `ws://127.0.0.1:${WS_PORT}`; + const now = Math.floor(Date.now() / 1000); + + relay.seedEvent( + finalizeEvent( + { kind: 10002, created_at: now, tags: [["r", relayUrl]], content: "" }, + USER_SK, + ), + ); + for (let i = 0; i < VIDEOS; i++) { + const dTag = `seed-${i}`; + relay.seedEvent( + finalizeEvent( + { + kind: 30078, + created_at: now - i * 60, + tags: [["d", dTag], ["t", "video"], ["title", `Sim Video ${i}`], ["url", `https://example.com/v${i}.mp4`]], + content: JSON.stringify({ version: 3, title: `Sim Video ${i}`, videoRootId: dTag, mode: "live", isPrivate: false, deleted: false, url: `https://example.com/v${i}.mp4` }), + }, + USER_SK, + ), + ); + } + const expectedBlocked = seedEncryptedBlockList(relay, now); + + const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" }); + const context = await browser.newContext(); + + await context.exposeBinding("__extGetPublicKey", () => viaChannel("getPublicKey", () => USER_PK)); + await context.exposeBinding("__extSignEvent", (_s, e) => viaChannel("signEvent", () => finalizeEvent({ ...e, pubkey: USER_PK }, USER_SK))); + await context.exposeBinding("__extNip04Encrypt", (_s, pk, t) => viaChannel("nip04.encrypt", () => nip04.encrypt(USER_SK, pk, t))); + await context.exposeBinding("__extNip04Decrypt", (_s, pk, ct) => viaChannel("nip04.decrypt", () => nip04.decrypt(USER_SK, pk, ct))); + await context.exposeBinding("__extNip44Encrypt", (_s, pk, t) => viaChannel("nip44.encrypt", () => nip44.v2.encrypt(t, convKey(pk)))); + await context.exposeBinding("__extNip44Decrypt", (_s, pk, ct) => viaChannel("nip44.decrypt", () => nip44.v2.decrypt(ct, convKey(pk)))); + // enable()/getRelays are answered through the SAME channel so the handshake + // competes with decrypts exactly as it does in the real extension. + await context.exposeBinding("__extEnable", () => viaChannel("enable", () => true)); + + await context.addInitScript((url) => { + localStorage.setItem("hasSeenDisclaimer", "true"); + localStorage.setItem("__bitvidTestMode__", "1"); + localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); + window.__bitvidTestRelays__ = [url]; + const w = (n) => (...a) => window[n](...a); + window.nostr = { + getPublicKey: () => w("__extGetPublicKey")(), + signEvent: (e) => w("__extSignEvent")(e), + getRelays: async () => ({}), + enable: () => w("__extEnable")(), + nip04: { encrypt: (p, t) => w("__extNip04Encrypt")(p, t), decrypt: (p, c) => w("__extNip04Decrypt")(p, c) }, + nip44: { + encrypt: (p, t) => w("__extNip44Encrypt")(p, t), + decrypt: (p, c) => w("__extNip44Decrypt")(p, c), + v2: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c) }, + }, + }; + }, relayUrl); + + const testUrl = `${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`; + + // --- console signal capture --- + const signals = { + signerReadyAt: null, + listsSyncCompleteAt: null, + decryptTimeouts: 0, + breakerOpened: 0, + breakerRecovered: 0, + channelUnresponsive: 0, + }; + let loginAt = 0; + const onConsole = (text) => { + if (text.includes("[signer-ready]") && signals.signerReadyAt === null) { + signals.signerReadyAt = Date.now() - loginAt; + } + if (text.includes("[lists-sync-complete]") && signals.listsSyncCompleteAt === null) { + signals.listsSyncCompleteAt = Date.now() - loginAt; + } + if (text.includes("Decryption timed out") || text.includes("keeping stale list")) { + signals.decryptTimeouts += 1; + } + if (text.includes("signer channel unresponsive")) signals.breakerOpened += 1; + if (text.includes("signer channel recovered")) signals.breakerRecovered += 1; + if (text.includes("nip07-channel-unresponsive")) signals.channelUnresponsive += 1; + }; + + const DEBUG = process.env.DEBUG === "1"; + const page = await context.newPage(); + page.on("console", (msg) => { + try { + const t = msg.text(); + onConsole(t); + if (DEBUG && /UserBlockList|Decryption|scheduleDecrypt|stale|channel|signer/i.test(t)) { + console.log("PAGE>", t.slice(0, 200)); + } + } catch (_) {} + }); + + await page.goto(testUrl, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); + await page.evaluate(() => { + const c = window.__bitvidTest__?.nostrClient; + if (c) c.videoEventVerifier = async (events) => new Set((events || []).map((e) => e && e.id).filter(Boolean)); + }).catch(() => {}); + + // Trigger the REAL nip-07 login path. + loginAt = Date.now(); + try { + await page.click('[data-testid="login-button"]', { timeout: 8000 }); + await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 }); + const extBtn = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first(); + if (await extBtn.count()) await extBtn.click({ timeout: 5000 }); + else await page.locator('[data-testid="login-provider-button"]').first().click({ timeout: 5000 }); + } catch (e) { + console.warn("login click failed:", e.message); + } + + await page.waitForFunction( + async () => (await window.__bitvidTest__?.getAppState?.())?.isLoggedIn === true, + { timeout: 20000 }, + ).catch(() => {}); + + // Observe the post-login window. + await page.waitForTimeout(OBSERVE_MS); + + // Did the block list actually decrypt? (the seeded blocked pubkey appears) + const blockedLoaded = await page.evaluate(async (expected) => { + const state = await window.__bitvidTest__?.getAppState?.(); + const blocked = state?.lists?.blockedPubkeys || []; + return Array.isArray(blocked) && blocked.includes(expected); + }, expectedBlocked).catch(() => false); + + const verdict = { + mode: MODE, + channel, + timeToSignerReadyMs: signals.signerReadyAt, + timeToListsSyncMs: signals.listsSyncCompleteAt, + decryptTimeouts: signals.decryptTimeouts, + breakerOpened: signals.breakerOpened, + breakerRecovered: signals.breakerRecovered, + fastFailsObserved: signals.channelUnresponsive, + channelDropsInjected: drops, + blockListDecrypted: blockedLoaded, + extCalls, + }; + + console.log("\n================ NIP-07 CHANNEL SIM ================"); + console.log(`MODE=${MODE} latency=${channel.latency}ms maxInflight=${channel.maxInflight} dropCooldown=${channel.dropCooldown}ms`); + console.log(`time → signer-ready : ${verdict.timeToSignerReadyMs ?? "NEVER"} ms`); + console.log(`time → lists-sync done : ${verdict.timeToListsSyncMs ?? "NEVER"} ms`); + console.log(`decrypt timeouts logged: ${verdict.decryptTimeouts}`); + console.log(`channel drops injected : ${verdict.channelDropsInjected}`); + console.log(`breaker opened / recov : ${verdict.breakerOpened} / ${verdict.breakerRecovered}`); + console.log(`fast-fails observed : ${verdict.fastFailsObserved}`); + console.log(`BLOCK LIST DECRYPTED : ${verdict.blockListDecrypted ? "YES ✓" : "NO ✗"}`); + console.log("ext calls:", JSON.stringify(extCalls)); + console.log("===================================================\n"); + + await page.close(); + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + // Exit non-zero for the "should have worked but didn't" cases so this can gate CI later. + const expectedToLoad = MODE === "healthy" || MODE === "overload"; + process.exit(expectedToLoad && !verdict.blockListDecrypted ? 1 : 0); +} + +main().catch((e) => { + console.error("nip07-channel-sim failed:", e); + process.exit(1); +});