Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Benchmarks & Stress Tests

## Quick start

```bash
# All benchmarks (parser + client)
deno task bench

# Parser only
deno task bench:parser

# Stress test — sustained flood for 30s
deno task stress
```

## What is measured

### `parser.bench.ts` — Parser throughput

Measures `parseChunk()` throughput on different message types:

- **Single message**: one plain or tagged PRIVMSG per iteration
- **Batch 1000**: 1000 messages in one chunk (realistic TCP read)

### `client.bench.ts` — Full message pipeline

End-to-end: `MockConn → read → decode → parse → emit(raw:*) → listener`

- **CoreClient** (no plugins): raw protocol overhead
- **Full Client** (50+ plugins): measures plugin dispatch overhead
- Single message and 80-message batch variants

### `client.flood.ts` — Sustained flood

Sends messages continuously for 30 seconds via chained microtask sends.
Reports per-second throughput and heap usage. Detects memory leaks by checking
for monotonically growing heap over 10+ consecutive seconds.

Runs two passes: CoreClient (no plugins) then full Client (50+ plugins).

## Interpreting results

`deno bench` outputs: `avg`, `min`, `max`, `p75`, `p99`, `p995`, `p999`, and
iterations/second.

For batch benchmarks, multiply iterations/sec by the batch size (80 or 1000) to
get messages/sec.
74 changes: 74 additions & 0 deletions bench/client.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { MockClient, MockCoreClient } from "../testing/client.ts";
import { MockServer } from "../testing/server.ts";
import type { CoreFeatures } from "../core/client.ts";
import type { EventEmitter } from "../core/events.ts";
import { MSG_PLAIN, SEND_80_PLAIN } from "./fixtures.ts";

function benchBatch<TEvents extends CoreFeatures["events"]>(
client: EventEmitter<TEvents>,
server: MockServer,
): () => Promise<void> {
return async () => {
let count = 0;
const done = new Promise<void>((resolve) => {
const off = client.on("raw:privmsg", () => {
if (++count === 80) {
off();
resolve();
}
});
});
server.send(SEND_80_PLAIN);
await done;
};
}

// CoreClient (no plugins)

const coreClient = new MockCoreClient([], {});
const coreServer = new MockServer(coreClient);
await coreClient.connect("");

Deno.bench({
name: "pipeline: single PRIVMSG (CoreClient)",
group: "pipeline-single",
baseline: true,
async fn() {
const p = coreClient.once("raw:privmsg");
coreServer.send(MSG_PLAIN);
await p;
},
});

Deno.bench({
name: "pipeline: 80 PRIVMSG batch (CoreClient)",
group: "pipeline-batch",
baseline: true,
fn: benchBatch(coreClient, coreServer),
});

// Full Client (50+ plugins)

const fullClient = new MockClient({
nick: "me",
pingTimeout: false,
});
const fullServer = new MockServer(fullClient);
await fullClient.connect("");
fullServer.receive();

Deno.bench({
name: "pipeline: single PRIVMSG (full Client)",
group: "pipeline-single",
async fn() {
const p = fullClient.once("raw:privmsg");
fullServer.send(MSG_PLAIN);
await p;
},
});

Deno.bench({
name: "pipeline: 80 PRIVMSG batch (full Client)",
group: "pipeline-batch",
fn: benchBatch(fullClient, fullServer),
});
157 changes: 157 additions & 0 deletions bench/client.flood.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { MockClient, MockCoreClient } from "../testing/client.ts";
import { MockServer } from "../testing/server.ts";
import type { CoreFeatures } from "../core/client.ts";
import type { EventEmitter } from "../core/events.ts";
import { MSG_PLAIN } from "./fixtures.ts";

const DURATION_SEC = 30;
const REPORT_INTERVAL_MS = 1000;

interface Sample {
elapsed: number;
msgsPerSec: number;
heapMB: number;
}

async function runFlood<TEvents extends CoreFeatures["events"]>(
label: string,
client: EventEmitter<TEvents>,
server: MockServer,
) {
const samples: Sample[] = [];
let totalCount = 0;
let intervalCount = 0;
let running = true;

const startHeap = Deno.memoryUsage().heapUsed;
const start = performance.now();
let lastReport = start;

const YIELD_EVERY = 1000;
let batchCount = 0;

client.on("raw:privmsg", () => {
totalCount++;
intervalCount++;
if (!running) return;
batchCount++;
if (batchCount >= YIELD_EVERY) {
// Yield to macrotask queue so setTimeout/setInterval can fire
batchCount = 0;
setTimeout(() => server.send(MSG_PLAIN), 0);
} else {
queueMicrotask(() => server.send(MSG_PLAIN));
}
});

// Start the chain
server.send(MSG_PLAIN);

// Report loop
const interval = setInterval(() => {
const now = performance.now();
const dt = (now - lastReport) / 1000;
const msgsPerSec = Math.round(intervalCount / dt);
const heapMB = Deno.memoryUsage().heapUsed / 1024 / 1024;

samples.push({
elapsed: Math.round((now - start) / 1000),
msgsPerSec,
heapMB: Math.round(heapMB * 10) / 10,
});

intervalCount = 0;
lastReport = now;
}, REPORT_INTERVAL_MS);

// Run for DURATION_SEC
await new Promise<void>((resolve) => {
setTimeout(() => {
running = false;
clearInterval(interval);
resolve();
}, DURATION_SEC * 1000);
});

// Wait for remaining in-flight messages
await new Promise((r) => setTimeout(r, 100));

const endHeap = Deno.memoryUsage().heapUsed;
const totalTime = (performance.now() - start) / 1000;

// Print report
console.log(`\n${"=".repeat(60)}`);
console.log(`Flood stress test: ${label} (${DURATION_SEC}s)`);
console.log("=".repeat(60));
console.log("");
console.log("Time(s) Msgs/sec Heap(MB)");
console.log("-".repeat(40));

for (const s of samples) {
console.log(
`${String(s.elapsed).padStart(4)} ${
String(s.msgsPerSec).padStart(10)
} ${s.heapMB.toFixed(1).padStart(7)}`,
);
}

const peak = Math.max(...samples.map((s) => s.msgsPerSec));
const avg = Math.round(
samples.reduce((a, s) => a + s.msgsPerSec, 0) / samples.length,
);
const heapStart = Math.round((startHeap / 1024 / 1024) * 10) / 10;
const heapEnd = Math.round((endHeap / 1024 / 1024) * 10) / 10;

// Leak detection: check if heap grew monotonically for >10 consecutive samples
let monotonic = 0;
let maxMonotonic = 0;
for (let i = 1; i < samples.length; i++) {
if (samples[i].heapMB > samples[i - 1].heapMB) {
monotonic++;
maxMonotonic = Math.max(maxMonotonic, monotonic);
} else {
monotonic = 0;
}
}

console.log("");
console.log("SUMMARY");
console.log(` Peak throughput: ${peak.toLocaleString()} msgs/sec`);
console.log(` Avg throughput: ${avg.toLocaleString()} msgs/sec`);
console.log(` Heap at start: ${heapStart} MB`);
console.log(` Heap at end: ${heapEnd} MB`);
console.log(` Heap growth: ${(heapEnd - heapStart).toFixed(1)} MB`);
console.log(` Total processed: ${totalCount.toLocaleString()} messages`);
console.log(` Duration: ${totalTime.toFixed(1)}s`);

if (maxMonotonic >= 10) {
console.log(
` ⚠ POSSIBLE LEAK: heap grew monotonically for ${maxMonotonic} consecutive seconds`,
);
} else {
console.log(" Heap: stable (no leak detected)");
}
}

// CoreClient (no plugins) ---
console.log("Setting up CoreClient...");
const coreClient = new MockCoreClient([], {});
const coreServer = new MockServer(coreClient);
await coreClient.connect("");

await runFlood("CoreClient (no plugins)", coreClient, coreServer);

// Shutdown and set up full client
coreClient.conn?.close();
await new Promise((r) => setTimeout(r, 200));

// Full Client (50+ plugins) ---
console.log("\nSetting up full Client...");
const fullClient = new MockClient({ nick: "me", pingTimeout: false });
const fullServer = new MockServer(fullClient);
await fullClient.connect("");
fullServer.receive();

await runFlood("Full Client (50+ plugins)", fullClient, fullServer);

fullClient.conn?.close();
28 changes: 28 additions & 0 deletions bench/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export const MSG_PLAIN = ":nick!user@host PRIVMSG #channel :Hello world";
export const MSG_PLAIN_RAW = MSG_PLAIN + "\r\n";

export const MSG_TAGGED =
"@time=2024-01-01T00:00:00Z;msgid=abc123 :nick!user@host PRIVMSG #channel :Hello";
export const MSG_TAGGED_RAW = MSG_TAGGED + "\r\n";

