Skip to content
Closed
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
67 changes: 67 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Benchmarks & Stress Tests

## Quick start

```bash
# All benchmarks (parser, emitter, pipeline)
deno task bench

# Parser only
deno task bench:parser

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

# Stress test — slow consumer / backpressure
deno task stress:backpressure
```

## 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)

### `emitter.bench.ts` — EventEmitter throughput

Internal characterization of the event system:

- Emit with 0, 1, 10, 50 listeners
- Multi-event dispatch (e.g. `privmsg` → `privmsg:channel` + `privmsg:private`)
- `on`/`off` registration cycle
- Promise-based `once`

### `pipeline.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

### `stress/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).

### `stress/backpressure.ts` — Slow consumer

Injects 1ms artificial delay in the message listener. Verifies that the read
loop stalls naturally (pull-based `once("read")`) without accumulating an
unbounded event queue. Reports send-to-receive latency percentiles.

## 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.

Use `--json` and pipe to `bench/report.ts` for a formatted comparison table.
99 changes: 99 additions & 0 deletions bench/emitter.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { EventEmitter } from "../core/events.ts";

interface Payload {
command: string;
params: string[];
}

interface BenchEvents {
"ev": Payload;
"privmsg": Payload;
"privmsg:channel": Payload;
"privmsg:private": Payload;
}

const PAYLOAD: Payload = { command: "privmsg", params: ["#ch", "hello"] };

// Isolated emitters per listener count
const emitter0 = new EventEmitter<BenchEvents>();

const emitter1 = new EventEmitter<BenchEvents>();
emitter1.on("ev", () => {});

const emitter10 = new EventEmitter<BenchEvents>();
for (let i = 0; i < 10; i++) emitter10.on("ev", () => {});

const emitter50 = new EventEmitter<BenchEvents>();
for (let i = 0; i < 50; i++) emitter50.on("ev", () => {});

// Multi-event: simulates privmsg → [privmsg:channel, privmsg:private]
const emitterMulti = new EventEmitter<BenchEvents>();
emitterMulti.createMultiEvent("privmsg", [
"privmsg:channel",
"privmsg:private",
]);
for (let i = 0; i < 5; i++) emitterMulti.on("privmsg:channel", () => {});
for (let i = 0; i < 5; i++) emitterMulti.on("privmsg:private", () => {});

const emitterReg = new EventEmitter<BenchEvents>();

Deno.bench({
name: "emit: no listener (fast path)",
group: "emit",
baseline: true,
fn() {
emitter0.emit("ev", PAYLOAD);
},
});

Deno.bench({
name: "emit: 1 listener",
group: "emit",
fn() {
emitter1.emit("ev", PAYLOAD);
},
});

Deno.bench({
name: "emit: 10 listeners",
group: "emit",
fn() {
emitter10.emit("ev", PAYLOAD);
},
});

Deno.bench({
name: "emit: 50 listeners",
group: "emit",
fn() {
emitter50.emit("ev", PAYLOAD);
},
});

Deno.bench({
name: "emit: multi-event (2 targets × 5 listeners)",
group: "emit",
fn() {
emitterMulti.emit("privmsg", PAYLOAD);
},
});

Deno.bench({
name: "on + off cycle",
group: "registration",
fn() {
const fn = () => {};
const off = emitterReg.on("ev", fn);
off();
},
});

Deno.bench({
name: "once (promise-based)",
group: "registration",
async fn() {
const p = emitterReg.once("ev");
emitterReg.emit("ev", PAYLOAD);
await p;
},
});
32 changes: 32 additions & 0 deletions bench/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Pre-built IRC message corpus for all benchmarks.
// Defined once at module load to avoid GC pressure inside measured loops.

export const MSG_PLAIN = ":nick!user@host PRIVMSG #channel :Hello world";

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

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

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

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

// Batches terminated with \r\n for Parser.parseMessages()
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),
]);

// Arrays of raw lines for MockServer.send() — fits in default 4096-byte buffer
export const SEND_80_PLAIN = Array.from({ length: 80 }, () => MSG_PLAIN);
45 changes: 45 additions & 0 deletions bench/parser.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { parseChunk } from "../core/parsers.ts";
import {
BATCH_1000_MIXED,
BATCH_1000_PLAIN,
MSG_PLAIN,
MSG_TAGGED,
} from "./fixtures.ts";

// Single message ---

Deno.bench({
name: "single plain PRIVMSG",
group: "single",
baseline: true,
fn() {
parseChunk(MSG_PLAIN + "\r\n");
},
});

Deno.bench({
name: "single tagged PRIVMSG",
group: "single",
fn() {
parseChunk(MSG_TAGGED + "\r\n");
},
});

// Batch 1000 messages ---

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);
},
});
77 changes: 77 additions & 0 deletions bench/pipeline.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { MockClient, MockCoreClient } from "../testing/client.ts";
import { MockServer } from "../testing/server.ts";
import { MSG_PLAIN, SEND_80_PLAIN } from "./fixtures.ts";

// 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,
async fn() {
let count = 0;
const done = new Promise<void>((resolve) => {
const off = coreClient.on("raw:privmsg", () => {
if (++count === 80) {
off();
resolve();
}
});
});
coreServer.send(SEND_80_PLAIN);
await done;
},
});

// Full Client (50+ plugins) ---

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

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",
async fn() {
let count = 0;
const done = new Promise<void>((resolve) => {
const off = fullClient.on("raw:privmsg", () => {
if (++count === 80) {
off();
resolve();
}
});
});
fullServer.send(SEND_80_PLAIN);
await done;
},
});
Loading