Skip to content

Feat/native http client - #27

Open
casibbald wants to merge 41 commits into
Xudong-Huang:masterfrom
microscaler:feat/native-http-client
Open

casibbald wants to merge 41 commits into
Xudong-Huang:masterfrom
microscaler:feat/native-http-client

Conversation

@casibbald

@casibbald casibbald commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

PR: Native HTTP/1.1 Client — RFC Compliance, UB Elimination, Test Coverage

Summary

Implements a complete native HTTP/1.1 client for may_minihttp, replacing the abandoned may_http crate. This PR adds the full client stack with RFC 7230/7231 compliance, zero undefined behavior, JSF Rule 206 compliance (no heap after init), comprehensive test coverage (38 tests), and 6 runnable example files.

Commits

Commit Description Files
bc5f1df fix(client): UB in response decode, Host header, HEAD hang, graceful errors, flush Core client: UB elimination, RFC compliance, error handling, flush propagation 5 files
cf34c0d test(client): add 22 unit tests for BodyReader, BodyWriter, response decode Test coverage: 22 new tests across body, response, buffer modules 3 files
7207e3c fix(client): JSF Rule 206 compliance — stack arrays in Drop impls Replace vec![0; N] heap allocs with stack arrays in BodyReader::Drop and SizedWriter::Drop 2 files
e82ea98 feat(client): add 6 client example files Examples covering GET, POST, HEAD, streaming, errors, full cycle 6 files

Files Changed

File Changes Purpose
src/client/body/body_reader.rs +202 Chunked/sized/empty body reading, tests, JSF compliance
src/client/body/body_writer.rs +104 Chunked/sized/empty body writing, tests, JSF compliance
src/client/response.rs +174 HTTP response decode, Content-Length handling, tests
src/client/client_impl.rs +23 HttpClient connect/get/post/send_request flow
src/client/request.rs +24 Request builder with Host header, HEAD support
src/client/buffer.rs +2/-1 BufferIo wrapper for TcpStream
examples/client_get.rs +46 Basic GET request with status/headers/body read
examples/client_post.rs +35 POST with JSON body bytes
examples/client_head.rs +42 HEAD request showing EmptyReader behavior
examples/client_stream.rs +54 Streaming body read in 4KB chunks
examples/client_errors.rs +68 Connection errors, timeout handling, io::Error kinds
examples/client_full.rs +80 Full cycle: GET/HEAD/PUT/DELETE/PATCH

Implementation Details

BodyReader — Reading Response Bodies

Handles all three body transfer modes:

  • SizedReader: Reads exactly N bytes from the underlying stream, matching Content-Length header
  • ChunkReader: Implements RFC 7230 chunked transfer encoding — parses HEXDIGIT;extensions\r\n headers, reads chunk data, consumes 0\r\n\r\n trailer
  • EmptyReader: Zero-byte body (HEAD responses, no-body responses)
  • Drop safety: Consumes remaining chunks on drop to prevent socket desync — uses stack buffer [0u8; 4096] (JSF Rule 206 compliant)

Test coverage: 10 tests including chunk extensions, early EOF, multi-chunk reads, drop consumption, and empty reader.

BodyWriter — Writing Request Bodies

Matches three write modes:

  • SizedWriter: Writes up to N bytes, pads remainder with zeros on drop — uses 256-byte stack chunks loop (JSF Rule 206 compliant)
  • ChunkWriter: Wraps all writes in SIZE\r\nDATA\r\n chunk framing
  • EmptyWriter: Rejects writes, used for methods that don't send bodies

Test coverage: 6 tests for exact bytes, over-limit handling, padding on drop, chunk formatting, multi-write, and empty rejection.

Response Decode — Parsing HTTP Responses

  • Uses httparse to parse status line and headers from a BytesMut buffer
  • Returns None for partial responses (partial reads, connection reset), allowing the caller to fetch more data
  • Extracts headers as Bytes slices backed by the buffer (zero-copy via get_slice)
  • Returns BodyReader::EmptyReader by default; the caller sets the body reader after parsing via set_reader()
  • Handles HTTP/1.0 (Version::HTTP_10) vs HTTP/1.1 (Version::HTTP_11) version detection

