diff --git a/PLAN.md b/PLAN.md index d05b7bb..6c3ebf7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -178,10 +178,10 @@ phase/step/milestone wording, ever. | ✓ | ~~`feature/static-files`~~ | root mapping, traversal guard, MIME, index, 404/405, HEAD (`std::filesystem`) + per-connection write cap | merged — traversal/HEAD/405/slow-loris/pipelined-burst tests both OSes; ASan + TSan green | | G | `feature/caching-and-range` | ETag, Last-Modified, If-None-Match, Range/206/416 | merged — 304 (weak/list/star/IMS-precedence) + single-range 206/416 + If-Range tests; multi-range ignored (200) by design; ASan + TSan green | | ✓ | ~~`feature/hardening`~~ | slow-body 408 budget, server-level smuggling corpus, env-gated 60 s RSS soak, restored threading suite | merged — corpus proves rejected streams die with the stream and never reach the handler; soak RSS drift < 16 MiB (Linux) | -| D | `feature/docs-and-benchmark` | README (build/run per OS, limits), load script, numbers | documented; req/s + RSS recorded on both platforms | +| D | `feature/docs-and-benchmark` | README (build/run per OS, limits), bench script, API/BUILDING docs | merged — README/BUILDING/API + `tools/bench-inside.sh`; numbers recorded on Linux (1 KiB keep-alive: 9.8k→47.3k req/s for 1→4 reactors). Windows numbers **not** recorded — no wrk/ab on the Windows box; same code path over Winsock. RSS: 60 s soak 109k iterations, 2.3 MB drift (CI `linux-soak`) | Then `release/0.1.0` → `develop` + PR to `main` (approval required) → tag `v0.1.0`. -All of C, R, T, S, G, H merged; D (docs/benchmark) remains. +All phases A–D merged; the release branch and the `main` PR remain. > **Integrity note (H-phase audit).** `tests/test_connection.cpp`, `tests/test_threads.cpp`, > `tests/test_util.cpp`, `tests/test_range.cpp` and a standalone `http::Range` module had diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a2c906 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# http-server-cpp + +A dependency-free HTTP/1.1 file server in C++20 — Windows and Linux from one +codebase. Acceptor thread + K single-threaded reactor threads, one push +request parser, one static-file handler. No frameworks, no libuv, no Boost. + +## What works today + +- HTTP/1.1 + 1.0: keep-alive, pipelining, HEAD, `408` on stalled requests, + silent close on idle keep-alive streams (RFC 9112). +- Request parser hardened against request smuggling: `CL+TE`, duplicate / + signed / garbage `Content-Length`, bare-LF endings, obsolete header + folding, NULs, non-token names/methods all rejected — and the stream is + dropped after any framing error (never answered and reused). +- Static files: traversal-proof resolver (strict percent-decode, canonical + containment, Windows case-folded), `405` + `Allow`, MIME table. +- Caching & ranges: strong `ETag` + `Last-Modified`, `If-None-Match` / + `If-Modified-Since` → `304` (before the file is read), single `Range` → + `206` / `416` with `If-Range` gating. +- Slow-client safety: per-connection deadlines, per-connection output cap + (`max_queued_bytes`) with throttling — one stalled reader never blocks a + reactor or another connection. +- Graceful `Ctrl-C` / SIGINT / SIGTERM: queued responses drain, then exit 0. + +## Quick start + +```sh +# Linux +cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build +./build/http_server 8080 ./web # serve ./web on :8080 + +# Windows (MinGW-w64 on PATH; MSVC preset: -preset msvc-x64-release) +cmake --preset mingw-x64-release && cmake --build --preset mingw-x64-release +build\mingw-x64-release\Release\http_server.exe 8080 .\web +``` + +`http_server [port] [root] [threads]` — threads `0` (default) means +`hardware_concurrency`. + +## Benchmarks + +Indicative only — server and benchmark clients share one WSL2 container +(Ubuntu 24.04, gcc 13 `-O2`, 56 visible cores); numbers scale with the host. +Reproduce with `tools/bench-inside.sh` inside any Linux container with +`wrk` + `apache2-utils`. + +| Scenario | 1 reactor | 4 reactors | +| ----------------------------------- | ---------------------- | ---------------------- | +| `wrk -t4 -c100`, 1 KiB keep-alive | 9,770 req/s · p99 19.8 ms | 47,275 req/s · p99 4.3 ms | +| `wrk -t4 -c100`, 64 KiB keep-alive | 5,683 req/s · 356 MB/s | 25,919 req/s · 1.59 GB/s | +| `ab -c50`, 1 KiB fresh connections | 8,027 req/s | 8,443 req/s | + +Reactor count scales near-linearly for keep-alive traffic; fresh-connection +throughput is acceptor/handshake-bound by design (single acceptor). Soak +signal (CI `linux-soak` job): 109,110 mixed-churn request iterations in 60 s, +0 errors, RSS drift 2.3 MB. + +## Limits & behaviour + +| Knob | Default | Effect | +| -------------------- | -------------------- | --------------------------------------------- | +| request head | 64 KiB | larger → `431` then drop | +| declared body | 8 MiB | larger → `413` then drop | +| header count | 100 | more → `431` then drop | +| `idle_timeout` | 5 s | stalled request → `408`; idle stream → close | +| `max_queued_bytes` | 1 MiB | output backlog before throttling the reader | +| `shutdown_grace` | 5 s | drain budget on stop | + +Not served: `Transfer-Encoding: chunked` requests, multi-range, POST bodies. +Static handler reads whole files into memory (bounded streaming is planned). + +## Layout & docs + +``` +src/net/ platform seam: sockets, poll, errors (raw_win / raw_posix) +src/http/ parser, headers, response, status, HTTP-date +src/server/ acceptor + reactors, connection, static handler +src/app/ shutdown signals (self-pipe on POSIX, console ctrl on Windows) +tests/ doctest suite incl. framing corpus, threading, env-gated soak +``` + +[ARCHITECTURE.md](docs/ARCHITECTURE.md) · [BUILDING.md](docs/BUILDING.md) · +[API.md](docs/API.md) + +## Verification + +Every push runs: Windows MinGW build + full suite; Linux ASan+UBSan and TSan +(container); 60 s RSS soak. Locally: `tools/ck.ps1` (Windows) and +`tools/ck-asan-docker.sh` (Linux). See [BUILDING.md](docs/BUILDING.md). diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..b383fa1 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,98 @@ +# API reference + +Four layers, strict one-way dependencies: `app → server → http → net`. +Only `net/` contains OS code. Everything below is header-exact as of this +commit; the headers carry the full contracts. + +## `net` — platform seam + +| Type | Notes | +| --- | --- | +| `net::Socket` | move-only handle wrapper. `tcp4()`, `bindAny(port)` (port 0 → ephemeral), `listen(backlog)`, `accept()`, `connect()`, `setNonBlocking()`, `setReuseAddress()`, `sendAll()`, `recvSome()` (kernel-accurate; `-1` + `ErrorKind::WouldBlock`), `localPort()`, `close()`. Factories return an invalid socket (`valid() == false`) instead of throwing. | +| `net::PollSet` / `net::pollSockets()` | thin WSAPoll/poll wrapper. `ErrorKind::Interrupted` (`EINTR`) is reported, never silently retried — the reactor treats it as a re-check. | +| `net::Context` | RAII WSAStartup / no-op on POSIX. One per process before sockets. | +| `net::lastError()` / `net::Error` | mapped error kinds shared by both backends. | + +Contract that costs the most care: **an accepted socket inherits the +listener's blocking mode on Winsock and must not on Linux** — +`accept4(SOCK_CLOEXEC)` + explicit `O_NONBLOCK` copy keep the portable +behaviour identical. Linux sends use `MSG_NOSIGNAL`. + +## `http` — protocol objects (no I/O) + +```cpp +http::RequestParser parser; // push parser, no sockets involved +parser.feed(bytes); // append raw bytes +switch (parser.next()) { // loop until Request/Error +case Result::Request: { + const http::Request& r = parser.request(); // owns its bytes (strings) + bool ka = parser.keepAlive(); // valid right here + parser.reset(); // consumes this request, keeps pipeline remainder +} ... +} +``` + +- `Request`: `method`, `target`, `version`, `headers` (`http::Headers`, + insertion-ordered, `find(name)` → index or `npos`), `body`. All owned — + safe across `reset()` and further `feed()`s. +- `Limits` (per parser): `maxHeadBytes` 64 KiB, `maxBodyBytes` 8 MiB, + `maxHeaderCount` 100. +- `Error` → connection mapping: `HeadTooLarge`/`TooManyHeaders` → `431`, + `BodyTooLarge` → `413`, `VersionNotSupported` → `505`, else `400`; any + error drops the connection after the best-effort reply. +- `idle()` — no request bytes since `reset()`: an idle timeout on such a + stream closes quietly; a half-received request gets `408`. +- `http::Response` — status, headers (`setHeader` replace / `addHeader` + append), body; `serialize()` / `serializeHead()`; `encodeChunk()` for + chunked bodies (response-side only). +- `http::formatHttpDate` / `parseHttpDate` — IMF out; IMF + RFC 850 + + asctime in (no libc `timegm`; portable civil-calendar math). + +## `server` — threads and handlers + +```cpp +server::StaticFiles files("./web"); // throws if root missing +server::Server server(8080, files); // Handler = std::function +server.threads = 0; // 0 = hardware_concurrency +server.run(signal); // blocks until stop requested +``` + +`Server` knobs: `idle_timeout` 5 s, `shutdown_grace` 5 s, `accept_batch` 64, +`max_queued_bytes` 1 MiB, `threads`. Constructor throws `std::runtime_error` +on bind/listen failure; `port()` reports the bound port (ephemeral-aware). + +**Threading model (the rule that makes this race-free):** one acceptor +thread hands accepted sockets to K reactor threads over a mutex-guarded +inbox + wakeup socket. A `Connection` (socket + parser + output queue) is +owned by exactly one reactor forever; no connection state is ever shared or +locked. Handlers run inline on their reactor thread and must not block it. + +`Reply{status, content_type, body, headers}` — HEAD drops the body but keeps +`Content-Length` honest (done in `Connection`; `304` omits both). +Handler exceptions are caught at the reactor seam → `500`. + +`server::StaticFiles` — serve-a-root as a `Handler`: traversal refusal +(before any disk access), index.html, `405` + `Allow`, MIME, ETag/ +Last-Modified/304, single-Range/206/416. Constructor canonicalises the root +(`std::filesystem::filesystem_error` if it doesn't exist). + +`ShutdownSignal` — pluggable stop trigger (`stopRequested()`, optional +`preparePoll`/`afterPoll`/`pollCap`). Tests drive it directly for +deterministic graceful-stop coverage. + +## `app` — signals + +`app::ShutdownSignals` implements `ShutdownSignal` from SIGINT/SIGTERM +(self-pipe on POSIX — poll wakes instantly; console-control handler on +Windows — sets the flag, blocks until the loop ends, capped 100 ms idle +poll). `notifyStopped()` completes the Windows handler. Exit code 0. + +## Error-handling conventions + +- `net` factories: invalid handle + `lastError()`; no exceptions. +- `http` parser: `Result::Error` + `Error` enum; never throws on hostile + input (suite-proven). +- `server`: constructor throws (config-level); runtime per-connection errors + close the connection, never the reactor. +- Handler exceptions → `500`; `StaticFiles` I/O errors → `404`/`500`, never + a leak of paths. diff --git a/docs/BUILDING.md b/docs/BUILDING.md new file mode 100644 index 0000000..cd955f5 --- /dev/null +++ b/docs/BUILDING.md @@ -0,0 +1,50 @@ +# Building & verifying + +C++20, CMake ≥ 3.25, no third-party runtime dependencies. Tests use +[doctest](https://github.com/doctest/doctest) as a vendored single header +under `external/doctest` — fully offline builds. + +## Linux + +```sh +cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build +./build/unit_tests +cmake --preset gcc-asan && cmake --build --preset gcc-asan # ASan+UBSan+LSan +``` + +GCC 12+/Clang 15+. `_GNU_SOURCE` is set by CMake (glibc hides `accept4` +under `-std=c++20`). + +## Windows + +- **MinGW-w64** (primary CI gate): `cmake --preset mingw-x64-debug` with + `mingw64/bin` on `PATH` — presets `mingw-x64-release`, `mingw-x64-asan` + too (the ASan preset is skipped automatically if the toolchain has no + sanitizer runtimes; `tools/ck.ps1` warns instead). +- **MSVC**: `cmake --preset msvc-x64-debug` (from a Developer prompt, or + use CMake Tools). W4 + `/WX` in CI. + +## Verification gates + +| Gate | What | +| ------------------------ | --------------------------------------------------------------------------- | +| `tools/ck.ps1` | Windows: configure+build (warnings-as-errors), clang-format `--dry-run -Werror` on `src/ tests/`, full suite | +| `tools/ck-asan-docker.sh`| Ubuntu 24.04 container: full suite under ASan+UBSan+LSan + SIGINT graceful-stop smoke; `SAN=thread` runs TSan | +| `tools/soak-inside.sh` | 60 s churn soak, RSS drift assertion (`HTTP_SERVER_SOAK_SECONDS` also gates the in-suite test) | +| `tools/bench-inside.sh` | Release build throughput numbers (needs `wrk`, `apache2-utils`) | +| GitHub Actions | `windows-native`, `linux-asan`, `linux-tsan`, `linux-soak` on every push/PR | + +Notes: + +- Docker Desktop on Windows: from Git Bash prefix container runs with + `MSYS_NO_PATHCONV=1` or `/src` mounts get rewritten. +- Local TSan in a Docker Desktop kernel may die with `unexpected memory + mapping` (ASLR vs TSan runtime): run with `--privileged` and + `sysctl -w kernel.randomize_va_space=0` inside. CI runners are unaffected. +- clang-format: `tools/ck.ps1` gates `src/` and `tests/` with + `clang-format --dry-run -Werror` (verified against LLVM 23). Different + major versions can disagree on details — reformat with + `clang-format -i $(git ls-files 'src/**.h' 'src/**.cpp' 'tests/**.h' 'tests/**.cpp')` + using your local binary before pushing. +- The soak test skips unless `HTTP_SERVER_SOAK_SECONDS=60` is set, keeping + the default suite fast; the CI `linux-soak` job sets it. diff --git a/tools/bench-inside.sh b/tools/bench-inside.sh new file mode 100644 index 0000000..e20c351 --- /dev/null +++ b/tools/bench-inside.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Throughput numbers for the README — indicative only (container CPU). +# Scenarios x {1 reactor, N reactors}. Release build. +set -euo pipefail +cd "$(dirname "$0")/.." + +command -v wrk >/dev/null 2>&1 || { + echo "need wrk (apt-get install -y wrk)" >&2 + exit 1 +} +command -v ab >/dev/null 2>&1 || { + echo "need ab (apt-get install -y apache2-utils)" >&2 + exit 1 +} + +cmake -S . -B /tmp/bench -G Ninja -DCMAKE_BUILD_TYPE=Release >/dev/null +cmake --build /tmp/bench -j >/dev/null + +mkdir -p /tmp/web +printf 'bench ok\n' >"/tmp/web/hi.txt" +head -c 1024 /dev/zero | tr '\0' 'A' >/tmp/web/1k.txt +head -c 65536 /dev/zero | tr '\0' 'B' >/tmp/web/64k.txt + +PORT=18080 +run_server() { # threads + /tmp/bench/http_server "$PORT" /tmp/web "$1" >/tmp/server.log 2>&1 & + echo $! +} +wait_ready() { + for _ in $(seq 1 50); do + # no curl in the image: a TCP connect is enough + (exec 3<>/dev/tcp/127.0.0.1/"$PORT") 2>/dev/null && return 0 + sleep 0.1 + done + echo "server did not come up" >&2 + exit 1 +} + +bench() { # threads label path -> wrk keep-alive + local T=$1 label=$2 path=$3 + local pid + pid=$(run_server "$T") + wait_ready + echo "### threads=$T $label wrk keep-alive" + wrk -t4 -c100 -d15s --latency "http://127.0.0.1:$PORT$path" | + grep -E "Requests/sec|Latency|99%|Transfer/sec" + kill "$pid" + wait "$pid" 2>/dev/null || true +} + +fresh() { # threads label path -> ab without keep-alive + local T=$1 label=$2 path=$3 + local pid + pid=$(run_server "$T") + wait_ready + echo "### threads=$T $label fresh-connections (ab -n 20000 -c 50)" + ab -n 20000 -c 50 "http://127.0.0.1:$PORT$path" | + grep -E "Requests per second|Failed requests|P99" + kill "$pid" + wait "$pid" 2>/dev/null || true +} + +for T in 1 4; do + bench "$T" "1KiB file" /1k.txt + bench "$T" "64KiB file" /64k.txt + fresh "$T" "1KiB file" /1k.txt +done +echo "cores: $(nproc)"