Merge pull request #1925 from PR0M3TH3AN/codex/enhance-timeout-handling-in-userblocks

Improve user block list decryption fallback and instrumentation
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-29 10:27:41 -05:00
commit 9acdca248a
1 file changed
+119 -22
+119 -22
View File
@@ -76,6 +76,7 @@ const FAST_BLOCKLIST_TIMEOUT_MS = 2500;
const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000;
const DECRYPT_TIMEOUT_MS = 90000;
const DECRYPT_RETRY_DELAY_MS = 10000;
const MAX_BLOCKLIST_ENTRIES = 5000;
function sanitizeRelayList(candidate) {
return Array.isArray(candidate)
@@ -303,13 +304,16 @@ function serializeBlockListTagMatrix(values, ownerPubkey, options = {}) {
return JSON.stringify(tags);
}
function extractPubkeysFromTags(tags, ownerPubkey = null) {
function extractPubkeysFromTags(tags, ownerPubkey = null, options = {}) {
if (!Array.isArray(tags)) {
return [];
}
const collected = [];
const seen = new Set();
const owner = ownerPubkey ? normalizeHex(ownerPubkey) : null;
const limit = Number.isFinite(options?.limit)
? Math.max(0, Math.floor(options.limit))
: null;
for (const entry of tags) {
if (!Array.isArray(entry) || entry.length < 2) {
@@ -325,11 +329,14 @@ function extractPubkeysFromTags(tags, ownerPubkey = null) {
}
seen.add(normalized);
collected.push(normalized);
if (limit !== null && collected.length >= limit) {
break;
}
}
return collected;
}
function parseBlockListPlaintext(plaintext, ownerPubkey) {
function parseBlockListPlaintext(plaintext, ownerPubkey, options = {}) {
if (typeof plaintext !== "string" || !plaintext) {
return [];
}
@@ -346,20 +353,45 @@ function parseBlockListPlaintext(plaintext, ownerPubkey) {
}
const owner = normalizeHex(ownerPubkey);
const limit = Number.isFinite(options?.limit)
? Math.max(0, Math.floor(options.limit))
: null;
if (Array.isArray(parsed)) {
return extractPubkeysFromTags(parsed, owner);
if (limit !== null && parsed.length > limit) {
devLogger.warn(
"[UserBlockList] Block list payload exceeds entry limit; truncating.",
{ limit, payloadSize: parsed.length },
);
}
return extractPubkeysFromTags(parsed, owner, { limit });
}
if (parsed && typeof parsed === "object") {
if (Array.isArray(parsed.tags) && parsed.tags.length) {
return extractPubkeysFromTags(parsed.tags, owner);
if (limit !== null && parsed.tags.length > limit) {
devLogger.warn(
"[UserBlockList] Block list tag payload exceeds entry limit; truncating.",
{ limit, payloadSize: parsed.tags.length },
);
}
return extractPubkeysFromTags(parsed.tags, owner, { limit });
}
const legacy = Array.isArray(parsed.blockedPubkeys) ? parsed.blockedPubkeys : [];
return legacy
if (limit !== null && legacy.length > limit) {
devLogger.warn(
"[UserBlockList] Legacy block list payload exceeds entry limit; truncating.",
{ limit, payloadSize: legacy.length },
);
}
const normalizedLegacy = legacy
.map((value) => normalizeHex(value))
.filter((value) => value && value !== owner);
if (limit === null) {
return normalizedLegacy;
}
return normalizedLegacy.slice(0, limit);
}
return [];
@@ -683,29 +715,32 @@ class UserBlockListManager {
return Array.from(this.blockedPubkeys);
}
scheduleDecryptRetry(userPubkey, error) {
scheduleDecryptRetry(userPubkey, detail = {}) {
if (!userPubkey) {
return;
}
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
}
const retryMeta = detail && typeof detail === "object" ? detail : {};
const signerStatus = retryMeta.signerStatus || "unknown";
this.decryptRetryTimeoutId = setTimeout(() => {
this.decryptRetryTimeoutId = null;
if (this.activePubkey !== userPubkey) {
return;
}
const sinceValue = Number.isFinite(this.blockEventCreatedAt)
? Math.max(0, this.blockEventCreatedAt - 1)
: undefined;
this.loadBlocks(userPubkey, {
since: Number.isFinite(this.blockEventCreatedAt)
? this.blockEventCreatedAt
: undefined,
since: sinceValue,
}).catch((retryError) => {
userLogger.warn("[UserBlockList] Decryption retry failed:", retryError);
});
}, DECRYPT_RETRY_DELAY_MS);
devLogger.log(
`[UserBlockList] Decryption timed out; scheduling retry in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`,
error,
`[UserBlockList] Decryption stalled (${signerStatus}); retrying in ${DECRYPT_RETRY_DELAY_MS / 1000}s.`,
retryMeta.error || retryMeta,
);
}
@@ -821,6 +856,13 @@ class UserBlockListManager {
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
const signerAvailable = Boolean(signer);
const signerHasDecryptors = signerHasNip04 || signerHasNip44;
const signerStatus = signerAvailable
? signerHasDecryptors
? "present"
: "present-missing-decryptor"
: "missing";
let permissionError = null;
if ((!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04)) {
@@ -942,6 +984,7 @@ class UserBlockListManager {
sources,
permissionError,
order: determineDecryptionOrder(event, Array.from(decryptors.keys())),
signerStatus,
};
};
@@ -1046,26 +1089,35 @@ class UserBlockListManager {
this.blockEventCreatedAt = newestCreatedAt;
// Helper to decrypt a single event
const decryptEvent = async (ev) => {
const decryptEvent = async (ev, meta = {}) => {
if (!ev) return [];
const isContentEmpty = !ev.content || !ev.content.trim();
if (isContentEmpty) {
// Handle tag-only lists (e.g. Kind 10000 with empty content but populated p tags)
if (Array.isArray(ev.tags) && ev.tags.length) {
return extractPubkeysFromTags(ev.tags, normalized);
return extractPubkeysFromTags(ev.tags, normalized, {
limit: MAX_BLOCKLIST_ENTRIES,
});
}
return [];
}
const { decryptors, order, sources, permissionError } =
const { decryptors, order, sources, permissionError, signerStatus } =
await resolveDecryptors(ev);
meta.signerStatus = signerStatus;
meta.decryptorSources = Array.from(sources.entries()).map(
([scheme, source]) => ({ scheme, source }),
);
if (!decryptors.size) {
if (permissionError) {
throw permissionError;
}
return [];
const error = new Error("No decryptor available for block list event.");
error.code = "user-blocklist-no-decryptor";
error.signerStatus = signerStatus;
throw error;
}
let decryptedText = "";
@@ -1089,16 +1141,36 @@ class UserBlockListManager {
return [];
}
return parseBlockListPlaintext(decryptedText, normalized);
return parseBlockListPlaintext(decryptedText, normalized, {
limit: MAX_BLOCKLIST_ENTRIES,
});
};
const mergedPubkeys = new Set();
let decryptionError = null;
const decryptMeta = {
mute: { eventId: newestMute?.id || null },
block: { eventId: newestBlock?.id || null },
};
const resolveSignerStatus = () => {
const statuses = [decryptMeta.mute.signerStatus, decryptMeta.block.signerStatus]
.filter(Boolean);
if (!statuses.length) {
return "unknown";
}
if (statuses.every((status) => status === "missing")) {
return "missing";
}
if (statuses.some((status) => status === "present-missing-decryptor")) {
return "present-missing-decryptor";
}
return "present";
};
try {
const decryptPromise = Promise.all([
decryptEvent(newestMute),
decryptEvent(newestBlock),
decryptEvent(newestMute, decryptMeta.mute),
decryptEvent(newestBlock, decryptMeta.block),
]);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
@@ -1107,6 +1179,7 @@ class UserBlockListManager {
`Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`,
);
timeoutError.code = "user-blocklist-decrypt-timeout";
timeoutError.signerStatus = resolveSignerStatus();
reject(timeoutError);
},
DECRYPT_TIMEOUT_MS,
@@ -1127,12 +1200,33 @@ class UserBlockListManager {
if (decryptionError) {
if (decryptionError.code === "user-blocklist-decrypt-timeout") {
this.blockedPubkeys = previousState.blockedPubkeys;
this.blockEventId = previousState.blockEventId;
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
const signerStatus =
decryptionError.signerStatus || resolveSignerStatus();
applyBlockedPubkeys([], {
source,
reason: "decrypt-timeout",
signerStatus,
events: [newestMute?.id, newestBlock?.id].filter(Boolean),
});
this.loaded = true;
emitStatus({
status: "applied-empty",
reason: "decrypt-timeout",
signerStatus,
source,
});
emitStatus({ status: "settled" });
this.scheduleDecryptRetry(normalized, decryptionError);
userLogger.warn(
"[UserBlockList] Decryption timed out; applying empty list and retrying in background.",
{
signerStatus,
events: [newestMute?.id, newestBlock?.id].filter(Boolean),
},
);
this.scheduleDecryptRetry(normalized, {
error: decryptionError,
signerStatus,
});
return;
}
userLogger.warn(
@@ -1143,10 +1237,13 @@ class UserBlockListManager {
this.blockEventId = previousState.blockEventId;
this.blockEventCreatedAt = previousState.blockEventCreatedAt;
this.loaded = true;
const signerStatus =
decryptionError.signerStatus || resolveSignerStatus();
emitStatus({
status: "error",
error: decryptionError,
decryptor: "mixed",
signerStatus,
});
emitStatus({ status: "settled" });
return;