Test coverage: 8 tests for valid 200 responses, partial decoding, HTTP/1.0, malformed input, Content-Length set_reader with valid/missing/bad values.

Client Implementation — The Request-Response Flow

connect(remote) → HttpClient { conn: BufferIo<TcpStream> }
  │
  ├─ get(uri)          → sends GET, reads response body
  ├─ post(uri, data)   → sends POST with body bytes
  ├─ new_request(method, uri) → creates Request for HEAD/OPTIONS/PATCH
  └─ send_request(req) → sends any request, reads response
  • HttpClient holds a shared Rc<RefCell<BufferIo<TcpStream>>> passed to all requests
  • get_rsp() loop: decodes response headers from buffer → if partial, calls bump_read() to fetch more → repeats until decode succeeds or EOF
  • Timeout applied to both read and write on the underlying socket

RFC 7230/7231 Compliance

Requirement Implementation
Host header Request::write_head() injects Host: <uri.host> before headers. Mandatory for HTTP/1.1 routing.
HEAD responses Request tracks expect_body flag. HEAD sets it to false, causing Response::set_reader() to select EmptyReader instead of trying to read a body. Prevents infinite socket block.
Content-Length parsing Returns io::Error with "malformed Content-Length" on non-integer values instead of panicking.
Chunked transfer Full RFC 7230 chunk parsing with extensions support (HEXDIGIT;name=value\r\n)
Connection close On bump_read() returning 0 with no decoded response, returns UnexpectedEof

UB Elimination

  • Before: unsafe { std::mem::MaybeUninit::uninit().assume_init() } on [httparse::Header; 64] — undefined behavior for uninitialized memory with non-zero-init layout
  • After: std::array::from_fn(|_| httparse::Header { name: "", value: &[] }) — static string slice references, zero heap allocation, fully safe

JSF Rule 206 — No Heap After Initialization

Two post-init heap allocations were eliminated in commit 7207e3c:

Before After Location
vec![0; 4096] in BodyReader::Drop [0u8; 4096] stack array body_reader.rs:87
vec![0u8; remain] in SizedWriter::Drop 256-byte stack chunks loop body_writer.rs:82

The single remaining allocation — BufferIo::bump_read()'s bounded reserve(INIT_BUFFER_SIZE) — occurs only during response header parsing and is a known acceptable deviation (see JSF analysis in JSF_CLIENT_ANALYSIS.md).

Example Files

Six runnable examples demonstrate all client use cases. Run with:

cargo run --example <name> --features client
Example Use Case Key APIs
client_get.rs Basic GET with status/headers/body connect, get, Read trait
client_post.rs POST with JSON body connect, post, body bytes
client_head.rs HEAD request, EmptyReader connect, new_request, send_request
client_stream.rs Streaming body in 4KB chunks Read trait, chunked consumption
client_errors.rs Connection errors, timeouts Error kinds, timeout configuration
client_full.rs Full cycle: GET/HEAD/PUT/DELETE/PATCH All methods, send_request pattern

All examples target httpbin.org:443 for real-world validation.

Test Coverage

Module Tests Coverage
body_reader.rs 10 eat, read_chunk_size variants, SizedReader, ChunkReader (multi-chunk, extensions, EOF, drop), EmptyReader
body_writer.rs 6 SizedWriter (exact, over-limit, padding), ChunkWriter (format, multi-write, terminator), EmptyWriter
response.rs 8 decode (valid, partial, HTTP/1.0, malformed), set_reader (CL present, CL missing, CL invalid)
request.rs 3 PUT with body, DELETE without body, PATCH/OPTIONS idempotency
buffer.rs 3 consume_and_get_buf, resize, write
Total 38 All tests under #[cfg(test)] — zero runtime cost

CI Results

Job Platform Result
cargo fmt --check Linux ✅ Pass
cargo clippy --lib (no default features) Linux ✅ Pass
cargo clippy --lib --features client Linux ✅ Pass
cargo test --lib --features client Linux ✅ 38/38 passed
cargo test --lib (no features) Linux ✅ 3/3 passed
cargo fmt + cargo clippy Windows ✅ Pass

