Skip to content

Commit 7d4264b

Browse files
Reflexclaude
andcommitted
test(h2-transport): comprehensive unit, integration, regression, and load tests
Implements the testing plan from #805. Adds 79 tests across 7 files covering H2Headers, H2Response, H2Session, H2Pool, createH2Fetch, end-to-end integration, and 11 named regression cases tied to specific source invariants. Adds 5 load scripts under loadtest/ (multiplex throughput, pool growth, leak/soak, chaos, nghttp2 h2load wrapper). Replaces the single tests/lib/h2-transport.test.ts with a colocated test directory under tests/lib/h2-transport/, sharing testServer and faultServer helpers across suites. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 572fa22 commit 7d4264b

18 files changed

Lines changed: 1929 additions & 479 deletions

loadtest/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
18+
## Run
19+
20+
```sh
21+
npx tsx loadtest/h2-multiplex.ts # default N=1000
22+
npx tsx loadtest/h2-multiplex.ts 10000
23+
npx tsx loadtest/h2-pool-growth.ts
24+
npx tsx loadtest/h2-leak.ts 600 # 10 min soak
25+
npx tsx loadtest/h2-chaos.ts 60
26+
./loadtest/h2load.sh 100000 100 32 # needs nghttp2 in $PATH
27+
```
28+
29+
For the leak test, run with `node --expose-gc $(which npx) tsx loadtest/h2-leak.ts`
30+
so the periodic `global.gc()` calls fire — otherwise heap-growth signal is
31+
noisy with retained but reclaimable buffers.
32+
33+
## Reading the output
34+
35+
Each script prints a single JSON object at the end. Useful invariants:
36+
37+
- `failures === 0` for `h2-multiplex` and `h2-pool-growth`.
38+
- `heapGrowthMB < 50` for `h2-leak`.
39+
- `getSuccessRate > 0.99` for `h2-chaos` once retries land; today we only
40+
assert `> 0.50` because POST requests are not retried on session loss.
41+
42+
Throughput numbers depend on the host and should be compared like-for-like,
43+
not against an absolute target.

