diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 0125d071..d41e0ddd 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -213,31 +213,35 @@ export function createAuthSessionCoordinator(deps) { // Start list loading as soon as relays are ready — lists don't depend on // profile data, only on relay URLs. This runs in parallel with profile // fetching instead of sequentially after it. + // + // PERF: Load blocks, subscriptions, and hashtag preferences ALL in + // parallel. They have no data dependencies on each other — only the feed + // render needs all three to be settled. Loading them concurrently shaves + // 20-30 seconds off the critical login path. const listStatePromise = Promise.all([ relaysReadyPromise, permissionPromise, ]).then(async () => { - // SEQUENTIAL LOAD: Blocks must load first (foundation for all filtering). + const parallelListTasks = []; + if ( activePubkey && typeof this.authService.loadBlocksForPubkey === "function" ) { - try { - await this.authService.loadBlocksForPubkey(activePubkey, { - allowPermissionPrompt: false, - }); - } catch (error) { - devLogger.warn( - "[Application] Failed to load blocks during login:", - error, - ); - } + parallelListTasks.push( + this.authService + .loadBlocksForPubkey(activePubkey, { + allowPermissionPrompt: false, + }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to load blocks during login:", + error, + ); + }), + ); } - // Subscriptions and hashtag preferences are independent of each other, - // so load them in parallel after blocks are ready. - const parallelListTasks = []; - if ( activePubkey && subscriptions && @@ -385,7 +389,7 @@ export function createAuthSessionCoordinator(deps) { // hashtag preferences) to settle before rendering the video grid. This // prevents the feed from briefly showing unfiltered content. We use a // time-boxed wait so the UI isn't blocked indefinitely if decryption stalls. - const FEED_SYNC_TIMEOUT_MS = 8000; + const FEED_SYNC_TIMEOUT_MS = 12000; const feedSyncPromise = Promise.race([ Promise.allSettled([ listStatePromise, diff --git a/js/nostr/nip07Permissions.js b/js/nostr/nip07Permissions.js index 2efa0055..7d6d444d 100644 --- a/js/nostr/nip07Permissions.js +++ b/js/nostr/nip07Permissions.js @@ -99,7 +99,10 @@ class Nip07RequestQueue { // This prevents "message channel closed" errors caused by race conditions // when multiple components (blocks, DMs, auth) query the extension simultaneously, // while allowing critical tasks (e.g. blocklist decryption) to jump the line. -const requestQueue = new Nip07RequestQueue(3); +// PERF: Increased from 3 to 5 to allow more parallel decrypt operations +// during login when blocks, subscriptions, and hashtag preferences all need +// to decrypt simultaneously. +const requestQueue = new Nip07RequestQueue(5); export function getEnableVariantTimeoutMs() { const overrideValue = diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 8c8709b5..63a4586f 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -946,10 +946,11 @@ class HashtagPreferencesService { sources.set(scheme, source); }; - // Use a shorter per-call timeout during background/login loads so stalled - // extension calls fail fast. Interactive loads (with permission prompts) - // get more time because the user may be approving a popup. - const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 5000; + // PERF: Previous 5s no-prompt timeout was too aggressive — extensions that + // have already granted permission still need time to decrypt. 8s gives a + // realistic window while still failing faster than the full 15s interactive + // timeout. + const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000; const signerDecryptOptions = { priority: NIP07_PRIORITY.NORMAL, timeoutMs: nip07DecryptTimeoutMs, @@ -1076,52 +1077,55 @@ class HashtagPreferencesService { Array.from(decryptors.keys()), cachedScheme, ); - const attemptErrors = []; - - for (const scheme of order) { - const decryptFn = decryptors.get(scheme); - if (!decryptFn) { - continue; - } - const source = sources.get(scheme); - - if (source === "extension") { - userLogger.info( - `${LOG_PREFIX} Attempting decryption via window.nostr fallback (${scheme})`, - ); - } + // PERF: Try all decryption schemes in parallel via Promise.any(). + // The first scheme to succeed wins, avoiding sequential 3-15s timeouts + // per scheme that previously made worst-case decryption take 45s+. + // Deduplicate schemes in the same family (nip44/nip44_v2 both call + // the same underlying signer.nip44Decrypt) to avoid redundant calls. + const getSchemeFamily = (s) => + s === "nip44" || s === "nip44_v2" ? "nip44" : s; + const seenFamilies = new Set(); + const attempts = order + .map((scheme) => { + const family = getSchemeFamily(scheme); + if (seenFamilies.has(family)) return null; + seenFamilies.add(family); + const decryptFn = decryptors.get(scheme); + if (!decryptFn) return null; + return decryptFn(ciphertext).then((plaintext) => { + if (typeof plaintext !== "string") { + throw new Error("Decryption returned non-string payload."); + } + return { plaintext, scheme }; + }); + }) + .filter(Boolean); + if (attempts.length) { try { - const plaintext = await decryptFn(ciphertext); - if (typeof plaintext !== "string") { - attemptErrors.push({ - scheme, - error: new Error("Decryption returned non-string payload."), - }); - continue; - } + const result = await Promise.any(attempts); decryptionSuccessCache.set(userPubkey, { - scheme, + scheme: result.scheme, timestamp: Date.now(), }); - return { ok: true, plaintext, scheme }; - } catch (error) { - if (source === "extension") { - userLogger.warn( - `${LOG_PREFIX} window.nostr fallback decryption failed (${scheme})`, - error, - ); + return { ok: true, plaintext: result.plaintext, scheme: result.scheme }; + } catch (aggregateError) { + const attemptErrors = (aggregateError.errors || []).map((err, i) => ({ + scheme: order[i] || "unknown", + error: err, + })); + const error = new Error("Failed to decrypt hashtag preferences."); + error.code = "hashtag-preferences-decrypt-failed"; + if (attemptErrors.length) { + error.cause = attemptErrors; } - attemptErrors.push({ scheme, error }); + return { ok: false, error, errors: attemptErrors }; } } const error = new Error("Failed to decrypt hashtag preferences."); error.code = "hashtag-preferences-decrypt-failed"; - if (attemptErrors.length) { - error.cause = attemptErrors; - } - return { ok: false, error, errors: attemptErrors }; + return { ok: false, error, errors: [] }; } async publish(options = {}) { diff --git a/js/subscriptions.js b/js/subscriptions.js index 34936b47..42d36d81 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -999,10 +999,11 @@ class SubscriptionsManager { decryptors.set(scheme, handler); }; - // Use a shorter per-call timeout during background/login loads (no - // permission prompt) so stalled extension calls fail fast. Interactive - // loads get more time because the user may be approving a popup. - const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 5000; + // PERF: Previous 5s no-prompt timeout was too aggressive — extensions that + // have already granted permission still need time to decrypt. 8s gives a + // realistic window while still failing faster than the full 15s interactive + // timeout. + const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000; const signerDecryptOptions = { priority: NIP07_PRIORITY.NORMAL, timeoutMs: nip07DecryptTimeoutMs, @@ -1107,37 +1108,55 @@ class SubscriptionsManager { availableSchemes, cachedScheme, ); - const attemptErrors = []; + // PERF: Try all decryption schemes in parallel via Promise.any(). + // The first scheme to succeed wins, avoiding sequential 3-15s timeouts + // per scheme that previously made worst-case decryption take 45s+. + // Deduplicate schemes in the same family (nip44/nip44_v2 both call + // the same underlying signer.nip44Decrypt) to avoid redundant calls. + const getSchemeFamily = (s) => + s === "nip44" || s === "nip44_v2" ? "nip44" : s; + const seenFamilies = new Set(); + const attempts = order + .map((scheme) => { + const family = getSchemeFamily(scheme); + if (seenFamilies.has(family)) return null; + seenFamilies.add(family); + const decryptFn = decryptors.get(scheme); + if (!decryptFn) return null; + return decryptFn(ciphertext).then((plaintext) => { + if (typeof plaintext !== "string") { + throw new Error("Decryption returned a non-string payload."); + } + return { plaintext, scheme }; + }); + }) + .filter(Boolean); - for (const scheme of order) { - const decryptFn = decryptors.get(scheme); - if (!decryptFn) { - continue; - } + if (attempts.length) { try { - const plaintext = await decryptFn(ciphertext); - if (typeof plaintext !== "string") { - const error = new Error("Decryption returned a non-string payload."); - error.code = "subscriptions-invalid-plaintext"; - attemptErrors.push({ scheme, error }); - continue; - } + const result = await Promise.any(attempts); decryptionSuccessCache.set(userPubkey, { - scheme, + scheme: result.scheme, timestamp: Date.now(), }); - return { ok: true, plaintext, scheme }; - } catch (error) { - attemptErrors.push({ scheme, error }); + return { ok: true, plaintext: result.plaintext, scheme: result.scheme }; + } catch (aggregateError) { + const attemptErrors = (aggregateError.errors || []).map((err, i) => ({ + scheme: order[i] || "unknown", + error: err, + })); + const error = new Error("Failed to decrypt subscription list with available schemes."); + error.code = "subscriptions-decrypt-failed"; + if (attemptErrors.length) { + error.cause = attemptErrors; + } + return { ok: false, error, errors: attemptErrors }; } } const error = new Error("Failed to decrypt subscription list with available schemes."); error.code = "subscriptions-decrypt-failed"; - if (attemptErrors.length) { - error.cause = attemptErrors; - } - return { ok: false, error, errors: attemptErrors }; + return { ok: false, error, errors: [] }; } async addChannel(channelHex, userPubkey) { diff --git a/js/userBlocks.js b/js/userBlocks.js index e57167eb..4509013d 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -902,8 +902,11 @@ class UserBlockListManager { : allowPermissionPrompt ? DECRYPT_TIMEOUT_MS : BACKGROUND_DECRYPT_TIMEOUT_MS; - // Fail fast if we can't prompt, otherwise allow more time. - const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 3000; + // PERF: Previous 3s no-prompt timeout was too aggressive — extensions that + // have already granted permission still need time to decrypt. 8s gives a + // realistic window while still failing faster than the full 15s interactive + // timeout. + const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000; const emitStatus = (detail) => { if (!detail || typeof detail !== "object") { @@ -1303,23 +1306,41 @@ class UserBlockListManager { throw error; } + // PERF: Try all decryption schemes in parallel via Promise.any(). + // The first scheme to succeed wins, avoiding sequential 3-15s timeouts + // per scheme that previously made worst-case decryption take 45s+. + // Deduplicate schemes in the same family (nip44/nip44_v2 both call + // the same underlying signer.nip44Decrypt) to avoid redundant calls. let decryptedText = ""; - for (const scheme of order) { - const handler = decryptors.get(scheme); - if (typeof handler !== "function") continue; - try { - const plaintext = await handler(ev.content); - if (typeof plaintext === "string") { - decryptedText = plaintext; - decryptionSuccessCache.set(normalized, { - scheme, - timestamp: Date.now(), + try { + const getSchemeFamily = (s) => + s === "nip44" || s === "nip44_v2" ? "nip44" : s; + const seenFamilies = new Set(); + const attempts = order + .map((scheme) => { + const family = getSchemeFamily(scheme); + if (seenFamilies.has(family)) return null; + seenFamilies.add(family); + const handler = decryptors.get(scheme); + if (typeof handler !== "function") return null; + return handler(ev.content).then((plaintext) => { + if (typeof plaintext !== "string") { + throw new Error("non-string-result"); + } + return { plaintext, scheme }; }); - break; - } - } catch { - // ignore + }) + .filter(Boolean); + if (attempts.length) { + const result = await Promise.any(attempts); + decryptedText = result.plaintext; + decryptionSuccessCache.set(normalized, { + scheme: result.scheme, + timestamp: Date.now(), + }); } + } catch { + // All schemes failed — decryptedText stays empty } if (!decryptedText) { diff --git a/tests/nip07-concurrency.test.mjs b/tests/nip07-concurrency.test.mjs index 1b86d38a..853834cc 100644 --- a/tests/nip07-concurrency.test.mjs +++ b/tests/nip07-concurrency.test.mjs @@ -21,9 +21,9 @@ describe("NIP-07 Concurrency Queue", () => { global.window = originalWindow; }); - test("runs up to 3 tasks concurrently", async () => { + test("runs up to 5 tasks concurrently", async () => { const start = Date.now(); - const delays = [200, 200, 200, 100]; + const delays = [200, 200, 200, 200, 200, 100]; const log = []; const task = (id, delay) => async () => { @@ -37,17 +37,19 @@ describe("NIP-07 Concurrency Queue", () => { const p2 = runNip07WithRetry(task(2, delays[1]), { priority: NIP07_PRIORITY.NORMAL }); const p3 = runNip07WithRetry(task(3, delays[2]), { priority: NIP07_PRIORITY.NORMAL }); const p4 = runNip07WithRetry(task(4, delays[3]), { priority: NIP07_PRIORITY.NORMAL }); + const p5 = runNip07WithRetry(task(5, delays[4]), { priority: NIP07_PRIORITY.NORMAL }); + const p6 = runNip07WithRetry(task(6, delays[5]), { priority: NIP07_PRIORITY.NORMAL }); - await Promise.all([p1, p2, p3, p4]); + await Promise.all([p1, p2, p3, p4, p5, p6]); const starts = log.filter(e => e.status === 'start'); - // Task 1, 2, 3 should start almost immediately (concurrency 3) - assert.ok(Math.abs(starts[0].time - starts[2].time) < 50, "Task 1, 2, 3 should start concurrently"); + // Task 1-5 should start almost immediately (concurrency 5) + assert.ok(Math.abs(starts[0].time - starts[4].time) < 50, "Task 1-5 should start concurrently"); - // Task 4 should start after roughly 200ms (when slot opens) + // Task 6 should start after roughly 200ms (when slot opens) // Note: Node's setTimeout is not precise, so we use loose bounds - assert.ok(starts[3].time >= 150, "Task 4 should wait for a slot"); + assert.ok(starts[5].time >= 150, "Task 6 should wait for a slot"); }); test("respects priority", async () => { @@ -60,10 +62,12 @@ describe("NIP-07 Concurrency Queue", () => { return id; }; - // Fill the queue (size 3) + // Fill the queue (size 5) runNip07WithRetry(task('blocker1', 100)); runNip07WithRetry(task('blocker2', 100)); runNip07WithRetry(task('blocker3', 100)); + runNip07WithRetry(task('blocker4', 100)); + runNip07WithRetry(task('blocker5', 100)); // Enqueue low priority const pLow = runNip07WithRetry(task('low', 50), { priority: NIP07_PRIORITY.LOW });