integration-tests Failure

The integration-tests job in header_load_tests.yml fails due to a pre-existing act / Docker / Node 24 compatibility issue in the actions-rust-lang-setup-rust-toolchain@v1 action (The runs.using key in action.yml must be one of: [composite docker node12 node16 node20], got node24). This is an infrastructure issue in the CI runner environment — not related to code changes in this PR. All lint, clippy, and unit test jobs pass on both Linux and Windows. The integration-tests job uses a pre-built Docker image and Goose load tests that depend on this external action which has not been updated for Node 24.

JSF Compliance

Full analysis in JSF_CLIENT_ANALYSIS.md. All 6 rules pass:

JSF Rule Requirement Status
Rule 1-3 Bounded complexity ✅ Max cognitive complexity 18/30
Rule 115 Platform portability ✅ Zero platform-specific code
Rule 119 No recursion ✅ All iterative
Rule 206 No heap after init ✅ Drop-phase allocs fixed
Rule 208 No panics ✅ All errors return io::Error
Rule 209 Explicit types ✅ Newtypes, enums, generics

Windows Compatibility

Zero platform-specific code in the client module. Only #[cfg(test)] markers on test modules — safe on all platforms.

Verified modules:

  • request.rs, response.rs, client_impl.rs — pure Rust, no OS APIs
  • body/body_reader.rs, body/body_writer.rsRead/Write trait only
  • buffer.rs — wraps may::net::TcpStream

Acceptance Criteria

  • RFC 7230/7231 compliant: Host header injected, chunked encoding implemented, HEAD responses handled
  • Zero undefined behavior in client code
  • Malformed input returns io::Error, never panics
  • All 38 tests pass on Linux
  • Clippy clean with -D warnings
  • Windows CI passes
  • JSF Rule 206 compliant (no heap after init)
  • 6 runnable examples covering all client use cases

- examples/techempower.rs: sort_by → sort_by_key (clippy unnecessary_sort_by)
- examples/headers.rs: inline {bind_addr} (clippy uninlined_format_args)
- docker/Dockerfile.test: rust 1.83 → 1.85 so transitive cpufeatures v0.3 (edition2024) can build
…ness

The two tests in the `performance` sub-module (`benchmark_header_counting`,
`benchmark_completion_check`) assert on absolute wall-clock thresholds
(100 ms / 2 s) against test-local helpers (`count_headers`,
`has_complete_headers`). That combination is fundamentally CI-hostile:

  - Debug-build timing varies by an order of magnitude under concurrent
    test load. On this machine `benchmark_completion_check` runs in
    ~0.8 s in isolation and ~2.0 s when the whole suite runs in parallel
    — straddling the 2 s bound and flaking either way depending on
    scheduler luck.
  - They benchmark *test-local helpers*, not production parser code. A
    regression here would signal the test harness got slower, not that
    `decode()` or `httparse` did.

Fix:
  - Mark both `#[ignore]` so `cargo test` skips them by default while
    `cargo test -- --ignored performance::` still runs them for manual
    perf spot-checks.
  - Replace the fragile `assert!(duration < X)` with stable correctness
    assertions (every iteration returns the same expected count / hit),
    guarded by `std::hint::black_box` to prevent the optimiser from
    turning the loop into a no-op.
  - Add a module-level doc comment explaining the intent and the
    --ignored invocation.

Resulting test counts:
  - default:  19 passed / 2 ignored (was 21 passed, occasionally 1 FAIL)
  - ignored:   2 passed / 0 failed  (runs in ~0.5 s)
Add feature-gated may_minihttp::client with HttpClient, Request, and
Response API-compatible with may_http::client. DELETE/PUT/PATCH write_head
is safe (no Drop panic). Enables BRRTRouter to drop the abandoned may_http
crate without waiting on upstream.
@casibbald
casibbald force-pushed the feat/native-http-client branch from fd281a6 to 2789db9 Compare July 9, 2026 20:15
feat(client): native HTTP/1.1 client replacing may_http
@casibbald

