Skip to content

Repository files navigation

bounded/http

for servers that stay within their bounds

Fast by design. Explicit about limits.

An experimental HTTP/1.1 framework and reference server for Zig 0.16.0, built on native event-driven networking.

Platform Native backend Network progress
Linux io_uring The kernel receives asynchronous operations through submission queues and reports their completion.
macOS kqueue Readiness events drive nonblocking socket operations.
Windows IOCP — I/O completion ports Overlapped socket operations report completion to their owning thread.

Each network owner is a thread that manages many connections through readiness or completion events. While one connection waits for network progress, its owner can service other ready connections. The framework reserves memory and threads before serving requests, with explicit connection, request, and response limits. The build target selects the native backend automatically.

The repository and executable use bounded-http. The Zig package and module use bounded_http. The project was previously named zig-http.

Read the published whitepaper in your browser. The site includes rendered Markdown guides and syntax highlighting.

Start with the architecture guide to understand clusters, ownership, startup, and request processing. The integration guide explains dependency setup, callbacks, resource limits, and response writing. The illustrated whitepaper presents the design and qualified performance results. Open the HTML file in a browser; the document includes every SVG and works offline. The independent embedding example demonstrates the complete application lifecycle.

Application callbacks can run on the network owner or on fixed startup workers. Inline execution is the default for trusted bounded, nonblocking handlers; blocking callbacks must explicitly select fixed startup workers. Worker callbacks can flush output without returning. The same reserved worker stack resumes after the owner completes transmission. For callbacks that return between events, application notifications let producers wake retained requests without keeping a worker occupied. Each connection has one bounded, generation-checked coalescing signal; timers and request deadlines remain explicit. The Windows receipt records native x64 evidence and its limits. This is the M4 implementation informed by the adjacent Zig LLM Wiki.

The first goal is a working ownership and pending/resume model that we can measure and change. This is not a production qualification or a TechEmpower ranking. It listens on IPv4 loopback only, speaks plain HTTP/1.1, and has no TLS, proxy protocol, upgrade/tunnel implementation or general file server.

Run these commands from the repository root with the exact zig compiler on PATH; verify checks that its version matches .zig-version. Python 3 is needed for the version check, integration suite and smoke client. ReleaseSafe is the preferred experiment mode; assertions remain enabled.

zig version
zig build verify -Doptimize=ReleaseSafe
zig build -Doptimize=ReleaseSafe
./zig-out/bin/bounded-http --port 8080 --connections 128

On Windows, use .\zig-out\bin\bounded-http.exe from PowerShell. Windows defaults to one I/O shard; --shards 3 enables three independent IOCP owners. One listener distributes socket ownership through startup-bounded queues. See the architecture guide and native shard receipt.

The version must print 0.16.0. The server prints READY to stderr after startup. Linux and macOS accept SIGINT or SIGTERM; Windows consoles accept Ctrl-C or Ctrl-Break. --duration-ms 30000 requests shutdown after a finite run. --port 0 asks the OS for an available loopback port, reported in READY. The HTML file is loaded before serving starts.

Route Behavior
/plaintext Hello, World!, exactly 13 bytes, text/plain.
/ and /index.html The startup-loaded assets/index.html; --index FILE selects another file, limited to 65,535 bytes (the current file-reader bound is exclusive).
/echo Echoes the complete bounded request body through borrowed spans; accepts Content-Length or chunked framing.
/chunks Three flush/resume turns followed by finish, producing first second third with chunked response framing.
/stall Worker-mode blocking fixture; inline returns 501.
/buffered Writes its distinct raw request target into the output arena; 413 if it exceeds Writer.capacity(), and a flush/retry when the shared arena is momentarily full.
/borrowed-body Borrows a fixed-length request body through finish; chunked input receives 501.
Other paths 404. CONNECT receives 501; no tunnel is opened.

HEAD follows the handler path but suppresses response body bytes. Keep-alive and ordered pipelining are supported, with one active callback per connection and a bounded batch of frozen finished responses. The parser validates all framing/header syntax and interprets only the fields needed for framing and connection control. request.header(name) performs a lazy, case-insensitive lookup; its first matching value is borrowed, not copied or combined with repeated fields. Chunk trailers are validated and discarded.

Run the wire/ownership suite against the installed binary, and optionally check Debug as well:

python3 tests/integration.py --server zig-out/bin/bounded-http --json .zig-cache/integration.json
zig build verify -Doptimize=Debug

