From 152b7fbf1aa6c674b51a3c335faed74bd29f8364 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:08:33 -0500 Subject: [PATCH] update --- js/nostr/nip07Permissions.js | 8 +++--- js/services/hashtagPreferencesService.js | 19 +++++++++++++-- js/subscriptions.js | 20 +++++++++++++-- js/ui/profileModalController.js | 31 +++++++++++++++++++++--- js/userBlocks.js | 19 +++++++++++++-- tests/nip07-concurrency.test.mjs | 16 +++++------- 6 files changed, 90 insertions(+), 23 deletions(-) diff --git a/js/nostr/nip07Permissions.js b/js/nostr/nip07Permissions.js index 49f09087..e6e84144 100644 --- a/js/nostr/nip07Permissions.js +++ b/js/nostr/nip07Permissions.js @@ -99,10 +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. -// 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); +// Stability: keep extension request concurrency conservative. Some NIP-07 +// providers/content scripts drop channels under high parallel load, which +// manifests as "message channel closed" and repeated decrypt timeouts. +const requestQueue = new Nip07RequestQueue(2); export function getEnableVariantTimeoutMs() { const overrideValue = diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index fda53d55..59e44108 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -45,6 +45,15 @@ const DECRYPT_TIMEOUT_MS = 6000; // PERF: Reduced from 3s to 1.5s — extensions that already granted permission // should recover near-instantly. Shorter delay speeds up the login path. const DECRYPT_RETRY_DELAY_MS = NETWORK_RETRY_DELAY_MS; +const MAX_DECRYPT_RETRY_DELAY_MS = 30000; + +function computeRetryDelay(baseDelayMs, attempt) { + const normalizedAttempt = + Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0; + const multiplier = 2 ** normalizedAttempt; + const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier)); + return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay); +} class TinyEventEmitter { constructor() { @@ -260,6 +269,7 @@ class HashtagPreferencesService { this.backgroundLoading = false; this.preferencesVersion = DEFAULT_VERSION; this.decryptRetryTimeoutId = null; + this.decryptRetryAttempt = 0; this.loadPromise = null; this.loadingPubkey = null; this.subscriptionKey = null; @@ -303,6 +313,7 @@ class HashtagPreferencesService { clearTimeout(this.decryptRetryTimeoutId); this.decryptRetryTimeoutId = null; } + this.decryptRetryAttempt = 0; this.loadPromise = null; this.loadingPubkey = null; this.emitChange("reset"); @@ -525,6 +536,9 @@ class HashtagPreferencesService { if (this.decryptRetryTimeoutId) { clearTimeout(this.decryptRetryTimeoutId); } + const attempt = this.decryptRetryAttempt; + const retryDelayMs = computeRetryDelay(DECRYPT_RETRY_DELAY_MS, attempt); + this.decryptRetryAttempt = attempt + 1; this.decryptRetryTimeoutId = setTimeout(() => { this.decryptRetryTimeoutId = null; if (this.activePubkey && this.activePubkey !== normalized) { @@ -533,9 +547,9 @@ class HashtagPreferencesService { this.load(normalized, options).catch((retryError) => { userLogger.warn(`${LOG_PREFIX} Decryption retry failed`, retryError); }); - }, DECRYPT_RETRY_DELAY_MS); + }, retryDelayMs); devLogger.log( - `${LOG_PREFIX} Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`, + `${LOG_PREFIX} Decryption timed out; scheduling retry in ${retryDelayMs / 1000}s.`, error, ); } @@ -939,6 +953,7 @@ class HashtagPreferencesService { this.loaded = true; this.loadedFromCache = false; this.lastSuccessfulSyncAt = Date.now(); + this.decryptRetryAttempt = 0; if (this.backgroundLoading) { this.backgroundLoading = false; } diff --git a/js/subscriptions.js b/js/subscriptions.js index 15baab96..ad5253d4 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -54,6 +54,15 @@ const DECRYPT_TIMEOUT_MS = 6000; // should recover near-instantly. The shorter delay prevents unnecessary wait // time during the critical login path when the first decrypt attempt fails. const DECRYPT_RETRY_DELAY_MS = 1500; +const MAX_DECRYPT_RETRY_DELAY_MS = 30000; + +function computeRetryDelay(baseDelayMs, attempt) { + const normalizedAttempt = + Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0; + const multiplier = 2 ** normalizedAttempt; + const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier)); + return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay); +} function normalizeHexPubkey(value) { if (typeof value !== "string") { @@ -369,6 +378,7 @@ class SubscriptionsManager { this.hasRenderedOnce = false; this.emitter = new TinyEventEmitter(); this.decryptRetryTimeoutId = null; + this.decryptRetryAttempt = 0; this.ensureNostrServiceListener(); this.subscriptionKey = null; @@ -458,6 +468,9 @@ class SubscriptionsManager { if (this.decryptRetryTimeoutId) { clearTimeout(this.decryptRetryTimeoutId); } + const attempt = this.decryptRetryAttempt; + const retryDelayMs = computeRetryDelay(DECRYPT_RETRY_DELAY_MS, attempt); + this.decryptRetryAttempt = attempt + 1; this.decryptRetryTimeoutId = setTimeout(() => { this.decryptRetryTimeoutId = null; if (this.currentUserPubkey && this.currentUserPubkey !== normalized) { @@ -466,9 +479,9 @@ class SubscriptionsManager { this.updateFromRelays(normalized, options).catch((retryError) => { userLogger.warn("[SubscriptionsManager] Decryption retry failed:", retryError); }); - }, DECRYPT_RETRY_DELAY_MS); + }, retryDelayMs); devLogger.log( - `[SubscriptionsManager] Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`, + `[SubscriptionsManager] Decryption timed out; scheduling retry in ${retryDelayMs / 1000}s.`, error, ); } @@ -741,6 +754,8 @@ class SubscriptionsManager { return; } + this.decryptRetryAttempt = 0; + const decryptedStr = decryptResult.plaintext; const normalized = parseSubscriptionPlaintext(decryptedStr); @@ -820,6 +835,7 @@ class SubscriptionsManager { clearTimeout(this.decryptRetryTimeoutId); this.decryptRetryTimeoutId = null; } + this.decryptRetryAttempt = 0; this.emitter.emit("change", { action: "reset", subscribedPubkeys: [] }); } diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index a8322d7c..a1801540 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -3342,8 +3342,17 @@ export class ProfileModalController { const failedTasks = Array.isArray(listsDetail.tasks) ? listsDetail.tasks.filter((task) => task && task.ok === false) : []; + const permissionFailureTasks = failedTasks.filter((task) => { + const code = + task?.error && typeof task.error.code === "string" + ? task.error.code.trim() + : ""; + return code.endsWith("-permission-required"); + }); const loadedFromCacheTasks = failedTasks.filter((task) => task.fromCache === true); const hardFailTasks = failedTasks.filter((task) => task.fromCache !== true); + const onlyPermissionFailures = + failedTasks.length > 0 && permissionFailureTasks.length === failedTasks.length; if (loadedFromCacheTasks.length > 0 && hardFailTasks.length === 0) { this.setSubscriptionsStatus( @@ -3357,6 +3366,18 @@ export class ProfileModalController { this.setBlockListLoadingState("error", { message: "Blocked creators loaded from cache; syncing latest in background.", }); + } else if (onlyPermissionFailures) { + this.setSubscriptionsStatus( + "Waiting for extension decrypt permissions to sync your lists.", + "warning", + ); + this.hashtagController.setHashtagStatus( + "Waiting for extension decrypt permissions to sync your lists.", + "warning", + ); + this.setBlockListLoadingState("error", { + message: "Waiting for extension decrypt permissions to sync your blocked creators.", + }); } else if (hardFailTasks.length > 0) { this.setSubscriptionsStatus( "Some profile lists failed to sync. Use Retry sync.", @@ -3375,11 +3396,15 @@ export class ProfileModalController { this.setPermissionPromptCtaState({ visible: true, message: - loadedFromCacheTasks.length > 0 + onlyPermissionFailures + ? "NIP-07 decrypt permissions are needed to finish syncing your profile lists." + : loadedFromCacheTasks.length > 0 ? "Some lists are currently from cache. Retry now to sync with relays." : "Failed to sync required profile lists. Retry now.", - buttonLabel: "Retry list sync", - action: "retry-auth-sync", + buttonLabel: onlyPermissionFailures + ? "Enable permissions" + : "Retry list sync", + action: onlyPermissionFailures ? "permission" : "retry-auth-sync", busy: false, }); return; diff --git a/js/userBlocks.js b/js/userBlocks.js index 20096a3e..b2627978 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -90,6 +90,15 @@ const DECRYPT_TIMEOUT_MS = STANDARD_TIMEOUT_MS; 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; + +function computeRetryDelay(baseDelayMs, attempt) { + const normalizedAttempt = + Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0; + const multiplier = 2 ** normalizedAttempt; + const nextDelay = Math.max(250, Math.floor(baseDelayMs * multiplier)); + return Math.min(MAX_DECRYPT_RETRY_DELAY_MS, nextDelay); +} function sanitizeRelayList(candidate) { return Array.isArray(candidate) @@ -699,6 +708,7 @@ class UserBlockListManager { this.emitter = new TinyEventEmitter(); this.seedStateCache = new Map(); this.decryptRetryTimeoutId = null; + this.decryptRetryAttempt = 0; this.loadPromise = null; this.loadingPubkey = null; this.blockListSubscriptionKey = null; @@ -730,6 +740,7 @@ class UserBlockListManager { clearTimeout(this.decryptRetryTimeoutId); this.decryptRetryTimeoutId = null; } + this.decryptRetryAttempt = 0; this.loadPromise = null; this.loadingPubkey = null; } @@ -805,6 +816,9 @@ class UserBlockListManager { if (this.decryptRetryTimeoutId) { clearTimeout(this.decryptRetryTimeoutId); } + const attempt = this.decryptRetryAttempt; + const retryDelayMs = computeRetryDelay(DECRYPT_RETRY_DELAY_MS, attempt); + this.decryptRetryAttempt = attempt + 1; const retryMeta = detail && typeof detail === "object" ? detail : {}; const signerStatus = retryMeta.signerStatus || "unknown"; this.decryptRetryTimeoutId = setTimeout(() => { @@ -820,9 +834,9 @@ class UserBlockListManager { }).catch((retryError) => { userLogger.warn("[UserBlockList] Decryption retry failed:", retryError); }); - }, DECRYPT_RETRY_DELAY_MS); + }, retryDelayMs); devLogger.log( - `[UserBlockList] Decryption stalled (${signerStatus}); retrying in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`, + `[UserBlockList] Decryption stalled (${signerStatus}); retrying in ${retryDelayMs / 1000}s.`, retryMeta.error || retryMeta, ); } @@ -1528,6 +1542,7 @@ class UserBlockListManager { reason: effectiveReason, events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }); + this.decryptRetryAttempt = 0; if (effectiveReason === "empty-events") { emitStatus({ status: "applied-empty", event: newestOverall, source }); diff --git a/tests/nip07-concurrency.test.mjs b/tests/nip07-concurrency.test.mjs index 853834cc..02d628a4 100644 --- a/tests/nip07-concurrency.test.mjs +++ b/tests/nip07-concurrency.test.mjs @@ -21,7 +21,7 @@ describe("NIP-07 Concurrency Queue", () => { global.window = originalWindow; }); - test("runs up to 5 tasks concurrently", async () => { + test("runs up to 2 tasks concurrently", async () => { const start = Date.now(); const delays = [200, 200, 200, 200, 200, 100]; const log = []; @@ -44,12 +44,11 @@ describe("NIP-07 Concurrency Queue", () => { const starts = log.filter(e => e.status === 'start'); - // 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 1-2 should start almost immediately (concurrency 2) + assert.ok(Math.abs(starts[0].time - starts[1].time) < 50, "Task 1-2 should start concurrently"); - // 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[5].time >= 150, "Task 6 should wait for a slot"); + // Task 3 should start only after the first slot opens (~200ms). + assert.ok(starts[2].time >= 150, "Task 3 should wait for a slot"); }); test("respects priority", async () => { @@ -62,12 +61,9 @@ describe("NIP-07 Concurrency Queue", () => { return id; }; - // Fill the queue (size 5) + // Fill the queue (size 2) 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 });