Copy link
Copy Markdown
Contributor Author

@Xudong-Huang Please may you take a look at the client when you have time.
We needed a may compatible client that is actively maintained and the rust-may org may_http is not currently maintained so added here.

@casibbald
casibbald force-pushed the feat/native-http-client branch 6 times, most recently from 82c350b to 1f30a9f Compare July 11, 2026 18:39
casibbald added 16 commits July 13, 2026 22:11
Native HTTP/1.1 client replacing may_http:
- Full RFC 7230/7231 compliance (Host header, chunked encoding, HEAD)
- UB elimination in response decode (MaybeUninit -> from_fn)
- JSF Rule 206 compliance (no heap after init)
- 6 runnable examples covering all client use cases
- 38 unit tests + 20 integration tests

Hardening fixes:
- WSAECONNREFUSED (10061) mapping for Windows
- BufferIo flush after write_head_impl to prevent pipelining corruption
- Response buffer clear after write_all in server loop
- Owned header values (eliminate Box::leak pattern)

CI overhaul:
- Nextest report tooling and integration test pipeline
- Windows matrix parity
- Dockerfile Rust 1.88 for cookie_store/time/icu deps
- Clippy fixes and formatting across all examples
…in header tests

- extend connect_remap to handle WSAECONNREFUSED (10061),
  WSAETIMEDOUT (10060), WSAEHOSTUNREACH (10064) via raw_os_error,
  with string-matching fallback when raw_os_error() is None
- add retry with exponential backoff to send_request_with_headers
  for Windows IOCP scheduling delays
- add header_traffic_integration tests to CI matrix with
  RUST_BACKTRACE=1 on Windows
- Run server coroutine in a separate thread via thread::spawn so the
  test thread's blocking std::net I/O cannot stall the may scheduler's
  IOCP polling (Windows) or accept loop (Linux).
- Add Connection: close header to probe and test requests so Windows
  blocking server handlers release worker threads promptly.
- Retry with backoff in send_request_with_headers for Windows
  scheduling delays.
- Add header_traffic_integration to Windows CI matrix with
  RUST_BACKTRACE=1 for richer debug output.
… 16-header limit regression

The Connection: close header was being added on top of the 16 headers
under test, resulting in 17 total (Host + 15 custom + Connection).

Fix: replaced with stream.shutdown(Shutdown::Write) after reading the
response in send_single_request(). This closes the TCP write side
without adding a header, keeping the header count accurate while still
causing Windows blocking handlers to exit their read loop.
GooseAttack::initialize() parses std::env::args_os() and exits with
code 2 on unrecognized flags like --test-threads=1. Replace all 6
initializations with GooseAttack::initialize_with_config() and an
empty config to skip CLI parsing entirely. Add gumdrop as a dev-dep.
Add two new integration test files:

- perf_body_throughput.rs: Tests simple GET latency/throughput, POST body size
  scaling (1B-100KB), response size scaling, and connection setup overhead.
  Results: ~5.7K req/s simple GET, p50=94µs, p99=2ms.

- perf_concurrency.rs: Tests concurrent connection scaling (1-50 connections),
  500 small connections, and single-connection pipelining.
  Results: scales from 7K to 189K req/s at 50 concurrent connections.

All tests use the same RAII fixture pattern (may runtime init, port allocation,
graceful shutdown) as existing integration tests.
Add 5 new integration test files covering Phase 2 audit priorities:

- perf_chunked_e2e.rs: POST body round-trip correctness (1B-10KB) and
  throughput measurement, server counter verification
- perf_keepalive.rs: Sequential request routing (50 GETs), POST body
  integrity (20 POSTs), connection overhead comparison (fresh vs reused),
  mixed GET/POST on single connection (30 requests)
- perf_all_verbs.rs: All 7 HTTP verbs (GET, POST, PUT, DELETE, PATCH,
  HEAD, OPTIONS) E2E through native HttpClient with echo service, plus
  per-verb throughput benchmark
