mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +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 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) {
|
||||
const normalizedAttempt =
|
||||
Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
||||
@@ -907,7 +945,9 @@ class HashtagPreferencesService {
|
||||
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, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate,
|
||||
|
||||
+38
-1
@@ -60,6 +60,41 @@ const DECRYPT_TIMEOUT_MS = 15000;
|
||||
const DECRYPT_RETRY_DELAY_MS = 1500;
|
||||
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) {
|
||||
const normalizedAttempt =
|
||||
Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
||||
@@ -690,7 +725,9 @@ class SubscriptionsManager {
|
||||
if (!decryptResult.ok) {
|
||||
this.lastLoadError = decryptResult.error || null;
|
||||
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, {
|
||||
allowPermissionPrompt,
|
||||
signerReadinessGate,
|
||||
|
||||
@@ -472,6 +472,15 @@ function getAppState() {
|
||||
}
|
||||
: null,
|
||||
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.
|
||||
const DECRYPT_RETRY_DELAY_MS = 3000;
|
||||
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) {
|
||||
const normalizedAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
||||
const multiplier = 2 ** normalizedAttempt;
|
||||
@@ -1453,8 +1482,29 @@ class UserBlockListManager {
|
||||
decryptedText = result.plaintext;
|
||||
setLastSuccessfulScheme(normalized, result.scheme);
|
||||
}
|
||||
} catch {
|
||||
// All schemes failed — decryptedText stays empty
|
||||
} catch (aggregateError) {
|
||||
// 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" });
|
||||
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 recoveredPrivateBlocks = new Set(previousState.privateBlocks);
|
||||
const recoveredPublicMutes = new Set([
|
||||
@@ -1566,14 +1619,14 @@ class UserBlockListManager {
|
||||
Number.isFinite(newestCreatedAt) ? newestCreatedAt : previousState.blockEventCreatedAt;
|
||||
applySets(recoveredPrivateBlocks, recoveredPublicMutes, {
|
||||
source,
|
||||
reason: "decrypt-timeout-partial",
|
||||
reason: `${reason}-partial`,
|
||||
events: [newestStandard?.id, newestLegacy?.id].filter(Boolean),
|
||||
});
|
||||
this.loaded = true;
|
||||
|
||||
emitStatus({
|
||||
status: "stale",
|
||||
reason: "decrypt-timeout",
|
||||
reason,
|
||||
signerStatus,
|
||||
source,
|
||||
blockedPubkeys: Array.from(this.blockedPubkeys),
|
||||
@@ -1581,9 +1634,12 @@ class UserBlockListManager {
|
||||
emitStatus({ status: "settled" });
|
||||
|
||||
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,
|
||||
reason,
|
||||
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