clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / scripts / qa / relay-stress.mjs
relay-stress.mjs 24.2K
1#!/usr/bin/env node2import fs from "node:fs/promises";3import { performance } from "node:perf_hooks";4import process from "node:process";5import WebSocket from "ws";6import { finalizeEvent, getPublicKey } from "nostr-tools/pure";78const TIMEOUT = 10000,9 FRAME_MAX = 1024 * 1024,10 PAYLOAD_MAX = 8 * 1024;11const 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};17function 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}53const wsURL = (u) => u.replace(/^http:/, "ws:").replace(/^https:/, "wss:");54const httpURL = (u) => u.replace(/^ws:/, "http:").replace(/^wss:/, "https:");55const bytes = (hex) =>56 Uint8Array.from(hex.match(/../g).map((x) => parseInt(x, 16)));57const sign = (secret, e) =>58 finalizeEvent(59 { created_at: Math.floor(Date.now() / 1000), ...e },60 bytes(secret),61 );62function prng(seed) {63 let x = seed >>> 0;64 return () => ((x = Math.imul(x, 1664525) + 1013904223) >>> 0) / 2 ** 32;65}66class 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}209210function 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}218async 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}808main().catch((e) => {809 console.error(e.message);810 usage();811});