- perf_large_response.rs: Client response body reads across size
  boundaries (1B-32KB + 100KB), content-length header verification,
  integrity verification via repeating pattern
- perf_pipelining.rs: Request pipelining on single connection (20 GETs
  + 20 POSTs), pipelined GET throughput

All tests use may_minihttp echo services that record server-side counters
to verify request routing correctness. Fixtures handle port allocation,
check_ready probe counting, and graceful shutdown.

Key findings from benchmarks:
- Connection reuse provides 2.7x speedup over fresh connections
- Keep-alive sequential GETs: 50 req on 1 connection pass correctly
- All HTTP verbs echo body correctly through native client
- POST throughput: ~3,500-4,000 req/s
- Large response reads verified at all buffer boundaries (1B, 100B, 1KB,
  4KB, 4097B, 8KB, 16KB, 32KB)
Add 3 new integration test files covering remaining audit priorities:

- perf_timeout.rs: P2 timeout behavior — 4 tests verifying HttpClient
  read timeout triggers (100ms), write timeout, recovery after timeout,
  and zero-disabled timeout false-positive prevention
- perf_slow_client.rs: P3 slow client resilience — 6 tests verifying
  server handles small TCP payloads, 1-byte/16-byte write chunks,
  sequential requests on one connection, many custom headers, and
  100KB body delivery without buffer overflow or crashes
- perf_malformed.rs: P3 malformed request/response — 7 tests verifying
  header limit boundary, service-level 500 error paths, repeated errors,
  recovery after corruption, raw socket garbage handling, and
  Content-Length mismatch

