Small, clean, single-binary HTTP/1.1 file server in C++20. Runs natively on Windows and Linux. No network library in the shipped binary. Portfolio artifact: readability and correctness are the deliverables, raw throughput is not.
v2 change: Linux became a first-class target. The split happens before the reactor exists — reactor, signals, shutdown, file serving, and all their tests are written cross-platform from their first commit.
| Topic | Decision | Why |
|---|---|---|
| Goal | Portfolio artifact, small and clean | Optimise for readable code + honest tests, not feature count |
| HTTP | HTTP/1.1 server core: methods, status codes, chunked, keep-alive, pipelining | Contract with every real client |
| Content | Static files, MIME, index, ETag/If-Modified-Since, Range |
Makes it actually usable |
| Platforms | Windows (native, MinGW-w64) and Linux (GCC in Docker) | v2; both built, tested, and sanitised on every PR |
| I/O | Non-blocking sockets, poll() / WSAPoll() everywhere, K reactor threads, per-connection timeouts, graceful shutdown |
Same pollfd semantics on both; reactor stays single-source |
| Event engines | No epoll/io_uring/IOCP backend abstraction | poll is right-sized for a file server; engines need a benchmark to justify them, and we have none |
| Standard | C++20 | Full support on both GCC 15.2 (MinGW) and Ubuntu 24.04's GCC 13 |
| Deps | doctest (tests only, vendored). Server binary: std + platform socket API only | Zero third-party code shipped |
| Config | CLI flags: --port --root --threads --max-body --timeout --log-level |
A config file is one more parser to justify |
| Linux toolchain | Docker-only on this host (http-cpp-asan image: Ubuntu 24.04, g++, cmake, ninja) |
No WSL distro; CI mirrors the container exactly |
| CI | GitHub Actions matrix ubuntu-latest + windows-latest on every PR to develop; ASan native on Linux; TSan once the reactor lands |
Linux CI turns the fake sanitizer gates real |
- HTTP/2 and HTTP/3 — separate binary framing, HPACK, multiplexing; different project.
- TLS / HTTPS — needs a crypto dependency; violates the deps policy.
- CGI, subprocess execution, embedded route handlers.
- WebSocket upgrade.
- macOS/kqueue (poll() portability means it likely works; we will not claim or test it).
- epoll/io_uring/IOCP event engines — revisit only with benchmark evidence.
- IPv6. IPv4 first; documented as a limitation.
- Daemon/service mode, Windows event log, systemd unit files. stderr logging with levels.
One codebase, one seam. Everything above net/ never sees a platform macro.
┌──────────────────────────────┐
client ── TCP ──► │ listener (non-blocking) │
└──────────────┬───────────────┘
│ round-robin hand-off (mutex-protected inbox;
┌─────────────────┬───┴────────────────┬─────────────┐ TSan-proven in CI)
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ reactor 0 │ │ reactor 1 │ … │ reactor K-1 │
│ poll loop │ │ poll loop │ │ poll loop │
│ conns owned │ │ conns owned │ │ conns owned │
└───────┬───────┘ └───────────────┘ └───────────────┘
▼
Connection state machine ──► http::Request ──► StaticFileHandler ──► http::Response
src/http, src/server, reactor, connection ─── single source
src/net: socket.h poll.h error.h (portable API)
raw_win.cpp (Winsock) raw_posix.cpp (BSD) ← the only seam
Ownership rule. A Connection is created by exactly one reactor and destroyed by
that same reactor. The one shared structure is the per-reactor hand-off inbox, guarded
by a short-held mutex — proof is TSan in Linux CI, not vibes.
Reactor loop (per thread, single source).
poll()/WSAPoll()with timeout = smallest remaining deadline.- Ready listener → accept loop (non-blocking), hand
Connectionto next reactor inbox. - Ready connection → drive state machine:
RECV→PARSE→SERVE→SEND→KEEP_ALIVEorCLOSE. - Expired deadline (idle / slow-body / send) →
408or400then close. - Shutdown flag set → stop accepting, drain or timeout in-flight, join, cleanup.
Connection state machine
ACCEPTED → READING_HEADS ──bad──► RESPOND_400 → CLOSING
│ complete
▼
SERVE (map path → file, stat, etag/304, range/206)
│
▼
SENDING ──WOULDBLOCK──► (poll POLLOUT) ──► SENDING
│ done keep-alive? ──yes──► READING_HEADS
▼ no
CLOSING (graceful: flush, shutdown(SD_SEND), close)
Failure policy. Every socket error closes the connection, never the process. Error
codes cross the seam as net::Error { WouldBlock | Interrupted | Closed | Other, code }
mapped from WSAGetLastError()/errno inside net/. No exceptions cross the reactor
boundary; exceptions confined to setup.
| Trap | Windows | Linux |
|---|---|---|
| Accepted socket blocking mode | inherits listener's non-blocking | does not inherit → accept4(SOCK_NONBLOCK | SOCK_CLOEXEC), fallback fcntl |
| Send to dead peer | send → WSAECONNRESET, no signal |
SIGPIPE kills process → MSG_NOSIGNAL on every send |
| Interrupted calls | effectively none | EINTR on poll/read/write → retry loop |
| Process shutdown | SetConsoleCtrlHandler, poll-timeout-bounded |
SIGINT/SIGTERM → self-pipe wakes poll |
| Session lifecycle | WSAStartup/WSACleanup in net::Context |
no-op in net::Context |
| Address reuse | SO_REUSEADDR |
SO_REUSEADDR (no SO_EXCLUSIVEADDRUSE analogue needed) |
select legacy ceiling |
n/a (WSAPoll used) |
n/a (poll() used) |
CMakeLists.txt CMakePresets.json (Windows; Linux configures inside container)
cmake/
.github/workflows/ci.yml matrix: ubuntu-latest, windows-latest + sanitizers
src/
main.cpp flags, logger, start/stop
app/ signals_win.cpp / signals_posix.cpp → sets shutdown flag, wakes reactor
net/ socket.h poll.h error.h context.h detail.h detail_raw.h portable API
socket_common.cpp shared socket logic
raw_win.cpp error_win.cpp context_win.cpp Winsock backend
raw_posix.cpp error_posix.cpp context_posix.cpp BSD socket backend
http/ request.* response.* headers.* mime.* status.* url.* (response/headers/status landed)
server/ server.* static_file.* single source, std::filesystem
include/ (none unless a public lib target is ever wanted)
tests/ doctest: unit + integration, cross-platform
tests/corpus/ parser test vectors (valid + malformed)
tools/ck.ps1 Windows gate: build + test + format + staged-secrets guard
tools/ck-asan-docker.sh Linux gate: full tree (net+http+server) ASan+UBSan
tools/ck-tsan-docker.sh Linux gate: TSan on threaded reactor tests (once reactor lands)
tools/Dockerfile.asan Ubuntu 24.04 + g++ + cmake + ninja
docs/ARCHITECTURE.md
Windows: presets gcc-debug, gcc-release (MinGW pinned absolutely, static runtime —
PATH-proof binaries). Linux: configure/build inside the http-cpp-asan image; no host
toolchain. ctest on both. CI matrix runs both plus ASan (and TSan post-reactor).
Flags both platforms: -std=c++20 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Werror.
Windows additionally: _WIN32_WINNT=0x0A00, link ws2_32, -static.
Sanitizer reality (verified). MinGW-w64 here has no libasan/libubsan and no TSan
on Windows at all. Therefore: ASan+UBSan (leak check on) native on Linux CI for the
whole tree; TSan Linux CI for reactor/concurrency tests. gcc-asan Windows preset
self-detects and fails with guidance; ck.ps1 warns and skips. tools/ck-asan-docker.sh
verified green on http/.
| Layer | Where | Covers |
|---|---|---|
| Unit | both OSes | parser (byte-exact state transitions), headers, URL decode/normalise, MIME, Range, chunked, timeout math |
| Corpus | both OSes + ASan | malformed / request-smuggling vectors; deterministic replay replaces a fuzzer |
| Socket | both OSes (once cross-platform) | bind/accept/echo/WOULDBLOCK/EOF — same test file compiled twice |
| Integration | both OSes | request→file→response, 206, 304, 416, 400, 405, 408, keep-alive, pipelining, truncated body, oversize heads |
| Concurrency | Linux + TSan | N clients × keep-alive × K reactors × hand-off inbox; no race, no leak, correct counts |
| Load | Linux (and Windows manually) | sanity req/s, fd leak, RSS flat |
Parser invariants asserted everywhere: never reads past declared body, header count and
sizes bounded, rejects bare-LF folding, duplicate Content-Length, and
Content-Length + Transfer-Encoding together.
Each milestone = one feature/* branch → PR → develop. Exit gate: CI green on both
OSes + the listed behaviour proven by a test. Commit messages: intent only, no
phase/step/milestone wording, ever.
| # | Branch | Delivers | Exit criterion |
|---|---|---|---|
| ✓ | feature/build-scaffold |
CMake, presets, doctest, ck.ps1 |
merged |
| ✓ | feature/socket-layer |
Winsock RAII, non-blocking, WSAPoll | merged |
| ✓ | feature/response-writer |
Response, headers, status, chunk encoder | merged |
| ✓ | feature/posix-socket-layer |
The seam: portable net/ API + raw_win/raw_posix backends; accept4 + MSG_NOSIGNAL + EINTR; cross-platform socket tests; Docker gate builds whole Linux tree |
merged — both gates green |
| C | feature/ci-matrix |
Actions matrix: Linux job = sanitizer container (tools/asan-inside.sh), Windows = native MinGW + ctest |
merged — PR #5 gated itself green on first run |
| R | feature/reactor-and-connection |
single-threaded poll reactor: connection state machine, timeouts (408), POLLOUT backpressure, signals (self-pipe / console ctrl), graceful drain | merged — 200/keep-alive/408/400/stop integration tests; ASan + TSan green; SIGINT-exit-0 smoke in the Linux gate |
| ✓ | feature/reactor-threading |
acceptor thread + K reactors + socket hand-off inbox (net::Wakeup) |
merged — cross-reactor keep-alive/pipelining/stop-drain tests; TSan green; EINTR double-join fixed |
| ✓ | 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), 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 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.cppand a standalonehttp::Rangemodule had only ever existed in the working tree — a botchedcommit --amendchain dropped them from the threading-phase commit, so every CI run since executed a silently smaller suite than reported. The threading suite is restored (tests/test_threads.cpp, now genuinely TSan-covered); range/date coverage demonstrably lives insidetests/test_static.cppandtests/test_date.cpp, which were committed.
main— releases only. I open a PR and stop; merge waits for your approval.mainis currently held/absent by explicit decision.develop— integration. I push and merge freely here.feature/<topic>→develop, rebase-merge so develop stays linear.release/<x.y.z>→develop+mainwith--no-ff; tagvx.y.z.hotfix/<name>→main+develop; PR tomainstill needs your approval.- Conventional Commits, intent only — never "phase/step/milestone" wording.
- One logical change per commit; every commit builds on both OSes (CI enforces from C).
- Every PR must be green on both Ubuntu and Windows before merge to
develop.
- No secrets, tokens, or absolute private paths in any tracked file or CI secret-less log.
- No credentials in the remote URL;
ghcredential helper only. .gitignoreblocks build output,.env,*.pem,*.key.- Server-side security is a feature: path traversal (both separators, dot-segments),
header smuggling (nothing user-controlled echoed raw), oversized heads/bodies,
request smuggling (
CL.TE/TE.CLcorpus cases), fd exhaustion caps,MSG_NOSIGNAL.
- Remote:
https://github.com/AgentDudu/http-server-cpp.git, pushed viaghCLI credential helper — no tokens in remotes, configs, or files. - Default
--root ./wwwwith a bundled sample tree. - doctest v2.4.11 vendored in
external/doctest/. mainis held;developis the default branch.- v2: Linux is first-class; port happens now (only
net/is platform-specific — ~250 lines — and nothing above it exists yet). poll() on both OSes; Docker is the Linux toolchain; CI matrix with native ASan + TSan replaces the fake gates.