loadtest/h2-chaos.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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 { execSync } 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+
execSync(
21+
`openssl req -x509 -newkey rsa:2048 -keyout ${key} -out ${cert} -days 1 -nodes -subj "/CN=localhost" 2>/dev/null`,
22+
);
23+
return { key: fs.readFileSync(key), cert: fs.readFileSync(cert), tmp };
24+
}
25+
26+
async function startServer(seed: number) {
27+
const { key, cert, tmp } = makeCerts();
28+
let rng = seed >>> 0;
29+
const next = () => {
30+
rng = (rng * 1664525 + 1013904223) >>> 0;
31+
return rng / 0xffffffff;
32+
};
33+
const server = http2.createSecureServer({ key, cert });
34+
server.on('stream', (stream) => {
35+
stream.on('error', () => {});
36+
const r = next();
37+
if (r < 0.1) {
38+
try {
39+
stream.session?.socket?.destroy();
40+
} catch {}
41+
return;
42+
}
43+
if (r < 0.2) {
44+
try {
45+
stream.close(0x8);
46+
} catch {}
47+
return;
48+
}
49+
if (r < 0.21) {
50+
stream.respond({ ':status': 200 });
51+
stream.end('ok');
52+
try {
53+
stream.session?.goaway();
54+
} catch {}
55+
return;
56+
}
57+
const delay = r < 0.4 ? Math.floor(next() * 200) : 0;
58+
setTimeout(() => {
59+
if (stream.destroyed) return;
60+
stream.respond({ ':status': 200 });
61+
stream.end('ok');
62+
}, delay);
63+
});
64+
return new Promise<{ port: number; close: () => void }>((resolve) => {
65+
server.listen(0, () => {
66+
resolve({
67+
port: (server.address() as any).port,
68+
close: () => {
69+
server.close();
70+
fs.rmSync(tmp, { recursive: true, force: true });
71+
},
72+
});
73+
});
74+
});
75+
}
76+
77+
async function main() {
78+
const durationSec = Number(process.argv[2] ?? 60);
79+
const server = await startServer(42);
80+
const fetch = createH2Fetch({
81+
minConnections: 4,
82+
maxConnections: 20,
83+
connectTimeout: 5_000,
84+
tlsOptions: { rejectUnauthorized: false },
85+
});
86+
87+
const end = Date.now() + durationSec * 1000;
88+
let getOk = 0, getFail = 0, postOk = 0, postFail = 0;
89+
90+
async function get() {
91+
try {
92+
const r = (await fetch(`https://localhost:${server.port}/x`, { method: 'GET' } as any)) as any;
93+
await r.text();
94+
getOk++;
95+
} catch {
96+
getFail++;
97+
}
98+
}
99+
async function post() {
100+
try {
101+
const r = (await fetch(`https://localhost:${server.port}/x`, { method: 'POST', body: 'b' } as any)) as any;
102+
await r.text();
103+
postOk++;
104+
} catch {
105+
postFail++;
106+
}
107+
}
108+
109+
while (Date.now() < end) {
110+
await Promise.all([
111+
...Array.from({ length: 10 }, get),
112+
...Array.from({ length: 5 }, post),
113+
]);
114+
}
115+
116+
await fetch.close();
117+
server.close();
118+
119+
const getRate = getOk / (getOk + getFail);
120+
const postClean = postFail === 0 || postOk > 0;
121+
122+
console.log(JSON.stringify({
123+
getOk, getFail, getSuccessRate: Number(getRate.toFixed(3)),
124+
postOk, postFail,
125+
durationSec,
126+
}, null, 2));
127+
128+
if (getRate < 0.5) {
129+
console.error('GET success rate too low');
130+
process.exit(1);
131+
}
132+
if (!postClean) {
133+
console.error('POST never succeeded');
134+
process.exit(1);
135+
}
136+
}
137+
138+
main().catch((err) => {
139+
console.error(err);
140+
process.exit(1);
141+
});