For Linux Debug, build.zig explicitly selects bundled LLVM/LLD after the native ELF linker rejected .sframe relocations in the Linux test host's GCC 16 CRT. ReleaseSafe uses Zig's default toolchain selection. ReleaseFast and ReleaseSmall are deliberately rejected. The integration suite launches its own finite server instances and tests wire framing, fragmentation, 16-request pipelining, partial sends, flush/resume, overload recovery, worker stall separation, request deadlines, slow readers and shutdown ownership. Its client deadlines and process watchdogs are test bounds, not latency promises. See evidence inputs for source scope. Native Linux, macOS, and Windows gates establish their own results; cross-compilation never replaces them.

To use the framework, import the bounded_http module exported by build.zig, provide an api.Handler, and follow the startup/run/stop lifecycle in src/main.zig. Its actual demo callback is the maintained example. src/server.zig exposes Config, Cluster, Server, api and Budget; builds link libc. Treat this API as experimental and read the ownership contract before retaining slices or adding asynchronous application work.

The default ./zig-out/bin/bounded-http uses inline execution, gather sends, up to 128 responses per batch and, on Linux, one I/O shard per CPU the process may run on (--shards N sets it; macOS and Windows default to one). Windows also accepts explicit multiple inline shards through bounded socket handoff. --execution inline selects it explicitly. It provisions zero application workers and runs the same handler/writer path on the I/O owner. Callbacks and flush resumptions must be short and nonblocking; they must not sleep, perform blocking I/O or wait for work. The server cannot preempt a violating callback or enforce deadlines while that callback runs. The demo /stall route therefore returns501 in inline mode. Explicit --execution inline --workers 2 is rejected. Worker mode is selectable with --execution workers --workers 2; this is an execution-policy experiment, not yet a per-request offload API. Worker execution currently requires one shard. With several inline shards, callbacks on different connections can run concurrently; shared application state must be immutable or synchronized.

Run python3 tests/inline_integration.py for inline framing, partial sends, empty flush/resume, bounds and shutdown gates. Execution mode and separate inline/worker dispatch counters accompany READY/STATS. Both modes retain all safety assertions, borrowed payload ownership and configured connection limits.

A callback receives event = .request after the complete request has arrived, a writer, eight zeroed state words for its continuation, an application pointer and a cooperative cancellation flag. Use begin once; then reserve/commit to produce bytes directly in output storage, or borrow for eligible existing bytes. write is the explicit convenience-copying path.

return writer.flush() means send everything currently committed. It freezes that output snapshot and yields the callback. Partial sends continue on the I/O owner. Once all earlier batched responses and this snapshot have been accepted by the local socket, the handler resumes with event = .flushed and an empty writable buffer. Success means the local socket accepted the bytes; it does not prove peer receipt. return writer.finish() sends the remaining bytes and completes HTTP framing, without another application callback. No later writes belong to that response.

return try context.wait(delay_ns) releases the callback until a monotonic timer expires. The handler resumes with .timer; the original request deadline still applies. Applications can request cancellation callbacks to release retained state after abandoned waits or flushes. These callbacks run on the configured application executor after kernel borrows return.

borrow requires request-owned input or immutable server-lifetime storage. begin copies its content_type argument during the call. There is no dynamic lease-release callback on finish/cancellation. Stack locals and externally recycled buffers must not escape a callback this way. If reserve returns WouldBlock, flush the existing committed bytes and continue from context.state on resume; a reservation larger than the entire configured output buffer must be split. Commit only initialized bytes and return immediately after flush/finish.

The default startup limits are explicit:

