Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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).
98 changes: 98 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -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<Reply(const Request&)>
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.
50 changes: 50 additions & 0 deletions docs/BUILDING.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 68 additions & 0 deletions tools/bench-inside.sh
Original file line number Diff line number Diff line change
@@ -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)"
Loading