const MSG_NUMERIC = ":server.example.com 001 me :Welcome to the IRC Network";

const MSG_NOTICE =
":server.example.com NOTICE * :*** Looking up your hostname...";

function batch(msgs: string[]): string {
return msgs.join("\r\n") + "\r\n";
}

export const BATCH_1000_PLAIN = batch(
Array.from({ length: 1000 }, () => MSG_PLAIN),
);

export const BATCH_1000_MIXED = batch([
...Array.from({ length: 250 }, () => MSG_PLAIN),
...Array.from({ length: 250 }, () => MSG_TAGGED),
...Array.from({ length: 250 }, () => MSG_NUMERIC),
...Array.from({ length: 250 }, () => MSG_NOTICE),
]);

export const SEND_80_PLAIN = Array.from({ length: 80 }, () => MSG_PLAIN);
41 changes: 41 additions & 0 deletions bench/parser.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { parseChunk } from "../core/parsers.ts";
import {
BATCH_1000_MIXED,
BATCH_1000_PLAIN,
MSG_PLAIN_RAW,
MSG_TAGGED_RAW,
} from "./fixtures.ts";

Deno.bench({
name: "single plain PRIVMSG",
group: "single",
baseline: true,
fn() {
parseChunk(MSG_PLAIN_RAW);
},
});

Deno.bench({
name: "single tagged PRIVMSG",
group: "single",
fn() {
parseChunk(MSG_TAGGED_RAW);
},
});

Deno.bench({
name: "1000 plain PRIVMSG",
group: "batch",
baseline: true,
fn() {
parseChunk(BATCH_1000_PLAIN);
},
});

Deno.bench({
name: "1000 mixed messages",
group: "batch",
fn() {
parseChunk(BATCH_1000_MIXED);
},
});
11 changes: 8 additions & 3 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"plugins/",
"runtime/",
"testing/",
"integration/"
"integration/",
"bench/"
],
"exclude": [
"integration/ergo/",
Expand All @@ -37,7 +38,8 @@
"plugins",
"runtime",
"testing",
"integration"
"integration",
"bench"
],
"exclude": [
"runtime/node.ts",
Expand All @@ -62,7 +64,10 @@
"test:integration": "deno task test:integration:ergo && deno task test:integration:inspircd && deno task test:integration:unrealircd",
"test:integration:ergo": "docker compose -f integration/ergo/docker-compose.yml down -v 2>/dev/null; docker compose -f integration/ergo/docker-compose.yml up -d --force-recreate && sleep 2 && IRCD=ergo deno test --allow-net --allow-read --allow-env integration/integration_test.ts --failfast ; docker compose -f integration/ergo/docker-compose.yml down -v",
"test:integration:inspircd": "docker compose -f integration/inspircd/docker-compose.yml down -v 2>/dev/null; docker compose -f integration/inspircd/docker-compose.yml up -d --force-recreate && sleep 3 && IRCD=inspircd deno test --allow-net --allow-read --allow-env integration/integration_test.ts --failfast ; docker compose -f integration/inspircd/docker-compose.yml down -v",
"test:integration:unrealircd": "docker compose -f integration/unrealircd/docker-compose.yml down -v 2>/dev/null; docker compose -f integration/unrealircd/docker-compose.yml up -d --force-recreate && sleep 3 && IRCD=unrealircd deno test --allow-net --allow-read --allow-env integration/integration_test.ts --failfast ; docker compose -f integration/unrealircd/docker-compose.yml down -v"
"test:integration:unrealircd": "docker compose -f integration/unrealircd/docker-compose.yml down -v 2>/dev/null; docker compose -f integration/unrealircd/docker-compose.yml up -d --force-recreate && sleep 3 && IRCD=unrealircd deno test --allow-net --allow-read --allow-env integration/integration_test.ts --failfast ; docker compose -f integration/unrealircd/docker-compose.yml down -v",
"bench": "deno bench bench/parser.bench.ts bench/client.bench.ts",
"bench:parser": "deno bench bench/parser.bench.ts",
"stress": "deno run --allow-read bench/client.flood.ts"
},
"publish": {
"include": [
Expand Down
2 changes: 1 addition & 1 deletion integration/integration_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const IRCD = typeof Deno !== "undefined"
// deno-lint-ignore no-process-global
: process.env.IRCD ?? "ergo";

describe("integration", (test) => {
describe(`integration/${IRCD}`, (test) => {
let counter = 0;

const connect = async (base: string) => {
Expand Down
Loading
Loading