loadtest/h2-leak.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Leak / soak test — run at modest concurrency for a configurable window,
3+
* sample heap and FD count, report slope.
4+
*
5+
* Run: `npx tsx loadtest/h2-leak.ts [durationSeconds=600]`
6+
*/
7+
import http2 from 'node:http2';
8+
import { execSync } 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-leak-'));
18+
const key = path.join(tmp, 'key.pem');
19+
const cert = path.join(tmp, 'cert.pem');
20+
execSync(
21+
`openssl req -x509 -newkey rsa:2048 -keyout ${key} -out ${cert} -days 1 -nodes -subj "/CN=localhost" 2>/dev/null`,
22+
);
23+
return { key: fs.readFileSync(key), cert: fs.readFileSync(cert), tmp };
24+
}
25+
26+
function fdCount(): number | null {
27+
try {
28+
return fs.readdirSync('/proc/self/fd').length;
29+
} catch {
30+
return null;
31+
}
32+
}
33+
34+
async function startServer() {
35+
const { key, cert, tmp } = makeCerts();
36+
let streamN = 0;
37+
const server = http2.createSecureServer({ key, cert });
38+
server.on('stream', (stream) => {
39+
streamN++;
40+
const n = streamN;
41+
stream.on('error', () => {});
42+
stream.respond({ ':status': 200 });
43+
stream.end(JSON.stringify({ n }));
44+
if (n % 1000 === 0) {
45+
try {
46+
stream.session?.goaway();
47+
} catch {}
48+
}
49+
});
50+
return new Promise<{ port: number; close: () => void }>((resolve) => {
51+
server.listen(0, () => {
52+
resolve({
53+
port: (server.address() as any).port,
54+
close: () => {
55+
server.close();
56+
fs.rmSync(tmp, { recursive: true, force: true });
57+
},
58+
});
59+
});
60+
});
61+
}
62+
63+
async function main() {
64+
const durationSec = Number(process.argv[2] ?? 600);
65+
const server = await startServer();
66+
const fetch = createH2Fetch({
67+
minConnections: 4,
68+
maxConnections: 20,
69+
tlsOptions: { rejectUnauthorized: false },
70+
});
71+
72+
const startMs = Date.now();
73+
const end = startMs + durationSec * 1000;
74+
let completed = 0;
75+
let failed = 0;
76+
const samples: Array<{ t: number; heap: number; fds: number | null }> = [];
77+
78+
const sampler = setInterval(() => {
79+
if (global.gc) global.gc();
80+
samples.push({
81+
t: Math.round((Date.now() - startMs) / 1000),
82+
heap: process.memoryUsage().heapUsed,
83+
fds: fdCount(),
84+
});
85+
}, 30_000);
86+
87+
const RATE = 50;
88+
const tickMs = 20;
89+
const perTick = Math.max(1, Math.round((RATE * tickMs) / 1000));
90+
91+
while (Date.now() < end) {
92+
await Promise.all(
93+
Array.from({ length: perTick }, async () => {
94+
try {
95+
const r = (await fetch(`https://localhost:${server.port}/x`, { method: 'GET' } as any)) as any;
96+
await r.text();
97+
completed++;
98+
} catch {
99+
failed++;
100+
}
101+
}),
102+
);
103+
await new Promise((r) => setTimeout(r, tickMs));
104+
}
105+
106+
clearInterval(sampler);
107+
await fetch.close();
108+
server.close();
109+
110+
const warmup = samples[Math.min(2, samples.length - 1)]?.heap ?? 0;
111+
const final = samples[samples.length - 1]?.heap ?? 0;
112+
const heapGrowthMB = (final - warmup) / (1024 * 1024);
113+
114+
console.log(JSON.stringify({
115+
durationSec,
116+
completed,
117+
failed,
118+
rps: Math.round(completed / durationSec),
119+
heapWarmupMB: Math.round(warmup / (1024 * 1024)),
120+
heapFinalMB: Math.round(final / (1024 * 1024)),
121+
heapGrowthMB: Math.round(heapGrowthMB),
122+
samples,
123+
}, null, 2));
124+
125+
if (heapGrowthMB > 50) {
126+
console.error(`HEAP GREW BY ${heapGrowthMB.toFixed(1)}MB — possible leak`);
127+
process.exit(1);
128+
}
129+
}
130+
131+
main().catch((err) => {
132+
console.error(err);
133+
process.exit(1);
134+
});

loadtest/h2-multiplex-server.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Standalone multiplex test server for h2load.sh.
3+
* Logs its port on startup and serves a 1KB JSON body for every request.
4+
*/
5+
import http2 from 'node:http2';
6+
import { execSync } from 'node:child_process';
7+
import fs from 'node:fs';
8+
import os from 'node:os';
9+
import path from 'node:path';
10+
11+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'h2load-srv-'));
12+
const keyPath = path.join(tmp, 'key.pem');
13+
const certPath = path.join(tmp, 'cert.pem');
14+
execSync(
15+
`openssl req -x509 -newkey rsa:2048 -keyout ${keyPath} -out ${certPath} -days 1 -nodes -subj "/CN=localhost" 2>/dev/null`,
16+
);
17+
const payload = Buffer.from(JSON.stringify({ ok: true, data: 'x'.repeat(1000) }));
18+
19+
const server = http2.createSecureServer({
20+
key: fs.readFileSync(keyPath),
21+
cert: fs.readFileSync(certPath),
22+
});
23+
server.on('stream', (stream) => {
24+
stream.on('error', () => {});
25+
stream.respond({ ':status': 200, 'content-type': 'application/json' });
26+
stream.end(payload);
27+
});
28+
server.listen(0, () => {
29+
const port = (server.address() as any).port;
30+
console.log(`listening on port ${port}`);
31+
});
32+
33+
process.on('SIGTERM', () => {
34+
fs.rmSync(tmp, { recursive: true, force: true });
35+
server.close(() => process.exit(0));
36+
});

0 commit comments

Comments
 (0)