Verify publication, exact fanout, filter unions, counts, lifecycle behavior and authentication with local identities. Exercise malformed and oversized messages, reconnects and slow readers, and record counts and acknowledgment latency without signing secrets. Document setup and protocol fuzz commands.
- commit
- e8d32f37a63d7a5388d2d0a4bc2d170bbdf13d58
- parent
- b174a740aa0487bcdf8a899e2514959683a20741
- author
- Dami <dami.etoile@gmail.com>
| A | scripts/ | 49 | ++ |
| A | scripts/ | 811 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,49 @@
1+# Relay stress checks2+3+Run these checks against disposable combined or standalone relays. They publish signed events and retain them in the target database. Use a separate data directory and locally generated test identities.4+5+Generate 20 identities without printing their secrets:6+7+```sh8+mkdir -p artifacts/relay-stress9+node --input-type=module <<'JS'10+import { writeFileSync } from "node:fs";11+import { generateSecretKey, getPublicKey } from "nostr-tools/pure";12+const actors = Array.from({ length: 20 }, (_, i) => {13+ const key = generateSecretKey();14+ return { name: `actor-${i}`, secret: Buffer.from(key).toString("hex"), pubkey: getPublicKey(key) };15+});16+writeFileSync("artifacts/relay-stress/identities.json", JSON.stringify({ actors }), { mode: 0o600, flag: "wx" });17+JS18+```19+20+Start the relay with a 1 MiB message limit and a 4 MiB pending-output limit, then run:21+22+```sh23+node scripts/qa/relay-stress.mjs \24+ --relay ws://127.0.0.1:7447 \25+ --identities artifacts/relay-stress/identities.json \26+ --out artifacts/relay-stress/result.json \27+ --actors 16 --events 200 --seed 1 --slow-reader28+```29+30+Add `--auth-required` when the standalone relay requires authentication. This also checks anonymous reads and writes, event author binding, incorrect relay and challenge proofs, multiple authenticated keys, and proof reuse across connections. A combined tenant with members-only reads needs a separate membership test; the stress actors are not automatically enrolled.31+32+The harness checks concurrent publication, exact fanout to four subscribers, duplicate suppression, historical ordering, overlapping filter limits, unique counts, replacement, deletion, protected events, invalid signatures, malformed input recovery and reconnects. Payloads range from empty to about 8 KiB. The slow-reader phase adds 192 unique events of about 64 KiB and checks that a healthy client remains responsive while the stalled client disconnects or catches up after resuming.33+34+The JSON report contains public actor identities, counts, failures and publish acknowledgment latency percentiles. It excludes signing secrets. Latencies measure each send through its acknowledgment, excluding the actor's local send queue. They reflect the test machine, relay and network conditions; this is a correctness stress check, not a capacity benchmark. Repeat with different seeds to vary signed payloads.35+36+Keep the identity file on the test client when using a remote relay. An SSH tunnel can connect the client to an isolated Docker instance without exposing a public port. Remove the temporary instance after collecting its logs and resource measurements.37+38+## Protocol fuzzing39+40+Go fuzz targets cover event and filter parsing, NIP-98 and Blossom proof parsing and request binding, and NIP-42 connection boundaries. For example:41+42+```sh43+go test ./protocol/nostr -run '^$' -fuzz '^FuzzParseFilter$' -fuzztime=60s -parallel=244+go test ./protocol/auth -run '^$' -fuzz '^FuzzNIP98Binding$' -fuzztime=60s -parallel=245+go test ./protocol/auth -run '^$' -fuzz '^FuzzBlossomBinding$' -fuzztime=60s -parallel=246+go test ./tinyrelay -run '^$' -fuzz '^FuzzStandaloneAuthTagParsing$' -fuzztime=60s -parallel=247+```48+49+These targets use local keys and temporary storage. Run the [conformance suite](../conformance/README.md) separately for HTTP, MCP, Git, Blossom and cross-relay integration coverage.@@ -0,0 +1,811 @@
1+2+import fs from "node:fs/promises";3+import { performance } from "node:perf_hooks";4+import process from "node:process";5+import WebSocket from "ws";6+import { finalizeEvent, getPublicKey } from "nostr-tools/pure";7+8+const TIMEOUT = 10000,9+ FRAME_MAX = 1024 * 1024,10+ PAYLOAD_MAX = 8 * 1024;11+const usage = () => {12+ console.error(13+ "usage: node scripts/qa/relay-stress.mjs --relay URL --identities FILE [--out FILE] [--auth-required] [--actors N] [--events N] [--seed N] [--slow-reader]",14+ );15+ process.exitCode = 2;16+};17+function parse(a) {18+ const o = {19+ actors: 16,20+ events: 100,21+ seed: 1,22+ authRequired: false,23+ slowReader: false,24+ };25+ const keys = {26+ "--relay": "relay",27+ "--identities": "identities",28+ "--out": "out",29+ "--actors": "actors",30+ "--events": "events",31+ "--seed": "seed",32+ };33+ for (let i = 2; i < a.length; i++) {34+ if (a[i] === "--auth-required") o.authRequired = true;35+ else if (a[i] === "--slow-reader") o.slowReader = true;36+ else if (keys[a[i]]) o[keys[a[i]]] = a[++i];37+ else throw Error(`unknown or incomplete option ${a[i]}`);38+ }39+ if (!o.relay || !o.identities)40+ throw Error("--relay and --identities are required");41+ for (const k of ["actors", "events", "seed"]) o[k] = Number(o[k]);42+ if (43+ !Number.isInteger(o.actors) ||44+ o.actors < 2 ||45+ !Number.isInteger(o.events) ||46+ o.events < 147+ )48+ throw Error(49+ "actors must be at least 2 and events must be positive integers",50+ );51+ return o;52+}53+const wsURL = (u) => u.replace(/^http:/, "ws:").replace(/^https:/, "wss:");54+const httpURL = (u) => u.replace(/^ws:/, "http:").replace(/^wss:/, "https:");55+const bytes = (hex) =>56+ Uint8Array.from(hex.match(/../g).map((x) => parseInt(x, 16)));57+const sign = (secret, e) =>58+ finalizeEvent(59+ { created_at: Math.floor(Date.now() / 1000), ...e },60+ bytes(secret),61+ );62+function prng(seed) {63+ let x = seed >>> 0;64+ return () => ((x = Math.imul(x, 1664525) + 1013904223) >>> 0) / 2 ** 32;65+}66+class Client {67+ constructor(url, id, relay, authRequired = false) {68+ this.url = url;69+ this.id = id;70+ this.relay = relay;71+ this.q = [];72+ this.waiters = [];73+ this.events = new Map();74+ this.closed = false;75+ this.sendTail = Promise.resolve();76+ this.authRequired = authRequired;77+ }78+ receive(raw) {79+ let m;80+ try {81+ m = JSON.parse(raw.toString());82+ } catch {83+ m = ["MALFORMED"];84+ }85+ if (m[0] === "EVENT" && m[2]?.id)86+ this.events.set(m[2].id, (this.events.get(m[2].id) || 0) + 1);87+ const w = this.waiters.shift();88+ w ? w._resolve(m) : this.q.push(m);89+ }90+ next(ms = TIMEOUT) {91+ if (this.q.length) return Promise.resolve(this.q.shift());92+ const w = {};93+ this.waiters.push(w);94+ return new Promise((resolve, reject) => {95+ w._timer = setTimeout(() => {96+ const i = this.waiters.indexOf(w);97+ if (i >= 0) this.waiters.splice(i, 1);98+ reject(Error(`timeout after ${ms}ms`));99+ }, ms);100+ w._resolve = (value) => {101+ clearTimeout(w._timer);102+ resolve(value);103+ };104+ }).catch((e) => {105+ const i = this.waiters.indexOf(w);106+ if (i >= 0) this.waiters.splice(i, 1);107+ throw e;108+ });109+ }110+ send(raw) {111+ const s = typeof raw === "string" ? raw : JSON.stringify(raw);112+ if (Buffer.byteLength(s) > FRAME_MAX) throw Error("frame exceeds 1MiB");113+ this.ws.send(s);114+ }115+ serial(raw, predicate) {116+ const op = this.sendTail.then(async () => {117+ this.send(raw);118+ return this.until(predicate);119+ });120+ this.sendTail = op.catch(() => {});121+ return op;122+ }123+ serialTimed(raw, predicate) {124+ const op = this.sendTail.then(async () => {125+ const sentAt = performance.now();126+ this.send(raw);127+ const message = await this.until(predicate);128+ return { message, elapsed: performance.now() - sentAt };129+ });130+ this.sendTail = op.catch(() => {});131+ return op;132+ }133+ async until(predicate, ms = TIMEOUT) {134+ const deadline = performance.now() + ms;135+ while (performance.now() < deadline) {136+ const m = await this.next(Math.max(1, deadline - performance.now()));137+ if (predicate(m)) return m;138+ if (this.closed)139+ throw Error("connection closed before expected response");140+ }141+ throw Error("response deadline exceeded");142+ }143+ async open(authenticate = true) {144+ this.ws = new WebSocket(this.url, { handshakeTimeout: 5000 });145+ this.ws.on("message", (d) => this.receive(d));146+ this.ws.on("close", () => {147+ this.closed = true;148+ for (const w of this.waiters.splice(0)) w._resolve(["CLOSED"]);149+ });150+ await new Promise((res, rej) => {151+ this.ws.once("open", res);152+ this.ws.once("error", rej);153+ });154+ const m = await this.next(5000);155+ if (m?.[0] === "AUTH") {156+ this.challenge = m[1];157+ if (!authenticate) return this;158+ const authEvent = sign(this.id.secret, {159+ kind: 22242,160+ tags: [161+ ["relay", this.relay],162+ ["challenge", m[1]],163+ ],164+ content: "",165+ });166+ const ack = await this.serial(167+ ["AUTH", authEvent],168+ (x) => x[0] === "OK" && x[1] === authEvent.id,169+ );170+ if (ack[2] !== true) throw Error(`AUTH rejected: ${ack[3] || "unknown"}`);171+ } else throw Error("relay sent no AUTH challenge");172+ return this;173+ }174+ async subscribe(id, filter) {175+ this.send(["REQ", id, ...(Array.isArray(filter) ? filter : [filter])]);176+ await this.until((m) => m[0] === "EOSE" && m[1] === id);177+ }178+ async query(filter) {179+ const id = `q-${Math.random().toString(36).slice(2)}`,180+ got = [];181+ this.send(["REQ", id, ...(Array.isArray(filter) ? filter : [filter])]);182+ while (true) {183+ const m = await this.next();184+ if (m[0] === "CLOSED")185+ throw Error(`query rejected: ${m[2] || "connection closed"}`);186+ if (m[0] === "EVENT" && m[1] === id) got.push(m[2]);187+ if (m[0] === "EOSE" && m[1] === id) {188+ this.send(["CLOSE", id]);189+ return got;190+ }191+ }192+ }193+ async close() {194+ if (!this.ws || this.ws.readyState === WebSocket.CLOSED) return;195+ this.ws._socket?.resume();196+ await new Promise((resolve) => {197+ const timer = setTimeout(() => {198+ this.ws.terminate();199+ resolve();200+ }, 1000);201+ this.ws.once("close", () => {202+ clearTimeout(timer);203+ resolve();204+ });205+ this.ws.close();206+ });207+ }208+}209+210+function identity(x, i) {211+ if (!x?.name || !/^[0-9a-f]{64}$/.test(x.secret))212+ throw Error(`invalid identity ${i}`);213+ const pubkey = getPublicKey(bytes(x.secret));214+ if (x.pubkey && x.pubkey !== pubkey)215+ throw Error(`pubkey mismatch for ${x.name}`);216+ return { name: x.name, secret: x.secret, pubkey };217+}218+async function main() {219+ const o = parse(process.argv),220+ source = JSON.parse(await fs.readFile(o.identities, "utf8")),221+ ids = (source.actors || []).slice(0, o.actors).map(identity);222+ if (ids.length !== o.actors)223+ throw Error("identity file contains too few actors");224+ const run = `stress-${Date.now().toString(36)}-${o.seed}`,225+ out = {226+ relay: o.relay,227+ run,228+ authRequired: o.authRequired,229+ actors: ids.map((x) => ({ name: x.name, pubkey: x.pubkey })),230+ counts: {},231+ checks: 0,232+ latencyMs: {},233+ failures: [],234+ startedAt: new Date().toISOString(),235+ },236+ clients = [],237+ samples = [],238+ started = performance.now();239+ const fail = (stage, e) =>240+ out.failures.push({241+ stage,242+ error: String(e.message || e).replaceAll(243+ /secret|private/gi,244+ "[redacted]",245+ ),246+ });247+ const check = (stage, ok, detail) => {248+ out.checks++;249+ if (!ok) fail(stage, Error(detail));250+ };251+ const timed = async (stage, fn) => {252+ const t = performance.now();253+ try {254+ const v = await fn();255+ const n = performance.now() - t;256+ out.latencyMs[stage] = n;257+ return v;258+ } catch (e) {259+ fail(stage, e);260+ return null;261+ }262+ };263+ try {264+ const h = await fetch(`${httpURL(o.relay)}/healthz`);265+ check("health", h.ok, `health ${h.status}`);266+ out.counts.health = h.ok ? 1 : 0;267+ const actors = await Promise.all(268+ ids.map(async (id) => {269+ const c = new Client(wsURL(o.relay), id, o.relay, o.authRequired);270+ clients.push(c);271+ return c.open();272+ }),273+ );274+ const observers = await Promise.all(275+ [0, 1, 2, 3].map(async (n) => {276+ const c = new Client(277+ wsURL(o.relay),278+ ids[n % ids.length],279+ o.relay,280+ o.authRequired,281+ );282+ clients.push(c);283+ await c.open();284+ await c.subscribe(`fan-${n}`, { kinds: [1], "#t": [run] });285+ return c;286+ }),287+ );288+ const random = prng(o.seed),289+ events = [];290+ for (let ai = 0; ai < ids.length; ai++)291+ for (let n = 0; n < o.events; n++) {292+ const sizes = [0, 64, 512, 2048, 7900];293+ const content =294+ "x".repeat(sizes[n % sizes.length]) +295+ `${run}:${ai}:${n}:${Math.floor(random() * 1e9)}`;296+ if (Buffer.byteLength(content) > PAYLOAD_MAX)297+ throw Error("payload exceeds 8KiB");298+ events.push({299+ actor: ai,300+ event: sign(ids[ai].secret, {301+ kind: 1,302+ tags: [303+ ["t", run],304+ ["batch", String(o.seed)],305+ ],306+ content,307+ }),308+ });309+ }310+ let confirmed = 0;311+ await timed("concurrentPublish", () =>312+ Promise.all(313+ events.map(({ actor, event }) => {314+ return actors[actor]315+ .serialTimed(316+ ["EVENT", event],317+ (m) => m[0] === "OK" && m[1] === event.id,318+ )319+ .then(({ message: m, elapsed }) => {320+ samples.push(elapsed);321+ check(322+ "publishAck",323+ m[2] === true,324+ `${m[3] || "publish rejected"}`,325+ );326+ if (m[2] === true) confirmed++;327+ });328+ }),329+ ),330+ );331+ out.counts.published = confirmed;332+ check(333+ "publishCount",334+ confirmed === events.length,335+ `confirmed ${confirmed}/${events.length}`,336+ );337+ const expectedIDs = new Set(events.map((e) => e.event.id));338+ await timed("fanoutDrain", async () => {339+ await Promise.all(340+ observers.map(async (c) => {341+ const complete = () =>342+ [...c.events.keys()].filter((id) => expectedIDs.has(id)).length ===343+ events.length;344+ if (!complete()) await c.until(complete, 20000);345+ }),346+ );347+ });348+ out.counts.observerDisconnected = observers.map((c) => c.closed);349+ out.counts.observerFanout = observers.map((c) =>350+ events.reduce((n, e) => n + (c.events.get(e.event.id) || 0), 0),351+ );352+ check(353+ "exactFanout",354+ out.counts.observerFanout.every((n) => n === events.length),355+ JSON.stringify(out.counts.observerFanout),356+ );357+ const dup = events[0];358+ const dm = await actors[dup.actor].serial(359+ ["EVENT", dup.event],360+ (m) => m[0] === "OK" && m[1] === dup.event.id,361+ );362+ check("duplicateAck", dm[2] === true, "duplicate not acknowledged");363+ await Promise.all(364+ observers.map(async (c, n) => {365+ const id = `barrier-${n}`;366+ await c.subscribe(id, { ids: [] });367+ c.send(["CLOSE", id]);368+ }),369+ );370+ check(371+ "duplicateFanout",372+ observers.every((c) => c.events.get(dup.event.id) === 1),373+ "duplicate was fanned out",374+ );375+ out.counts.duplicate = dm[2] === true ? 1 : 0;376+ const own = await timed("historicalQuery", () =>377+ actors[0].query({378+ kinds: [1],379+ authors: [ids[0].pubkey],380+ "#t": [run],381+ limit: 10,382+ }),383+ );384+ check(385+ "ownQuery",386+ own?.length === Math.min(10, o.events) &&387+ own.every(388+ (e) =>389+ e.pubkey === ids[0].pubkey &&390+ e.tags.some((t) => t[0] === "t" && t[1] === run),391+ ),392+ "query leaked another run or author",393+ );394+ out.counts.historical = own?.length || 0;395+ const firstFilter = {396+ kinds: [1],397+ authors: [ids[0].pubkey],398+ "#t": [run],399+ limit: 3,400+ };401+ const overlapFilter = {402+ kinds: [1],403+ authors: ids.slice(0, 2).map((x) => x.pubkey),404+ "#t": [run],405+ limit: 5,406+ };407+ const union = await actors[0].query([firstFilter, overlapFilter]);408+ const newest = (a, b) =>409+ b.event.created_at - a.event.created_at ||410+ a.event.id.localeCompare(b.event.id);411+ const firstIDs = events412+ .filter((x) => x.actor === 0)413+ .sort(newest)414+ .slice(0, 3)415+ .map((x) => x.event.id);416+ const overlapIDs = events417+ .filter((x) => x.actor < 2)418+ .sort(newest)419+ .slice(0, 5)420+ .map((x) => x.event.id);421+ const expectedUnion = new Set([...firstIDs, ...overlapIDs]);422+ check(423+ "unionLimit",424+ union.length === expectedUnion.size &&425+ union.every((e) => expectedUnion.has(e.id)),426+ "overlapping filter union mismatch",427+ );428+ const countFilters = [429+ { kinds: [1], authors: [ids[0].pubkey], "#t": [run], limit: 1 },430+ {431+ kinds: [1],432+ authors: [ids[0].pubkey, ids[1].pubkey],433+ "#t": [run],434+ limit: 1,435+ },436+ ];437+ actors[0].send(["COUNT", "count", ...countFilters]);438+ const cm = await actors[0].until(439+ (m) => m[0] === "COUNT" && m[1] === "count",440+ );441+ check(442+ "countOracle",443+ cm[2]?.count === ids.slice(0, 2).length * o.events,444+ `count ${cm[2]?.count}`,445+ );446+ out.counts.union = union.length;447+ out.counts.count = cm[2]?.count;448+ const a = sign(ids[0].secret, {449+ kind: 30023,450+ tags: [["d", `${run}-address`]],451+ content: "one",452+ }),453+ b = sign(ids[0].secret, {454+ kind: 30023,455+ tags: [["d", `${run}-address`]],456+ content: "two",457+ created_at: a.created_at + 1,458+ });459+ for (const e of [a, b]) {460+ const m = await actors[0].serial(461+ ["EVENT", e],462+ (x) => x[0] === "OK" && x[1] === e.id,463+ );464+ check("addressableAck", m[2] === true, "addressable rejected");465+ }466+ const aq = await actors[0].query({467+ kinds: [30023],468+ authors: [ids[0].pubkey],469+ "#d": [`${run}-address`],470+ });471+ check(472+ "replacement",473+ aq.length === 1 && aq[0].id === b.id,474+ "replacement state wrong",475+ );476+ const d = sign(ids[0].secret, {477+ kind: 5,478+ tags: [["e", b.id]],479+ content: "",480+ });481+ const dm2 = await actors[0].serial(482+ ["EVENT", d],483+ (x) => x[0] === "OK" && x[1] === d.id,484+ );485+ check("deleteAck", dm2[2] === true, "delete rejected");486+ const gone = await actors[0].query({ ids: [b.id] });487+ check("deletion", gone.length === 0, "deleted event remained visible");488+ out.counts.replaceDelete = gone.length === 0 ? 1 : 0;489+ const protectedEvent = sign(ids[0].secret, {490+ kind: 1,491+ tags: [["-"], ["t", run]],492+ content: "protected",493+ }),494+ pm = await actors[1 % actors.length].serial(495+ ["EVENT", protectedEvent],496+ (x) => x[0] === "OK" && x[1] === protectedEvent.id,497+ );498+ check(499+ "protectedWrongAuthor",500+ pm[2] === false,501+ "protected event accepted under wrong author",502+ );503+ const protectedAck = await actors[0].serial(504+ ["EVENT", protectedEvent],505+ (x) => x[0] === "OK" && x[1] === protectedEvent.id,506+ );507+ check(508+ "protectedOwnAuthor",509+ protectedAck[2] === true,510+ "author's protected event rejected",511+ );512+ const invalidEvent = {513+ ...events[0].event,514+ content: "tampered after signing",515+ };516+ const invalidAck = await actors[0].serial(517+ ["EVENT", invalidEvent],518+ (x) => x[0] === "OK" && x[1] === invalidEvent.id,519+ );520+ check(521+ "invalidSignature",522+ invalidAck[2] === false,523+ "tampered event accepted",524+ );525+ if (o.authRequired) {526+ const anonymous = new Client(wsURL(o.relay), ids[0], o.relay, true);527+ clients.push(anonymous);528+ await anonymous.open(false);529+ for (const kind of ["REQ", "COUNT", "NEG-OPEN"]) {530+ const id = `anonymous-${kind}`;531+ anonymous.send(532+ kind === "NEG-OPEN" ? [kind, id, {}, "61"] : [kind, id, {}],533+ );534+ const denied = await anonymous.until(535+ (x) =>536+ x[1] === id &&537+ ["EVENT", "COUNT", "CLOSED", "NEG-ERR", "NEG-MSG"].includes(x[0]),538+ );539+ check(540+ `anonymous${kind}`,541+ (denied[0] === "CLOSED" || denied[0] === "NEG-ERR") &&542+ /^auth-required:/.test(denied[2]),543+ "anonymous read was not denied",544+ );545+ }546+ const unauthenticatedEvent = sign(ids[0].secret, {547+ kind: 1,548+ tags: [],549+ content: `${run}-anonymous`,550+ });551+ const anonymousAck = await anonymous.serial(552+ ["EVENT", unauthenticatedEvent],553+ (x) => x[0] === "OK" && x[1] === unauthenticatedEvent.id,554+ );555+ check(556+ "anonymousWrite",557+ anonymousAck[2] === false,558+ "unauthenticated write accepted",559+ );560+ const foreignAck = await actors[1].serial(561+ ["EVENT", unauthenticatedEvent],562+ (x) => x[0] === "OK" && x[1] === unauthenticatedEvent.id,563+ );564+ check(565+ "foreignAuthorWrite",566+ foreignAck[2] === false,567+ "another authenticated key published the author's event",568+ );569+ const wrong = sign(ids[0].secret, {570+ kind: 22242,571+ tags: [572+ ["relay", `${o.relay}-wrong`],573+ ["challenge", actors[0].challenge],574+ ],575+ content: "",576+ }),577+ wm = await actors[0].serial(578+ ["AUTH", wrong],579+ (x) => x[0] === "OK" && x[1] === wrong.id,580+ );581+ check("wrongRelay", wm[2] === false, "wrong relay AUTH accepted");582+ const wrongChallenge = sign(ids[0].secret, {583+ kind: 22242,584+ tags: [585+ ["relay", o.relay],586+ ["challenge", "wrong-challenge"],587+ ],588+ content: "",589+ }),590+ wcm = await actors[0].serial(591+ ["AUTH", wrongChallenge],592+ (x) => x[0] === "OK" && x[1] === wrongChallenge.id,593+ );594+ check(595+ "wrongChallenge",596+ wcm[2] === false,597+ "wrong challenge AUTH accepted",598+ );599+ const multi = sign(ids[1 % ids.length].secret, {600+ kind: 22242,601+ tags: [602+ ["relay", o.relay],603+ ["challenge", actors[0].challenge],604+ ],605+ content: "",606+ }),607+ mm = await actors[0].serial(608+ ["AUTH", multi],609+ (x) => x[0] === "OK" && x[1] === multi.id,610+ );611+ check(612+ "sameConnectionMultikey",613+ mm[2] === true,614+ "valid second key rejected",615+ );616+ if (actors[1 % actors.length].challenge) {617+ const reuse = sign(ids[0].secret, {618+ kind: 22242,619+ tags: [620+ ["relay", o.relay],621+ ["challenge", actors[1 % actors.length].challenge],622+ ],623+ content: "",624+ }),625+ rm = await actors[0].serial(626+ ["AUTH", reuse],627+ (x) => x[0] === "OK" && x[1] === reuse.id,628+ );629+ check(630+ "crossConnectionChallenge",631+ rm[2] === false,632+ "other connection challenge accepted",633+ );634+ }635+ }636+ await timed("malformedRecovery", async () => {637+ const mut = prng(o.seed ^ 0xa5a5);638+ const cases = [639+ "not-json",640+ "[]",641+ JSON.stringify(["REQ"]),642+ '{"broken":',643+ "null",644+ ];645+ for (let i = 0; i < 16; i++) {646+ const n = Math.floor(mut() * cases.length);647+ try {648+ actors[0].send(cases[n]);649+ } catch {}650+ }651+ const r = await fetch(`${httpURL(o.relay)}/healthz`);652+ check("healthRecovery", r.ok, `health ${r.status}`);653+ for (const filter of [null, { since: null }, { kinds: [null] }]) {654+ const id = `invalid-filter-${Math.random()}`;655+ actors[0].send(["REQ", id, filter]);656+ const response = await actors[0].until((x) => x[1] === id);657+ check(658+ "invalidFilter",659+ response[0] === "CLOSED",660+ "malformed filter opened a subscription",661+ );662+ }663+ const recovered = await actors[0].query({ ids: [events[0].event.id] });664+ check(665+ "validAfterMalformed",666+ recovered.length === 1,667+ "valid query failed after malformed frames",668+ );669+ });670+ await timed("reconnect", async () => {671+ await actors[0].close();672+ const c = await new Client(673+ wsURL(o.relay),674+ ids[0],675+ o.relay,676+ o.authRequired,677+ ).open();678+ clients.push(c);679+ check(680+ "reconnectQuery",681+ (682+ await c.query({683+ kinds: [1],684+ authors: [ids[0].pubkey],685+ "#t": [run],686+ limit: 1,687+ })688+ ).length > 0,689+ "empty reconnect query",690+ );691+ });692+ await timed("messageLimit", async () => {693+ const oversized = new Client(694+ wsURL(o.relay),695+ ids[0],696+ o.relay,697+ o.authRequired,698+ );699+ clients.push(oversized);700+ await oversized.open();701+ oversized.ws.send("x".repeat(FRAME_MAX + 1));702+ if (!oversized.closed) await oversized.until(() => oversized.closed);703+ check(704+ "oversizedMessage",705+ oversized.closed,706+ "relay accepted a message beyond its configured 1 MiB limit",707+ );708+ const healthy = await actors[1].query({ ids: [events[0].event.id] });709+ check(710+ "healthyAfterOversize",711+ healthy.length === 1,712+ "oversized input disrupted another client",713+ );714+ });715+ if (o.slowReader)716+ await timed("slowReader", async () => {717+ const slow = await new Client(718+ wsURL(o.relay),719+ ids[0],720+ o.relay,721+ o.authRequired,722+ ).open();723+ clients.push(slow);724+ await slow.subscribe("slow", { kinds: [1], "#t": [`${run}-slow`] });725+ const publisher = await new Client(726+ wsURL(o.relay),727+ ids[0],728+ o.relay,729+ o.authRequired,730+ ).open();731+ clients.push(publisher);732+ const slowIDs = new Set();733+ slow.ws.pause();734+ let acknowledged = 0;735+ for (let i = 0; i < 192; i++) {736+ const e = sign(ids[0].secret, {737+ kind: 1,738+ tags: [["t", `${run}-slow`]],739+ content: `${run}-slow-${i}-` + "x".repeat(64 * 1024),740+ });741+ slowIDs.add(e.id);742+ const p = publisher.serialTimed(743+ ["EVENT", e],744+ (m) => m[0] === "OK" && m[1] === e.id,745+ );746+ const result = await p.catch(() => null);747+ const ack = result?.message;748+ if (result) samples.push(result.elapsed);749+ if (ack?.[2] === true) acknowledged++;750+ }751+ const r = await fetch(`${httpURL(o.relay)}/healthz`);752+ check("slowHealth", r.ok, `health ${r.status}`);753+ check(754+ "slowPublishAcks",755+ acknowledged === 192 && slowIDs.size === 192,756+ `acknowledged ${acknowledged}/192`,757+ );758+ const healthy = await publisher.query({759+ ids: [Array.from(slowIDs)[0]],760+ });761+ check(762+ "slowHealthyQuery",763+ healthy.length === 1,764+ "healthy reader stalled",765+ );766+ slow.ws.resume();767+ if (768+ !slow.closed &&769+ !Array.from(slowIDs).every((id) => slow.events.has(id))770+ ) {771+ await slow.until(772+ () =>773+ slow.closed ||774+ Array.from(slowIDs).every((id) => slow.events.has(id)),775+ );776+ }777+ check(778+ "slowReaderRecovery",779+ slow.closed ||780+ Array.from(slowIDs).every((id) => slow.events.get(id) === 1),781+ "slow reader neither disconnected nor caught up after resume",782+ );783+ out.counts.slowPublications = acknowledged;784+ out.counts.slowReaderDisconnected = slow.closed;785+ });786+ } catch (e) {787+ fail("fatal", e);788+ } finally {789+ await Promise.allSettled(clients.map((c) => c.close()));790+ out.elapsedMs = performance.now() - started;791+ const v = samples.sort((a, b) => a - b);792+ const p = (n) =>793+ v.length ? v[Math.min(v.length - 1, Math.ceil(v.length * n) - 1)] : null;794+ out.latencyMs.p50 = p(0.5);795+ out.latencyMs.p95 = p(0.95);796+ out.latencyMs.p99 = p(0.99);797+ out.finishedAt = new Date().toISOString();798+ }799+ if (o.out)800+ await fs.writeFile(o.out, JSON.stringify(out, null, 2) + "\n", {801+ mode: 0o600,802+ });803+ console.log(804+ JSON.stringify({ ...out, actors: out.actors.map((x) => x.name) }, null, 2),805+ );806+ if (out.failures.length) process.exitCode = 1;807+}808+main().catch((e) => {809+ console.error(e.message);810+ usage();811+});