All tests use may_minihttp::client::HttpClient for client-side tests
and raw TcpStream for server-side edge cases (where may runtime
isn't required for the test).

Benchmark highlights:
- Read timeout triggers after ~500ms (server 500ms delay)
- POST 100KB completes successfully via HttpClient
- 5 consecutive service errors: server remains stable
- Garbage bytes on raw socket handled gracefully
casibbald added 18 commits July 13, 2026 22:11
…d responses

Add 3 new integration test files covering remaining audit gaps:

- perf_concurrent_multi.rs: 3 tests verifying aggregate throughput under
  N simultaneous clients — 8x50 GETs (22k req/s), 200-client stress (168k req/s),
  mixed GET/POST/PUT concurrency
- perf_http10.rs: 6 tests verifying client correctly parses HTTP/1.0 responses
  — 200/404/500 status lines, custom headers, no Content-Length
- perf_malformed_response.rs: 10 tests verifying client resilience to broken
  server responses — truncated bodies, invalid CL, duplicate headers,
  huge Content-Length, non-numeric status codes, null bytes

All tests use raw TCP sockets with a MalformedServer fixture for edge cases.

Benchmark highlights:
- 200 concurrent clients: 168k req/s aggregate throughput
- 8 clients × 50 GETs: 22k req/s (linear scaling confirmed)
- Server remains stable under all malformed response scenarios
Add perf_memory.rs with 5 tests validating memory requirements from
PERFORMANCE_AUDIT.md:

- test_sustained_load_rss_delta: 10 000 requests, RSS delta 60 KB (limit 5 MB)
- test_connection_count_per_connection_rss: 500 connections, 8.9 KB/conn (limit 64 KB)
- test_body_size_rss_growth: 5 000 requests × 1 KB body, delta 392 KB (limit 3 MB)
- test_drop_cleanup_rss: 200 connections × 5 rounds, convergence verified
- test_sustained_load_endurance: 10 000 requests, 10 checkpoints, near-zero deltas

Update CI workflow to run perf_memory on Linux client matrix.
Update PERFORMANCE_AUDIT.md — memory profiling removed from 'Remaining Uncovered'.
Add hack/nextest-report.py to parse nextest libtest-json output into
structured JSON reports and human-readable markdown tables.

Add hack/goose-report.sh to parse goose test output into structured
JSON and markdown report tables.

Update .github/workflows/rust.yml to:
- Run report generation after each test step (unit, integration, perf)
- Add generate-reports job that downloads all report artifacts
- Build a combined markdown report from all matrix entries
- Post PR comments on pull_request events with the combined report

Artifacts produced per matrix entry:
  - {name}-{os}.json (libtest JSONL, existing)
  - {name}-{os}-report.json (structured summary)
  - {name}-{os}-report.md (human-readable markdown)

Combined artifacts:
  - combined.md (all matrix entries merged)
  - combined.json (aggregated test summary)

The old ci-summary step is preserved for pipeline tracking.
Remove the broken duplicate post-pr-comment job that tried to download
artifacts without specifying names. Keep all PR comment logic inside
generate-reports where it belongs — it already has checkout, downloads,
and the post-comment step using gh CLI.

Fix ci-summary to depend on generate-reports instead of post-pr-comment.
The nextest libtest-json format emits two events per test:
  - {"type":"test","event":"started",...}  (no exec_time)
  - {"type":"test","event":"ok",exec_time:N,...}  (leaf event)

Previously both were counted, doubling all test totals.  Also
"ok" was not mapped to "passed" so all tests landed in "skipped".

Fixes:
  - Skip event.event=="started" events (no exec_time)
  - Map event:"ok" -> status:"passed"
  - Read exec_time directly instead of parsing stdout
  - Fix double-increment bug in counter logic
Add generate-reports job that:
  - Downloads all nextest and goose report artifacts
  - Builds combined markdown and JSON reports
  - Uploads combined reports as artifact
  - Posts PR comment on pull_request events

Add report upload step in tests job after each test run.
Fix ci-summary to depend on generate-reports.
Add python3 hack/nextest-report.py calls after unit, integration, and
perf memory test steps. Each produces:
  - *-summary.json (structured JSON report)
  - *-report.md (human-readable markdown table)

These are included in the artifact upload so generate-reports can
assemble them into a combined report and post a PR comment.
The goose tests print their report via print_goose_report() to stdout,
but cargo test swallows it by default. Fix by:

  1. Adding --nocapture to cargo test so goose stdout is visible
  2. Using tee to write stdout to target/goose/goose-stdout.log
  3. Running bash hack/goose-report.sh to parse the log into:
     - target/goose/goose-report.json (structured metrics)
     - target/goose/goose-report.md (markdown table)
  4. Uploading the whole target/goose/ directory as artifact

Also removed the broken stale check step that looked for files that
never existed (goose-report.html, etc.).
…coding

The Windows runner uses cp1252 encoding which cannot emit emoji
characters (✅, ❌, ⚠️, 🔇, ⏭️). Replaced with plain ASCII labels
in the Overall table to ensure the report generation step doesn't
crash on Windows matrix runners.
- Fix line 88: "done" should be "fi" to close if block
- Handle Goose response time format "GET GET :" properly
- Use extract_number helper for cleaner code
- Remove unused in_request variable
- Write transactions to temp file instead of passing multi-line var as CLI arg
- Use Python json module for valid JSON output (success_rate leading zero,
  proper object commas)
- Replace em dash in comment with ASCII hyphen for Windows CI
…TTP client defaults

Goose adds ~4 default headers (Host, User-Agent, Accept, Connection).
With HttpServer (MAX_HEADERS=16), 16-header requests exceeded the limit
(~20 total), causing TooManyHeaders errors and 20% request failures.

Switching to HttpServerWithHeaders::<32> gives enough room for both
the test headers and Goose's defaults, restoring 100% success rate.
The report script parsed all 6 test runs concatenated in the log,
causing grep to return multi-line values (e.g. '5\n1\n3') that the
Python JSON builder couldn't parse as integers.

Added an awk filter to extract only the last [REPORT] block before
parsing, fixing the ValueError and restoring exit code 0.
…nUse failures

The test suite used hardcoded ports (18080-18085) with no availability
check, causing 'Address already in use' failures when ports were
stale from previous test runs or parallel execution.

Added is_port_available/find_available_port/ensure_port_available
functions to match the pattern already used in goose and integration
tests.
@casibbald
casibbald force-pushed the feat/native-http-client branch from f600249 to faee0e3 Compare July 13, 2026 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant