Skip to content

Commit 5ee1cf9

Browse files
jrvb-rlReflexclaude
authored
test(h2-transport): comprehensive unit, integration, regression, and load tests (#806)
Co-authored-by: Reflex <agent@reflex.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent be107b9 commit 5ee1cf9

20 files changed

Lines changed: 2358 additions & 479 deletions

‎.github/workflows/h2-loadtest.yml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: H2 Load Tests
2+
3+
on:
4+
schedule:
5+
- cron: '0 6 * * *' # 06:00 UTC daily
6+
workflow_dispatch:
7+
8+
jobs:
9+
loadtest:
10+
name: h2-transport load tests
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 15
13+
14+
steps:
15+
- uses: runloopai/checkout@main
16+
17+
- name: Set up Node
18+
uses: runloopai/setup-node@main
19+
with:
20+
node-version: '20'
21+
22+
- name: Install dependencies
23+
run: yarn --frozen-lockfile
24+
25+
- name: Connect to Tailscale
26+
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2
27+
with:
28+
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
29+
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
30+
tags: tag:ci
31+
hostname: github-ci-${{ github.run_id }}-loadtest
32+
33+
- name: Run load tests and push to Loki
34+
env:
35+
LOKI_URL: https://dev-loki
36+
run: npx tsx loadtest/push-to-loki.ts

‎loadtest/README.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# h2-transport load tests
2+
3+
Standalone scripts that exercise `src/lib/h2-transport/` under load. Each boots
4+
an in-process h2 server and runs the client until completion or a fixed
5+
duration. Not part of `npm test`; run on demand.
6+
7+
## Scripts
8+
9+
| Script | Purpose |
10+
|---|---|
11+
| `h2-multiplex.ts` | Fire N concurrent requests, report throughput + p50/p95/p99. |
12+
| `h2-pool-growth.ts` | Ramp 1 → 50 → 200 → 50 → 1 concurrent. Observe pool size over time. |
13+
| `h2-leak.ts` | Soak at 50 r/s. Sample heap + FD count. Periodic GOAWAY injection. |
14+
| `h2-chaos.ts` | Server randomly drops sockets / RSTs / GOAWAYs / delays. Asserts ≥50 % GET success. |
15+
| `h2load.sh` | Wraps `nghttp2`'s `h2load` against a long-running test server. |
16+
| `sse-test.ts` | Pre-existing manual SSE round-trip. |
17+
| `push-to-loki.ts` | Runs all four timed tests and pushes results to Loki for Grafana. |
18+
19+
## Automated daily runs
20+
21+
The `.github/workflows/h2-loadtest.yml` workflow fires at 06:00 UTC every day.
22+
It connects to the dev Tailscale network and runs `push-to-loki.ts`, which
23+
executes each script with CI-appropriate durations (multiplex N=1000,
24+
chaos 30 s, leak 120 s) and posts one structured JSON log line per test to
25+
Loki under `{job="h2-loadtest", test="<name>"}`.
26+
27+
Results are visible in the **H2 Transport Load Tests** Grafana dashboard on
28+
dev-grafana. Each panel uses `last_over_time(...[1d])` to produce one data
29+
point per daily run, so regressions show up as step changes on the trend lines.
30+
31+
To push results from a local run (requires Tailscale + access to dev-loki):
32+
33+
```sh
34+
LOKI_URL=https://dev-loki npx tsx loadtest/push-to-loki.ts
35+
```
36+
37+
## Run
38+
39+
```sh
40+
npx tsx loadtest/h2-multiplex.ts # default N=1000
41+
npx tsx loadtest/h2-multiplex.ts 10000
42+
npx tsx loadtest/h2-pool-growth.ts
43+
npx tsx loadtest/h2-leak.ts 600 # 10 min soak
44+
npx tsx loadtest/h2-chaos.ts 60
45+
./loadtest/h2load.sh 100000 100 32 # needs nghttp2 in $PATH
46+
```
47+
48+
For the leak test, run with `node --expose-gc $(which npx) tsx loadtest/h2-leak.ts`
49+
so the periodic `global.gc()` calls fire — otherwise heap-growth signal is
50+
noisy with retained but reclaimable buffers.
51+
52+
## Reading the output
53+
54+
Each script prints a single JSON object at the end. Useful invariants:
55+
56+
- `failures === 0` for `h2-multiplex` and `h2-pool-growth`.
57+
- `heapGrowthMB < 50` for `h2-leak`.
58+
- `getSuccessRate > 0.99` for `h2-chaos` once retries land; today we only
59+
assert `> 0.50` because POST requests are not retried on session loss.
60+
61+
Throughput numbers depend on the host and should be compared like-for-like,
62+
not against an absolute target.

‎loadtest/h2-chaos.ts‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* Chaos test — server randomly drops sockets, sends RST_STREAM, GOAWAY, or
3+
* delays headers. Asserts the client survives.
4+
*
5+
* Run: `npx tsx loadtest/h2-chaos.ts [durationSeconds=60]`
6+
*/
7+
import http2 from 'node:http2';
8+
import { execFileSync } from 'node:child_process';
9+
import fs from 'node:fs';
10+
import os from 'node:os';
11+
import path from 'node:path';
12+
import { createH2Fetch } from '../src/lib/h2-transport/index';
13+
14+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
15+
16+
function makeCerts() {
17+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'h2-chaos-'));
18+
const key = path.join(tmp, 'key.pem');
19+
const cert = path.join(tmp, 'cert.pem');
20+
execFileSync(
21+
'openssl',
22+
[
23+
'req',
24+
'-x509',
25+
'-newkey',
26+
'rsa:2048',
27+
'-keyout',
28+
key,
29+
'-out',
30+
cert,
31+
'-days',
32+
'1',
33+
'-nodes',
34+
'-subj',
35+
'/CN=localhost',
36+
],
37+
{ stdio: ['ignore', 'ignore', 'ignore'] },
38+
);
39+
return { key: fs.readFileSync(key), cert: fs.readFileSync(cert), tmp };
40+
}
41+
42+
async function startServer(seed: number) {
43+
const { key, cert, tmp } = makeCerts();
44+
let rng = seed >>> 0;
45+
const next = () => {
46+
rng = (rng * 1664525 + 1013904223) >>> 0;
47+
return rng / 0xffffffff;
48+
};
49+
const server = http2.createSecureServer({ key, cert });
50+
server.on('stream', (stream) => {
51+
stream.on('error', () => {});
52+
const r = next();
53+
if (r < 0.1) {
54+
try {
55+
stream.session?.socket?.destroy();
56+
} catch {}
57+
return;
58+
}
59+
if (r < 0.2) {
60+
try {
61+
stream.close(0x8);
62+
} catch {}
63+
return;
64+
}
65+
if (r < 0.21) {
66+
stream.respond({ ':status': 200 });
67+
stream.end('ok');
68+
try {
69+
stream.session?.goaway();
70+
} catch {}
71+
return;
72+
}
73+
const delay = r < 0.4 ? Math.floor(next() * 200) : 0;
74+
setTimeout(() => {
75+
if (stream.destroyed) return;
76+
stream.respond({ ':status': 200 });
77+
stream.end('ok');
78+
}, delay);
79+
});
80+
return new Promise<{ port: number; close: () => void }>((resolve) => {
81+
server.listen(0, () => {
82+
resolve({
83+
port: (server.address() as any).port,
84+
close: () => {
85+
server.close();
86+
fs.rmSync(tmp, { recursive: true, force: true });
87+
},
88+
});
89+
});
90+
});
91+
}
92+
93+
async function main() {
94+
const durationSec = Number(process.argv[2] ?? 60);
95+
if (!Number.isFinite(durationSec) || durationSec <= 0) {
96+
console.error(`invalid duration: ${process.argv[2]} (must be a positive number of seconds)`);
97+
process.exit(2);
98+
}
99+
const server = await startServer(42);
100+
const fetch = createH2Fetch({
101+
minConnections: 4,
102+
maxConnections: 20,
103+
connectTimeout: 5_000,
104+
tlsOptions: { rejectUnauthorized: false },
105+
});
106+
107+
const end = Date.now() + durationSec * 1000;
108+
let getOk = 0,
109+
getFail = 0,
110+
postOk = 0,
111+
postFail = 0;
112+
113+
async function get() {
114+
try {
115+
const r = (await fetch(`https://localhost:${server.port}/x`, { method: 'GET' } as any)) as any;
116+
await r.text();
117+
getOk++;
118+
} catch {
119+
getFail++;
120+
}
121+
}
122+
async function post() {
123+
try {
124+
const r = (await fetch(`https://localhost:${server.port}/x`, {
125+
method: 'POST',
126+
body: 'b',
127+
} as any)) as any;
128+
await r.text();
129+
postOk++;
130+
} catch {
131+
postFail++;
132+
}
133+
}
134+
135+
while (Date.now() < end) {
136+
await Promise.all([...Array.from({ length: 10 }, get), ...Array.from({ length: 5 }, post)]);
137+
}
138+
139+
await fetch.close();
140+
server.close();
141+
142+
const getTotal = getOk + getFail;
143+
const postTotal = postOk + postFail;
144+
if (getTotal === 0) {
145+
console.error('no GET requests were issued — load harness produced zero coverage');
146+
process.exit(1);
147+
}
148+
const getRate = getOk / getTotal;
149+
const postClean = postFail === 0 || postOk > 0;
150+
151+
console.log(
152+
JSON.stringify(
153+
{
154+
getOk,
155+
getFail,
156+
getTotal,
157+
getSuccessRate: Number(getRate.toFixed(3)),
158+
postOk,
159+
postFail,
160+
postTotal,
161+
durationSec,
162+
},
163+
null,
164+
2,
165+
),
166+
);
167+
168+
if (getRate < 0.5) {
169+
console.error('GET success rate too low');
170+
process.exit(1);
171+
}
172+
if (!postClean) {
173+
console.error('POST never succeeded');
174+
process.exit(1);
175+
}
176+
}
177+
178+
main().catch((err) => {
179+
console.error(err);
180+
process.exit(1);
181+
});

0 commit comments

Comments
 (0)