diff --git a/.github/workflows/h2-loadtest.yml b/.github/workflows/h2-loadtest.yml new file mode 100644 index 000000000..f5ea5a2c6 --- /dev/null +++ b/.github/workflows/h2-loadtest.yml @@ -0,0 +1,36 @@ +name: H2 Load Tests + +on: + schedule: + - cron: '0 6 * * *' # 06:00 UTC daily + workflow_dispatch: + +jobs: + loadtest: + name: h2-transport load tests + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: runloopai/checkout@main + + - name: Set up Node + uses: runloopai/setup-node@main + with: + node-version: '20' + + - name: Install dependencies + run: yarn --frozen-lockfile + + - name: Connect to Tailscale + uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} + tags: tag:ci + hostname: github-ci-${{ github.run_id }}-loadtest + + - name: Run load tests and push to Loki + env: + LOKI_URL: https://dev-loki + run: npx tsx loadtest/push-to-loki.ts diff --git a/loadtest/README.md b/loadtest/README.md new file mode 100644 index 000000000..7ab30bce0 --- /dev/null +++ b/loadtest/README.md @@ -0,0 +1,62 @@ +# h2-transport load tests + +Standalone scripts that exercise `src/lib/h2-transport/` under load. Each boots +an in-process h2 server and runs the client until completion or a fixed +duration. Not part of `npm test`; run on demand. + +## Scripts + +| Script | Purpose | +|---|---| +| `h2-multiplex.ts` | Fire N concurrent requests, report throughput + p50/p95/p99. | +| `h2-pool-growth.ts` | Ramp 1 → 50 → 200 → 50 → 1 concurrent. Observe pool size over time. | +| `h2-leak.ts` | Soak at 50 r/s. Sample heap + FD count. Periodic GOAWAY injection. | +| `h2-chaos.ts` | Server randomly drops sockets / RSTs / GOAWAYs / delays. Asserts ≥50 % GET success. | +| `h2load.sh` | Wraps `nghttp2`'s `h2load` against a long-running test server. | +| `sse-test.ts` | Pre-existing manual SSE round-trip. | +| `push-to-loki.ts` | Runs all four timed tests and pushes results to Loki for Grafana. | + +## Automated daily runs + +The `.github/workflows/h2-loadtest.yml` workflow fires at 06:00 UTC every day. +It connects to the dev Tailscale network and runs `push-to-loki.ts`, which +executes each script with CI-appropriate durations (multiplex N=1000, +chaos 30 s, leak 120 s) and posts one structured JSON log line per test to +Loki under `{job="h2-loadtest", test=""}`. + +Results are visible in the **H2 Transport Load Tests** Grafana dashboard on +dev-grafana. Each panel uses `last_over_time(...[1d])` to produce one data +point per daily run, so regressions show up as step changes on the trend lines. + +To push results from a local run (requires Tailscale + access to dev-loki): + +```sh +LOKI_URL=https://dev-loki npx tsx loadtest/push-to-loki.ts +``` + +## Run + +```sh +npx tsx loadtest/h2-multiplex.ts # default N=1000 +npx tsx loadtest/h2-multiplex.ts 10000 +npx tsx loadtest/h2-pool-growth.ts +npx tsx loadtest/h2-leak.ts 600 # 10 min soak +npx tsx loadtest/h2-chaos.ts 60 +./loadtest/h2load.sh 100000 100 32 # needs nghttp2 in $PATH +``` + +For the leak test, run with `node --expose-gc $(which npx) tsx loadtest/h2-leak.ts` +so the periodic `global.gc()` calls fire — otherwise heap-growth signal is +noisy with retained but reclaimable buffers. + +## Reading the output + +Each script prints a single JSON object at the end. Useful invariants: + +- `failures === 0` for `h2-multiplex` and `h2-pool-growth`. +- `heapGrowthMB < 50` for `h2-leak`. +- `getSuccessRate > 0.99` for `h2-chaos` once retries land; today we only + assert `> 0.50` because POST requests are not retried on session loss. + +Throughput numbers depend on the host and should be compared like-for-like, +not against an absolute target. diff --git a/loadtest/h2-chaos.ts b/loadtest/h2-chaos.ts new file mode 100644 index 000000000..81a3e8bdf --- /dev/null +++ b/loadtest/h2-chaos.ts @@ -0,0 +1,181 @@ +/** + * Chaos test — server randomly drops sockets, sends RST_STREAM, GOAWAY, or + * delays headers. Asserts the client survives. + * + * Run: `npx tsx loadtest/h2-chaos.ts [durationSeconds=60]` + */ +import http2 from 'node:http2'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createH2Fetch } from '../src/lib/h2-transport/index'; + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +function makeCerts() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'h2-chaos-')); + const key = path.join(tmp, 'key.pem'); + const cert = path.join(tmp, 'cert.pem'); + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-keyout', + key, + '-out', + cert, + '-days', + '1', + '-nodes', + '-subj', + '/CN=localhost', + ], + { stdio: ['ignore', 'ignore', 'ignore'] }, + ); + return { key: fs.readFileSync(key), cert: fs.readFileSync(cert), tmp }; +} + +async function startServer(seed: number) { + const { key, cert, tmp } = makeCerts(); + let rng = seed >>> 0; + const next = () => { + rng = (rng * 1664525 + 1013904223) >>> 0; + return rng / 0xffffffff; + }; + const server = http2.createSecureServer({ key, cert }); + server.on('stream', (stream) => { + stream.on('error', () => {}); + const r = next(); + if (r < 0.1) { + try { + stream.session?.socket?.destroy(); + } catch {} + return; + } + if (r < 0.2) { + try { + stream.close(0x8); + } catch {} + return; + } + if (r < 0.21) { + stream.respond({ ':status': 200 }); + stream.end('ok'); + try { + stream.session?.goaway(); + } catch {} + return; + } + const delay = r < 0.4 ? Math.floor(next() * 200) : 0; + setTimeout(() => { + if (stream.destroyed) return; + stream.respond({ ':status': 200 }); + stream.end('ok'); + }, delay); + }); + return new Promise<{ port: number; close: () => void }>((resolve) => { + server.listen(0, () => { + resolve({ + port: (server.address() as any).port, + close: () => { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + }, + }); + }); + }); +} + +async function main() { + const durationSec = Number(process.argv[2] ?? 60); + if (!Number.isFinite(durationSec) || durationSec <= 0) { + console.error(`invalid duration: ${process.argv[2]} (must be a positive number of seconds)`); + process.exit(2); + } + const server = await startServer(42); + const fetch = createH2Fetch({ + minConnections: 4, + maxConnections: 20, + connectTimeout: 5_000, + tlsOptions: { rejectUnauthorized: false }, + }); + + const end = Date.now() + durationSec * 1000; + let getOk = 0, + getFail = 0, + postOk = 0, + postFail = 0; + + async function get() { + try { + const r = (await fetch(`https://localhost:${server.port}/x`, { method: 'GET' } as any)) as any; + await r.text(); + getOk++; + } catch { + getFail++; + } + } + async function post() { + try { + const r = (await fetch(`https://localhost:${server.port}/x`, { + method: 'POST', + body: 'b', + } as any)) as any; + await r.text(); + postOk++; + } catch { + postFail++; + } + } + + while (Date.now() < end) { + await Promise.all([...Array.from({ length: 10 }, get), ...Array.from({ length: 5 }, post)]); + } + + await fetch.close(); + server.close(); + + const getTotal = getOk + getFail; + const postTotal = postOk + postFail; + if (getTotal === 0) { + console.error('no GET requests were issued — load harness produced zero coverage'); + process.exit(1); + } + const getRate = getOk / getTotal; + const postClean = postFail === 0 || postOk > 0; + + console.log( + JSON.stringify( + { + getOk, + getFail, + getTotal, + getSuccessRate: Number(getRate.toFixed(3)), + postOk, + postFail, + postTotal, + durationSec, + }, + null, + 2, + ), + ); + + if (getRate < 0.5) { + console.error('GET success rate too low'); + process.exit(1); + } + if (!postClean) { + console.error('POST never succeeded'); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/loadtest/h2-leak.ts b/loadtest/h2-leak.ts new file mode 100644 index 000000000..5e4a0e4ef --- /dev/null +++ b/loadtest/h2-leak.ts @@ -0,0 +1,156 @@ +/** + * Leak / soak test — run at modest concurrency for a configurable window, + * sample heap and FD count, report slope. + * + * Run: `npx tsx loadtest/h2-leak.ts [durationSeconds=600]` + */ +import http2 from 'node:http2'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createH2Fetch } from '../src/lib/h2-transport/index'; + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +function makeCerts() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'h2-leak-')); + const key = path.join(tmp, 'key.pem'); + const cert = path.join(tmp, 'cert.pem'); + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-keyout', + key, + '-out', + cert, + '-days', + '1', + '-nodes', + '-subj', + '/CN=localhost', + ], + { stdio: ['ignore', 'ignore', 'ignore'] }, + ); + return { key: fs.readFileSync(key), cert: fs.readFileSync(cert), tmp }; +} + +function fdCount(): number | null { + try { + return fs.readdirSync('/proc/self/fd').length; + } catch { + return null; + } +} + +async function startServer() { + const { key, cert, tmp } = makeCerts(); + let streamN = 0; + const server = http2.createSecureServer({ key, cert }); + server.on('stream', (stream) => { + streamN++; + const n = streamN; + stream.on('error', () => {}); + stream.respond({ ':status': 200 }); + stream.end(JSON.stringify({ n })); + if (n % 1000 === 0) { + try { + stream.session?.goaway(); + } catch {} + } + }); + return new Promise<{ port: number; close: () => void }>((resolve) => { + server.listen(0, () => { + resolve({ + port: (server.address() as any).port, + close: () => { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + }, + }); + }); + }); +} + +async function main() { + const durationSec = Number(process.argv[2] ?? 600); + const server = await startServer(); + const fetch = createH2Fetch({ + minConnections: 4, + maxConnections: 20, + tlsOptions: { rejectUnauthorized: false }, + }); + + const startMs = Date.now(); + const end = startMs + durationSec * 1000; + let completed = 0; + let failed = 0; + const samples: Array<{ t: number; heap: number; fds: number | null }> = []; + + const sampler = setInterval(() => { + if (global.gc) global.gc(); + samples.push({ + t: Math.round((Date.now() - startMs) / 1000), + heap: process.memoryUsage().heapUsed, + fds: fdCount(), + }); + }, 30_000); + + const RATE = 50; + const tickMs = 20; + const perTick = Math.max(1, Math.round((RATE * tickMs) / 1000)); + + while (Date.now() < end) { + await Promise.all( + Array.from({ length: perTick }, async () => { + try { + const r = (await fetch(`https://localhost:${server.port}/x`, { method: 'GET' } as any)) as any; + await r.text(); + completed++; + } catch { + failed++; + } + }), + ); + await new Promise((r) => setTimeout(r, tickMs)); + } + + clearInterval(sampler); + await fetch.close(); + server.close(); + + const warmup = samples[Math.min(2, samples.length - 1)]?.heap ?? 0; + const final = samples[samples.length - 1]?.heap ?? 0; + const heapGrowthMB = (final - warmup) / (1024 * 1024); + + console.log( + JSON.stringify( + { + durationSec, + completed, + failed, + rps: Math.round(completed / durationSec), + heapWarmupMB: Math.round(warmup / (1024 * 1024)), + heapFinalMB: Math.round(final / (1024 * 1024)), + heapGrowthMB: Math.round(heapGrowthMB), + samples, + }, + null, + 2, + ), + ); + + if (heapGrowthMB > 50) { + console.error(`HEAP GREW BY ${heapGrowthMB.toFixed(1)}MB — possible leak`); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/loadtest/h2-multiplex-server.ts b/loadtest/h2-multiplex-server.ts new file mode 100644 index 000000000..1c591094a --- /dev/null +++ b/loadtest/h2-multiplex-server.ts @@ -0,0 +1,52 @@ +/** + * Standalone multiplex test server for h2load.sh. + * Logs its port on startup and serves a 1KB JSON body for every request. + */ +import http2 from 'node:http2'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'h2load-srv-')); +const keyPath = path.join(tmp, 'key.pem'); +const certPath = path.join(tmp, 'cert.pem'); +execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-keyout', + keyPath, + '-out', + certPath, + '-days', + '1', + '-nodes', + '-subj', + '/CN=localhost', + ], + { stdio: ['ignore', 'ignore', 'ignore'] }, +); +const payload = Buffer.from(JSON.stringify({ ok: true, data: 'x'.repeat(1000) })); + +const server = http2.createSecureServer({ + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), +}); +server.on('stream', (stream) => { + stream.on('error', () => {}); + stream.respond({ ':status': 200, 'content-type': 'application/json' }); + stream.end(payload); +}); +server.listen(0, () => { + const port = (server.address() as any).port; + console.log(`listening on port ${port}`); +}); + +process.on('SIGTERM', () => { + fs.rmSync(tmp, { recursive: true, force: true }); + server.close(() => process.exit(0)); +}); diff --git a/loadtest/h2-multiplex.ts b/loadtest/h2-multiplex.ts new file mode 100644 index 000000000..ce577ff15 --- /dev/null +++ b/loadtest/h2-multiplex.ts @@ -0,0 +1,131 @@ +/** + * In-process multiplex throughput test. + * + * Boots a local h2 server that returns a 1KB JSON body after ~5ms of simulated + * latency, then fires N concurrent requests through createH2Fetch and reports + * throughput, p50/p95/p99, and peak pool size. + * + * Run: `npx tsx loadtest/h2-multiplex.ts [N=1000]` + */ +import http2 from 'node:http2'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createH2Fetch } from '../src/lib/h2-transport/index'; + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +function makeCerts() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'h2-mux-')); + const key = path.join(tmp, 'key.pem'); + const cert = path.join(tmp, 'cert.pem'); + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-keyout', + key, + '-out', + cert, + '-days', + '1', + '-nodes', + '-subj', + '/CN=localhost', + ], + { stdio: ['ignore', 'ignore', 'ignore'] }, + ); + return { key: fs.readFileSync(key), cert: fs.readFileSync(cert), tmp }; +} + +async function startServer(simulatedLatencyMs: number) { + const { key, cert, tmp } = makeCerts(); + const payload = Buffer.from(JSON.stringify({ ok: true, data: 'x'.repeat(1000) })); + const server = http2.createSecureServer({ key, cert }); + server.on('stream', (stream) => { + stream.on('error', () => {}); + setTimeout(() => { + if (stream.destroyed) return; + stream.respond({ ':status': 200, 'content-type': 'application/json' }); + stream.end(payload); + }, simulatedLatencyMs); + }); + return new Promise<{ port: number; close: () => void }>((resolve) => { + server.listen(0, () => { + resolve({ + port: (server.address() as any).port, + close: () => { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + }, + }); + }); + }); +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const i = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); + return sorted[i]!; +} + +async function main() { + const N = Number(process.argv[2] ?? 1000); + const server = await startServer(5); + const fetch = createH2Fetch({ + minConnections: 4, + maxConnections: 20, + tlsOptions: { rejectUnauthorized: false }, + }); + + const latencies: number[] = []; + let failures = 0; + const t0 = process.hrtime.bigint(); + await Promise.all( + Array.from({ length: N }, async (_, i) => { + const start = process.hrtime.bigint(); + try { + const r = (await fetch(`https://localhost:${server.port}/i/${i}`, { method: 'GET' } as any)) as any; + await r.json(); + const us = Number(process.hrtime.bigint() - start) / 1000; + latencies.push(us); + } catch { + failures++; + } + }), + ); + const totalMs = Number(process.hrtime.bigint() - t0) / 1e6; + + latencies.sort((a, b) => a - b); + const successful = latencies.length; + console.log( + JSON.stringify( + { + N, + successful, + failures, + totalMs: Math.round(totalMs), + attemptedRps: Math.round((N / totalMs) * 1000), + effectiveRps: Math.round((successful / totalMs) * 1000), + p50_us: Math.round(percentile(latencies, 50)), + p95_us: Math.round(percentile(latencies, 95)), + p99_us: Math.round(percentile(latencies, 99)), + max_us: Math.round(latencies[latencies.length - 1] ?? 0), + }, + null, + 2, + ), + ); + + await fetch.close(); + server.close(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/loadtest/h2-pool-growth.ts b/loadtest/h2-pool-growth.ts new file mode 100644 index 000000000..80e1675e4 --- /dev/null +++ b/loadtest/h2-pool-growth.ts @@ -0,0 +1,170 @@ +/** + * Pool growth dynamics — ramp concurrency up then down against a real H2Pool, + * sample actual session count over time, then assert grow-on-ramp / + * no-shrink / bounded-by-maxConnections behavior. + * + * Server advertises maxConcurrentStreams=10. We drive the pool directly + * (instead of through createH2Fetch) so we can read `_sessions.length` — + * createH2Fetch's per-origin pool map is closure-private. + * + * Run: `npx tsx loadtest/h2-pool-growth.ts` + */ +import http2 from 'node:http2'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { H2Pool } from '../src/lib/h2-transport/pool'; + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +const MAX_CONNECTIONS = 50; + +function makeCerts() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'h2-grow-')); + const key = path.join(tmp, 'key.pem'); + const cert = path.join(tmp, 'cert.pem'); + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-keyout', + key, + '-out', + cert, + '-days', + '1', + '-nodes', + '-subj', + '/CN=localhost', + ], + { stdio: ['ignore', 'ignore', 'ignore'] }, + ); + return { key: fs.readFileSync(key), cert: fs.readFileSync(cert), tmp }; +} + +async function startServer() { + const { key, cert, tmp } = makeCerts(); + const server = http2.createSecureServer({ key, cert, settings: { maxConcurrentStreams: 10 } }); + server.on('stream', (stream) => { + stream.on('error', () => {}); + setTimeout(() => { + if (stream.destroyed) return; + stream.respond({ ':status': 200 }); + stream.end('ok'); + }, 50); + }); + return new Promise<{ port: number; close: () => void }>((resolve) => { + server.listen(0, () => { + resolve({ + port: (server.address() as any).port, + close: () => { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + }, + }); + }); + }); +} + +async function main() { + const server = await startServer(); + const pool = new H2Pool(`https://localhost:${server.port}`, { + minConnections: 1, + maxConnections: MAX_CONNECTIONS, + tlsOptions: { rejectUnauthorized: false }, + }); + + const observations: Array<{ t: number; concurrency: number; sessions: number }> = []; + const t0 = Date.now(); + let inFlight = 0; + const stages: Record = {}; + let currentStage = ''; + + const sessionsNow = () => (pool as any)._sessions.length as number; + + const sample = () => { + const sessions = sessionsNow(); + observations.push({ t: Date.now() - t0, concurrency: inFlight, sessions }); + if (currentStage) { + const s = stages[currentStage]!; + s.peakSessions = Math.max(s.peakSessions, sessions); + s.peakConcurrency = Math.max(s.peakConcurrency, inFlight); + } + }; + const sampler = setInterval(sample, 100); + + async function burst(name: string, target: number, durationMs: number): Promise { + currentStage = name; + stages[name] = { peakSessions: 0, peakConcurrency: 0 }; + const end = Date.now() + durationMs; + const ongoing = new Set>(); + while (Date.now() < end) { + while (ongoing.size < target && Date.now() < end) { + inFlight++; + const p = (async () => { + try { + const r = await pool.request('/x', 'GET', {}, null); + await r.text(); + } finally { + inFlight--; + } + })(); + ongoing.add(p); + p.finally(() => ongoing.delete(p)); + } + await new Promise((r) => setTimeout(r, 10)); + } + await Promise.all(ongoing); + currentStage = ''; + } + + console.log('ramp: 1 → 50 → 200 → 50 → 1'); + await burst('s1_low', 1, 2_000); + await burst('s2_mid', 50, 5_000); + await burst('s3_peak', 200, 8_000); + await burst('s4_back_mid', 50, 5_000); + await burst('s5_back_low', 1, 5_000); + + clearInterval(sampler); + const finalSessions = sessionsNow(); + await pool.close(); + server.close(); + + const result = { + stages, + peakSessionsOverall: Math.max(...observations.map((o) => o.sessions)), + finalSessions, + samples: observations.length, + }; + console.log(JSON.stringify(result, null, 2)); + + const failures: string[] = []; + if (stages.s3_peak!.peakSessions <= stages.s1_low!.peakSessions) { + failures.push( + `pool did not grow on ramp-up: low=${stages.s1_low!.peakSessions} peak=${stages.s3_peak!.peakSessions}`, + ); + } + if (finalSessions < stages.s3_peak!.peakSessions) { + failures.push( + `pool shrank after ramp-down: peak=${stages.s3_peak!.peakSessions} final=${finalSessions} (no-shrink is documented behavior)`, + ); + } + if (result.peakSessionsOverall > MAX_CONNECTIONS) { + failures.push(`pool exceeded maxConnections: peak=${result.peakSessionsOverall} > ${MAX_CONNECTIONS}`); + } + + if (failures.length) { + for (const f of failures) console.error('FAIL:', f); + process.exit(1); + } + console.log('all invariants held'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/loadtest/h2load.sh b/loadtest/h2load.sh new file mode 100755 index 000000000..a1d26245a --- /dev/null +++ b/loadtest/h2load.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# Wraps nghttp2's h2load against the multiplex test server for an apples-to- +# apples comparison against undici/node-fetch. Requires nghttp2 in $PATH. +# +# Usage: +# ./loadtest/h2load.sh # default: 100k req, 100 conns, 32 streams +# ./loadtest/h2load.sh 10000 50 10 # custom N, connections, streams +set -euo pipefail + +if ! command -v h2load >/dev/null 2>&1; then + echo "h2load not found. Install nghttp2 (apt: nghttp2-client, brew: nghttp2)" >&2 + exit 1 +fi + +N="${1:-100000}" +C="${2:-100}" +M="${3:-32}" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LOG="$(mktemp)" +PORT="" + +cleanup() { + if [[ -n "${SERVER_PGID:-}" ]]; then + # Kill the entire process group so npx + the spawned node process both die. + kill -TERM -- "-${SERVER_PGID}" 2>/dev/null || true + sleep 0.2 + kill -KILL -- "-${SERVER_PGID}" 2>/dev/null || true + fi + rm -f "$LOG" +} +trap cleanup EXIT INT TERM + +# Run the server in its own process group via setsid so we can signal the whole +# group on cleanup. exec replaces the subshell so the PID we capture *is* setsid. +setsid bash -c "cd \"$ROOT\" && exec npx tsx loadtest/h2-multiplex-server.ts" \ + > "$LOG" 2>&1 & +SERVER_PGID=$! + +for _ in $(seq 1 50); do + if grep -q "listening on port" "$LOG" 2>/dev/null; then + PORT="$(grep -o 'port [0-9]\+' "$LOG" | head -1 | awk '{print $2}')" + break + fi + sleep 0.1 +done + +if [[ -z "$PORT" ]]; then + echo "server failed to start; log:" >&2 + cat "$LOG" >&2 + exit 1 +fi + +echo "h2load against https://localhost:$PORT/ (N=$N, C=$C, M=$M)" +# -k: skip TLS verification (server uses an ephemeral self-signed cert) +h2load -k -n "$N" -c "$C" -m "$M" "https://localhost:$PORT/" diff --git a/loadtest/push-to-loki.ts b/loadtest/push-to-loki.ts new file mode 100644 index 000000000..64bab9652 --- /dev/null +++ b/loadtest/push-to-loki.ts @@ -0,0 +1,114 @@ +/** + * Runs all h2-transport load tests and pushes results to Loki as structured + * log entries. One log line per test per run, labelled {job="h2-loadtest", + * test=""}. Grafana reads these via LogQL unwrap to chart trends. + * + * Usage: + * LOKI_URL=https://dev-loki npx tsx loadtest/push-to-loki.ts + * + * LOKI_URL must be the Loki gateway base URL (no trailing slash, no path). + * Optionally set LOKI_USER and LOKI_PASSWORD for basic auth. + */ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const LOKI_URL = process.env['LOKI_URL']; +if (!LOKI_URL) { + console.error('LOKI_URL is required'); + process.exit(1); +} + +const LOKI_USER = process.env['LOKI_USER'] ?? ''; +const LOKI_PASSWORD = process.env['LOKI_PASSWORD'] ?? ''; + +interface Test { + name: string; + script: string; + args?: string[]; +} + +const TESTS: Test[] = [ + { name: 'multiplex', script: 'h2-multiplex.ts', args: ['1000'] }, + { name: 'pool-growth', script: 'h2-pool-growth.ts' }, + { name: 'chaos', script: 'h2-chaos.ts', args: ['30'] }, + { name: 'leak', script: 'h2-leak.ts', args: ['120'] }, +]; + +function runTest(test: Test): unknown { + const scriptPath = path.join(__dirname, test.script); + const args = ['tsx', scriptPath, ...(test.args ?? [])]; + console.log(`running ${test.name}...`); + const stdout = execFileSync('npx', args, { + encoding: 'utf8', + timeout: 300_000, + stdio: ['ignore', 'pipe', 'inherit'], + }); + // Scripts may print progress lines before the final JSON object; extract it. + const match = stdout.match(/\{[\s\S]*\}(?=[^{}]*$)/); + if (!match) throw new Error(`no JSON in output of ${test.name}`); + return JSON.parse(match[0]); +} + +async function pushToLoki(test: string, result: unknown): Promise { + const nowNs = String(BigInt(Date.now()) * 1_000_000n); + const body = JSON.stringify({ + streams: [ + { + stream: { job: 'h2-loadtest', test }, + values: [[nowNs, JSON.stringify(result)]], + }, + ], + }); + + const headers: Record = { 'Content-Type': 'application/json' }; + if (LOKI_USER && LOKI_PASSWORD) { + headers['Authorization'] = 'Basic ' + Buffer.from(`${LOKI_USER}:${LOKI_PASSWORD}`).toString('base64'); + } + + const res = await fetch(`${LOKI_URL}/loki/api/v1/push`, { + method: 'POST', + headers, + body, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Loki push failed for ${test}: ${res.status} ${text}`); + } + console.log(`pushed ${test} → Loki (${res.status})`); +} + +async function main() { + const results: Array<{ test: string; result: unknown }> = []; + + for (const test of TESTS) { + try { + const result = runTest(test); + results.push({ test: test.name, result }); + } catch (err) { + console.error(`${test.name} failed:`, err); + process.exitCode = 1; + } + } + + for (const { test, result } of results) { + try { + await pushToLoki(test, result); + } catch (err) { + console.error(`push failed for ${test}:`, err); + process.exitCode = 1; + } + } + + console.log('\nsummary:'); + for (const { test, result } of results) { + console.log(` ${test}:`, JSON.stringify(result)); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/lib/h2-transport.test.ts b/tests/lib/h2-transport.test.ts deleted file mode 100644 index 60a187577..000000000 --- a/tests/lib/h2-transport.test.ts +++ /dev/null @@ -1,479 +0,0 @@ -import http2 from 'node:http2'; -import { execSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { H2Headers } from '../../src/lib/h2-transport/headers'; -import { H2Response } from '../../src/lib/h2-transport/response'; -import { H2Session, SessionState } from '../../src/lib/h2-transport/session'; -import { H2Pool } from '../../src/lib/h2-transport/pool'; -import { createH2Fetch } from '../../src/lib/h2-transport/index'; - -// --------------------------------------------------------------------------- -// Shared test server infrastructure -// --------------------------------------------------------------------------- - -let tmpDir: string; -let keyPath: string; -let certPath: string; -const testTls = { rejectUnauthorized: false }; - -beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h2-test-')); - keyPath = path.join(tmpDir, 'key.pem'); - certPath = path.join(tmpDir, 'cert.pem'); - execSync( - `openssl req -x509 -newkey rsa:2048 -keyout ${keyPath} -out ${certPath} ` + - `-days 1 -nodes -subj "/CN=localhost" 2>/dev/null`, - ); -}); - -afterAll(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); -}); - -function startTestServer( - handler: (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => void, -): Promise<{ port: number; close: () => Promise }> { - return new Promise((resolve) => { - const sessions = new Set(); - const server = http2.createSecureServer({ - key: fs.readFileSync(keyPath), - cert: fs.readFileSync(certPath), - }); - server.on('stream', (stream, headers) => { - stream.on('error', () => {}); - handler(stream, headers); - }); - server.on('session', (session) => { - sessions.add(session); - session.on('error', () => {}); - session.on('close', () => sessions.delete(session)); - }); - server.listen(0, () => { - const port = (server.address() as any).port; - resolve({ - port, - close: () => - new Promise((res) => { - for (const session of sessions) session.close(); - server.close(() => res()); - }), - }); - }); - }); -} - -function jsonHandler(stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) { - const reqPath = headers[':path'] as string; - const method = headers[':method'] as string; - - if (reqPath === '/echo' && method === 'POST') { - const chunks: Buffer[] = []; - stream.on('data', (chunk: Buffer) => chunks.push(chunk)); - stream.on('end', () => { - const body = Buffer.concat(chunks).toString('utf-8'); - stream.respond({ ':status': 200, 'content-type': 'application/json' }); - stream.end(JSON.stringify({ echoed: body })); - }); - return; - } - - if (reqPath === '/sse') { - stream.respond({ ':status': 200, 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); - const events = [ - 'data: {"id":1,"msg":"hello"}\n\n', - 'data: {"id":2,"msg":"world"}\n\n', - ]; - let i = 0; - const interval = setInterval(() => { - if (i < events.length) { - stream.write(events[i]!); - i++; - } else { - clearInterval(interval); - stream.end(); - } - }, 20); - return; - } - - // Default: return JSON with request info - stream.respond({ ':status': 200, 'content-type': 'application/json', 'x-custom': 'test-value' }); - stream.end(JSON.stringify({ path: reqPath, method })); -} - -// --------------------------------------------------------------------------- -// H2Headers -// --------------------------------------------------------------------------- - -describe('H2Headers', () => { - test('strips pseudo-headers and lowercases keys', () => { - const h = new H2Headers({ ':status': '200' as any, 'Content-Type': 'application/json', 'X-Custom': 'val' }); - expect(h.get('content-type')).toBe('application/json'); - expect(h.get('x-custom')).toBe('val'); - expect(h.get(':status')).toBeNull(); - }); - - test('joins array values with comma', () => { - const h = new H2Headers({ 'set-cookie': ['a=1', 'b=2'] } as any); - expect(h.get('set-cookie')).toBe('a=1, b=2'); - }); - - test('get is case-insensitive', () => { - const h = new H2Headers({ 'Content-Type': 'text/plain' }); - expect(h.get('CONTENT-TYPE')).toBe('text/plain'); - expect(h.get('content-type')).toBe('text/plain'); - }); - - test('entries iterates all non-pseudo headers', () => { - const h = new H2Headers({ ':status': '200' as any, 'a': '1', 'b': '2' }); - const entries = [...h.entries()]; - expect(entries).toEqual([['a', '1'], ['b', '2']]); - }); - - test('returns null for missing headers', () => { - const h = new H2Headers({}); - expect(h.get('x-missing')).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// H2Response -// --------------------------------------------------------------------------- - -describe('H2Response', () => { - function makeResponse(data: string, status = 200): H2Response { - const { ReadableStream } = require('node:stream/web'); - const body = new ReadableStream({ - start(controller: any) { - controller.enqueue(Buffer.from(data)); - controller.close(); - }, - }); - return new H2Response(status, new H2Headers({ 'content-type': 'application/json' }), body, 'https://test/'); - } - - test('.status and .ok', () => { - expect(makeResponse('{}', 200).ok).toBe(true); - expect(makeResponse('{}', 201).ok).toBe(true); - expect(makeResponse('{}', 400).ok).toBe(false); - expect(makeResponse('{}', 500).ok).toBe(false); - expect(makeResponse('{}', 204).status).toBe(204); - }); - - test('.json() parses response body', async () => { - const resp = makeResponse('{"key":"value"}'); - expect(await resp.json()).toEqual({ key: 'value' }); - }); - - test('.text() returns body as string', async () => { - const resp = makeResponse('hello world'); - expect(await resp.text()).toBe('hello world'); - }); - - test('.json() and .text() can be called multiple times', async () => { - const resp = makeResponse('{"a":1}'); - expect(await resp.json()).toEqual({ a: 1 }); - expect(await resp.text()).toBe('{"a":1}'); - }); - - test('.body is a readable stream', async () => { - const resp = makeResponse('streamed'); - const reader = resp.body.getReader(); - const { value, done } = await reader.read(); - expect(done).toBe(false); - expect(Buffer.from(value!).toString()).toBe('streamed'); - reader.releaseLock(); - }); - - test('.url and .headers are set', () => { - const resp = makeResponse('{}'); - expect(resp.url).toBe('https://test/'); - expect(resp.headers.get('content-type')).toBe('application/json'); - }); -}); - -// --------------------------------------------------------------------------- -// H2Session -// --------------------------------------------------------------------------- - -describe('H2Session', () => { - let server: { port: number; close: () => Promise }; - - beforeAll(async () => { - server = await startTestServer(jsonHandler); - }); - - afterAll(async () => { await server.close(); }); - - test('connects and reaches READY state', async () => { - const session = new H2Session(`https://localhost:${server.port}`, { tlsOptions: testTls }); - await session.connect(); - expect(session.state).toBe(SessionState.READY); - expect(session.available).toBe(true); - session.close(); - }); - - test('sends GET and receives JSON response', async () => { - const session = new H2Session(`https://localhost:${server.port}`, { tlsOptions: testTls }); - await session.connect(); - const resp = await session.request('/info', 'GET', {}, null); - expect(resp.status).toBe(200); - const body = await resp.json(); - expect(body.path).toBe('/info'); - expect(body.method).toBe('GET'); - session.close(); - }); - - test('sends POST with body', async () => { - const session = new H2Session(`https://localhost:${server.port}`, { tlsOptions: testTls }); - await session.connect(); - const resp = await session.request('/echo', 'POST', { 'content-type': 'application/json' }, '{"test":true}'); - const body = await resp.json(); - expect(body.echoed).toBe('{"test":true}'); - session.close(); - }); - - test('tracks activeStreams count', async () => { - const session = new H2Session(`https://localhost:${server.port}`, { tlsOptions: testTls }); - await session.connect(); - expect(session.activeStreams).toBe(0); - - const p1 = session.request('/info', 'GET', {}, null); - // activeStreams incremented synchronously in the Promise executor - expect(session.activeStreams).toBe(1); - - await p1; - // Stream is still active until body is consumed - expect(session.activeStreams).toBeGreaterThanOrEqual(0); - session.close(); - }); - - test('rejects request when closed', async () => { - const session = new H2Session(`https://localhost:${server.port}`, { tlsOptions: testTls }); - await session.connect(); - session.close(); - await expect(session.request('/info', 'GET', {}, null)).rejects.toThrow('closed'); - }); - - test('abort signal cancels request', async () => { - const session = new H2Session(`https://localhost:${server.port}`, { tlsOptions: testTls }); - await session.connect(); - const controller = new AbortController(); - controller.abort(); - await expect(session.request('/info', 'GET', {}, null, controller.signal)).rejects.toThrow('aborted'); - session.close(); - }); -}); - -// --------------------------------------------------------------------------- -// H2Pool -// --------------------------------------------------------------------------- - -describe('H2Pool', () => { - let server: { port: number; close: () => Promise }; - - beforeAll(async () => { - server = await startTestServer(jsonHandler); - }); - - afterAll(async () => { await server.close(); }); - - test('dispatches multiple requests', async () => { - const pool = new H2Pool(`https://localhost:${server.port}`, { minConnections: 1, maxConnections: 1, tlsOptions: testTls }); - const resp1 = await pool.request('/item/0', 'GET', {}, null); - expect(resp1.status).toBe(200); - expect((await resp1.json()).path).toBe('/item/0'); - - const resp2 = await pool.request('/item/1', 'GET', {}, null); - expect(resp2.status).toBe(200); - expect((await resp2.json()).path).toBe('/item/1'); - - const resp3 = await pool.request('/item/2', 'GET', {}, null); - expect(resp3.status).toBe(200); - expect((await resp3.json()).path).toBe('/item/2'); - await pool.close(); - }); - - test('queues requests when all sessions are at capacity', async () => { - const pool = new H2Pool(`https://localhost:${server.port}`, { - minConnections: 1, - maxConnections: 1, - maxConcurrentStreams: 2, - tlsOptions: testTls, - }); - const responses = await Promise.all( - Array.from({ length: 4 }, (_, i) => - pool.request(`/item/${i}`, 'GET', {}, null), - ), - ); - expect(responses.length).toBe(4); - for (const resp of responses) { - expect(resp.status).toBe(200); - } - await pool.close(); - }); - - test('rejects after close', async () => { - const pool = new H2Pool(`https://localhost:${server.port}`, { tlsOptions: testTls }); - await pool.close(); - await expect(pool.request('/info', 'GET', {}, null)).rejects.toThrow('closed'); - }); -}); - -// --------------------------------------------------------------------------- -// createH2Fetch (public API) -// --------------------------------------------------------------------------- - -describe('createH2Fetch', () => { - let server: { port: number; close: () => Promise }; - - beforeAll(async () => { - server = await startTestServer(jsonHandler); - }); - - afterAll(async () => { await server.close(); }); - - test('GET request returns JSON', async () => { - const h2Fetch = createH2Fetch({ tlsOptions: testTls }); - const resp = (await h2Fetch(`https://localhost:${server.port}/info`, { - method: 'GET', - headers: {}, - })) as any; - expect(resp.status).toBe(200); - const body = await resp.json(); - expect(body.path).toBe('/info'); - }); - - test('POST request sends body', async () => { - const h2Fetch = createH2Fetch({ tlsOptions: testTls }); - const resp = (await h2Fetch(`https://localhost:${server.port}/echo`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{"sent":true}', - })) as any; - const body = await resp.json(); - expect(body.echoed).toBe('{"sent":true}'); - }); - - test('response headers are accessible', async () => { - const h2Fetch = createH2Fetch({ tlsOptions: testTls }); - const resp = (await h2Fetch(`https://localhost:${server.port}/info`, { - method: 'GET', - headers: {}, - })) as any; - expect(resp.headers.get('content-type')).toBe('application/json'); - expect(resp.headers.get('x-custom')).toBe('test-value'); - }); - - test('SSE streaming via getReader', async () => { - const h2Fetch = createH2Fetch({ tlsOptions: testTls }); - const resp = (await h2Fetch(`https://localhost:${server.port}/sse`, { - method: 'GET', - headers: { accept: 'text/event-stream' }, - })) as any; - expect(resp.headers.get('content-type')).toBe('text/event-stream'); - - const reader = resp.body.getReader(); - const decoder = new TextDecoder(); - const chunks: string[] = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(decoder.decode(value, { stream: true })); - } - reader.releaseLock(); - expect(chunks.length).toBeGreaterThanOrEqual(1); - const combined = chunks.join(''); - expect(combined).toContain('"id":1'); - expect(combined).toContain('"id":2'); - }); - - test('SSE streaming via async iteration', async () => { - const h2Fetch = createH2Fetch({ tlsOptions: testTls }); - const resp = (await h2Fetch(`https://localhost:${server.port}/sse`, { - method: 'GET', - headers: { accept: 'text/event-stream' }, - })) as any; - - const decoder = new TextDecoder(); - const chunks: string[] = []; - for await (const chunk of resp.body) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - expect(chunks.length).toBeGreaterThanOrEqual(1); - const combined = chunks.join(''); - expect(combined).toContain('"id":1'); - expect(combined).toContain('"id":2'); - }); - - test('strips agent from init (node-fetch compat)', async () => { - const h2Fetch = createH2Fetch({ tlsOptions: testTls }); - // The SDK injects `agent` for node-fetch; h2Fetch should ignore it without error - const resp = (await h2Fetch(`https://localhost:${server.port}/info`, { - method: 'GET', - headers: {}, - agent: 'should-be-ignored', - } as any)) as any; - expect(resp.status).toBe(200); - }); -}); - -// --------------------------------------------------------------------------- -// normalizeBody (via index.ts) -// --------------------------------------------------------------------------- - -describe('normalizeBody', () => { - let server: { port: number; close: () => Promise }; - - beforeAll(async () => { - server = await startTestServer(jsonHandler); - }); - - afterAll(async () => { await server.close(); }); - - test('null body (GET request)', async () => { - const h2Fetch = createH2Fetch({ tlsOptions: testTls }); - const resp = (await h2Fetch(`https://localhost:${server.port}/info`, { - method: 'GET', - headers: {}, - })) as any; - expect(resp.status).toBe(200); - }); - - test('string body', async () => { - const h2Fetch = createH2Fetch({ tlsOptions: testTls }); - const resp = (await h2Fetch(`https://localhost:${server.port}/echo`, { - method: 'POST', - headers: {}, - body: 'plain text', - })) as any; - const body = await resp.json(); - expect(body.echoed).toBe('plain text'); - }); - - test('Buffer body', async () => { - const h2Fetch = createH2Fetch({ tlsOptions: testTls }); - const resp = (await h2Fetch(`https://localhost:${server.port}/echo`, { - method: 'POST', - headers: {}, - body: Buffer.from('buffer data'), - })) as any; - const body = await resp.json(); - expect(body.echoed).toBe('buffer data'); - }); - - test('Readable body is buffered', async () => { - const { Readable } = await import('node:stream'); - const h2Fetch = createH2Fetch({ tlsOptions: testTls }); - const stream = Readable.from([Buffer.from('chunk-1|'), Buffer.from('chunk-2')]); - const resp = (await h2Fetch(`https://localhost:${server.port}/echo`, { - method: 'POST', - headers: {}, - body: stream, - })) as any; - const body = await resp.json(); - expect(body.echoed).toBe('chunk-1|chunk-2'); - }); -}); diff --git a/tests/lib/h2-transport/headers.test.ts b/tests/lib/h2-transport/headers.test.ts new file mode 100644 index 000000000..10298b15a --- /dev/null +++ b/tests/lib/h2-transport/headers.test.ts @@ -0,0 +1,52 @@ +import { H2Headers } from '../../../src/lib/h2-transport/headers'; + +describe('H2Headers', () => { + test('strips pseudo-headers and lowercases keys', () => { + const h = new H2Headers({ + ':status': '200' as any, + ':authority': 'example.com' as any, + 'Content-Type': 'application/json', + 'X-Custom': 'val', + }); + expect(h.get('content-type')).toBe('application/json'); + expect(h.get('x-custom')).toBe('val'); + expect(h.get(':status')).toBeNull(); + expect(h.get(':authority')).toBeNull(); + }); + + test('get is case-insensitive', () => { + const h = new H2Headers({ 'Content-Type': 'text/plain' }); + expect(h.get('CONTENT-TYPE')).toBe('text/plain'); + expect(h.get('content-type')).toBe('text/plain'); + expect(h.get('Content-Type')).toBe('text/plain'); + }); + + test('returns null for missing headers', () => { + expect(new H2Headers({}).get('x-missing')).toBeNull(); + }); + + test('joins multi-value (array) headers with ", "', () => { + const h = new H2Headers({ 'set-cookie': ['a=1', 'b=2', 'c=3'] } as any); + expect(h.get('set-cookie')).toBe('a=1, b=2, c=3'); + }); + + test('drops undefined values', () => { + const h = new H2Headers({ 'x-defined': 'yes', 'x-undef': undefined as any }); + expect(h.get('x-defined')).toBe('yes'); + expect(h.get('x-undef')).toBeNull(); + }); + + test('preserves empty-string values', () => { + const h = new H2Headers({ 'x-empty': '' }); + expect(h.get('x-empty')).toBe(''); + expect(h.get('x-missing')).toBeNull(); + }); + + test('entries() yields lowercased keys in insertion order', () => { + const h = new H2Headers({ ':status': '200' as any, 'A-First': '1', 'B-Second': '2' }); + expect([...h.entries()]).toEqual([ + ['a-first', '1'], + ['b-second', '2'], + ]); + }); +}); diff --git a/tests/lib/h2-transport/helpers/certs.ts b/tests/lib/h2-transport/helpers/certs.ts new file mode 100644 index 000000000..efa4d6404 --- /dev/null +++ b/tests/lib/h2-transport/helpers/certs.ts @@ -0,0 +1,55 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +let cached: { key: Buffer; cert: Buffer; tmpDir: string } | null = null; + +/** + * Generate (once per process) a self-signed cert for localhost and return the + * key/cert buffers. Cleanup happens via cleanupCerts() in a global afterAll. + * + * Uses execFileSync (argv array, no shell) so paths containing spaces or + * shell metacharacters — common on Windows/macOS user dirs — don't break. + */ +export function ensureCerts(): { key: Buffer; cert: Buffer } { + if (cached) return { key: cached.key, cert: cached.cert }; + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h2-test-')); + const keyPath = path.join(tmpDir, 'key.pem'); + const certPath = path.join(tmpDir, 'cert.pem'); + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-keyout', + keyPath, + '-out', + certPath, + '-days', + '1', + '-nodes', + '-subj', + '/CN=localhost', + ], + { stdio: ['ignore', 'ignore', 'ignore'] }, + ); + cached = { + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + tmpDir, + }; + return { key: cached.key, cert: cached.cert }; +} + +export function cleanupCerts(): void { + if (cached) { + fs.rmSync(cached.tmpDir, { recursive: true, force: true }); + cached = null; + } +} + +export const testTls = { rejectUnauthorized: false }; diff --git a/tests/lib/h2-transport/helpers/faultServer.ts b/tests/lib/h2-transport/helpers/faultServer.ts new file mode 100644 index 000000000..b57cd61c3 --- /dev/null +++ b/tests/lib/h2-transport/helpers/faultServer.ts @@ -0,0 +1,104 @@ +import http2 from 'node:http2'; +import { ensureCerts } from './certs'; + +export interface FaultPlan { + /** Send GOAWAY to the session as soon as the Nth stream opens. */ + goawayOnStream?: number; + /** RST_STREAM with the given code for the first N streams. */ + rstFirstNStreams?: { count: number; code: number }; + /** Delay headers by this many ms. */ + delayHeadersMs?: number; + /** Destroy the TCP socket after the Nth stream opens. */ + destroySocketOnStream?: number; +} + +export interface FaultServer { + port: number; + origin: string; + setPlan: (plan: FaultPlan) => void; + streamCount: () => number; + close: () => Promise; +} + +/** + * A fault-injection HTTP/2 server. Each test configures a FaultPlan describing + * exactly which streams should misbehave (GOAWAY mid-flight, RST_STREAM, slow + * headers, raw socket drop). The server otherwise behaves like defaultHandler. + */ +export function startFaultServer(): Promise { + return new Promise((resolve) => { + const { key, cert } = ensureCerts(); + const sessions = new Set(); + const server = http2.createSecureServer({ key, cert }); + + let plan: FaultPlan = {}; + let streamN = 0; + + server.on('stream', (stream, headers) => { + streamN++; + const n = streamN; + stream.on('error', () => {}); + + if (plan.rstFirstNStreams && n <= plan.rstFirstNStreams.count) { + stream.close(plan.rstFirstNStreams.code); + return; + } + + if (plan.destroySocketOnStream === n) { + stream.session?.socket?.destroy(); + return; + } + + if (plan.goawayOnStream === n) { + // GOAWAY this session after responding; the client should see the response + // *and* notice the session is no longer usable for new streams. + stream.respond({ ':status': 200, 'content-type': 'text/plain' }); + stream.end('ok-then-goaway'); + stream.session?.goaway(); + return; + } + + const respond = () => { + const reqPath = (headers[':path'] as string) ?? '/'; + const method = (headers[':method'] as string) ?? 'GET'; + stream.respond({ ':status': 200, 'content-type': 'application/json' }); + stream.end(JSON.stringify({ path: reqPath, method, streamN: n })); + }; + + if (plan.delayHeadersMs) { + const t = setTimeout(respond, plan.delayHeadersMs); + stream.on('close', () => clearTimeout(t)); + } else { + respond(); + } + }); + + server.on('session', (s) => { + sessions.add(s); + s.on('error', () => {}); + s.on('close', () => sessions.delete(s)); + }); + + server.listen(0, () => { + const port = (server.address() as any).port; + resolve({ + port, + origin: `https://localhost:${port}`, + setPlan: (p) => { + plan = p; + streamN = 0; + }, + streamCount: () => streamN, + close: () => + new Promise((res) => { + for (const s of sessions) { + try { + s.destroy(); + } catch {} + } + server.close(() => res()); + }), + }); + }); + }); +} diff --git a/tests/lib/h2-transport/helpers/testServer.ts b/tests/lib/h2-transport/helpers/testServer.ts new file mode 100644 index 000000000..547c53a85 --- /dev/null +++ b/tests/lib/h2-transport/helpers/testServer.ts @@ -0,0 +1,206 @@ +import http2 from 'node:http2'; +import net from 'node:net'; +import { ensureCerts } from './certs'; + +export type StreamHandler = (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => void; + +export interface TestServer { + port: number; + origin: string; + /** Active HTTP/2 server sessions — useful to forcibly GOAWAY/close them mid-test. */ + sessions: Set; + close: () => Promise; + /** Send GOAWAY to every connected session and wait for them to close. */ + goawayAll: () => Promise; +} + +export interface ServerOptions { + settings?: http2.Settings; +} + +/** + * Boot an in-process TLS HTTP/2 server with a self-signed cert. + * + * The handler receives every stream. Stream errors are swallowed so the test + * isn't littered with "Error: " from intentional aborts/resets. + */ +export function startTestServer(handler: StreamHandler, opts: ServerOptions = {}): Promise { + return new Promise((resolve) => { + const { key, cert } = ensureCerts(); + const sessions = new Set(); + const server = http2.createSecureServer({ key, cert, settings: opts.settings }); + + server.on('stream', (stream, headers) => { + stream.on('error', () => {}); + handler(stream, headers); + }); + server.on('session', (session) => { + sessions.add(session); + session.on('error', () => {}); + session.on('close', () => sessions.delete(session)); + }); + + server.listen(0, () => { + const port = (server.address() as any).port; + resolve({ + port, + origin: `https://localhost:${port}`, + sessions, + close: () => + new Promise((res) => { + for (const session of sessions) { + try { + session.destroy(); + } catch {} + } + server.close(() => res()); + }), + goawayAll: () => + new Promise((res) => { + if (sessions.size === 0) return res(); + const snapshot = [...sessions]; + let remaining = snapshot.length; + const done = () => { + if (--remaining <= 0) { + clearTimeout(fallback); + res(); + } + }; + // Fallback: if a peer doesn't close within 1s, destroy sessions. + // GOAWAY does not obligate the peer to close promptly, so without + // this the promise could hang indefinitely. + const fallback = setTimeout(() => { + for (const s of snapshot) { + try { + s.destroy(); + } catch {} + } + }, 1000); + for (const session of snapshot) { + session.once('close', done); + try { + session.goaway(); + } catch { + done(); + } + } + }), + }); + }); + }); +} + +/** + * A raw TCP listener that accepts connections and never speaks HTTP/2. + * Used to drive H2Session connect-timeout coverage. + */ +export function startBlackholeServer(): Promise<{ port: number; close: () => Promise }> { + return new Promise((resolve) => { + const sockets = new Set(); + const server = net.createServer((s) => { + sockets.add(s); + s.on('close', () => sockets.delete(s)); + }); + server.listen(0, () => { + const port = (server.address() as any).port; + resolve({ + port, + close: () => + new Promise((res) => { + for (const s of sockets) s.destroy(); + server.close(() => res()); + }), + }); + }); + }); +} + +/** + * Generic JSON echo + introspection handler used by most tests. + * - POST /echo → returns {echoed: } + * - GET /sse → server-sent events stream + * - GET /slow?ms=N → respond after N ms + * - GET /large?bytes=N → respond with N bytes of body + * - any /headers → echo received request headers + * - any /trailers → respond + send trailer block + * - any * → {path, method} + */ +export function defaultHandler(stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders): void { + const reqPath = (headers[':path'] as string) ?? '/'; + const method = (headers[':method'] as string) ?? 'GET'; + + if (reqPath === '/echo') { + const chunks: Buffer[] = []; + stream.on('data', (c: Buffer) => chunks.push(c)); + stream.on('end', () => { + const body = Buffer.concat(chunks).toString('utf-8'); + stream.respond({ ':status': 200, 'content-type': 'application/json' }); + stream.end(JSON.stringify({ echoed: body })); + }); + return; + } + + if (reqPath === '/sse') { + stream.respond({ ':status': 200, 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + const events = ['data: {"id":1,"msg":"hello"}\n\n', 'data: {"id":2,"msg":"world"}\n\n']; + let i = 0; + const interval = setInterval(() => { + if (i < events.length) { + stream.write(events[i]!); + i++; + } else { + clearInterval(interval); + stream.end(); + } + }, 10); + stream.on('close', () => clearInterval(interval)); + return; + } + + if (reqPath.startsWith('/slow')) { + const ms = Number(new URL(`http://x${reqPath}`).searchParams.get('ms') ?? 100); + const timer = setTimeout(() => { + if (stream.destroyed) return; + stream.respond({ ':status': 200, 'content-type': 'text/plain' }); + stream.end('slow'); + }, ms); + stream.on('close', () => clearTimeout(timer)); + return; + } + + if (reqPath.startsWith('/large')) { + const bytes = Number(new URL(`http://x${reqPath}`).searchParams.get('bytes') ?? 1024); + stream.respond({ ':status': 200, 'content-type': 'application/octet-stream' }); + stream.end(Buffer.alloc(bytes, 0xab)); + return; + } + + if (reqPath === '/headers') { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (k.startsWith(':')) continue; + out[k] = Array.isArray(v) ? v.join(', ') : (v as string); + } + stream.respond({ ':status': 200, 'content-type': 'application/json' }); + stream.end(JSON.stringify(out)); + return; + } + + if (reqPath === '/trailers') { + stream.respond({ ':status': 200, 'content-type': 'text/plain' }, { waitForTrailers: true }); + stream.on('wantTrailers', () => { + stream.sendTrailers({ 'x-trailer': 'value' }); + }); + stream.end('body'); + return; + } + + if (reqPath === '/204') { + stream.respond({ ':status': 204 }); + stream.end(); + return; + } + + stream.respond({ ':status': 200, 'content-type': 'application/json', 'x-custom': 'test-value' }); + stream.end(JSON.stringify({ path: reqPath, method })); +} diff --git a/tests/lib/h2-transport/index.test.ts b/tests/lib/h2-transport/index.test.ts new file mode 100644 index 000000000..4765be31b --- /dev/null +++ b/tests/lib/h2-transport/index.test.ts @@ -0,0 +1,163 @@ +import { Readable } from 'node:stream'; +import { MultipartBody } from '../../../src/_shims/MultipartBody'; +import { createH2Fetch } from '../../../src/lib/h2-transport/index'; +import { cleanupCerts, testTls } from './helpers/certs'; +import { defaultHandler, startTestServer, TestServer } from './helpers/testServer'; + +describe('createH2Fetch', () => { + let server: TestServer; + + beforeAll(async () => { + server = await startTestServer(defaultHandler); + }); + + afterAll(async () => { + await server.close(); + cleanupCerts(); + }); + + test('GET returns JSON', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/info?q=1`, { method: 'GET', headers: {} })) as any; + expect(r.status).toBe(200); + expect(r.url).toBe(`${server.origin}/info?q=1`); + const body = await r.json(); + expect(body.path).toBe('/info?q=1'); + await fetch.close(); + }); + + test('headers are lowercased before being sent', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/headers`, { + method: 'GET', + headers: { 'X-Mixed-Case': 'yes', Accept: 'application/json' }, + })) as any; + const echoed = await r.json(); + expect(echoed['x-mixed-case']).toBe('yes'); + expect(echoed['accept']).toBe('application/json'); + await fetch.close(); + }); + + test('headers accepts Headers-like (with .entries()) and plain record', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const hdrsLike = { + entries() { + return [ + ['X-Iter', 'one'], + ['X-Other', 'two'], + ][Symbol.iterator](); + }, + }; + const r = (await fetch(`${server.origin}/headers`, { method: 'GET', headers: hdrsLike as any })) as any; + const echoed = await r.json(); + expect(echoed['x-iter']).toBe('one'); + expect(echoed['x-other']).toBe('two'); + await fetch.close(); + }); + + test('URL object and string URL both work', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const u = new URL(`${server.origin}/info`); + const r = (await fetch(u, { method: 'GET', headers: {} })) as any; + expect(r.status).toBe(200); + await fetch.close(); + }); + + test('same origin reuses one pool; distinct origins get distinct pools', async () => { + const second = await startTestServer(defaultHandler); + try { + const fetch = createH2Fetch({ tlsOptions: testTls }); + await fetch(`${server.origin}/info`, { method: 'GET' } as any); + await fetch(`${server.origin}/info`, { method: 'GET' } as any); + await fetch(`${second.origin}/info`, { method: 'GET' } as any); + expect(server.sessions.size).toBeGreaterThanOrEqual(1); + expect(second.sessions.size).toBeGreaterThanOrEqual(1); + await fetch.close(); + } finally { + await second.close(); + } + }); + + test('close() closes every pool and is safe to call twice', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + await fetch(`${server.origin}/info`, { method: 'GET' } as any); + await fetch.close(); + await expect(fetch.close()).resolves.toBeUndefined(); + }); + + test('null body (GET)', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/info`, { method: 'GET' } as any)) as any; + expect(r.status).toBe(200); + await fetch.close(); + }); + + test('string body', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/echo`, { + method: 'POST', + body: 'plain text', + } as any)) as any; + expect((await r.json()).echoed).toBe('plain text'); + await fetch.close(); + }); + + test('Buffer body', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/echo`, { + method: 'POST', + body: Buffer.from('buffer data'), + } as any)) as any; + expect((await r.json()).echoed).toBe('buffer data'); + await fetch.close(); + }); + + test('Uint8Array / ArrayBuffer body', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const u8 = new TextEncoder().encode('typed-array body'); + const r1 = (await fetch(`${server.origin}/echo`, { method: 'POST', body: u8 } as any)) as any; + expect((await r1.json()).echoed).toBe('typed-array body'); + + const ab = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + const r2 = (await fetch(`${server.origin}/echo`, { method: 'POST', body: ab } as any)) as any; + expect((await r2.json()).echoed).toBe('typed-array body'); + await fetch.close(); + }); + + test('MultipartBody is unwrapped and re-normalized', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const inner = Buffer.from('inside multipart'); + const mp = new MultipartBody(inner); + const r = (await fetch(`${server.origin}/echo`, { method: 'POST', body: mp } as any)) as any; + expect((await r.json()).echoed).toBe('inside multipart'); + await fetch.close(); + }); + + test('Readable body is buffered', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const stream = Readable.from([Buffer.from('chunk-1|'), Buffer.from('chunk-2')]); + const r = (await fetch(`${server.origin}/echo`, { method: 'POST', body: stream } as any)) as any; + expect((await r.json()).echoed).toBe('chunk-1|chunk-2'); + await fetch.close(); + }); + + test('node-fetch `agent` init field is ignored', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/info`, { + method: 'GET', + agent: 'should-be-ignored', + } as any)) as any; + expect(r.status).toBe(200); + await fetch.close(); + }); + + test('throws on Node < 18', () => { + const real = process.versions.node; + Object.defineProperty(process.versions, 'node', { value: '16.20.0', configurable: true }); + try { + expect(() => createH2Fetch()).toThrow(/Node\.js 18/); + } finally { + Object.defineProperty(process.versions, 'node', { value: real, configurable: true }); + } + }); +}); diff --git a/tests/lib/h2-transport/integration.test.ts b/tests/lib/h2-transport/integration.test.ts new file mode 100644 index 000000000..0bf9b54eb --- /dev/null +++ b/tests/lib/h2-transport/integration.test.ts @@ -0,0 +1,136 @@ +import { createH2Fetch } from '../../../src/lib/h2-transport/index'; +import { cleanupCerts, testTls } from './helpers/certs'; +import { defaultHandler, startTestServer, TestServer } from './helpers/testServer'; + +describe('integration through createH2Fetch', () => { + let server: TestServer; + + beforeAll(async () => { + server = await startTestServer(defaultHandler); + }); + + afterAll(async () => { + await server.close(); + cleanupCerts(); + }); + + test('POST /echo round-trip with JSON body', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/echo`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"sent":true}', + } as any)) as any; + expect(r.status).toBe(200); + expect(r.headers.get('content-type')).toBe('application/json'); + expect((await r.json()).echoed).toBe('{"sent":true}'); + await fetch.close(); + }); + + test('SSE streaming via getReader', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/sse`, { + method: 'GET', + headers: { accept: 'text/event-stream' }, + } as any)) as any; + expect(r.headers.get('content-type')).toBe('text/event-stream'); + + const reader = r.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + } + expect(buf).toContain('"id":1'); + expect(buf).toContain('"id":2'); + await fetch.close(); + }); + + test('SSE streaming via async iteration', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/sse`, { + method: 'GET', + headers: { accept: 'text/event-stream' }, + } as any)) as any; + + const decoder = new TextDecoder(); + let buf = ''; + for await (const chunk of r.body) { + buf += decoder.decode(chunk, { stream: true }); + } + expect(buf).toContain('"id":1'); + expect(buf).toContain('"id":2'); + await fetch.close(); + }); + + test('1MB binary download arrives byte-identical', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const bytes = 1024 * 1024; + const r = (await fetch(`${server.origin}/large?bytes=${bytes}`, { method: 'GET' } as any)) as any; + const buf = Buffer.from(await r.arrayBuffer()); + expect(buf.length).toBe(bytes); + expect(buf[0]).toBe(0xab); + expect(buf[bytes - 1]).toBe(0xab); + await fetch.close(); + }); + + test('1MB binary upload arrives byte-identical at server', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const payload = Buffer.alloc(1024 * 1024, 0x42); + const r = (await fetch(`${server.origin}/echo`, { + method: 'POST', + body: payload, + } as any)) as any; + const echoed = (await r.json()).echoed as string; + expect(echoed.length).toBe(payload.length); + expect(echoed.charCodeAt(0)).toBe(0x42); + expect(echoed.charCodeAt(echoed.length - 1)).toBe(0x42); + await fetch.close(); + }); + + test('204 No Content does not hang on .text()', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/204`, { method: 'GET' } as any)) as any; + expect(r.status).toBe(204); + expect(await r.text()).toBe(''); + await fetch.close(); + }); + + test('trailers are silently dropped (documented behavior)', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/trailers`, { method: 'GET' } as any)) as any; + expect(await r.text()).toBe('body'); + expect(r.headers.get('x-trailer')).toBeNull(); + await fetch.close(); + }); + + test('concurrent requests to two origins do not cross-talk', async () => { + const other = await startTestServer(defaultHandler); + const fetch = createH2Fetch({ tlsOptions: testTls }); + try { + const [a, b] = await Promise.all([ + fetch(`${server.origin}/info`, { method: 'GET' } as any), + fetch(`${other.origin}/info`, { method: 'GET' } as any), + ]); + expect((a as any).url.startsWith(server.origin)).toBe(true); + expect((b as any).url.startsWith(other.origin)).toBe(true); + } finally { + await fetch.close(); + await other.close(); + } + }); + + test('AbortSignal propagates end-to-end through createH2Fetch', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + try { + const ac = new AbortController(); + const p = fetch(`${server.origin}/slow?ms=5000`, { method: 'GET', signal: ac.signal } as any); + setTimeout(() => ac.abort(), 30); + await expect(p).rejects.toThrow(); + } finally { + await fetch.close(); + } + }); +}); diff --git a/tests/lib/h2-transport/pool.test.ts b/tests/lib/h2-transport/pool.test.ts new file mode 100644 index 000000000..2e0bd1fb7 --- /dev/null +++ b/tests/lib/h2-transport/pool.test.ts @@ -0,0 +1,130 @@ +import { H2Pool } from '../../../src/lib/h2-transport/pool'; +import { cleanupCerts, testTls } from './helpers/certs'; +import { defaultHandler, startTestServer, TestServer } from './helpers/testServer'; + +describe('H2Pool', () => { + let server: TestServer; + + beforeAll(async () => { + server = await startTestServer(defaultHandler); + }); + + afterAll(async () => { + await server.close(); + cleanupCerts(); + }); + + test('first request initializes the pool', async () => { + const pool = new H2Pool(server.origin, { minConnections: 2, tlsOptions: testTls }); + const r = await pool.request('/info', 'GET', {}, null); + expect(r.status).toBe(200); + expect((pool as any)._sessions.length).toBeGreaterThanOrEqual(2); + await pool.close(); + }); + + test('dispatches multiple sequential requests on one pool', async () => { + const pool = new H2Pool(server.origin, { minConnections: 1, maxConnections: 1, tlsOptions: testTls }); + for (let i = 0; i < 5; i++) { + const r = await pool.request(`/item/${i}`, 'GET', {}, null); + expect((await r.json()).path).toBe(`/item/${i}`); + } + await pool.close(); + }); + + test('queues requests when sessions are saturated', async () => { + const pool = new H2Pool(server.origin, { + minConnections: 1, + maxConnections: 1, + maxConcurrentStreams: 2, + tlsOptions: testTls, + }); + const responses = await Promise.all( + Array.from({ length: 6 }, (_, i) => pool.request(`/item/${i}`, 'GET', {}, null)), + ); + expect(responses).toHaveLength(6); + for (const r of responses) expect(r.status).toBe(200); + await pool.close(); + }); + + test('concurrent first requests share one _initPromise', async () => { + const pool = new H2Pool(server.origin, { minConnections: 4, tlsOptions: testTls }); + const responses = await Promise.all( + Array.from({ length: 8 }, (_, i) => pool.request(`/x/${i}`, 'GET', {}, null)), + ); + for (const r of responses) expect(r.status).toBe(200); + expect((pool as any)._sessions.length).toBeLessThanOrEqual(8); + await pool.close(); + }); + + test('init failure is not cached', async () => { + const pool = new H2Pool('https://localhost:1', { minConnections: 1, tlsOptions: testTls }); + await expect(pool.request('/x', 'GET', {}, null)).rejects.toThrow(); + expect((pool as any)._initPromise).toBeNull(); + await pool.close(); + }); + + test('grows past minConnections when saturated, up to maxConnections', async () => { + const pool = new H2Pool(server.origin, { + minConnections: 1, + maxConnections: 3, + maxConcurrentStreams: 1, + tlsOptions: testTls, + }); + await pool.request('/warm', 'GET', {}, null); + await Promise.all(Array.from({ length: 9 }, (_, i) => pool.request(`/grow/${i}`, 'GET', {}, null))); + expect((pool as any)._sessions.length).toBeGreaterThan(1); + expect((pool as any)._sessions.length).toBeLessThanOrEqual(3); + await pool.close(); + }); + + test('_findAvailable picks the least-loaded READY session', async () => { + const pool = new H2Pool(server.origin, { + minConnections: 3, + maxConnections: 3, + maxConcurrentStreams: 10, + tlsOptions: testTls, + }); + await pool.request('/warm', 'GET', {}, null); + await new Promise((r) => setImmediate(r)); + + const sessions = (pool as any)._sessions as Array<{ activeStreams: number }>; + const ps = Array.from({ length: 6 }, () => pool.request('/slow?ms=80', 'GET', {}, null)); + await new Promise((r) => setImmediate(r)); + const counts = sessions.map((s) => s.activeStreams); + const max = Math.max(...counts); + const min = Math.min(...counts); + expect(max - min).toBeLessThanOrEqual(1); + await Promise.all(ps); + await pool.close(); + }); + + test('close() rejects queued requests with "Pool is closed"', async () => { + const pool = new H2Pool(server.origin, { + minConnections: 1, + maxConnections: 1, + maxConcurrentStreams: 1, + tlsOptions: testTls, + }); + await pool.request('/warm', 'GET', {}, null); + const slow = pool.request('/slow?ms=500', 'GET', {}, null); + const queued = pool.request('/info', 'GET', {}, null); + // Attach the assertion BEFORE close() to avoid an unhandled rejection window. + const queuedAssertion = expect(queued).rejects.toThrow(/closed/); + const slowSettled = slow.catch(() => {}); + await new Promise((r) => setImmediate(r)); + await pool.close(); + await queuedAssertion; + await slowSettled; + }); + + test('request after close() rejects', async () => { + const pool = new H2Pool(server.origin, { tlsOptions: testTls }); + await pool.close(); + await expect(pool.request('/x', 'GET', {}, null)).rejects.toThrow(/closed/); + }); + + test('close() before initialize() is safe', async () => { + const pool = new H2Pool(server.origin, { tlsOptions: testTls }); + await expect(pool.close()).resolves.toBeUndefined(); + }); +}); diff --git a/tests/lib/h2-transport/regression.test.ts b/tests/lib/h2-transport/regression.test.ts new file mode 100644 index 000000000..5c87ddddb --- /dev/null +++ b/tests/lib/h2-transport/regression.test.ts @@ -0,0 +1,205 @@ +/** + * Named regression cases R1–R13 from src/lib/h2-transport/testing.md §5. + * + * Each test maps to a specific source comment or invariant. Keep the R# tags + * in the test name so a failure points back to the documented behavior. + */ +import { H2Pool } from '../../../src/lib/h2-transport/pool'; +import { H2Session, SessionState } from '../../../src/lib/h2-transport/session'; +import { createH2Fetch } from '../../../src/lib/h2-transport/index'; +import { cleanupCerts, testTls } from './helpers/certs'; +import { defaultHandler, startTestServer, TestServer } from './helpers/testServer'; +import { startFaultServer, FaultServer } from './helpers/faultServer'; + +async function waitFor(predicate: () => boolean, timeoutMs: number, stepMs = 10): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await new Promise((r) => setTimeout(r, stepMs)); + } + return predicate(); +} + +describe('h2-transport regression suite', () => { + let server: TestServer; + let fault: FaultServer; + + beforeAll(async () => { + server = await startTestServer(defaultHandler); + fault = await startFaultServer(); + }); + + afterAll(async () => { + await server.close(); + await fault.close(); + cleanupCerts(); + }); + + test('R1: 100 concurrent requests during init spread across sessions', async () => { + const pool = new H2Pool(server.origin, { + minConnections: 4, + maxConnections: 8, + maxConcurrentStreams: 100, + tlsOptions: testTls, + }); + const responses = await Promise.all( + Array.from({ length: 100 }, (_, i) => pool.request(`/r1/${i}`, 'GET', {}, null)), + ); + expect(responses).toHaveLength(100); + for (const r of responses) expect(r.status).toBe(200); + expect((pool as any)._sessions.length).toBeLessThanOrEqual(8); + await pool.close(); + }); + + test('R2a: GET is transparently retried on GOAWAY race', async () => { + fault.setPlan({ goawayOnStream: 1 }); + const pool = new H2Pool(fault.origin, { + minConnections: 1, + maxConnections: 4, + tlsOptions: testTls, + }); + const r1 = await pool.request('/r2', 'GET', {}, null); + expect(r1.status).toBe(200); + await new Promise((res) => setTimeout(res, 50)); + const r2 = await pool.request('/r2', 'GET', {}, null); + expect(r2.status).toBe(200); + await pool.close(); + }); + + test('R2b: POST on a draining/closed session is not retried — error surfaces', async () => { + // Drive the negative case at the session level (deterministic) rather than + // the pool level (timing-dependent). Pool's RETRYABLE_METHODS excludes POST + // by construction, so once session.request rejects, the pool re-raises. + const local = await startTestServer(defaultHandler); + try { + const s = new H2Session(local.origin, { tlsOptions: testTls }); + await s.connect(); + // One successful request to ensure the session is fully wired before GOAWAY. + await (await s.request('/info', 'GET', {}, null)).text(); + for (const sess of local.sessions) sess.goaway(); + const drained = await waitFor( + () => s.state === SessionState.DRAINING || s.state === SessionState.CLOSED, + 1000, + ); + expect(drained).toBe(true); + await expect(s.request('/r2', 'POST', {}, 'body')).rejects.toThrow(); + } finally { + await local.close(); + } + }); + + test('R4: init failure is not cached — retry on a later request', async () => { + const pool = new H2Pool('https://localhost:1', { minConnections: 1, tlsOptions: testTls }); + await expect(pool.request('/x', 'GET', {}, null)).rejects.toThrow(); + expect((pool as any)._initPromise).toBeNull(); + await pool.close(); + }); + + test('R5: activeStreams increments synchronously for all concurrent requests', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + const promises = Array.from({ length: 10 }, () => s.request('/info', 'GET', {}, null)); + expect(s.activeStreams).toBe(10); + expect(s.available).toBe(true); + await Promise.all(promises.map((p) => p.then((r) => r.text()))); + await s.close(); + }); + + test('R6: GOAWAY while streams open keeps DRAINING until last completes', async () => { + const local = await startTestServer(defaultHandler); + try { + const s = new H2Session(local.origin, { tlsOptions: testTls }); + await s.connect(); + const slow = s.request('/slow?ms=500', 'GET', {}, null); + expect(s.activeStreams).toBe(1); + // Let the HEADERS frame reach the server before we GOAWAY, otherwise the + // server may process GOAWAY before knowing about the stream. + await new Promise((r) => setTimeout(r, 50)); + expect(local.sessions.size).toBeGreaterThan(0); + for (const sess of local.sessions) sess.goaway(); + const draining = await waitFor(() => s.state === SessionState.DRAINING, 500); + expect(draining).toBe(true); + expect(s.activeStreams).toBe(1); + const r = await slow; + await r.text(); + const closed = await waitFor(() => s.state === SessionState.CLOSED, 500); + expect(closed).toBe(true); + } finally { + await local.close(); + } + }); + + test('R7: AbortSignal listener is removed on every code path', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + + { + const ac = new AbortController(); + const r = await s.request('/info', 'GET', {}, null, ac.signal); + await r.text(); + await new Promise((r) => setImmediate(r)); + ac.abort(); + } + + { + const ac = new AbortController(); + const p = s.request('/slow?ms=5000', 'GET', {}, null, ac.signal); + setTimeout(() => ac.abort(), 30); + await expect(p).rejects.toThrow(); + ac.abort(); + } + + expect(s.activeStreams).toBe(0); + await s.close(); + }); + + test('R8: createH2Fetch ignores init.agent without falling back to h1', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const r = (await fetch(`${server.origin}/info`, { + method: 'GET', + agent: { fake: true } as any, + } as any)) as any; + expect(r.status).toBe(200); + await fetch.close(); + }); + + test('R11: AbortController cleanly cancels a slow response', async () => { + const fetch = createH2Fetch({ tlsOptions: testTls }); + const ac = new AbortController(); + setTimeout(() => ac.abort(), 50); + await expect( + fetch(`${server.origin}/slow?ms=5000`, { method: 'GET', signal: ac.signal } as any), + ).rejects.toThrow(); + await fetch.close(); + }); + + test('R12: client adopts server-advertised maxConcurrentStreams', async () => { + const small = await startTestServer(defaultHandler, { settings: { maxConcurrentStreams: 2 } }); + try { + // Test directly at the session level — pool-level reliable queueing + // around the settings race is a known sharp edge (see testing.md §9). + const s = new H2Session(small.origin, { tlsOptions: testTls }); + await s.connect(); + // Poll for remoteSettings to land rather than relying on a fixed delay. + const adopted = await waitFor(() => (s as any)._maxConcurrentStreams === 2, 2000); + expect(adopted).toBe(true); + await s.close(); + } finally { + await small.close(); + } + }); + + test('R13: RST_STREAM does not poison the pool', async () => { + const REFUSED_STREAM = 0x7; + fault.setPlan({ rstFirstNStreams: { count: 1, code: REFUSED_STREAM } }); + const pool = new H2Pool(fault.origin, { + minConnections: 1, + maxConnections: 2, + tlsOptions: testTls, + }); + await pool.request('/warm', 'GET', {}, null).catch(() => {}); + const r = await pool.request('/after-rst', 'GET', {}, null); + expect(r.status).toBe(200); + await pool.close(); + }); +}); diff --git a/tests/lib/h2-transport/response.test.ts b/tests/lib/h2-transport/response.test.ts new file mode 100644 index 000000000..a7ec95961 --- /dev/null +++ b/tests/lib/h2-transport/response.test.ts @@ -0,0 +1,88 @@ +import { ReadableStream } from 'node:stream/web'; +import { H2Headers } from '../../../src/lib/h2-transport/headers'; +import { H2Response } from '../../../src/lib/h2-transport/response'; + +function makeResponse(data: string | Buffer, status = 200, contentType = 'application/json'): H2Response { + const buf = typeof data === 'string' ? Buffer.from(data) : data; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buf)); + controller.close(); + }, + }); + return new H2Response( + status, + new H2Headers({ 'content-type': contentType }), + body, + 'https://test/path?q=1', + ); +} + +describe('H2Response', () => { + test('.status and .ok', () => { + expect(makeResponse('', 200).ok).toBe(true); + expect(makeResponse('', 201).ok).toBe(true); + expect(makeResponse('', 299).ok).toBe(true); + expect(makeResponse('', 300).ok).toBe(false); + expect(makeResponse('', 400).ok).toBe(false); + expect(makeResponse('', 500).ok).toBe(false); + expect(makeResponse('', 204).status).toBe(204); + }); + + test('.url echoes constructor input verbatim including query string', () => { + expect(makeResponse('').url).toBe('https://test/path?q=1'); + }); + + test('.text() returns UTF-8 string', async () => { + expect(await makeResponse('hello world').text()).toBe('hello world'); + expect(await makeResponse('héllo 世界').text()).toBe('héllo 世界'); + }); + + test('.json() parses body', async () => { + expect(await makeResponse('{"key":"value","n":1}').json()).toEqual({ key: 'value', n: 1 }); + }); + + test('.text() then .json() returns the same parsed value (cached buffer)', async () => { + const r = makeResponse('{"a":1}'); + expect(await r.text()).toBe('{"a":1}'); + expect(await r.json()).toEqual({ a: 1 }); + }); + + test('.text() twice returns the same string', async () => { + const r = makeResponse('repeatable'); + expect(await r.text()).toBe('repeatable'); + expect(await r.text()).toBe('repeatable'); + }); + + test('.arrayBuffer() returns a tight ArrayBuffer', async () => { + const r = makeResponse(Buffer.from([1, 2, 3, 4])); + const ab = await r.arrayBuffer(); + expect(ab.byteLength).toBe(4); + expect(new Uint8Array(ab)).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + test('.blob() carries the content-type', async () => { + const r = makeResponse('hello', 200, 'text/plain'); + const blob = await r.blob(); + expect(blob.type).toBe('text/plain'); + expect(await blob.text()).toBe('hello'); + }); + + test('.json() on empty body throws SyntaxError', async () => { + await expect(makeResponse('').json()).rejects.toThrow(SyntaxError); + }); + + // Note: reading .body directly bypasses _bodyConsumed tracking, so a + // subsequent .text()/.json() call sees an empty stream and returns "" rather + // than throwing. This is documented current behavior — fixing it would + // require wrapping .body in a tracking ReadableStream. See testing.md §9. + test('reading .body directly then calling .text() returns empty (current behavior)', async () => { + const r = makeResponse('payload'); + const reader = r.body.getReader(); + while (!(await reader.read()).done) { + /* drain */ + } + reader.releaseLock(); + expect(await r.text()).toBe(''); + }); +}); diff --git a/tests/lib/h2-transport/session.test.ts b/tests/lib/h2-transport/session.test.ts new file mode 100644 index 000000000..ccfb50f3b --- /dev/null +++ b/tests/lib/h2-transport/session.test.ts @@ -0,0 +1,260 @@ +import { H2Session, SessionState } from '../../../src/lib/h2-transport/session'; +import { cleanupCerts, testTls } from './helpers/certs'; +import { startBlackholeServer, startTestServer, defaultHandler, TestServer } from './helpers/testServer'; + +async function waitFor(predicate: () => boolean, timeoutMs = 1000, stepMs = 5): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await new Promise((r) => setTimeout(r, stepMs)); + } + return predicate(); +} + +/** + * Wraps an AbortSignal to instrument `addEventListener`/`removeEventListener` + * for `'abort'`. Tests can assert that `activeAbortListeners() === 0` after + * cleanup to prove the request layer balances add/remove. + */ +function instrumentedAbortSignal(): { + signal: AbortSignal; + abort: () => void; + activeAbortListeners: () => number; +} { + const ac = new AbortController(); + let count = 0; + const realAdd = ac.signal.addEventListener.bind(ac.signal); + const realRemove = ac.signal.removeEventListener.bind(ac.signal); + (ac.signal as any).addEventListener = (type: string, listener: any, options?: any) => { + if (type === 'abort') count++; + return realAdd(type, listener, options); + }; + (ac.signal as any).removeEventListener = (type: string, listener: any, options?: any) => { + if (type === 'abort') count--; + return realRemove(type, listener, options); + }; + return { signal: ac.signal, abort: () => ac.abort(), activeAbortListeners: () => count }; +} + +describe('H2Session', () => { + let server: TestServer; + + beforeAll(async () => { + server = await startTestServer(defaultHandler); + }); + + afterAll(async () => { + await server.close(); + cleanupCerts(); + }); + + test('connects and reaches READY', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + expect(s.state).toBe(SessionState.READY); + expect(s.available).toBe(true); + await s.close(); + }); + + test('connect() is idempotent — concurrent callers share one promise', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + const p1 = s.connect(); + const p2 = s.connect(); + expect(p1).toBe(p2); + await Promise.all([p1, p2]); + await s.close(); + }); + + test('connect timeout rejects after the configured ms', async () => { + const blackhole = await startBlackholeServer(); + const s = new H2Session(`https://localhost:${blackhole.port}`, { + connectTimeout: 50, + tlsOptions: testTls, + }); + await expect(s.connect()).rejects.toThrow(/timeout/i); + await blackhole.close(); + }); + + test('GET returns JSON', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + const r = await s.request('/info', 'GET', {}, null); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.path).toBe('/info'); + expect(body.method).toBe('GET'); + await s.close(); + }); + + test('POST with string body', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + const r = await s.request('/echo', 'POST', { 'content-type': 'application/json' }, '{"x":1}'); + expect((await r.json()).echoed).toBe('{"x":1}'); + await s.close(); + }); + + test('POST with empty string body', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + const r = await s.request('/echo', 'POST', {}, ''); + expect((await r.json()).echoed).toBe(''); + await s.close(); + }); + + test('POST with large (>64KB) buffer body — flow control does not deadlock', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + const big = Buffer.alloc(128 * 1024, 0x41); + const r = await s.request('/echo', 'POST', {}, big); + const body = await r.json(); + expect(body.echoed.length).toBe(big.length); + await s.close(); + }); + + test('activeStreams increments synchronously inside request()', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + expect(s.activeStreams).toBe(0); + + const p1 = s.request('/info', 'GET', {}, null); + expect(s.activeStreams).toBe(1); + + const p2 = s.request('/info', 'GET', {}, null); + expect(s.activeStreams).toBe(2); + + await Promise.all([p1.then((r) => r.text()), p2.then((r) => r.text())]); + await new Promise((r) => setImmediate(r)); + expect(s.activeStreams).toBe(0); + await s.close(); + }); + + test('available is false when activeStreams >= maxConcurrentStreams', async () => { + const s = new H2Session(server.origin, { maxConcurrentStreams: 2, tlsOptions: testTls }); + await s.connect(); + const p1 = s.request('/info', 'GET', {}, null); + const p2 = s.request('/info', 'GET', {}, null); + expect(s.activeStreams).toBe(2); + expect(s.available).toBe(false); + await Promise.all([p1.then((r) => r.text()), p2.then((r) => r.text())]); + await new Promise((r) => setImmediate(r)); + expect(s.available).toBe(true); + await s.close(); + }); + + test('adopts server-advertised maxConcurrentStreams when no opt was given', async () => { + const constrained = await startTestServer(defaultHandler, { settings: { maxConcurrentStreams: 3 } }); + try { + const s = new H2Session(constrained.origin, { tlsOptions: testTls }); + await s.connect(); + // Poll for remoteSettings to be observed rather than sleeping a fixed amount. + const adopted = await waitFor(() => (s as any)._maxConcurrentStreams === 3, 2000); + expect(adopted).toBe(true); + await s.close(); + } finally { + await constrained.close(); + } + }); + + test('explicit maxConcurrentStreams opt overrides server SETTINGS', async () => { + const constrained = await startTestServer(defaultHandler, { settings: { maxConcurrentStreams: 1 } }); + try { + const s = new H2Session(constrained.origin, { maxConcurrentStreams: 5, tlsOptions: testTls }); + await s.connect(); + // Drive a round-trip request so we know the SETTINGS frame has been + // processed by the time we read _maxConcurrentStreams. + await (await s.request('/info', 'GET', {}, null)).text(); + expect((s as any)._maxConcurrentStreams).toBe(5); + await s.close(); + } finally { + await constrained.close(); + } + }); + + test('request after close() rejects', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + await s.close(); + await expect(s.request('/info', 'GET', {}, null)).rejects.toThrow(/closed/); + }); + + test('close() is idempotent', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + await s.close(); + await expect(s.close()).resolves.toBeUndefined(); + }); + + test('close() on never-connected session is safe', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await expect(s.close()).resolves.toBeUndefined(); + }); + + test('pre-aborted signal rejects immediately and decrements activeStreams', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + const ac = new AbortController(); + ac.abort(); + await expect(s.request('/info', 'GET', {}, null, ac.signal)).rejects.toThrow(/aborted/i); + expect(s.activeStreams).toBe(0); + await s.close(); + }); + + test('mid-flight abort cancels a slow request', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + const ac = new AbortController(); + const p = s.request('/slow?ms=5000', 'GET', {}, null, ac.signal); + setTimeout(() => ac.abort(), 30); + await expect(p).rejects.toThrow(); + await new Promise((r) => setImmediate(r)); + expect(s.activeStreams).toBe(0); + await s.close(); + }); + + test('abort signal listener is removed on success (no listener leak)', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + const ai = instrumentedAbortSignal(); + const r = await s.request('/info', 'GET', {}, null, ai.signal); + await r.text(); + // Listener cleanup happens in stream.once('close', cleanup), so wait until + // the count actually goes to zero rather than racing on a single tick. + const settled = await waitFor(() => ai.activeAbortListeners() === 0, 500); + expect(settled).toBe(true); + await s.close(); + }); + + test('abort signal listener is removed after mid-flight abort', async () => { + const s = new H2Session(server.origin, { tlsOptions: testTls }); + await s.connect(); + const ai = instrumentedAbortSignal(); + const p = s.request('/slow?ms=5000', 'GET', {}, null, ai.signal); + setTimeout(ai.abort, 30); + await expect(p).rejects.toThrow(); + const settled = await waitFor(() => ai.activeAbortListeners() === 0, 500); + expect(settled).toBe(true); + await s.close(); + }); + + test('GOAWAY with zero active streams transitions to CLOSED and fires onClose', async () => { + const local = await startTestServer(defaultHandler); + try { + const s = new H2Session(local.origin, { tlsOptions: testTls }); + let closed = false; + s.onClose = () => { + closed = true; + }; + await s.connect(); + await (await s.request('/info', 'GET', {}, null)).text(); + await new Promise((r) => setImmediate(r)); + // Server-side GOAWAY without waiting for the server's close event + for (const sess of local.sessions) sess.goaway(); + await new Promise((r) => setTimeout(r, 100)); + expect(s.state).toBe(SessionState.CLOSED); + expect(closed).toBe(true); + } finally { + await local.close(); + } + }); +});