From aabaaa7dc94f90351a2ca67d038d5a435de4cf0e Mon Sep 17 00:00:00 2001 From: Samuel Gbafa Date: Fri, 31 Jul 2026 14:41:10 -0400 Subject: [PATCH] perf: implement TC-408 optimization --- package.json | 4 +- test/load/http-edge/README.md | 122 +++++ test/load/http-edge/probe.mjs | 751 +++++++++++++++++++++++++++++ test/load/http-edge/probe.test.mjs | 414 ++++++++++++++++ 4 files changed, 1290 insertions(+), 1 deletion(-) create mode 100644 test/load/http-edge/README.md create mode 100644 test/load/http-edge/probe.mjs create mode 100644 test/load/http-edge/probe.test.mjs diff --git a/package.json b/package.json index 18e3f8cc..14f54dda 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "scripts": { "release": "cargo build --release", "gen:capabilities": "node scripts/gen-capabilities.mjs", - "check:capabilities": "node scripts/gen-capabilities.mjs --check" + "check:capabilities": "node scripts/gen-capabilities.mjs --check", + "test:http-edge-probe": "node --test test/load/http-edge/probe.test.mjs", + "probe:http-edge": "node test/load/http-edge/probe.mjs" }, "dependencies": { "zod": "3.22.4", diff --git a/test/load/http-edge/README.md b/test/load/http-edge/README.md new file mode 100644 index 00000000..20cd425c --- /dev/null +++ b/test/load/http-edge/README.md @@ -0,0 +1,122 @@ +# HTTP edge protocol probe (TC-408) + +Diagnostic CLI for measuring ALPN negotiation, HTTP/1.1 vs HTTP/2 connection +reuse, and latency at a TLS ingress in front of tinycloud-node, **without** +changing Rocket, deployment, CORS, authorization, replay, revocation, or the +LAN proxy. This tool is read-only: it only ever issues unauthenticated GET +requests against `/healthz`. + +## What it measures + +- Certificate identity/fingerprint, negotiated ALPN protocol, response + protocol/status, remote address, HTTP/2 peer settings, request timings, + errors, connection/socket counts, and HTTP/2 stream IDs. +- HTTP/1.1 traffic uses one bounded keep-alive `https.Agent`; HTTP/2 traffic + uses one explicitly created `http2` client session. Socket and session + creation are counted directly by wrapping each connection factory, not + inferred from request completion. +- Warm HTTP/1.1 and HTTP/2 rounds alternate at concurrency 1, 8, and 32. DNS + lookup, TCP connect, and TLS handshake time are recorded separately from + request latency; one warm-up round per concurrency level is executed but + excluded from the reported p50/p95/p99. + +## What it never records + +Authorization headers, cookies, request/response bodies, credentials, or any +URL containing user data. The CLI only accepts a bare `https://` origin (no +embedded credentials, path, query, or fragment) and requires `--path` to be +on a fixed allowlist that today contains exactly `/healthz` — the only +unauthenticated, side-effect-free GET route tinycloud-node exposes. Every +other route either requires an Authorization header or mutates state and is +out of scope for this tool. Errors are reported as a sanitized `{ code, kind +}` pair; raw Node error messages (which can echo connection options) are +never surfaced. This redaction is sentinel-tested in `probe.test.mjs`. + +## Fail-closed validation + +The probe exits non-zero and sets `"ok": false` in its JSON output whenever: + +- ALPN is missing, or a forced HTTP/1.1 connection fails to negotiate + `http/1.1`, or the HTTP/2 session fails to negotiate `h2`. +- A response status does not match `--expected-status`, a request times out, + or a response is truncated by the `--max-response-bytes` bound. +- Sample counts are incomplete (fewer successes than attempted requests). +- HTTP/2 stream IDs are not distinct (accounting ambiguity). +- **Concurrency-32 HTTP/2 specifically** also requires 32 distinct successful + streams multiplexed on exactly one client TLS connection, and a peer + `maxConcurrentStreams >= 32`. Anything else — including a mid-run + reconnect — fails that result closed. + +The full JSON report is built in memory and written in a single atomic write +at the end of the run, so a crash mid-run cannot produce truncated or +malformed output; any uncaught error instead yields a minimal +`{ ok: false, fatal: true, reason }` envelope. + +## Usage + +```bash +node test/load/http-edge/probe.mjs \ + --origin https://node.tinycloud.xyz \ + --path /healthz \ + --out /tmp/tc408-node-probe.json + +npm run probe:http-edge -- --origin https://node.tinycloud.xyz --path /healthz +npm run test:http-edge-probe +``` + +Key flags (all bounded; see `probe.mjs` for exact limits): `--concurrency` +(default `1,8,32`), `--rounds`, `--warmup-rounds`, `--max-response-bytes`, +`--request-timeout-ms`, `--connection-max-lifetime-ms`, `--expected-status`, +and `--ca` (adds a trusted CA for verification, e.g. for a private ingress — +it never disables certificate validation). + +## Results as of 2026-07-31 (provisional) + +Ran once against each of `node.tinycloud.xyz` and `tee.node.tinycloud.xyz` +using the command above at default settings (concurrency 1/8/32, 3 measured +rounds, 1 warm-up round). **These two runs are provisional spot checks of +the ingress in front of these two hosts on this date — they are not a +complete inventory of every TinyCloud ingress, and they are not a substitute +for a proper production ALPN trace** (see follow-ups below). Treat any +numbers here as indicative, not as an SLA baseline. + +| Host | ALPN (h1 forced / h2) | Connections at c=1/8/32 (h1 / h2) | h2 peer maxConcurrentStreams | p50 ms h1 vs h2 (c=32) | Overall verdict | +|---|---|---|---|---|---| +| `node.tinycloud.xyz` | `http/1.1` / `h2` | 1,8,32 / 1,1,1 | 100 | 425.9 / 421.4 | `ok: true` | +| `tee.node.tinycloud.xyz` | `http/1.1` / `h2` | 1,8,32 / 1,1,1 | 100 | 405.5 / 406.1 | `ok: true` | + +Both hosts already negotiate `h2` at the TLS edge and hold every +HTTP/2 concurrency level (1, 8, and 32) on exactly one client TLS +connection, while forced HTTP/1.1 opens one socket per concurrent request +(up to the bound). Neither run is a complete picture of production +behavior under real client traffic; re-run the two commands above and +attach fresh JSON artifacts before relying on these numbers for a +go/no-go decision. + +## Follow-ups that do not block this PR + +- **Production ALPN traces**: a longer-running, packet-level capture (e.g. + via the ingress's own access logs or a `tcpdump`/`tshark` trace) across all + production ingresses, not just the two spot-checked above. +- **Deployed ingress inspection**: enumerate every TinyCloud ingress/host and + confirm HTTP/2 is enabled and configured consistently across all of them. +- **Upstream socket telemetry**: instrument the ingress-to-Rocket keep-alive + pool itself (connection count, reuse rate, queueing) rather than inferring + it from the client side, as this probe does. +- **Rollout and fallback plan**: a staged plan for enabling `h2` at each + ingress with a documented fallback to HTTP/1.1 if a client or intermediary + misbehaves. +- **HTTP/3 evaluation on a representative network**: measure on a + lossy/high-latency network profile (e.g. simulated mobile 3G/4G) before + deciding whether HTTP/3 is worth pursuing. + +## Why HTTP/3 is deferred + +An `alt-svc` header advertising `h3` does not, by itself, prove a client +actually used QUIC or that doing so improved anything — it has to be +measured on a representative lossy network, which is one of the follow-ups +above. Direct-ingress QUIC/HTTP/3 support has not been verified for our +deployment target. Separately, TLS/QUIC 0-RTT resumption is a replay risk +for mutating invocations and requires its own security review before it can +be enabled for anything beyond idempotent GETs — it is out of scope here and +remains disabled. diff --git a/test/load/http-edge/probe.mjs b/test/load/http-edge/probe.mjs new file mode 100644 index 00000000..ea49b8bf --- /dev/null +++ b/test/load/http-edge/probe.mjs @@ -0,0 +1,751 @@ +#!/usr/bin/env node +// HTTP edge protocol probe: measures ALPN negotiation, HTTP/1.1 vs HTTP/2 +// connection reuse, and latency against a single public, read-only endpoint. +// +// This tool never sends or logs Authorization headers, cookies, bodies, or +// credentials. It only ever issues unauthenticated GET requests against the +// `/healthz` path, which is the sole endpoint on the allowlist below. Output +// is a single JSON document written atomically at the end of the run; the +// process fails closed (non-zero exit, explicit `ok: false`) on any missing +// ALPN, protocol/status mismatch, incomplete samples, connection-accounting +// ambiguity, timeout, or truncation. + +import { parseArgs } from 'node:util'; +import { performance } from 'node:perf_hooks'; +import https from 'node:https'; +import http2 from 'node:http2'; +import tls from 'node:tls'; +import fs from 'node:fs'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; + +// Only paths on this allowlist may be probed. `/healthz` is the only +// unauthenticated, side-effect-free GET endpoint exposed by tinycloud-node +// (see routes/mod.rs); every other route either requires an Authorization +// header or mutates state, and is out of scope for this diagnostic tool. +const SAFE_PATH_ALLOWLIST = new Set(['/healthz']); + +const LIMITS = { + maxConcurrency: 64, + maxConcurrencyLevels: 6, + maxRounds: 10, + maxWarmupRounds: 3, + maxResponseBytes: 1_048_576, + maxRequestTimeoutMs: 30_000, + maxConnectionLifetimeMs: 300_000, +}; + +const DEFAULTS = { + concurrency: '1,8,32', + rounds: 3, + warmupRounds: 1, + maxResponseBytes: 65_536, + requestTimeoutMs: 5_000, + connectionMaxLifetimeMs: 60_000, + expectedStatus: 200, +}; + +class ProbeError extends Error {} + +function parseCliArgs(argv) { + const { values } = parseArgs({ + args: argv, + options: { + origin: { type: 'string' }, + path: { type: 'string' }, + concurrency: { type: 'string', default: DEFAULTS.concurrency }, + rounds: { type: 'string', default: String(DEFAULTS.rounds) }, + 'warmup-rounds': { type: 'string', default: String(DEFAULTS.warmupRounds) }, + 'max-response-bytes': { type: 'string', default: String(DEFAULTS.maxResponseBytes) }, + 'request-timeout-ms': { type: 'string', default: String(DEFAULTS.requestTimeoutMs) }, + 'connection-max-lifetime-ms': { type: 'string', default: String(DEFAULTS.connectionMaxLifetimeMs) }, + 'expected-status': { type: 'string', default: String(DEFAULTS.expectedStatus) }, + ca: { type: 'string' }, + out: { type: 'string' }, + help: { type: 'boolean', default: false }, + }, + allowPositionals: false, + strict: true, + }); + return values; +} + +function requireHttpsOrigin(rawOrigin) { + if (!rawOrigin) { + throw new ProbeError('--origin is required and must be an https:// origin'); + } + let url; + try { + url = new URL(rawOrigin); + } catch { + throw new ProbeError(`--origin is not a valid URL`); + } + if (url.protocol !== 'https:') { + throw new ProbeError('--origin must use https:// (plaintext HTTP is not permitted)'); + } + if (url.username || url.password) { + throw new ProbeError('--origin must not contain credentials'); + } + if (url.pathname !== '/' || url.search || url.hash) { + throw new ProbeError('--origin must be a bare scheme+host[:port], use --path for the request path'); + } + return `${url.protocol}//${url.host}`; +} + +function requireSafePath(rawPath) { + if (!rawPath || !SAFE_PATH_ALLOWLIST.has(rawPath)) { + throw new ProbeError( + `--path must be one of the allowlisted safe public paths: ${[...SAFE_PATH_ALLOWLIST].join(', ')}`, + ); + } + return rawPath; +} + +// Requires a canonical decimal integer (no leading/trailing junk, no +// fractional or hex/octal forms) so malformed values like "1junk" or "5x" +// are rejected outright rather than silently truncated by parseInt. +const CANONICAL_INT = /^-?(0|[1-9]\d*)$/; + +function boundedInt(name, raw, { min, max }) { + const trimmed = typeof raw === 'string' ? raw.trim() : ''; + if (!CANONICAL_INT.test(trimmed)) { + throw new ProbeError(`--${name} must be an integer between ${min} and ${max}`); + } + const n = Number(trimmed); + if (!Number.isSafeInteger(n) || n < min || n > max) { + throw new ProbeError(`--${name} must be an integer between ${min} and ${max}`); + } + return n; +} + +function parseConcurrencyLevels(raw) { + const parts = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (parts.length === 0) { + throw new ProbeError('--concurrency must list at least one level'); + } + if (parts.length > LIMITS.maxConcurrencyLevels) { + throw new ProbeError(`--concurrency must list at most ${LIMITS.maxConcurrencyLevels} levels`); + } + const levels = parts.map((s) => boundedInt('concurrency', s, { min: 1, max: LIMITS.maxConcurrency })); + if (new Set(levels).size !== levels.length) { + throw new ProbeError('--concurrency must not contain duplicate levels'); + } + return levels; +} + +function buildConfig(values) { + const origin = requireHttpsOrigin(values.origin); + const path = requireSafePath(values.path); + const concurrencyLevels = parseConcurrencyLevels(values.concurrency); + const rounds = boundedInt('rounds', values.rounds, { min: 1, max: LIMITS.maxRounds }); + const warmupRounds = boundedInt('warmup-rounds', values['warmup-rounds'], { + min: 0, + max: LIMITS.maxWarmupRounds, + }); + const maxResponseBytes = boundedInt('max-response-bytes', values['max-response-bytes'], { + min: 1, + max: LIMITS.maxResponseBytes, + }); + const requestTimeoutMs = boundedInt('request-timeout-ms', values['request-timeout-ms'], { + min: 1, + max: LIMITS.maxRequestTimeoutMs, + }); + const connectionMaxLifetimeMs = boundedInt( + 'connection-max-lifetime-ms', + values['connection-max-lifetime-ms'], + { min: 1_000, max: LIMITS.maxConnectionLifetimeMs }, + ); + const expectedStatus = boundedInt('expected-status', values['expected-status'], { min: 100, max: 599 }); + let ca; + if (values.ca) { + ca = fs.readFileSync(values.ca); + } + return { + origin, + path, + concurrencyLevels, + rounds, + warmupRounds, + maxResponseBytes, + requestTimeoutMs, + connectionMaxLifetimeMs, + expectedStatus, + ca, + out: values.out, + }; +} + +function percentile(sortedValues, p) { + if (sortedValues.length === 0) return null; + const idx = Math.min(sortedValues.length - 1, Math.ceil((p / 100) * sortedValues.length) - 1); + return sortedValues[Math.max(0, idx)]; +} + +function summarizeLatency(samples) { + const sorted = [...samples].sort((a, b) => a - b); + return { + samples: sorted.length, + min: sorted.length ? sorted[0] : null, + max: sorted.length ? sorted[sorted.length - 1] : null, + p50: percentile(sorted, 50), + p95: percentile(sorted, 95), + p99: percentile(sorted, 99), + }; +} + +// Only a fixed, non-sensitive classification of an error is ever recorded. +// Raw error messages are never included since Node error messages can echo +// back connection options. +function classifyError(err) { + const code = typeof err?.code === 'string' ? err.code : 'ERR_UNKNOWN'; + if (err?.probeTimeout) return { code: 'ERR_PROBE_TIMEOUT', kind: 'timeout' }; + if (err?.probeTruncated) return { code: 'ERR_PROBE_TRUNCATED', kind: 'truncated' }; + if (code.startsWith('ERR_TLS') || code.includes('CERT')) return { code, kind: 'tls' }; + if (code === 'ECONNRESET' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT') { + return { code, kind: 'network' }; + } + return { code, kind: 'other' }; +} + +function certSummary(socket) { + const cert = typeof socket.getPeerCertificate === 'function' ? socket.getPeerCertificate() : null; + if (!cert || !cert.subject) return { fingerprint256: null, subject: null }; + const subject = Object.entries(cert.subject) + .map(([k, v]) => `${k}=${v}`) + .join(', '); + return { fingerprint256: cert.fingerprint256 ?? null, subject }; +} + +// Counts socket creation directly by wrapping the Agent's own connection +// factory rather than inferring counts from request completion. +class CountingHttpsAgent extends https.Agent { + constructor(opts) { + super(opts); + this.socketsCreated = 0; + this.handshakes = []; + // Every socket's connection info is retained (not just the most recent) + // so a mismatched or missing ALPN on an earlier socket cannot be lost. + this.connectionInfos = []; + } + + createConnection(options, callback) { + const start = performance.now(); + const socket = super.createConnection({ ...options, ALPNProtocols: ['http/1.1'] }, callback); + this.socketsCreated += 1; + const timing = {}; + socket.once('lookup', () => { + timing.dnsMs = performance.now() - start; + }); + socket.once('connect', () => { + timing.tcpMs = performance.now() - start; + }); + socket.once('secureConnect', () => { + timing.tlsMs = performance.now() - start; + this.handshakes.push({ ...timing, totalMs: timing.tlsMs }); + this.connectionInfos.push({ + alpnProtocol: socket.alpnProtocol, + remoteAddress: socket.remoteAddress, + remotePort: socket.remotePort, + ...certSummary(socket), + }); + }); + return socket; + } +} + +function connectHttp2Session(origin, ca) { + const counters = { connectionsCreated: 0, handshakes: [], connectionInfos: [] }; + const url = new URL(origin); + const session = http2.connect(origin, { + ca, + createConnection: (authority, options) => { + const start = performance.now(); + const socket = tls.connect({ + host: url.hostname, + port: Number(url.port) || 443, + servername: url.hostname, + ALPNProtocols: ['h2'], + ca, + ...options, + }); + counters.connectionsCreated += 1; + const timing = {}; + socket.once('lookup', () => { + timing.dnsMs = performance.now() - start; + }); + socket.once('connect', () => { + timing.tcpMs = performance.now() - start; + }); + socket.once('secureConnect', () => { + timing.tlsMs = performance.now() - start; + counters.handshakes.push({ ...timing, totalMs: timing.tlsMs }); + counters.connectionInfos.push({ + alpnProtocol: socket.alpnProtocol, + remoteAddress: socket.remoteAddress, + remotePort: socket.remotePort, + ...certSummary(socket), + }); + }); + return socket; + }, + }); + return { session, counters }; +} + +function doHttp1Request(agent, origin, requestPath, { timeoutMs, maxResponseBytes, expectedStatus }) { + return new Promise((resolve) => { + const start = performance.now(); + let bytes = 0; + let settled = false; + // `finish` resolves exactly once and is called directly from the + // timeout/truncation branches instead of being deferred to a later + // 'error' event, which never fires once the request/response has + // already been destroyed with `settled` set — that gap previously left + // the returned promise (and Promise.all in the caller) hanging forever. + const finish = (result) => { + if (settled) return; + settled = true; + resolve(result); + }; + const req = https.request( + `${origin}${requestPath}`, + { agent, method: 'GET', timeout: timeoutMs }, + (res) => { + res.on('data', (chunk) => { + bytes += chunk.length; + if (bytes > maxResponseBytes) { + const err = new Error('response too large'); + err.probeTruncated = true; + finish({ ok: false, error: classifyError(err) }); + res.destroy(); + } + }); + res.on('end', () => { + finish({ + ok: true, + protocol: `http/${res.httpVersion}`, + status: res.statusCode, + statusOk: res.statusCode === expectedStatus, + durationMs: performance.now() - start, + }); + }); + res.on('error', (err) => { + finish({ ok: false, error: classifyError(err) }); + }); + }, + ); + req.on('timeout', () => { + const err = new Error('request timeout'); + err.probeTimeout = true; + finish({ ok: false, error: classifyError(err) }); + req.destroy(); + }); + req.on('error', (err) => { + finish({ ok: false, error: classifyError(err) }); + }); + req.end(); + }); +} + +function doHttp2Request(session, requestPath, { timeoutMs, maxResponseBytes, expectedStatus }) { + return new Promise((resolve) => { + const start = performance.now(); + let bytes = 0; + let settled = false; + let status = null; + let streamId = null; + const finish = (result) => { + if (settled) return; + settled = true; + resolve(result); + }; + const stream = session.request({ ':method': 'GET', ':path': requestPath }); + streamId = stream.id ?? null; + stream.setTimeout(timeoutMs, () => { + const err = new Error('stream timeout'); + err.probeTimeout = true; + finish({ ok: false, error: classifyError(err), streamId: stream.id ?? streamId }); + stream.destroy(err); + }); + stream.on('response', (headers) => { + status = headers[':status']; + streamId = stream.id ?? streamId; + }); + stream.on('data', (chunk) => { + bytes += chunk.length; + if (bytes > maxResponseBytes) { + const err = new Error('response too large'); + err.probeTruncated = true; + finish({ ok: false, error: classifyError(err), streamId: stream.id ?? streamId }); + stream.close(http2.constants.NGHTTP2_CANCEL); + } + }); + stream.on('end', () => { + finish({ + ok: true, + protocol: 'h2', + status, + statusOk: status === expectedStatus, + streamId, + durationMs: performance.now() - start, + }); + }); + stream.on('error', (err) => { + finish({ ok: false, error: classifyError(err), streamId: stream.id ?? streamId }); + }); + stream.end(); + }); +} + +async function runRound({ protocol, concurrency, isWarmup, runner, config }) { + const outcomes = await Promise.all(Array.from({ length: concurrency }, () => runner())); + const succeeded = outcomes.filter((o) => o.ok && o.statusOk); + const mismatched = outcomes.filter((o) => o.ok && !o.statusOk); + const failed = outcomes.filter((o) => !o.ok); + const timedOut = failed.filter((o) => o.error?.kind === 'timeout'); + const truncated = failed.filter((o) => o.error?.kind === 'truncated'); + // Compare against the protocol string each request path actually reports + // (doHttp1Request reports "http/", doHttp2Request always "h2"), + // not against the round's short 'h1'/'h2' label, which would never match. + const expectedProtocol = protocol === 'h1' ? 'http/1.1' : 'h2'; + const protocolMismatch = outcomes.filter((o) => o.ok && o.protocol !== expectedProtocol); + return { + protocol, + concurrency, + isWarmup, + attempted: concurrency, + succeeded: succeeded.length, + statusMismatched: mismatched.length, + failed: failed.length, + timedOut: timedOut.length, + truncated: truncated.length, + protocolMismatched: protocolMismatch.length, + latencies: isWarmup ? [] : succeeded.map((o) => o.durationMs), + streamIds: protocol === 'h2' ? succeeded.map((o) => o.streamId).filter((id) => id !== null) : null, + errors: failed.map((o) => o.error), + }; +} + +function buildInterleavedPlan(rounds, warmupRounds) { + const plan = []; + const totalRounds = warmupRounds + rounds; + for (let i = 0; i < totalRounds; i += 1) { + const isWarmup = i < warmupRounds; + plan.push({ protocol: 'h1', isWarmup, forced: true }); + plan.push({ protocol: 'h2', isWarmup, forced: true }); + } + return plan; +} + +function aggregateConcurrencyResults(roundResults, concurrency, protocol) { + const measured = roundResults.filter( + (r) => r.concurrency === concurrency && r.protocol === protocol && !r.isWarmup, + ); + const latencies = measured.flatMap((r) => r.latencies); + const totalAttempted = measured.reduce((a, r) => a + r.attempted, 0); + const totalSucceeded = measured.reduce((a, r) => a + r.succeeded, 0); + const totalFailed = measured.reduce((a, r) => a + r.failed, 0); + const totalTimedOut = measured.reduce((a, r) => a + r.timedOut, 0); + const totalTruncated = measured.reduce((a, r) => a + r.truncated, 0); + const totalStatusMismatched = measured.reduce((a, r) => a + r.statusMismatched, 0); + const totalProtocolMismatched = measured.reduce((a, r) => a + r.protocolMismatched, 0); + const streamIds = protocol === 'h2' ? measured.flatMap((r) => r.streamIds ?? []) : null; + const errors = measured.flatMap((r) => r.errors); + return { + protocol, + concurrency, + requests: { + attempted: totalAttempted, + succeeded: totalSucceeded, + failed: totalFailed, + timedOut: totalTimedOut, + truncated: totalTruncated, + statusMismatched: totalStatusMismatched, + protocolMismatched: totalProtocolMismatched, + }, + streamIds, + latencyMs: summarizeLatency(latencies), + errors, + }; +} + +function verdictFor(entry, config, h2ConnectionsCreated, h2Settings) { + const reasons = []; + const { requests } = entry; + if (requests.attempted === 0 || requests.succeeded !== requests.attempted) { + reasons.push('incomplete samples: not all attempted requests succeeded'); + } + if (requests.timedOut > 0) reasons.push('one or more requests timed out'); + if (requests.truncated > 0) reasons.push('one or more responses were truncated'); + if (requests.statusMismatched > 0) reasons.push('response status did not match expected status'); + if (requests.protocolMismatched > 0) { + reasons.push('one or more responses reported an unexpected protocol version'); + } + const connectionInfos = entry.connectionInfos ?? []; + if (entry.protocol === 'h1') { + if (connectionInfos.length === 0 || !connectionInfos.every((info) => info.alpnProtocol === 'http/1.1')) { + reasons.push('missing or unexpected ALPN: forced HTTP/1.1 did not negotiate http/1.1 on every connection'); + } + } + if (entry.protocol === 'h2') { + if (connectionInfos.length === 0 || !connectionInfos.every((info) => info.alpnProtocol === 'h2')) { + reasons.push('missing or unexpected ALPN: HTTP/2 session did not negotiate h2 on every connection'); + } + } + if (entry.protocol === 'h2') { + const distinct = new Set(entry.streamIds ?? []); + if (distinct.size !== (entry.streamIds ?? []).length) { + reasons.push('accounting ambiguity: duplicate HTTP/2 stream IDs observed'); + } + if (entry.concurrency === 32) { + // Multiple measured rounds legitimately accumulate more than 32 + // distinct stream IDs (rounds * concurrency); the requirement is a + // floor of 32 per the acceptance criteria, not an exact match. + if (distinct.size < 32) { + reasons.push('concurrency-32 requires at least 32 distinct successful HTTP/2 streams'); + } + if (h2ConnectionsCreated !== 1) { + reasons.push('concurrency-32 requires exactly one client TLS connection for HTTP/2'); + } + if (!h2Settings || !(h2Settings.maxConcurrentStreams >= 32)) { + reasons.push('peer maxConcurrentStreams must be >= 32 for a valid concurrency-32 result'); + } + } + } + return { ok: reasons.length === 0, reasons }; +} + +export { + requireHttpsOrigin, + requireSafePath, + buildConfig, + parseCliArgs, + classifyError, + verdictFor, + buildInterleavedPlan, + aggregateConcurrencyResults, + summarizeLatency, + SAFE_PATH_ALLOWLIST, + ProbeError, + doHttp1Request, + doHttp2Request, + withDeadline, +}; + +// Races `promise` against a deadline timer, always clearing the timer. +// Used to bound both the initial HTTP/2 handshake and every measured round +// under a single absolute connection-lifetime deadline, rather than only +// checking elapsed time between completed rounds. +function withDeadline(promise, ms, message) { + if (!(ms > 0)) { + return Promise.reject(new ProbeError(message)); + } + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new ProbeError(message)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +export async function runProbe(config) { + const runStart = performance.now(); + const deadlineAt = runStart + config.connectionMaxLifetimeMs; + const remainingMs = () => deadlineAt - performance.now(); + + const h1Agent = new CountingHttpsAgent({ + keepAlive: true, + maxSockets: Math.max(...config.concurrencyLevels), + ca: config.ca, + timeout: config.requestTimeoutMs, + }); + const { session: h2Session, counters: h2Counters } = connectHttp2Session(config.origin, config.ca); + + try { + let h2Settings = null; + await withDeadline( + new Promise((resolve, reject) => { + h2Session.once('connect', () => { + h2Settings = h2Session.remoteSettings ? { ...h2Session.remoteSettings } : null; + resolve(); + }); + h2Session.once('error', reject); + }), + remainingMs(), + 'connection-max-lifetime-ms exceeded while establishing the HTTP/2 session', + ); + + const plan = buildInterleavedPlan(config.rounds, config.warmupRounds); + // Snapshot connection counters right after each concurrency level's + // rounds complete, so a level's report reflects sockets/sessions created + // up to that point rather than the final total across the whole run. + const levelSnapshots = new Map(); + const results = []; + + for (const concurrency of config.concurrencyLevels) { + const roundResults = []; + for (const step of plan) { + const runner = + step.protocol === 'h1' + ? () => + doHttp1Request(h1Agent, config.origin, config.path, { + timeoutMs: config.requestTimeoutMs, + maxResponseBytes: config.maxResponseBytes, + expectedStatus: config.expectedStatus, + }) + : () => + doHttp2Request(h2Session, config.path, { + timeoutMs: config.requestTimeoutMs, + maxResponseBytes: config.maxResponseBytes, + expectedStatus: config.expectedStatus, + }); + // eslint-disable-next-line no-await-in-loop + const result = await withDeadline( + runRound({ ...step, concurrency, runner, config }), + remainingMs(), + 'connection-max-lifetime-ms exceeded during run; aborting to bound connection age', + ); + roundResults.push(result); + } + levelSnapshots.set(concurrency, { + h1SocketsCreated: h1Agent.socketsCreated, + h2ConnectionsCreated: h2Counters.connectionsCreated, + }); + for (const protocol of ['h1', 'h2']) { + const entry = aggregateConcurrencyResults(roundResults, concurrency, protocol); + const snapshot = levelSnapshots.get(concurrency); + entry.connectionInfos = + protocol === 'h1' ? h1Agent.connectionInfos.slice() : h2Counters.connectionInfos.slice(); + entry.connectionInfo = entry.connectionInfos.at(-1) ?? null; + entry.connectionsCreated = protocol === 'h1' ? snapshot.h1SocketsCreated : snapshot.h2ConnectionsCreated; + entry.handshakes = protocol === 'h1' ? h1Agent.handshakes : h2Counters.handshakes; + entry.http2Settings = protocol === 'h2' ? h2Settings : null; + entry.verdict = verdictFor(entry, config, snapshot.h2ConnectionsCreated, h2Settings); + results.push(entry); + } + } + + const overallOk = results.every((r) => r.verdict.ok); + return { + tool: 'http-edge-probe', + schemaVersion: 1, + generatedAt: new Date().toISOString(), + environment: { + node: process.version, + platform: process.platform, + arch: process.arch, + }, + target: { origin: config.origin, path: config.path }, + config: { + concurrencyLevels: config.concurrencyLevels, + rounds: config.rounds, + warmupRounds: config.warmupRounds, + maxResponseBytes: config.maxResponseBytes, + requestTimeoutMs: config.requestTimeoutMs, + connectionMaxLifetimeMs: config.connectionMaxLifetimeMs, + expectedStatus: config.expectedStatus, + }, + results, + ok: overallOk, + }; + } finally { + // Always torn down — including when the deadline aborts a round or the + // HTTP/2 handshake itself fails — so no socket/session outlives the run. + h2Session.destroy(); + h1Agent.destroy(); + } +} + +function helpText() { + return ( + [ + 'Usage: node test/load/http-edge/probe.mjs --origin https://host --path /healthz [options]', + '', + 'Required:', + ' --origin Target origin, e.g. https://node.tinycloud.xyz', + ' --path Must be an allowlisted safe path (currently: /healthz)', + '', + 'Options:', + ' --concurrency Comma-separated concurrency levels (default: 1,8,32)', + ' --rounds Measured rounds per concurrency level (default: 3)', + ' --warmup-rounds Warm-up rounds excluded from latency stats (default: 1)', + ' --max-response-bytes Response byte bound (default: 65536)', + ' --request-timeout-ms Per-request timeout (default: 5000)', + ' --connection-max-lifetime-ms Safety bound on total run duration (default: 60000)', + ' --expected-status Expected HTTP status (default: 200)', + ' --ca Additional trusted CA PEM file (does not disable verification)', + ' --out Write JSON report to a file instead of stdout', + '', + 'Never emits headers, bodies, cookies, credentials, or URLs containing user data.', + ].join('\n') + '\n' + ); +} + +// Waits for the write to be fully flushed through the stdout pipe before the +// caller proceeds, instead of racing an immediate process.exit() (which can +// truncate output still buffered in the pipe). +function writeStdout(text) { + return new Promise((resolve, reject) => { + process.stdout.write(text, (err) => (err ? reject(err) : resolve())); + }); +} + +// Writes to a same-directory temp file, fsyncs it, then atomically renames +// it into place, so a reader never observes a partially written report. +function writeFileAtomic(targetPath, contents) { + const dir = path.dirname(targetPath) || '.'; + const tmpPath = path.join(dir, `.${path.basename(targetPath)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`); + let fd; + try { + fd = fs.openSync(tmpPath, 'w'); + fs.writeSync(fd, contents); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + fs.renameSync(tmpPath, targetPath); + } finally { + if (fd !== undefined) { + fs.closeSync(fd); + } + if (fs.existsSync(tmpPath)) { + fs.rmSync(tmpPath, { force: true }); + } + } +} + +async function main() { + const values = parseCliArgs(process.argv.slice(2)); + if (values.help) { + await writeStdout(helpText()); + process.exitCode = 0; + return; + } + const config = buildConfig(values); + const report = await runProbe(config); + const json = JSON.stringify(report, null, 2) + '\n'; + if (config.out) { + writeFileAtomic(config.out, json); + } else { + await writeStdout(json); + } + process.exitCode = report.ok ? 0 : 1; +} + +const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`; +if (isMain) { + main().catch(async (err) => { + // Fail closed with a minimal, sanitized error envelope. Never emit a + // partial JSON document. + const safe = { + tool: 'http-edge-probe', + schemaVersion: 1, + ok: false, + fatal: true, + reason: err instanceof ProbeError ? err.message : classifyError(err).code, + }; + await writeStdout(JSON.stringify(safe, null, 2) + '\n'); + process.exitCode = 1; + }); +} diff --git a/test/load/http-edge/probe.test.mjs b/test/load/http-edge/probe.test.mjs new file mode 100644 index 00000000..7e602982 --- /dev/null +++ b/test/load/http-edge/probe.test.mjs @@ -0,0 +1,414 @@ +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile, execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import https from 'node:https'; +import http2 from 'node:http2'; +import { + requireHttpsOrigin, + requireSafePath, + buildConfig, + classifyError, + summarizeLatency, + runProbe, + doHttp1Request, + doHttp2Request, + withDeadline, +} from './probe.mjs'; + +const PROBE_PATH = new URL('./probe.mjs', import.meta.url).pathname; +const SENTINEL_SECRET = 'sk_test_sentinel_do_not_leak_9f3c2a'; + +function runProbeCli(args) { + return new Promise((resolve, reject) => { + execFile(process.execPath, [PROBE_PATH, ...args], { encoding: 'utf8' }, (error, stdout, stderr) => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + reject(error); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + +// --- CLI validation ------------------------------------------------------- + +test('rejects a non-https origin', () => { + assert.throws(() => requireHttpsOrigin('http://example.com'), /https:\/\//); +}); + +test('rejects an origin carrying credentials, and never echoes the secret', () => { + try { + requireHttpsOrigin(`https://user:${SENTINEL_SECRET}@example.com`); + assert.fail('expected requireHttpsOrigin to throw'); + } catch (err) { + assert.match(err.message, /credentials/); + assert.doesNotMatch(err.message, new RegExp(SENTINEL_SECRET)); + } +}); + +test('rejects an origin with a path, query, or hash', () => { + assert.throws(() => requireHttpsOrigin('https://example.com/foo'), /bare scheme\+host/); + assert.throws(() => requireHttpsOrigin(`https://example.com/?token=${SENTINEL_SECRET}`), /bare scheme\+host/); +}); + +test('accepts a bare https origin', () => { + assert.equal(requireHttpsOrigin('https://example.com'), 'https://example.com'); + assert.equal(requireHttpsOrigin('https://example.com:8443'), 'https://example.com:8443'); +}); + +test('only /healthz is an allowlisted safe path', () => { + assert.equal(requireSafePath('/healthz'), '/healthz'); + assert.throws(() => requireSafePath('/invoke'), /allowlisted/); + assert.throws(() => requireSafePath('/delegate'), /allowlisted/); + assert.throws(() => requireSafePath(`/healthz?x=${SENTINEL_SECRET}`), /allowlisted/); +}); + +test('buildConfig enforces bounds on concurrency, rounds, and timeouts', () => { + const base = { origin: 'https://example.com', path: '/healthz', rounds: '3', 'warmup-rounds': '1', + 'max-response-bytes': '65536', 'request-timeout-ms': '5000', 'connection-max-lifetime-ms': '60000', + 'expected-status': '200' }; + assert.throws(() => buildConfig({ ...base, concurrency: '1,8,999' }), /concurrency/); + assert.throws(() => buildConfig({ ...base, concurrency: '1,8,32', rounds: '999' }), /rounds/); + assert.throws(() => buildConfig({ ...base, concurrency: '1,8,32', 'request-timeout-ms': '999999' }), /request-timeout-ms/); + const ok = buildConfig({ ...base, concurrency: '1,8,32' }); + assert.deepEqual(ok.concurrencyLevels, [1, 8, 32]); +}); + +test('buildConfig rejects malformed numeric arguments instead of silently truncating them', () => { + const base = { origin: 'https://example.com', path: '/healthz', rounds: '3', 'warmup-rounds': '1', + 'max-response-bytes': '65536', 'request-timeout-ms': '5000', 'connection-max-lifetime-ms': '60000', + 'expected-status': '200' }; + assert.throws(() => buildConfig({ ...base, concurrency: '1junk,8,32' }), /concurrency/); + assert.throws(() => buildConfig({ ...base, concurrency: '1,2oops,32' }), /concurrency/); + assert.throws(() => buildConfig({ ...base, concurrency: '1,8,32', rounds: '5x' }), /rounds/); + assert.throws(() => buildConfig({ ...base, concurrency: '1,8,32', rounds: '3.5' }), /rounds/); + assert.throws(() => buildConfig({ ...base, concurrency: '1,8,32', rounds: '0x3' }), /rounds/); +}); + +test('buildConfig rejects oversized or duplicate concurrency lists (bounded runtime/output)', () => { + const base = { origin: 'https://example.com', path: '/healthz', rounds: '1', 'warmup-rounds': '0', + 'max-response-bytes': '65536', 'request-timeout-ms': '5000', 'connection-max-lifetime-ms': '60000', + 'expected-status': '200' }; + assert.throws(() => buildConfig({ ...base, concurrency: '1,2,3,4,5,6,7,8' }), /at most/); + assert.throws(() => buildConfig({ ...base, concurrency: '1,8,8,32' }), /duplicate/); +}); + +// --- redaction -------------------------------------------------------- + +test('classifyError never surfaces the raw error message', () => { + const err = new Error(`leaked ${SENTINEL_SECRET} in Authorization: Bearer abc`); + err.code = 'ECONNRESET'; + const classified = classifyError(err); + const serialized = JSON.stringify(classified); + assert.doesNotMatch(serialized, new RegExp(SENTINEL_SECRET)); + assert.doesNotMatch(serialized, /Authorization/); + assert.deepEqual(Object.keys(classified).sort(), ['code', 'kind']); +}); + +test('summarizeLatency never includes non-numeric or header-shaped data', () => { + const summary = summarizeLatency([1, 2, 3, 4, 5]); + assert.equal(summary.samples, 5); + assert.ok(typeof summary.p50 === 'number'); +}); + +// --- CLI-level sentinel redaction (spawned process) ------------------------ + +test('CLI output never contains injected sentinel secrets, even via env or CA path names', () => { + const result = spawnSync( + process.execPath, + [PROBE_PATH, '--origin', `https://user:${SENTINEL_SECRET}@example.com`, '--path', '/healthz'], + { encoding: 'utf8', env: { ...process.env, AUTHORIZATION: `Bearer ${SENTINEL_SECRET}` } }, + ); + const combined = `${result.stdout}${result.stderr}`; + assert.doesNotMatch(combined, new RegExp(SENTINEL_SECRET)); + assert.notEqual(result.status, 0); +}); + +test('CLI rejects an unsafe path outright (fails closed) without contacting the network', () => { + const result = spawnSync( + process.execPath, + [PROBE_PATH, '--origin', 'https://example.com', '--path', '/invoke'], + { encoding: 'utf8' }, + ); + assert.notEqual(result.status, 0); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.fatal, true); +}); + +// --- integration against a local HTTP/2+1.1 server ------------------------- + +let tmpDir; +let keyPath; +let certPath; +let server; +let serverOrigin; +// The '/slow' route intentionally never calls res.end(); each response +// object is tracked here so tests that hit it can force-destroy the +// server-side response immediately afterward instead of leaving it (and its +// stream/socket) dangling on the shared server for the rest of the file. +const pendingSlowResponses = new Set(); + +before(async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'http-edge-probe-')); + keyPath = path.join(tmpDir, 'key.pem'); + certPath = path.join(tmpDir, 'cert.pem'); + execFileSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-keyout', keyPath, '-out', certPath, + '-days', '1', '-nodes', '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', + ]); + + server = http2.createSecureServer({ + key: readFileSync(keyPath), + cert: readFileSync(certPath), + allowHTTP1: true, + }); + // In compat mode (allowHTTP1: true) the 'request' event alone handles + // both HTTP/1.1 and HTTP/2 streams; also registering 'stream' would + // respond twice to the same HTTP/2 request. + server.on('request', (req, res) => { + if (req.url === '/healthz') { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('ok'); + } else if (req.url === '/slow') { + // Deliberately never responds, to deterministically exercise the + // fail-closed timeout path without depending on real network flakiness. + pendingSlowResponses.add(res); + res.once('close', () => pendingSlowResponses.delete(res)); + } else if (req.url === '/big') { + res.writeHead(200, { 'content-type': 'application/octet-stream' }); + res.end(Buffer.alloc(200_000, 'a')); + } else { + res.writeHead(404); + res.end(); + } + }); + + // Start listening here (rather than inside the first test) so every test + // in this file can rely on `serverOrigin`/`certPath` regardless of order. + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + serverOrigin = `https://127.0.0.1:${port}`; +}); + +after(() => { + for (const res of pendingSlowResponses) res.destroy(); + pendingSlowResponses.clear(); + server?.close(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('probe negotiates http/1.1 and h2 against a local dual-protocol server', async () => { + const config = buildConfig({ + origin: serverOrigin, + path: '/healthz', + concurrency: '1,2', + rounds: '1', + 'warmup-rounds': '1', + 'max-response-bytes': '65536', + 'request-timeout-ms': '5000', + 'connection-max-lifetime-ms': '60000', + 'expected-status': '200', + ca: certPath, + }); + + const report = await runProbe(config); + + assert.equal(report.tool, 'http-edge-probe'); + assert.equal(report.target.origin, serverOrigin); + assert.equal(report.target.path, '/healthz'); + + const h1Results = report.results.filter((r) => r.protocol === 'h1'); + const h2Results = report.results.filter((r) => r.protocol === 'h2'); + assert.equal(h1Results.length, 2); + assert.equal(h2Results.length, 2); + + for (const r of h1Results) { + assert.equal(r.connectionInfo.alpnProtocol, 'http/1.1'); + assert.ok(r.connectionInfos.length > 0); + assert.ok(r.connectionInfos.every((info) => info.alpnProtocol === 'http/1.1')); + assert.equal(r.requests.succeeded, r.requests.attempted); + assert.equal(r.requests.failed, 0); + assert.equal(r.requests.protocolMismatched, 0); + } + for (const r of h2Results) { + assert.equal(r.connectionInfo.alpnProtocol, 'h2'); + assert.ok(r.connectionInfos.length > 0); + assert.ok(r.connectionInfos.every((info) => info.alpnProtocol === 'h2')); + assert.equal(r.requests.succeeded, r.requests.attempted); + assert.equal(r.requests.protocolMismatched, 0); + const distinctStreamIds = new Set(r.streamIds); + assert.equal(distinctStreamIds.size, r.streamIds.length); + } + + // Exactly one HTTP/2 TLS connection for the whole run, reused across + // every concurrency level. + assert.equal(h2Results[0].connectionsCreated, 1); + assert.equal(h2Results[1].connectionsCreated, 1); + + const serialized = JSON.stringify(report); + assert.doesNotMatch(serialized, /authorization/i); + assert.doesNotMatch(serialized, /cookie/i); + assert.equal(report.ok, true); +}); + +test('probe fails closed when the expected status is not met', async () => { + const config = buildConfig({ + origin: serverOrigin, + path: '/healthz', + concurrency: '1', + rounds: '1', + 'warmup-rounds': '0', + 'max-response-bytes': '65536', + 'request-timeout-ms': '5000', + 'connection-max-lifetime-ms': '60000', + 'expected-status': '204', // server always returns 200, so this must fail closed + ca: certPath, + }); + + const report = await runProbe(config); + assert.equal(report.ok, false); + for (const r of report.results) { + assert.equal(r.verdict.ok, false); + assert.ok(r.verdict.reasons.length > 0); + } +}); + +// --- fail-closed timeout/truncation must resolve, never hang -------------- + +test('doHttp1Request resolves a fail-closed timeout instead of hanging when the server never responds', async () => { + const agent = new https.Agent({ ca: readFileSync(certPath), keepAlive: true }); + try { + const result = await doHttp1Request(agent, serverOrigin, '/slow', { + timeoutMs: 200, + maxResponseBytes: 65_536, + expectedStatus: 200, + }); + assert.equal(result.ok, false); + assert.equal(result.error.kind, 'timeout'); + } finally { + agent.destroy(); + for (const res of pendingSlowResponses) res.destroy(); + pendingSlowResponses.clear(); + } +}); + +test('doHttp1Request resolves a fail-closed truncation instead of hanging on an oversized response', async () => { + const agent = new https.Agent({ ca: readFileSync(certPath), keepAlive: true }); + try { + const result = await doHttp1Request(agent, serverOrigin, '/big', { + timeoutMs: 5000, + maxResponseBytes: 1024, + expectedStatus: 200, + }); + assert.equal(result.ok, false); + assert.equal(result.error.kind, 'truncated'); + } finally { + agent.destroy(); + } +}); + +test('doHttp2Request resolves a fail-closed timeout instead of hanging when the server never responds', async () => { + const session = http2.connect(serverOrigin, { ca: readFileSync(certPath) }); + try { + await new Promise((resolve, reject) => { + session.once('connect', resolve); + session.once('error', reject); + }); + const result = await doHttp2Request(session, '/slow', { + timeoutMs: 200, + maxResponseBytes: 65_536, + expectedStatus: 200, + }); + assert.equal(result.ok, false); + assert.equal(result.error.kind, 'timeout'); + } finally { + session.destroy(); + for (const res of pendingSlowResponses) res.destroy(); + pendingSlowResponses.clear(); + } +}); + +test('doHttp2Request resolves a fail-closed truncation instead of hanging on an oversized response', async () => { + const session = http2.connect(serverOrigin, { ca: readFileSync(certPath) }); + try { + await new Promise((resolve, reject) => { + session.once('connect', resolve); + session.once('error', reject); + }); + const result = await doHttp2Request(session, '/big', { + timeoutMs: 5000, + maxResponseBytes: 1024, + expectedStatus: 200, + }); + assert.equal(result.ok, false); + assert.equal(result.error.kind, 'truncated'); + } finally { + session.destroy(); + } +}); + +// --- connection-max-lifetime-ms is enforced end-to-end --------------------- + +// withDeadline is the single mechanism runProbe uses to bound both the +// initial HTTP/2 handshake and every measured round under one absolute +// connection-lifetime deadline; test it directly and deterministically +// rather than via a real hung socket (flaky, and leaks OS-level state). +test('withDeadline rejects with the given message once the deadline elapses, for a promise that never settles', async () => { + const neverSettles = new Promise(() => {}); + await assert.rejects( + () => withDeadline(neverSettles, 30, 'connection-max-lifetime-ms exceeded (test)'), + /connection-max-lifetime-ms exceeded \(test\)/, + ); +}); + +test('withDeadline rejects immediately when no time remains', async () => { + await assert.rejects( + () => withDeadline(new Promise(() => {}), 0, 'connection-max-lifetime-ms exceeded (no time left)'), + /connection-max-lifetime-ms exceeded \(no time left\)/, + ); +}); + +test('withDeadline resolves normally when the promise settles before the deadline', async () => { + const result = await withDeadline(Promise.resolve('fast'), 5000, 'should not fire'); + assert.equal(result, 'fast'); +}); + +// --- atomic output ----------------------------------------------------- + +test('CLI --out writes a single complete JSON file with no leftover temp file', async () => { + const outPath = path.join(tmpDir, 'report.json'); + await runProbeCli([ + '--origin', serverOrigin, + '--path', '/healthz', + '--concurrency', '1', + '--rounds', '1', + '--warmup-rounds', '0', + '--ca', certPath, + '--out', outPath, + ]); + assert.ok(existsSync(outPath)); + const parsed = JSON.parse(readFileSync(outPath, 'utf8')); + assert.equal(parsed.ok, true); + const leftoverTmp = readdirSync(tmpDir).filter((f) => f.includes('.tmp')); + assert.deepEqual(leftoverTmp, []); +}); + +test('CLI stdout output is a single complete, parseable JSON document', async () => { + const result = await runProbeCli([ + '--origin', serverOrigin, + '--path', '/healthz', + '--concurrency', '1', + '--rounds', '1', + '--warmup-rounds', '0', + '--ca', certPath, + ]); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, true); +});