fix(lists): give slow signers room to decrypt (raise timeout budget)

Real-env console: signerStatus "present" but every block/hashtag decrypt
timed out at the 15s service budget, forever, retrying every ~15s for
15+ minutes — lists never loaded.

Reproduced deterministically with the channel-sim harness (latency
override): with per-call latency BELOW the service budget all lists load;
the instant it crosses the budget every list fails (NO) and retries
forever. A nip-07 decrypt is a postMessage round-trip to the extension's
single-threaded, possibly-sleeping service worker — under cold-login load
real extensions take 15-25s to answer even though the crypto is instant.
The earlier 6s per-call timeout was killing those in-progress decrypts.

Raise the budget across blocks/hashtags/subscriptions so a slow-but-
responsive signer completes instead of timing out:
- service-level DECRYPT_TIMEOUT_MS 15s → 30s
- per-call nip-07 decrypt timeout 6s → 25s interactive / ~10-12s background
- backoff cap 30s → 60s so a persistently dead signer settles into an
  occasional heartbeat instead of pinning the extension every ~15s

Harness: at 16s/call (previously NO across the board) blocks +
subscriptions + hashtags now all decrypt (0 timeouts); healthy mode
unchanged (signer-ready ~1.2s). Full unit suite green. KNOWN_BUGS #0.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-16 11:18:00 -04:00
1 parent 0db4f0a603
commit 38c9c52c0a
4 files changed
+47 -27

No files matched your search

+10 -7
View File
@@ -46,11 +46,14 @@ const DEFAULT_VERSION = 1;
// nip-07 extension, and the main thread can be busy connecting to relays. 6s
// proved too aggressive on real accounts (lists timed out and showed empty);
// 15s matches the block-list timeout and lets decryption complete under load.
const DECRYPT_TIMEOUT_MS = 15000;
// Generous decrypt budget: real extensions can take 15-25s to answer a nip-07
// decrypt under cold-login load even though the crypto is instant. A tighter
// budget times out mid-flight and retries forever (KNOWN_BUGS #0).
const DECRYPT_TIMEOUT_MS = 30000;
// 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;
const MAX_DECRYPT_RETRY_DELAY_MS = 60000;
// 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"),
@@ -1130,11 +1133,11 @@ class HashtagPreferencesService {
sources.set(scheme, source);
};
// PERF: Reduced no-prompt timeout from 8s to 5s — the signer readiness
// gate guarantees the extension is ready before decryption starts, so
// decrypt calls should complete within 1-2s. 5s accommodates slow
// extensions while still failing fast for scheme fallback.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 6000 : SHORT_TIMEOUT_MS;
// Per-call nip-07 decrypt budget. A short value kills in-progress decrypts
// on slow extensions (15-25s under cold-login load), so the list never
// decrypts and retries forever. Give the interactive call real room; the
// background refresh stays tighter for fast scheme fallback.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 25000 : 10000;
const signerDecryptOptions = {
priority: NIP07_PRIORITY.NORMAL,
timeoutMs: nip07DecryptTimeoutMs,
+11 -8
View File
@@ -53,12 +53,15 @@ const SUBSCRIPTION_SET_KIND =
// so decryption should succeed within 2-3s. 6s is generous enough for slow
// relays while still failing fast enough for the scheme fallback to try the
// next decryption method without stalling the login path.
const DECRYPT_TIMEOUT_MS = 15000;
// Generous decrypt budget: real extensions can take 15-25s to answer a nip-07
// decrypt under cold-login load even though the crypto is instant. A tighter
// budget times out mid-flight and retries forever (KNOWN_BUGS #0).
const DECRYPT_TIMEOUT_MS = 30000;
// PERF: Reduced from 3s to 1.5s — extensions that already granted permission
// 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;
const MAX_DECRYPT_RETRY_DELAY_MS = 60000;
// Transient decrypt failures that must RETRY rather than give up: our own
// timeout, a severed nip-07 message port ("message channel closed"), and the
@@ -1240,13 +1243,13 @@ class SubscriptionsManager {
decryptors.set(scheme, handler);
};
// PERF: Reduced no-prompt timeout from 8s to 5s — the signer readiness
// gate now guarantees the extension is ready before decryption starts, so
// decrypt calls should complete within 1-2s. 5s accommodates slow
// extensions while still failing fast enough for scheme fallback.
// Per-call nip-07 decrypt budget. A short value kills in-progress decrypts
// on slow extensions (15-25s under cold-login load), so the list never
// decrypts and retries forever. Give the interactive call real room; the
// background refresh stays tighter for fast scheme fallback.
const nip07DecryptTimeoutMs = allowPermissionPrompt
? 6000
: SHORT_TIMEOUT_MS;
? 25000
: 10000;
const signerDecryptOptions = {
priority: NIP07_PRIORITY.NORMAL,
timeoutMs: nip07DecryptTimeoutMs,
+19 -11
View File
@@ -89,12 +89,21 @@ const FAST_BLOCKLIST_RELAY_LIMIT = 3;
const FAST_BLOCKLIST_TIMEOUT_MS = 2500;
const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000;
const MAX_BACKGROUND_BLOCKLIST_RELAY_LIMIT = 24;
// Keep interactive decrypt timeout above extension retry timeout window.
const DECRYPT_TIMEOUT_MS = Math.max(STANDARD_TIMEOUT_MS, 15000);
const BACKGROUND_DECRYPT_TIMEOUT_MS = 8000;
// Decrypt timeout budget. A nip-07 decrypt is a postMessage round-trip to the
// extension's (single-threaded, possibly sleeping) service worker — under
// cold-login load real extensions can take 15-25s to answer even though the
// crypto itself is instant. The harness (scripts/perf/nip07-channel-sim.mjs)
// shows lists fail to load the moment per-call latency crosses this budget, so
// keep it generous (30s) to give a slow-but-responsive signer room to complete
// rather than timing out and retrying forever (KNOWN_BUGS #0).
const DECRYPT_TIMEOUT_MS = Math.max(STANDARD_TIMEOUT_MS, 30000);
const BACKGROUND_DECRYPT_TIMEOUT_MS = 12000;
// PERF: Reduced from 10s to 3s for faster recovery during login.
const DECRYPT_RETRY_DELAY_MS = 3000;
const MAX_DECRYPT_RETRY_DELAY_MS = 30000;
// Cap the exponential backoff well above the decrypt budget so a persistently
// unresponsive signer settles into an occasional heartbeat instead of pinning
// the extension/CPU every ~15s indefinitely.
const MAX_DECRYPT_RETRY_DELAY_MS = 60000;
// Transient decrypt failures that must KEEP the stale list and RETRY in the
// background (the channel/extension recovers shortly), rather than giving up
@@ -982,13 +991,12 @@ class UserBlockListManager {
: allowPermissionPrompt
? DECRYPT_TIMEOUT_MS
: BACKGROUND_DECRYPT_TIMEOUT_MS;
// A healthy nip04/nip44 decrypt is local crypto (~1s). The login-time value
// was 15s purely to tolerate a slow first-grant prompt — but permissions are
// pre-granted by the readiness gate, so a 15s timeout just monopolizes one of
// the two NIP-07 queue slots on a doomed call, starving the gate (real-env
// ~48s signer-ready — KNOWN_BUGS #0). 6s still tolerates a slow extension
// while freeing the slot ~2.5x faster.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 6000 : 6000;
// Per-call nip-07 decrypt budget. A 6s value (tried earlier) killed
// in-progress decrypts on slow extensions that legitimately need 15-25s to
// answer under cold-login load, so the list never decrypted and retried
// forever. Give the call real room (interactive 25s, background 12s) — it
// sits below the service-level DECRYPT_TIMEOUT_MS backstop.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 25000 : 12000;
const emitStatus = (detail) => {
if (!detail || typeof detail !== "object") {
return;
+7 -1
View File
@@ -54,7 +54,13 @@ const MODES = {
dead: { latency: 400, maxInflight: 1, dropCooldown: 10 ** 9 },
};
const HEALTHY = MODES.healthy;
const TARGET = MODES[MODE] || MODES.overload;
const TARGET = { ...(MODES[MODE] || MODES.overload) };
// Allow overriding the per-call latency to model a genuinely slow extension
// (the real-env case: signer present but every decrypt sits in the nip-07
// queue long enough to blow the 15s service timeout).
if (Number.isFinite(Number(process.env.LATENCY_MS))) {
TARGET.latency = Number(process.env.LATENCY_MS);
}
const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex"));
const USER_PK = getPublicKey(USER_SK);