mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
fix(nostr): retry list decrypt on transient channel failure (not give up)
Built a deterministic NIP-07 channel-simulation harness (scripts/perf/nip07-channel-sim.mjs) that reproduces the real cold-login failure locally with a fake extension whose message channel drops under concurrent load (MODE=overload), recovers (MODE=slow), or stays dead. It seeds a real encrypted block list and reports whether it decrypts. The harness pinned the actual root cause that the relay/breaker/timeout work hadn't fixed: when the channel briefly drops during the login burst, the per-scheme decrypt probe (Promise.any) rejects with "message channel closed" — and every list service SWALLOWED that and treated it as "the user has no blocks / no subs / no hashtags", or routed it to a give-up branch. Either way: no retry, so the list stayed empty until a full page refresh, even though the channel recovered seconds later. Fix: classify transient failures (channel-death messages, breaker fast-fail code, our own decrypt-timeout) and route them to the existing keep-stale + scheduleDecryptRetry path instead of swallowing/abandoning: - userBlocks: re-throw a transient sub-error out of the Promise.any probe (was silently returning [] = "no blocks"); route it (and channel/breaker errors) through the retry branch. - hashtagPreferencesService + subscriptions: broaden the retry condition from the timeout code only to any transient error, recursing into the nested Promise.any AggregateError causes. Harness verdict (MODE=overload, 16 channel drops): block list BLOCK LIST DECRYPTED NO -> YES after the fix; signer-ready ~1.9s. Adds getAppState().lists observability to the test harness. Full unit suite green. KNOWN_BUGS #0. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
265b7813f2
commit
db0fc07989
5 files changed
+426
-8
No files matched your search
@@ -52,6 +52,44 @@ const DECRYPT_TIMEOUT_MS = 15000;
|
|||||||
const DECRYPT_RETRY_DELAY_MS = NETWORK_RETRY_DELAY_MS;
|
const DECRYPT_RETRY_DELAY_MS = NETWORK_RETRY_DELAY_MS;
|
||||||
const MAX_DECRYPT_RETRY_DELAY_MS = 30000;
|
const MAX_DECRYPT_RETRY_DELAY_MS = 30000;
|
||||||
|
|
||||||
|
// 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"),
|
||||||
|
// and the circuit breaker's fast-fail. Without this a brief channel drop during
|
||||||
|
// the login burst abandons hashtag prefs until a refresh (KNOWN_BUGS #0).
|
||||||
|
const TRANSIENT_DECRYPT_ERROR_CODES = new Set([
|
||||||
|
"hashtag-preferences-decrypt-timeout",
|
||||||
|
"nip07-channel-unresponsive",
|
||||||
|
]);
|
||||||
|
const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [
|
||||||
|
"message channel closed",
|
||||||
|
"could not establish connection",
|
||||||
|
"receiving end does not exist",
|
||||||
|
"extension context invalidated",
|
||||||
|
"connection lost",
|
||||||
|
"channel is unresponsive",
|
||||||
|
];
|
||||||
|
function isTransientDecryptError(error) {
|
||||||
|
if (!error) return false;
|
||||||
|
if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) return true;
|
||||||
|
const message =
|
||||||
|
typeof error.message === "string" ? error.message.toLowerCase() : "";
|
||||||
|
if (TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// decryptEvent wraps the per-scheme Promise.any failures in a generic
|
||||||
|
// "decrypt-failed" error and nests the real causes under .errors/.cause —
|
||||||
|
// recurse so a channel-death sub-error still triggers a retry.
|
||||||
|
const nested = Array.isArray(error.errors)
|
||||||
|
? error.errors
|
||||||
|
: Array.isArray(error.cause)
|
||||||
|
? error.cause
|
||||||
|
: null;
|
||||||
|
if (nested) {
|
||||||
|
return nested.some((entry) => isTransientDecryptError(entry?.error || entry));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function computeRetryDelay(baseDelayMs, attempt) {
|
function computeRetryDelay(baseDelayMs, attempt) {
|
||||||
const normalizedAttempt =
|
const normalizedAttempt =
|
||||||
Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
||||||
@@ -907,7 +945,9 @@ class HashtagPreferencesService {
|
|||||||
decryptResult.error,
|
decryptResult.error,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") {
|
if (isTransientDecryptError(decryptResult.error)) {
|
||||||
|
// Channel/timeout failures recover shortly — keep any cached prefs and
|
||||||
|
// retry instead of giving up until a page refresh.
|
||||||
this.scheduleDecryptRetry(normalized, decryptResult.error, {
|
this.scheduleDecryptRetry(normalized, decryptResult.error, {
|
||||||
allowPermissionPrompt,
|
allowPermissionPrompt,
|
||||||
signerReadinessGate,
|
signerReadinessGate,
|
||||||
|
|||||||
+38
-1
@@ -60,6 +60,41 @@ const DECRYPT_TIMEOUT_MS = 15000;
|
|||||||
const DECRYPT_RETRY_DELAY_MS = 1500;
|
const DECRYPT_RETRY_DELAY_MS = 1500;
|
||||||
const MAX_DECRYPT_RETRY_DELAY_MS = 30000;
|
const MAX_DECRYPT_RETRY_DELAY_MS = 30000;
|
||||||
|
|
||||||
|
// Transient decrypt failures that must RETRY rather than give up: our own
|
||||||
|
// timeout, a severed nip-07 message port ("message channel closed"), and the
|
||||||
|
// circuit breaker's fast-fail. Without this a brief channel drop during the
|
||||||
|
// login burst abandons the subscription list until a refresh (KNOWN_BUGS #0).
|
||||||
|
const TRANSIENT_DECRYPT_ERROR_CODES = new Set([
|
||||||
|
"subscriptions-decrypt-timeout",
|
||||||
|
"nip07-channel-unresponsive",
|
||||||
|
]);
|
||||||
|
const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [
|
||||||
|
"message channel closed",
|
||||||
|
"could not establish connection",
|
||||||
|
"receiving end does not exist",
|
||||||
|
"extension context invalidated",
|
||||||
|
"connection lost",
|
||||||
|
"channel is unresponsive",
|
||||||
|
];
|
||||||
|
function isTransientDecryptError(error) {
|
||||||
|
if (!error) return false;
|
||||||
|
if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) return true;
|
||||||
|
const message =
|
||||||
|
typeof error.message === "string" ? error.message.toLowerCase() : "";
|
||||||
|
if (TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const nested = Array.isArray(error.errors)
|
||||||
|
? error.errors
|
||||||
|
: Array.isArray(error.cause)
|
||||||
|
? error.cause
|
||||||
|
: null;
|
||||||
|
if (nested) {
|
||||||
|
return nested.some((entry) => isTransientDecryptError(entry?.error || entry));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function computeRetryDelay(baseDelayMs, attempt) {
|
function computeRetryDelay(baseDelayMs, attempt) {
|
||||||
const normalizedAttempt =
|
const normalizedAttempt =
|
||||||
Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
||||||
@@ -690,7 +725,9 @@ class SubscriptionsManager {
|
|||||||
if (!decryptResult.ok) {
|
if (!decryptResult.ok) {
|
||||||
this.lastLoadError = decryptResult.error || null;
|
this.lastLoadError = decryptResult.error || null;
|
||||||
this.uiReady = true;
|
this.uiReady = true;
|
||||||
if (decryptResult.error?.code === "subscriptions-decrypt-timeout") {
|
if (isTransientDecryptError(decryptResult.error)) {
|
||||||
|
// Channel/timeout failures recover shortly — retry instead of
|
||||||
|
// abandoning the subscription list until a page refresh.
|
||||||
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
|
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
|
||||||
allowPermissionPrompt,
|
allowPermissionPrompt,
|
||||||
signerReadinessGate,
|
signerReadinessGate,
|
||||||
|
|||||||
@@ -472,6 +472,15 @@ function getAppState() {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
decryptBehavior: { ...activeDecryptBehavior },
|
decryptBehavior: { ...activeDecryptBehavior },
|
||||||
|
lists: {
|
||||||
|
// Observable list-decrypt outcomes for harnesses validating the cold-login
|
||||||
|
// decrypt path (see scripts/perf/nip07-channel-sim.mjs / KNOWN_BUGS #0).
|
||||||
|
blocksLoaded: Boolean(userBlocks?.loaded),
|
||||||
|
blockedPubkeys:
|
||||||
|
typeof userBlocks?.getBlockedPubkeys === "function"
|
||||||
|
? userBlocks.getBlockedPubkeys()
|
||||||
|
: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+62
-6
@@ -95,6 +95,35 @@ const BACKGROUND_DECRYPT_TIMEOUT_MS = 8000;
|
|||||||
// PERF: Reduced from 10s to 3s for faster recovery during login.
|
// PERF: Reduced from 10s to 3s for faster recovery during login.
|
||||||
const DECRYPT_RETRY_DELAY_MS = 3000;
|
const DECRYPT_RETRY_DELAY_MS = 3000;
|
||||||
const MAX_DECRYPT_RETRY_DELAY_MS = 30000;
|
const MAX_DECRYPT_RETRY_DELAY_MS = 30000;
|
||||||
|
|
||||||
|
// Transient decrypt failures that must KEEP the stale list and RETRY in the
|
||||||
|
// background (the channel/extension recovers shortly), rather than giving up
|
||||||
|
// permanently. Besides our own timeout, this covers a severed nip-07 message
|
||||||
|
// port ("message channel closed" — KNOWN_BUGS #0) and the circuit breaker's
|
||||||
|
// fast-fail while it probes for recovery. Without this, a brief channel drop
|
||||||
|
// during the login burst abandons the block list until a page refresh, even
|
||||||
|
// though the channel comes back seconds later.
|
||||||
|
const TRANSIENT_DECRYPT_ERROR_CODES = new Set([
|
||||||
|
"user-blocklist-decrypt-timeout",
|
||||||
|
"nip07-channel-unresponsive",
|
||||||
|
]);
|
||||||
|
const TRANSIENT_DECRYPT_MESSAGE_PATTERNS = [
|
||||||
|
"message channel closed",
|
||||||
|
"could not establish connection",
|
||||||
|
"receiving end does not exist",
|
||||||
|
"extension context invalidated",
|
||||||
|
"connection lost",
|
||||||
|
"channel is unresponsive",
|
||||||
|
];
|
||||||
|
function isTransientDecryptError(error) {
|
||||||
|
if (!error) return false;
|
||||||
|
if (error.code && TRANSIENT_DECRYPT_ERROR_CODES.has(error.code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
typeof error.message === "string" ? error.message.toLowerCase() : "";
|
||||||
|
return TRANSIENT_DECRYPT_MESSAGE_PATTERNS.some((p) => message.includes(p));
|
||||||
|
}
|
||||||
function computeRetryDelay(baseDelayMs, attempt) {
|
function computeRetryDelay(baseDelayMs, attempt) {
|
||||||
const normalizedAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
const normalizedAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
||||||
const multiplier = 2 ** normalizedAttempt;
|
const multiplier = 2 ** normalizedAttempt;
|
||||||
@@ -1453,8 +1482,29 @@ class UserBlockListManager {
|
|||||||
decryptedText = result.plaintext;
|
decryptedText = result.plaintext;
|
||||||
setLastSuccessfulScheme(normalized, result.scheme);
|
setLastSuccessfulScheme(normalized, result.scheme);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (aggregateError) {
|
||||||
// All schemes failed — decryptedText stays empty
|
// Promise.any rejects with an AggregateError once EVERY scheme
|
||||||
|
// failed. If any failure was a transient channel/extension error
|
||||||
|
// (a severed message port, breaker fast-fail, etc.) we must NOT
|
||||||
|
// swallow it and return [] — that silently misreads a brief
|
||||||
|
// channel drop as "the user has no blocks" and never retries
|
||||||
|
// (KNOWN_BUGS #0). Re-throw so applyEvents keeps the stale list
|
||||||
|
// and schedules a background retry; once the channel recovers the
|
||||||
|
// retry succeeds. Genuine decrypt mismatches (wrong key/content)
|
||||||
|
// still fall through to the empty result.
|
||||||
|
const subErrors =
|
||||||
|
aggregateError && Array.isArray(aggregateError.errors)
|
||||||
|
? aggregateError.errors
|
||||||
|
: [aggregateError];
|
||||||
|
const transient = subErrors.find((e) => isTransientDecryptError(e));
|
||||||
|
if (transient) {
|
||||||
|
if (!transient.signerStatus) {
|
||||||
|
transient.signerStatus = signerStatus;
|
||||||
|
}
|
||||||
|
throw transient;
|
||||||
|
}
|
||||||
|
// All schemes failed for non-transient reasons — decryptedText
|
||||||
|
// stays empty (treated as "no blocks").
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1552,7 +1602,10 @@ class UserBlockListManager {
|
|||||||
emitStatus({ status: "settled" });
|
emitStatus({ status: "settled" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (decryptionError.code === "user-blocklist-decrypt-timeout") {
|
if (isTransientDecryptError(decryptionError)) {
|
||||||
|
const isTimeout =
|
||||||
|
decryptionError.code === "user-blocklist-decrypt-timeout";
|
||||||
|
const reason = isTimeout ? "decrypt-timeout" : "channel-unavailable";
|
||||||
const signerStatus = decryptionError.signerStatus || resolveSignerStatus();
|
const signerStatus = decryptionError.signerStatus || resolveSignerStatus();
|
||||||
const recoveredPrivateBlocks = new Set(previousState.privateBlocks);
|
const recoveredPrivateBlocks = new Set(previousState.privateBlocks);
|
||||||
const recoveredPublicMutes = new Set([
|
const recoveredPublicMutes = new Set([
|
||||||
@@ -1566,14 +1619,14 @@ class UserBlockListManager {
|
|||||||
Number.isFinite(newestCreatedAt) ? newestCreatedAt : previousState.blockEventCreatedAt;
|
Number.isFinite(newestCreatedAt) ? newestCreatedAt : previousState.blockEventCreatedAt;
|
||||||
applySets(recoveredPrivateBlocks, recoveredPublicMutes, {
|
applySets(recoveredPrivateBlocks, recoveredPublicMutes, {
|
||||||
source,
|
source,
|
||||||
reason: "decrypt-timeout-partial",
|
reason: `${reason}-partial`,
|
||||||
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
||||||
});
|
});
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
|
||||||
emitStatus({
|
emitStatus({
|
||||||
status: "stale",
|
status: "stale",
|
||||||
reason: "decrypt-timeout",
|
reason,
|
||||||
signerStatus,
|
signerStatus,
|
||||||
source,
|
source,
|
||||||
blockedPubkeys: Array.from(this.blockedPubkeys),
|
blockedPubkeys: Array.from(this.blockedPubkeys),
|
||||||
@@ -1581,9 +1634,12 @@ class UserBlockListManager {
|
|||||||
emitStatus({ status: "settled" });
|
emitStatus({ status: "settled" });
|
||||||
|
|
||||||
userLogger.warn(
|
userLogger.warn(
|
||||||
"[UserBlockList] Decryption timed out; keeping stale list and retrying in background.",
|
isTimeout
|
||||||
|
? "[UserBlockList] Decryption timed out; keeping stale list and retrying in background."
|
||||||
|
: "[UserBlockList] Signer channel unavailable; keeping stale list and retrying in background.",
|
||||||
{
|
{
|
||||||
signerStatus,
|
signerStatus,
|
||||||
|
reason,
|
||||||
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
// Deterministic NIP-07 CHANNEL simulation harness.
|
||||||
|
//
|
||||||
|
// Reproduces the real-env cold-login failure (KNOWN_BUGS #0) WITHOUT a real
|
||||||
|
// browser extension, so fixes can be validated locally instead of round-tripping
|
||||||
|
// through a user's wallet. A fake window.nostr (backed by real nostr-tools
|
||||||
|
// crypto) models a configurable message-channel:
|
||||||
|
//
|
||||||
|
// MODE=healthy fast, never drops (baseline — lists should decrypt)
|
||||||
|
// MODE=slow every call delayed (overwhelmed but alive)
|
||||||
|
// MODE=overload drops the channel under concurrent load ("message channel
|
||||||
|
// closed"), recovers after a cooldown — the real failure
|
||||||
|
// MODE=dead drops on first overload and never recovers (refresh-only)
|
||||||
|
//
|
||||||
|
// It seeds the user's encrypted lists (block list + watch history) so login
|
||||||
|
// triggers genuine decrypt contention against the signer-readiness handshake,
|
||||||
|
// then reports the things the real console showed: time-to-signer-ready, whether
|
||||||
|
// the circuit breaker opened/recovered, decrypt-timeout count, and which lists
|
||||||
|
// actually loaded.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// MODE=overload APP=http://localhost:3000 node scripts/perf/nip07-channel-sim.mjs
|
||||||
|
// for m in healthy slow overload dead; do MODE=$m node scripts/perf/nip07-channel-sim.mjs; done
|
||||||
|
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { startRelay } from "../agent/simple-relay.mjs";
|
||||||
|
import { finalizeEvent, getPublicKey, nip04 } from "nostr-tools";
|
||||||
|
import * as nip44 from "nostr-tools/nip44";
|
||||||
|
|
||||||
|
const APP = process.env.APP || "http://localhost:3000";
|
||||||
|
const MODE = (process.env.MODE || "overload").toLowerCase();
|
||||||
|
const WS_PORT = Number(process.env.WS_PORT ?? 8974);
|
||||||
|
const VIDEOS = Number(process.env.VIDEOS ?? 12);
|
||||||
|
const OBSERVE_MS = Number(process.env.OBSERVE_MS ?? 40000);
|
||||||
|
|
||||||
|
// Channel models per mode. LATENCY_MS = per-call delay; MAX_INFLIGHT = how many
|
||||||
|
// concurrent extension calls before the port "drops"; DROP_COOLDOWN_MS = how long
|
||||||
|
// the dropped channel stays dead before recovering.
|
||||||
|
const MODES = {
|
||||||
|
healthy: { latency: 80, maxInflight: 99, dropCooldown: 0 },
|
||||||
|
slow: { latency: 2500, maxInflight: 99, dropCooldown: 0 },
|
||||||
|
overload: { latency: 400, maxInflight: 1, dropCooldown: 4000 },
|
||||||
|
dead: { latency: 400, maxInflight: 1, dropCooldown: 10 ** 9 },
|
||||||
|
};
|
||||||
|
const channel = MODES[MODE] || MODES.overload;
|
||||||
|
|
||||||
|
const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex"));
|
||||||
|
const USER_PK = getPublicKey(USER_SK);
|
||||||
|
const CHANNEL_CLOSED_MESSAGE =
|
||||||
|
"A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received";
|
||||||
|
|
||||||
|
// --- channel model state (Node side) ---
|
||||||
|
let pending = 0;
|
||||||
|
let droppedUntil = 0;
|
||||||
|
let extCalls = {};
|
||||||
|
let drops = 0;
|
||||||
|
|
||||||
|
function viaChannel(method, fn) {
|
||||||
|
extCalls[method] = (extCalls[method] || 0) + 1;
|
||||||
|
const now = Date.now();
|
||||||
|
// Already-dropped channel: reject fast (the severed port).
|
||||||
|
if (now < droppedUntil) {
|
||||||
|
drops += 1;
|
||||||
|
return Promise.reject(new Error(CHANNEL_CLOSED_MESSAGE));
|
||||||
|
}
|
||||||
|
// Concurrency overload trips the drop.
|
||||||
|
if (pending >= channel.maxInflight && channel.dropCooldown > 0) {
|
||||||
|
droppedUntil = now + channel.dropCooldown;
|
||||||
|
drops += 1;
|
||||||
|
return Promise.reject(new Error(CHANNEL_CLOSED_MESSAGE));
|
||||||
|
}
|
||||||
|
pending += 1;
|
||||||
|
return (async () => {
|
||||||
|
try {
|
||||||
|
if (channel.latency > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, channel.latency));
|
||||||
|
}
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
pending -= 1;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
const convKey = (peerPk) => nip44.v2.utils.getConversationKey(USER_SK, peerPk);
|
||||||
|
|
||||||
|
function seedEncryptedBlockList(relay, createdAt) {
|
||||||
|
// A blocked pubkey the app must reveal by decrypting the kind-10000 mute list.
|
||||||
|
const blocked = "b".repeat(64);
|
||||||
|
const plaintext = JSON.stringify({ blockedPubkeys: [blocked] });
|
||||||
|
const content = nip04.encrypt(USER_SK, USER_PK, plaintext);
|
||||||
|
relay.seedEvent(
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 10000,
|
||||||
|
created_at: createdAt,
|
||||||
|
tags: [["encrypted", "nip04"]],
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
USER_SK,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const relay = startRelay(WS_PORT, { httpPort: false });
|
||||||
|
const relayUrl = `ws://127.0.0.1:${WS_PORT}`;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
relay.seedEvent(
|
||||||
|
finalizeEvent(
|
||||||
|
{ kind: 10002, created_at: now, tags: [["r", relayUrl]], content: "" },
|
||||||
|
USER_SK,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (let i = 0; i < VIDEOS; i++) {
|
||||||
|
const dTag = `seed-${i}`;
|
||||||
|
relay.seedEvent(
|
||||||
|
finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 30078,
|
||||||
|
created_at: now - i * 60,
|
||||||
|
tags: [["d", dTag], ["t", "video"], ["title", `Sim Video ${i}`], ["url", `https://example.com/v${i}.mp4`]],
|
||||||
|
content: JSON.stringify({ version: 3, title: `Sim Video ${i}`, videoRootId: dTag, mode: "live", isPrivate: false, deleted: false, url: `https://example.com/v${i}.mp4` }),
|
||||||
|
},
|
||||||
|
USER_SK,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expectedBlocked = seedEncryptedBlockList(relay, now);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
|
||||||
|
const context = await browser.newContext();
|
||||||
|
|
||||||
|
await context.exposeBinding("__extGetPublicKey", () => viaChannel("getPublicKey", () => USER_PK));
|
||||||
|
await context.exposeBinding("__extSignEvent", (_s, e) => viaChannel("signEvent", () => finalizeEvent({ ...e, pubkey: USER_PK }, USER_SK)));
|
||||||
|
await context.exposeBinding("__extNip04Encrypt", (_s, pk, t) => viaChannel("nip04.encrypt", () => nip04.encrypt(USER_SK, pk, t)));
|
||||||
|
await context.exposeBinding("__extNip04Decrypt", (_s, pk, ct) => viaChannel("nip04.decrypt", () => nip04.decrypt(USER_SK, pk, ct)));
|
||||||
|
await context.exposeBinding("__extNip44Encrypt", (_s, pk, t) => viaChannel("nip44.encrypt", () => nip44.v2.encrypt(t, convKey(pk))));
|
||||||
|
await context.exposeBinding("__extNip44Decrypt", (_s, pk, ct) => viaChannel("nip44.decrypt", () => nip44.v2.decrypt(ct, convKey(pk))));
|
||||||
|
// enable()/getRelays are answered through the SAME channel so the handshake
|
||||||
|
// competes with decrypts exactly as it does in the real extension.
|
||||||
|
await context.exposeBinding("__extEnable", () => viaChannel("enable", () => true));
|
||||||
|
|
||||||
|
await context.addInitScript((url) => {
|
||||||
|
localStorage.setItem("hasSeenDisclaimer", "true");
|
||||||
|
localStorage.setItem("__bitvidTestMode__", "1");
|
||||||
|
localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url]));
|
||||||
|
window.__bitvidTestRelays__ = [url];
|
||||||
|
const w = (n) => (...a) => window[n](...a);
|
||||||
|
window.nostr = {
|
||||||
|
getPublicKey: () => w("__extGetPublicKey")(),
|
||||||
|
signEvent: (e) => w("__extSignEvent")(e),
|
||||||
|
getRelays: async () => ({}),
|
||||||
|
enable: () => w("__extEnable")(),
|
||||||
|
nip04: { encrypt: (p, t) => w("__extNip04Encrypt")(p, t), decrypt: (p, c) => w("__extNip04Decrypt")(p, c) },
|
||||||
|
nip44: {
|
||||||
|
encrypt: (p, t) => w("__extNip44Encrypt")(p, t),
|
||||||
|
decrypt: (p, c) => w("__extNip44Decrypt")(p, c),
|
||||||
|
v2: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, relayUrl);
|
||||||
|
|
||||||
|
const testUrl = `${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`;
|
||||||
|
|
||||||
|
// --- console signal capture ---
|
||||||
|
const signals = {
|
||||||
|
signerReadyAt: null,
|
||||||
|
listsSyncCompleteAt: null,
|
||||||
|
decryptTimeouts: 0,
|
||||||
|
breakerOpened: 0,
|
||||||
|
breakerRecovered: 0,
|
||||||
|
channelUnresponsive: 0,
|
||||||
|
};
|
||||||
|
let loginAt = 0;
|
||||||
|
const onConsole = (text) => {
|
||||||
|
if (text.includes("[signer-ready]") && signals.signerReadyAt === null) {
|
||||||
|
signals.signerReadyAt = Date.now() - loginAt;
|
||||||
|
}
|
||||||
|
if (text.includes("[lists-sync-complete]") && signals.listsSyncCompleteAt === null) {
|
||||||
|
signals.listsSyncCompleteAt = Date.now() - loginAt;
|
||||||
|
}
|
||||||
|
if (text.includes("Decryption timed out") || text.includes("keeping stale list")) {
|
||||||
|
signals.decryptTimeouts += 1;
|
||||||
|
}
|
||||||
|
if (text.includes("signer channel unresponsive")) signals.breakerOpened += 1;
|
||||||
|
if (text.includes("signer channel recovered")) signals.breakerRecovered += 1;
|
||||||
|
if (text.includes("nip07-channel-unresponsive")) signals.channelUnresponsive += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEBUG = process.env.DEBUG === "1";
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
try {
|
||||||
|
const t = msg.text();
|
||||||
|
onConsole(t);
|
||||||
|
if (DEBUG && /UserBlockList|Decryption|scheduleDecrypt|stale|channel|signer/i.test(t)) {
|
||||||
|
console.log("PAGE>", t.slice(0, 200));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(testUrl, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {});
|
||||||
|
await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {});
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const c = window.__bitvidTest__?.nostrClient;
|
||||||
|
if (c) c.videoEventVerifier = async (events) => new Set((events || []).map((e) => e && e.id).filter(Boolean));
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Trigger the REAL nip-07 login path.
|
||||||
|
loginAt = Date.now();
|
||||||
|
try {
|
||||||
|
await page.click('[data-testid="login-button"]', { timeout: 8000 });
|
||||||
|
await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 });
|
||||||
|
const extBtn = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first();
|
||||||
|
if (await extBtn.count()) await extBtn.click({ timeout: 5000 });
|
||||||
|
else await page.locator('[data-testid="login-provider-button"]').first().click({ timeout: 5000 });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("login click failed:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
async () => (await window.__bitvidTest__?.getAppState?.())?.isLoggedIn === true,
|
||||||
|
{ timeout: 20000 },
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
// Observe the post-login window.
|
||||||
|
await page.waitForTimeout(OBSERVE_MS);
|
||||||
|
|
||||||
|
// Did the block list actually decrypt? (the seeded blocked pubkey appears)
|
||||||
|
const blockedLoaded = await page.evaluate(async (expected) => {
|
||||||
|
const state = await window.__bitvidTest__?.getAppState?.();
|
||||||
|
const blocked = state?.lists?.blockedPubkeys || [];
|
||||||
|
return Array.isArray(blocked) && blocked.includes(expected);
|
||||||
|
}, expectedBlocked).catch(() => false);
|
||||||
|
|
||||||
|
const verdict = {
|
||||||
|
mode: MODE,
|
||||||
|
channel,
|
||||||
|
timeToSignerReadyMs: signals.signerReadyAt,
|
||||||
|
timeToListsSyncMs: signals.listsSyncCompleteAt,
|
||||||
|
decryptTimeouts: signals.decryptTimeouts,
|
||||||
|
breakerOpened: signals.breakerOpened,
|
||||||
|
breakerRecovered: signals.breakerRecovered,
|
||||||
|
fastFailsObserved: signals.channelUnresponsive,
|
||||||
|
channelDropsInjected: drops,
|
||||||
|
blockListDecrypted: blockedLoaded,
|
||||||
|
extCalls,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("\n================ NIP-07 CHANNEL SIM ================");
|
||||||
|
console.log(`MODE=${MODE} latency=${channel.latency}ms maxInflight=${channel.maxInflight} dropCooldown=${channel.dropCooldown}ms`);
|
||||||
|
console.log(`time → signer-ready : ${verdict.timeToSignerReadyMs ?? "NEVER"} ms`);
|
||||||
|
console.log(`time → lists-sync done : ${verdict.timeToListsSyncMs ?? "NEVER"} ms`);
|
||||||
|
console.log(`decrypt timeouts logged: ${verdict.decryptTimeouts}`);
|
||||||
|
console.log(`channel drops injected : ${verdict.channelDropsInjected}`);
|
||||||
|
console.log(`breaker opened / recov : ${verdict.breakerOpened} / ${verdict.breakerRecovered}`);
|
||||||
|
console.log(`fast-fails observed : ${verdict.fastFailsObserved}`);
|
||||||
|
console.log(`BLOCK LIST DECRYPTED : ${verdict.blockListDecrypted ? "YES ✓" : "NO ✗"}`);
|
||||||
|
console.log("ext calls:", JSON.stringify(extCalls));
|
||||||
|
console.log("===================================================\n");
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
await browser.close();
|
||||||
|
if (typeof relay.close === "function") await relay.close();
|
||||||
|
// Exit non-zero for the "should have worked but didn't" cases so this can gate CI later.
|
||||||
|
const expectedToLoad = MODE === "healthy" || MODE === "overload";
|
||||||
|
process.exit(expectedToLoad && !verdict.blockListDecrypted ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("nip07-channel-sim failed:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user