Peb is a C++17 HTTP/HTTPS server and reverse proxy with multiple concurrency models.
- Thread-pool server – one blocking thread per connection, HTTP/1.1
- Epoll + io_uring server – non-blocking I/O for many connections per thread
- Reverse proxy – backend pool with health checks
- TLS / HTTPS via OpenSSL
- Optional mutual TLS (mTLS)
- HTTP/2 (epoll target only)
- HTTP/3 over QUIC (UDP, picoquic; dynamic QPACK)
- WebSocket support
- Static file serving
- Chunked transfer encoding
- Request body parsing (Content-Length and chunked)
- Linux (liburing required for epoll target)
- CMake ≥ 3.20
- C++17 compiler
- OpenSSL development headers
- liburing development headers
Debian/Ubuntu:
sudo apt install build-essential cmake libssl-dev liburing-devFedora:
sudo dnf install cmake gcc-c++ openssl-devel liburing-develcmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -jExecutables are placed in build/:
| Target | Description |
|---|---|
| peb | Thread-pool HTTP/HTTPS server |
| peb_epoll | Epoll + io_uring async server |
| peb_proxy | Reverse proxy with health checks |
| peb_tests | Unit tests |
| peb_benchmarks | Microbenchmarks |
Self-signed certs are generated for local testing only. Do not use in production.
./certs/gen_cert.sh
./certs/gen_client_cert.sh./build/peb [http_port] [https_port] [static_root] [cert_path] [key_path] [client_ca_path] [require_client_cert] [quic_port]Example with default paths:
./build/peb 8080 8443 ./publicWhen the certificate is valid, the server also listens for HTTP/3 on UDP port
4433 by default. Set the final quic_port argument to change that port. The
QPACK advertises a 4 KiB dynamic table and supports dynamic table instructions,
field references, acknowledgments, and up to 16 blocked streams. It also accepts
Huffman-encoded strings from clients.
peb_epoll accepts optional worker count and maximum TCP connections per
worker after its existing arguments. The worker count defaults to the available
CPU count and is clamped to 64; each worker accepts up to 4096 connections by
default. At capacity, new TCP connections are closed promptly, leaving the
kernel listen backlog to absorb short bursts. TCP/TLS connections have a 60
second idle timeout, TLS handshakes have a 10 second deadline, and HTTP/1
requests have a 120 second absolute deadline. HTTP/2, WebSocket, and HTTP/3
connections use the idle timeout; HTTP/3 is capped at 1024 simultaneous
connections and uses a 10 second handshake deadline.
The blocking peb server uses eight workers per listening port, with at most
64 queued connections per worker. When workers and the queue are full, it closes
newly accepted sockets. Its blocking reads and writes have a 15 second socket
timeout.
All HTTP server modes, including the reverse proxy, expose process-wide
Prometheus text metrics at GET /metrics:
active and rejected connections, thread-pool queue depth, HTTP/3 application
send-queue bytes, resident memory,
buffered bytes and budget rejections, request counts and errors,
protocol/application errors, and a request-handler latency histogram.
Server-owned parser, body, file-read, and output buffers share a 256 MiB
process-wide budget by default. Set PEB_MAX_BUFFERED_BYTES to a positive byte
count to change it. When a request cannot reserve memory, its connection or
stream is closed with a protocol overload error; oversized response buffering
is rejected with 503 where the protocol path can still send one. The handler
histogram measures route execution time. Handler-local allocations and allocator
overhead are outside the reservation counter; RSS reports actual process memory.
The load test reports client-observed latency, including connection setup and
response transfer.
Run the dependency-free concurrency sweep against a running server:
python3 bench/load_test.py --url http://127.0.0.1:8080 --path /api/health --concurrency 1,8,32,64,128 --duration 10It prints CSV with throughput, errors, p50/p95/p99 latency, RSS, and server connection/queue/request metrics at each load level. Increase concurrency until throughput flattens or tail latency and errors rise; results depend on hardware, build mode, kernel, and the selected endpoint.
- GET /api/health – { "status":"ok" }
- GET /api/time – current Unix time
- GET /api/stream – chunked response (5 chunks)
- POST /api/echo – echoes request body
- GET /api/whoami – client cert subject if using mTLS
Static files are served from static_root (default ./public).
Use Router::post_streaming when a handler can consume an upload incrementally.
HTTP/1.1 request body bytes, HTTP/2 DATA payloads, and HTTP/3 DATA payloads
are passed to on_body as they arrive. Streaming routes do not copy request
payloads into HttpRequest::body; the existing post API continues to buffer
bodies for handlers that need the complete string. The string_view passed to
on_body is valid only until that callback returns, so copy data the handler
needs to retain.
router.post_streaming("/upload", [](const HttpRequest&) {
auto received = std::make_shared<std::size_t>(0);
return StreamingRequestSession{
[received](std::string_view chunk) { *received += chunk.size(); },
[received](const HttpRequest&) {
HttpResponse response;
response.set_body(std::to_string(*received), "text/plain");
return response;
}
};
});HttpResponse::enable_chunked_encoding streams generated response chunks over
HTTP/1.1, HTTP/2, and HTTP/3 rather than constructing one large body string.
Chunks are requested as the transport is ready to send more data. HTTP/3 also
caps its per-connection application send queue at 256 KiB. The reverse proxy
streams fixed-length and chunked client uploads to the backend, then streams
fixed-length, chunked, or close-delimited backend responses to the client.
Proxy uploads remain capped at 20 MiB and upstream responses at 50 MiB.
cd build
ctest --output-on-failureThe end-to-end slow-client, overload, TLS, HTTP/2, HTTP/3, and sustained-load shutdown checks require the built server binaries and curl with HTTP/2 and HTTP/3 support:
python3 tests/protocol_load.pyThe C++ proxy integration tests start a local mock backend and check slow downloads, chunked request trailers, truncated upstream bodies, timeout expiry, overload rejection, TLS client connections, and worker release under stalled uploads. Run them with the regular test suite:
ctest --test-dir build --output-on-failureThe primary HTTP/1.1 proxy relay copies at most one 8 KiB reader chunk at a time. Defaults are a 5 second client/backend inactivity timeout, a 5 second backend connect timeout, a 120 second exchange deadline, 16 workers, a 32 connection queue, and eight idle backend connections per backend. Every value can be overridden with command-line options; excess accepted connections are rejected.
Each backend keeps up to eight idle HTTP/1.1 connections for reuse. A socket is returned only after a complete length-delimited response and when the backend connection semantics permit reuse; stale, truncated, and close-delimited connections are discarded. The C++ load test reports achieved requests per second and p50, p95, and p99 request latency in its output and test properties.
The proxy's primary listener speaks HTTP/1.1. Optional protocol listeners can
be enabled with TLS credentials: --https-port enables HTTP/1.1 plus HTTP/2
over TLS, and --http3-port enables HTTP/3 over QUIC. HTTP/2 and HTTP/3 requests
are translated to HTTP/1.1 upstream requests through the backend pool. Those
protocol adapters currently buffer each request and response and perform
blocking upstream I/O in their reactor callback; use the primary HTTP/1.1
listener for large streaming transfers.
build/peb_proxy 8080 127.0.0.1:9000 /api/health 5 \
certs/server.crt certs/server.key \
--workers 16 --queue-limit 32 --backend-pool-size 8 \
--client-timeout 5 --backend-timeout 5 --connect-timeout 5 \
--request-deadline 120 --https-port 8443 --http3-port 4433./build/peb_benchmarks