mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
perf: speed up nostr login by parallelizing list sync and decryption
Login with NIP-07 extensions was extremely slow because list loading (blocks, subscriptions, hashtag preferences) happened sequentially and each list's decryption tried encryption schemes one at a time. Changes: - Load blocks, subscriptions, and hashtags in parallel instead of sequentially (blocks no longer blocks other lists from starting) - Use Promise.any() to try all decryption schemes simultaneously instead of trying nip44_v2 → nip44 → nip04 one-by-one - Deduplicate same-family schemes (nip44/nip44_v2) to avoid redundant extension calls - Increase NIP-07 request queue concurrency from 3 to 5 to support parallel decrypt operations during login - Raise no-prompt decrypt timeout from 3s/5s to 8s since extensions that have already granted permission still need time to decrypt - Increase feed sync timeout from 8s to 12s to give lists more time to settle before rendering unfiltered content https://claude.ai/code/session_01V6m1gTecsppPrBHQnUyPnF
This commit is contained in:
6 files changed
+148
-93
No files matched your search
@@ -213,30 +213,34 @@ 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, {
|
||||
parallelListTasks.push(
|
||||
this.authService
|
||||
.loadBlocksForPubkey(activePubkey, {
|
||||
allowPermissionPrompt: false,
|
||||
});
|
||||
} catch (error) {
|
||||
})
|
||||
.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 &&
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,46 +1077,43 @@ class HashtagPreferencesService {
|
||||
Array.from(decryptors.keys()),
|
||||
cachedScheme,
|
||||
);
|
||||
const attemptErrors = [];
|
||||
|
||||
for (const scheme of order) {
|
||||
// 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) {
|
||||
continue;
|
||||
}
|
||||
const source = sources.get(scheme);
|
||||
|
||||
if (source === "extension") {
|
||||
userLogger.info(
|
||||
`${LOG_PREFIX} Attempting decryption via window.nostr fallback (${scheme})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const plaintext = await decryptFn(ciphertext);
|
||||
if (!decryptFn) return null;
|
||||
return decryptFn(ciphertext).then((plaintext) => {
|
||||
if (typeof plaintext !== "string") {
|
||||
attemptErrors.push({
|
||||
scheme,
|
||||
error: new Error("Decryption returned non-string payload."),
|
||||
});
|
||||
continue;
|
||||
throw new Error("Decryption returned non-string payload.");
|
||||
}
|
||||
return { plaintext, scheme };
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (attempts.length) {
|
||||
try {
|
||||
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,
|
||||
);
|
||||
}
|
||||
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 hashtag preferences.");
|
||||
error.code = "hashtag-preferences-decrypt-failed";
|
||||
if (attemptErrors.length) {
|
||||
@@ -1123,6 +1121,12 @@ class HashtagPreferencesService {
|
||||
}
|
||||
return { ok: false, error, errors: attemptErrors };
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error("Failed to decrypt hashtag preferences.");
|
||||
error.code = "hashtag-preferences-decrypt-failed";
|
||||
return { ok: false, error, errors: [] };
|
||||
}
|
||||
|
||||
async publish(options = {}) {
|
||||
// nostrClient imported from nostrClientFacade
|
||||
|
||||
+42
-23
@@ -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,31 +1108,43 @@ class SubscriptionsManager {
|
||||
availableSchemes,
|
||||
cachedScheme,
|
||||
);
|
||||
const attemptErrors = [];
|
||||
|
||||
for (const scheme of order) {
|
||||
// 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) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const plaintext = await decryptFn(ciphertext);
|
||||
if (!decryptFn) return null;
|
||||
return decryptFn(ciphertext).then((plaintext) => {
|
||||
if (typeof plaintext !== "string") {
|
||||
const error = new Error("Decryption returned a non-string payload.");
|
||||
error.code = "subscriptions-invalid-plaintext";
|
||||
attemptErrors.push({ scheme, error });
|
||||
continue;
|
||||
throw new Error("Decryption returned a non-string payload.");
|
||||
}
|
||||
return { plaintext, scheme };
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (attempts.length) {
|
||||
try {
|
||||
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) {
|
||||
@@ -1139,6 +1152,12 @@ class SubscriptionsManager {
|
||||
}
|
||||
return { ok: false, error, errors: attemptErrors };
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error("Failed to decrypt subscription list with available schemes.");
|
||||
error.code = "subscriptions-decrypt-failed";
|
||||
return { ok: false, error, errors: [] };
|
||||
}
|
||||
|
||||
async addChannel(channelHex, userPubkey) {
|
||||
const normalizedChannel = normalizeHexPubkey(channelHex);
|
||||
|
||||
+33
-12
@@ -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;
|
||||
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 };
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (attempts.length) {
|
||||
const result = await Promise.any(attempts);
|
||||
decryptedText = result.plaintext;
|
||||
decryptionSuccessCache.set(normalized, {
|
||||
scheme,
|
||||
scheme: result.scheme,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// All schemes failed — decryptedText stays empty
|
||||
}
|
||||
|
||||
if (!decryptedText) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user