Resource Default Configuration
Admitted connections/request slots 128 --connections; 1–4096.
Application workers 0 Inline default; --execution workers selects 2 unless --workers sets 1–64, at most the connection count.
Request body 64 KiB --max-body; at most 16 MiB, cumulative after chunk decoding.
Header bytes 16 KiB --max-header; 128 bytes–64 KiB, including request line and shared trailer budget.
Header/trailer count 64 combined Config.max_headers; 1–1024.
Request target 8192 bytes Fixed MVP parser setting, also subject to the header budget.
Receive wire buffer per slot max_header + 2 * max_body + 4096 Checked startup derivation; chunk framing consumes this independent bound.
Output arena per connection 64 KiB --output-bytes; 1 KiB–1 MiB; holds heads, generated bodies, framing and copied small borrows of one batch.
Response cells per connection 128 inline, 1 workers --response-batch-limit; 1–511, effective limit 1 in worker mode.
Borrow copy threshold 256 bytes --borrow-copy-threshold; borrowed spans up to this size are copied into the arena; 0 keeps every borrow a separate vector.
Inline callbacks per event-loop turn connections × batch limit, at most 8192 --callbacks-per-turn; each connection gets at most its batch limit per turn from a FIFO ready ring.
Deadline sweep 100 ms --deadline-sweep-ms; 1–1000; touched slots are checked sooner.
Submit batch 0 (at poll) --submit-batch; submit queued sends after this many drains within a turn; measured no gain on omarx1, kept as an experiment.
Pre-armed receive off `--prearm-receive 0
Per-callback timing off --callback-timing 1 records exact queue/handler maxima at two clock reads per callback.
I/O shards one per allowed CPU (Linux, at most 16), 1 (macOS and Windows) Linux and Windows accept --shards 1–64 with inline execution. Each shard reserves full slot storage. Shared admission preserves the connection ceiling. Linux --shard-affinity 1 pins each shard to its allowed CPU.
Logical response body 16 MiB --max-response; counted across flushes.
Request cycle deadline 5000 ms --timeout-ms; includes receive, worker queue/execution and response sending.
Shutdown drain deadline 5000 ms Config.shutdown_ms; positive.
Worker stack request 1 MiB each Config.worker_stack_bytes; at least 64 KiB.
Startup memory budget 512 MiB --memory-budget; see the accounting boundary below.
Requested socket send buffer 64 KiB --socket-send-buffer; positive, up to 16 MiB. The OS may adjust this request.
Bytes per send operation 64 KiB --send-chunk; positive, useful for forcing partial progress in tests.

Startup rejects inconsistent or over-budget configurations. The configured connection count bounds admitted slots, not the kernel TCP backlog. The server may transiently accept one extra descriptor per shard/listener and immediately close it when all admitted slots are occupied; it creates no extra request state and does not promise an HTTP 503. Each connection has one receive and one send operation cell plus a cancel cell for each, 4 * connections + 2 operation records per shard (shards * (4 * connections + 2) across the cluster); the optional --prearm-receive 1 path can arm the next receive while the previous batch is still being sent. Admission resumes when the old application and transport owners have actually released a slot.

Windows reserves connections + 1 socket-table entries per shard and one listener across the cluster. Secondary queues each reserve connections + 1 optional entries for connections usable positions. Admission charges remain held across queue residence and socket adoption. The cluster owns at most connections + 1 nonlistener socket handles, plus one listener. Each pending accept creates a Winsock socket within that table capacity. Provider allocations and delayed TCP cleanup remain outside the framework heap budget. The fixed table bounds application-owned handles, rather than every kernel resource retained over time.

The demo caps requested live bytes through its framework allocator at memory_budget_bytes - Cluster.stackBytes(config), reserving the requested worker and secondary I/O-owner stack budget separately. Budget tracks live/peak requested bytes and refuses allocation, resize or remap growth beyond that heap cap. Startup also checks exact requested framework heap bytes plus requested startup stacks before allocation, including the cluster coordinator, shard arrays, every response cell and gather descriptor. Each secondary shard and each application worker reserves worker_stack_bytes; shard 0 uses the caller's existing stack. Each shard reserves the full slot capacity, so heap usage multiplies with shard count even though admission is a shared process-wide ceiling. This is not an RSS limit: allocator metadata, libc/pthread metadata and actual stack mappings, mapped kernel rings, socket queues, loaded assets and arbitrary application allocations require separate accounting. Zig 0.16's pthread implementation uses its C allocator for thread bookkeeping despite the supplied spawn allocator. The demo seals its framework allocator after startup and counts/refuses subsequent allocation attempts through it. See Budget for the implementation.

Worker separation keeps a finite slow handler off the I/O thread, but is not isolation from arbitrary application code. Slots have fixed worker affinity; a blocked callback delays other requests assigned to the same worker. Threads share memory and process fate. Deadlines set cancellation and close networking; they cannot safely preempt arbitrary code or recycle its borrowed buffers. If shutdown cannot recover all owners within its deadline, the demo terminates the process with exit code 70 instead of freeing storage still in use.

For a first measurement, keep the server running and use a second terminal:

python3 tools/benchmark.py http://127.0.0.1:8080/plaintext --connections 8 --requests 10000 --pipeline 16 --json .zig-cache/plaintext-smoke.json
python3 tools/benchmark.py http://127.0.0.1:8080/index.html --connections 8 --requests 1000 --expect-file assets/index.html --json .zig-cache/html-smoke.json

This Python client checks bodies and records throughput and closed-loop pipeline latencies; it may be the bottleneck. It is a smoke experiment, not server capacity or an open-loop service-level measurement. The separate Linux contender comparison measures pinned Round23 mrhttp/libreactor with wrk: our unchanged MVP is substantially slower, especially under pipelining. Subsequent inline/gather and batching experiments preserve that baseline. Its CPU budget, different callback/resource contracts and rejected tail-latency evidence are explicit. The subsequent bounded batching report preserves inline/gather improvements, one-core comparisons, deeper-pipeline plateaus and run-to-run variation. The demo emits STATS JSON on clean shutdown: connection/operation peaks, refusals, timeouts, flush/resume counts, byte counters, maximum queue/handler/ request-cycle durations, loop processing time, pipeline bytes copied, framework_heap_peak_bytes, framework_heap_limit_bytes and late framework allocation attempts. It does not yet provide latency histograms, per-reason rejection metrics or a live metrics endpoint.

Zero-copy here describes borrowed parsing and worker/transport payload handoff. Response heads are written straight into the connection's output arena at begin(), generated bodies follow them, and borrowed spans up to the copy threshold are copied there too (counted as borrow_copies), so a batch of small responses is one contiguous span and one SEND. Larger borrows stay separate vectors of a SENDMSG and remain borrowed through terminal completion. --gather-send 0 retains the scalar path for controlled comparison. Ordinary socket I/O still copies across the kernel boundary; this MVP does not use SEND_ZC, zero-copy receive or file sendfile. Pipelined suffix compaction copies the remaining suffix once after a whole batch drains and is counted explicitly. The Linux adapter uses raw std.os.linux.IoUring, not std.Io.Evented or std.Io.Threaded; the kernel's own resources are outside the fixed application worker count.

For two-binary experiments, tools/compare.py --order abba runs adjacent A/B/B/A trials at identical connections and pipeline depth; the configuration's first server is A. It shuffles whole blocks only. --repeats 2 means two blocks and therefore four samples per server/workload. Receipts identify each block, position and sample count. This reduces linear time-order bias; it does not isolate the host or remove thermal/client variation. The default shuffled ordering retains its original one-sample-per-repeat behavior. Use host-local /tmp/zig-http-measurement.lock reservations for preparation and timed runs, and finish all builds before measuring.

Two earlier experiments on the pre-arena implementation are preserved: direct operation cells (48 paired Linux trials and native identity/cancellation gates) and the batch/callback matrix (24 same-binary trials; at client depth 128, B16/Q64 median 1.951M/s versus B64/Q256 2.780M/s). The current transport addresses every operation by cell and the arena replaces fixed cells, so those knobs map onto --response-batch-limit (1–511) and --callbacks-per-turn; --inline-callback-budget is accepted as an alias. The arena/shard report records the original implementation's preliminary Mac ladder and interleaved Linux pairs. The adoption report tracks integration fixes, qualified comparisons and current verification.

Reproduce all three finite smoke workloads with python3 tools/smoke.py. It checks the running binary reports ReleaseSafe. For isolated Linux verification from the Mac, run tools/verify_linux_ssh.sh omarx1; use a clean pushed checkout for publication evidence.

The manual Windows workflow builds and runs native x64 tests on GitHub-hosted Windows. Its finite supervisor captures compiler, operating-system, source, binary, and test evidence. Four POSIX suspension fixtures remain explicitly excluded on Windows. Windows timings do not qualify comparative performance or physical deployment behavior.

Response batching uses the ordinary handler and writer for every request; there is no cached plaintext response path. Each finished response is a range of the connection's arena plus an optional borrowed span, frozen until terminal sends release the whole batch. A batch drains at its configured limit, when the available pipeline ends, when the arena is nearly full, on flush/close, or when the callback budget is exhausted. It never waits for a batch to fill. --response-batch-limit 1 gives a controlled unbatched comparison. At most 2 × limit + 1 vectors describe a batch; the aggregate --send-chunk limit still applies. Request input stays immutable while any response borrows it. STATS reports shards, callbacks_per_turn, single_span_send_operations and borrow_copies; with several shards a SHARDS line lists per-shard admission and completion counts.

Run python3 tests/arena_lifecycle_integration.py for EOF/interim ordering and worker/inline half-close cases. Run python3 tests/batch_integration.py for distinct generated and borrowed bodies, mixed routes, small send caps, flush/order barriers, fairness and cancellation; tests/gather_integration.py tests transport operations separately. The server counts completed requests and cycle maxima when their containing batch fully drains. A successfully sent prefix of a later canceled batch may therefore be omitted; these are conservative batch completion observations, not exact per-request latency measurements.

The Linux wrk harness accepts pipeline depths 1,16,32,64,128; its default remains 1/16 for the original TechEmpower comparison. Deeper client pipelines do not raise the server's configured response-cell limit. Preflight validates two complete pipelines at each measured depth (at least16), followed by timed wrk framing/status/error checks. The independent batch wire suite checks distinct bodies and ordering through repeated bounded drains.

Comparison receipts record the power profile, CPU driver/governor/EPP, frequency bounds, instantaneous endpoint frequency and Intel pstate limits before and after each timed trial. An optional expected_power_profile configuration field rejects an absent or changed endpoint profile. Those snapshots are outside the timed region; they do not measure average frequency, residency, or continuous policy.

About

bounded/http — for servers that stay within their bounds. Fast by design. Explicit about limits. A Zig HTTP/1.1 framework.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages