|
KEEL 1.0.0
Minimal C11 HTTP client/server library built on epoll/kqueue/io_uring/poll
|
Keel is a portable asynchronous networking substrate for C: model-neutral stream, listener and datagram transports driven by either execution model, over a socket seam that does not assume an OS. HTTP/1.1, HTTP/2, WebSocket, SSE and an async DNS resolver ship on top of it as consumers of that substrate, not as its purpose.
Three axes, orthogonal in the code rather than only in the diagram:
Readiness and completion are kept as two honest execution models, not one emulating the other: readiness registers interest and retries on EAGAIN; completion submits an owned operation and retires it. What is unified is the layer above them, the semantic transport contract, which is why the same async HTTP client runs unchanged over epoll, io_uring, IOCP, a raw lwIP callback stack with no OS sockets, and UEFI firmware tokens with no libc.
Swapping the event backend or the socket provider touches no protocol code, and that is enforced mechanically rather than by convention: default-deny structural gates fail the build on a substrate that includes a protocol header, a protocol that reaches an integration, or an integration that leaves the frozen seam. See docs/architecture/invariants.md.
Pluggable allocator, parser, TLS, compression and body readers; per-route middleware; streaming responses; multipart uploads; connection timeouts; thread pool; zero forced buffering.
111K req/s on a single thread. Tested under ASan/UBSan on both execution models. One vendored dependency (llhttp).
MSVC notes: cl builds do not track header dependencies (MSVC has no -MMD equivalent whose output is worth parsing), so run make clean after editing a header. Object trees are per-toolchain, so a make and a make CC=cl never mix objects. See docs/msvc_build.md.
Supported toolchains, event backends, socket/platform providers, integrations, and the version and compatibility policy are in one place: docs/operations/platform_support.md.
Set unix_socket_path to serve the same HTTP routes over a UNIX domain stream socket instead of TCP. unix_socket_unlink removes a stale socket file before binding and removes the socket path again when the server is freed after a successful bind. It refuses to unlink non-socket files; place socket paths in an application-owned directory rather than a shared writable directory.
unix_socket_group / unix_socket_owner name a group / user to chown the socket to: the classic "only members of group X may connect" pattern (pair with unix_socket_mode = 0660). Ownership is applied before listen(), so the socket is never reachable with the wrong owner/group. Setting the group to one the process belongs to works unprivileged; changing the owner to a different user needs CAP_CHOWN/root. Unknown names fail kl_http_server_run with KL_ERR_INVALID_ARG.
KlHttpServerConfig.transport = KL_HTTP_SERVER_TRANSPORT_UNIX is also available when you want to be explicit. TCP remains the zero-initialized default.
On a UNIX socket you can identify the connecting process from a handler:
Related accessors:
kl_http_request_peer_addr(req, ip, sizeof ip, &port): the client's IP/port for any request (TCP/TLS); -1 for UNIX sockets. Also kl_http_request_peer_sockaddr for the raw address.Behind an L4 load balancer, set proxy_trusted_cidrs to recover the real client address from a PROXY protocol (v1/v2) header:
The header is parsed only when the connection originates from a trusted CIDR (so an untrusted peer can't spoof its IP); from other sources it's ignored and the socket address is used. kl_http_request_peer_addr then returns the real client.
kl_ws_server_peer_cred(ws, &cred): same, for a live WebSocket connection after the HTTP upgrade (when there's no longer a KlHttpRequest).kl_http_request_peer_label(req, buf, sizeof buf): the peer's SELinux/AppArmor security label (Linux SO_PEERSEC; -1 elsewhere).kl_peer_cred_fd(fd, &cred): the building block, for any connected fd.When the server requests a client certificate (mutual TLS), a handler can read the verified client identity:
kl_http_request_peer_cert returns -1 for a plaintext connection, when the client presented no certificate, or on a TLS backend without client-cert support. pc.verified reports whether the certificate passed CA-chain validation; a certificate can be present but unverified under optional client auth, so always check it before trusting the identity. Extraction is provided by the TLS backend's optional peer_cert vtable method (implemented by the bundled mbedtls backend; the mTLS mode is set via kl_tls_mbedtls_ctx_create(..., KL_MTLS_OPTIONAL|KL_MTLS_REQUIRED, ...)).
Instead of binding its own socket, KEEL can adopt a pre-bound, already-listening fd passed by systemd, launchd, or a supervising parent. kl_systemd_listen_fd() implements the systemd LISTEN_FDS protocol; the transport (TCP vs UNIX) is auto-detected from the fd, and KEEL never unlinks an adopted UNIX socket.
For a unit passing several sockets, kl_systemd_listen_fds(&count) reports how many were passed (fds 3 … 3+count-1) and kl_systemd_listen_fd_by_name("https") selects one by its FileDescriptorName. Adopt one per KlHttpServer.
The client connects to a UNIX socket via the http+unix:// (or https+unix://) scheme, where the authority is the percent-encoded socket path:
Both the synchronous (kl_http_client_request) and asynchronous (kl_http_client_start) clients support it. The pooled variants (kl_http_client_request_pooled / kl_http_client_start_pooled) accept http+unix:// too but bypass the connection pool: UNIX sockets have no host:port to key on, and a local-socket connect is cheap. Proxying is incompatible with UNIX targets.
The WebSocket and HTTP/2 clients speak UNIX too: ws+unix:// / wss+unix:// (kl_ws_client_connect) and http+unix:// / https+unix:// (kl_http2_client_connect). Redirects are followed over UNIX (relative Location values keep the http+unix authority). In every case the Host / :authority defaults to localhost.
See examples/unix_socket_server.c for a server demonstrating all three.
@name, leading-NUL sun_path) are not supported: they carry no filesystem permissions, so unix_socket_mode / ownership cannot restrict access; that conflicts with KEEL's permission model. Use a path in an application-owned directory instead.SO_REUSEPORT multi-worker scaling does not apply to UNIX sockets.** For multiple workers on one UNIX socket, use socket activation / fd passing (bind once, share the fd) rather than each worker binding the same path.kl_systemd_listen_fds(&count)** reports how many fds were passed (consecutive from fd 3); KlHttpServerConfig.listen_fd adopts one at a time.The socket layer is a pluggable vtable (<keel/socket.h>), so you can run KEEL over a non-POSIX stack (Winsock is built in; lwIP / a test mock / an instrumentation decorator fit the same interface). A provider is an immutable ops table + context + capability flags:
Portability contract: the API exposes no platform-only types: sizes are kl_ssize_t, scatter-gather is KlIoVec, the sendfile offset is uint64_t; a provider translates to its native shapes (struct iovec/WSABUF/off_t) internally. A provider advertises what it supports via KL_SOCK_CAP_* (NATIVE_FD, WRITEV, SENDFILE) and KEEL falls back for anything it lacks; except the server requires KL_SOCK_CAP_NATIVE_FD (the readiness event loop polls real descriptors; a non-native provider is rejected at kl_http_server_init). Full runnable demo: **examples/custom_socket_provider.c**.
KlEventLoop interface, plus a portable poll()-based completion double for testing the completion driver on any POSIX host. Each backend honors its native semantics; the protocol layer sees a stable Keel-level contract, not epoll flags or CQEs.docs/archive/audits/keel_axis_audit.md) mechanically confirms protocols never touch a platform socket API or event engine directlyAF_UNIX/SOCK_STREAMkl_pipe_connect() and kl_pipe_listen() (<keel/pipe.h>) open and serve local \\.\pipe\ endpoints as ordinary KlStreams, the Windows local-IPC counterpart of an AF_UNIX stream; the listener hands each client over as a stream through KlListener. IOCP engine only (refused, not emulated, elsewhere); never routed through the socket provider. Server instances are local-only, current-user + SYSTEM DACLKlHttpServerConfig.parser:param capture, no allocation, pointers into read bufferKlAsyncOp, resume later from watchers or thread pool workers; no stalling the event loopkl_http_client_request() for simple use cases, event-driven kl_http_client_start() for non-blocking I/O, both with TLS supportKlEventCtx decouples the event loop from the server, enabling standalone clients and thread pools without a KlHttpServerKlWatcherkl_http_sse_event, kl_http_sse_comment) over chunked streamingContent-Encoding: gzip response decompressionKlDrain buffers unsent data on would-block, flushes on write-readinesskl_strerror()KlDatagram API (<keel/datagram.h>): the Tier-1 datagram primitive, a caller-owned, single-threaded handle over a prepared UDP fd with one-call socket create/configure/bind/adopt, provider-neutral connect, async per-datagram receive (source + local/destination address via pktinfo), a fixed-slot whole-datagram send queue with on-drain backpressure, source-pinned sends + per-packet TOS/ECN, multicast/broadcast, transparent recvmmsg/sendmmsg batching, UDP GSO/GRO offload, ECN/TOS/DSCP marking, and confirmed-detachment close. Validated live across every event backend: readiness (epoll/kqueue/poll/WSAPoll) and completion (pollcomp/io_uring/IOCP/lwIP-raw/EFI_UDP4). Its function+type contract is ABI-stable; the struct layout is opt-in via <keel/datagram_detail.h>. The built-in DNS resolver is built on KlDatagram; it is the base intended for new portable message protocols (a future QUIC/HTTP-3, mDNS/CoAP)kl_http_server_stats() for connection counts, enabling user-space load-shedding middlewareKlSocketProvider): POSIX and Winsock built in, lwIP shipped (both a BSD-socket layer and a raw NO_SYS callback stack with no OS sockets), and a freestanding EFI_TCP4/UDP4 provider for UEFI firmware. Capability-gated (readiness vs overlapped), selectable per server/client. See Custom socket provider and Frontier providersKeel is organized along three orthogonal axes, so any one can be replaced without disturbing the others. For the full model (the Tier-1 semantic transports (KlListener/KlStream/KlDatagram), the engine/provider split, and the rules that keep them separate) see `docs/architecture/overview.md` and the enforceable `docs/architecture/invariants.md`.
event.h + completion.h): how the loop learns work is ready. Readiness backends (epoll, kqueue, WSAPoll, poll) register interest and react to EAGAIN; completion backends (io_uring, IOCP) submit operations and reap completions. A capability negotiation (KL_EVENT_CAP_READINESS | _COMPLETION) matches a loop to a compatible socket provider; each backend keeps its native low-level semantics.socket.h, the KlSocketProvider vtable + pointer-width KlSocketHandle): where the bytes actually go. POSIX and Winsock built in; lwIP and a freestanding UEFI EFI_TCP4/UDP4 provider ship as integrations. Error translation, address conversion, and handle ownership live here, never above it.http_connection.c, http2_server.c, websocket.c, the http_client_* TUs, the KlTls vtable, body readers, http_response.c): HTTP/1.1, HTTP/2, WebSocket, SSE, DNS. Protocol code goes through the connection/stream + event abstractions only; it never includes a platform networking header or calls an event engine directly. docs/archive/audits/keel_axis_audit.md audits this separation mechanically.Below the axes sit orthogonal, independently testable modules:
| Module | Header | Description |
|---|---|---|
| allocator | allocator.h | Bring-your-own allocator interface |
| event | event.h + completion.h | Readiness (epoll / kqueue / WSAPoll / poll) + completion (io_uring / IOCP) behind one interface |
| socket | socket.h | Platform-neutral socket seam (KlSocketProvider): POSIX / Winsock built in, lwIP + UEFI shipped |
| event_ctx | event_ctx.h | Composable event loop context (watchers + allocator) |
| request | http_request.h | Parsed HTTP request struct (header-only, zero alloc) |
| parser | http1_parser.h | Pluggable request/response parser vtables |
| response | http_response.h | Response builder: buffered, sendfile, or streaming chunked |
| router | http_router.h | Route matching with :param capture + middleware chain |
| connection | http_connection.h | Pre-allocated connection pool + state machine |
| server | http_server.h | Top-level glue: TCP/UNIX bind, async event loop, stop |
| body_reader | http_body_reader.h | Pluggable body reader vtable + buffer reader |
| body_reader_multipart | http_body_reader_multipart.h | RFC 2046 multipart/form-data parser |
| chunked | http1_chunked.h | Parser-agnostic chunked transfer-encoding decoder |
| cors | http_cors.h | Built-in CORS middleware with configurable origins |
| tls | tls.h | Pluggable TLS transport vtable (bring-your-own backend) |
| async | async.h | Connection suspension for async operations |
| wakeup | wakeup.h | Cross-thread event-loop wakeup channel (watchable on every backend) |
| thread_pool | thread_pool.h | Worker thread pool with KlWakeup-based event loop wakeup |
| url | url.h | URL parser (http/https/ws/wss, IPv6, CRLF injection guard) |
| client | http_client.h | HTTP/1.1 client (sync blocking + async event-driven) |
| websocket | websocket.h + websocket_server.h | RFC 6455 WebSocket server (shared frame parser + server API) |
| websocket_client | websocket_client.h | RFC 6455 WebSocket client (masked frames, async handshake) |
| h2 | http2.h + http2_server.h | HTTP/2 server (pluggable session vtable) |
| h2_client | http2_client.h | HTTP/2 client (multiplexed streams, pluggable session) |
| resolver | resolver.h | Pluggable async DNS resolver vtable |
| sse | http_sse.h | Server-Sent Events: line framing over chunked streaming (zero alloc) |
| error | error.h | Diagnostic error codes (KlError enum) + kl_strerror() |
| timer | timer.h | One-shot timer scheduling on KlEventCtx (min-heap) |
| client_pool | http_client_pool.h | HTTP client connection pool with keep-alive reuse |
| redirect | http_redirect.h | Automatic 3xx redirect following (RFC 7231/7538) |
| compress | compress.h | Pluggable response compression vtable (buffer + streaming) |
| decompress | decompress.h | Pluggable response decompression vtable (client-side) |
| drain | drain.h | Backpressure write buffer with on_drain callback |
| file_io | file_io.h | Pluggable async file I/O vtable |
| resolver_cache | resolver_cache.h | Caching DNS resolver decorator with configurable TTL/capacity |
| datagram | datagram.h | STABLE Tier-1 datagram primitive (KlDatagram): async recv, fixed-slot send queue, batching + GSO/GRO, multicast, confirmed-detachment close |
| dns_resolver | dns_resolver.h | Built-in async DNS resolver (KlResolver) over KlDatagram: dual-family A+AAAA, EDNS0, cookies, TCP fallback |
| proxy_protocol | proxy_protocol.h | PROXY protocol v1/v2 header parser + CIDR trust matching |
Deliberate design choices:
KlThreadPool offloads blocking work to workers; multi-core scaling is horizontal via SO_REUSEPORT with multiple processes.memcmp scan over even hundreds of routes costs nanoseconds, invisible next to network I/O syscalls. A trie or radix tree would add complexity to param extraction and middleware pattern matching for no measurable gain.max_connections = 256 (default), this is a tight loop over a contiguous array well within L1 cache.kl_http_server_stats() exposes connection counts for user-space middleware to make load-shedding decisions. Thresholds and Retry-After policy belong in application code, not the framework.max_body_size, max_header_size, KlDrain.max_size) bound the main vectors; OS-level OOM handling covers the rest.KEEL uses a vtable-based body reader interface. Register a body reader factory per-route; the connection layer creates the reader after headers are parsed, feeds it data as it arrives, and makes the finished reader available in the handler via req->body_reader.
Built-in buffer reader: accumulates the body into a growable buffer:
Pass NULL as the body reader factory for routes that don't accept a body. If a request with a body arrives on a route with no reader, KEEL discards the body. If the reader factory returns NULL, KEEL sends 415 Unsupported Media Type.
Custom readers: implement the KlHttpBodyReader vtable (on_data, on_complete, on_error, destroy) and provide a factory function.
Register middleware that runs before handlers. Middleware can inspect/modify the request and response, or short-circuit the chain by returning a non-zero value (e.g., to reject unauthenticated requests).
Handles Access-Control-Allow-Origin, Allow-Credentials, and automatically responds to OPTIONS preflight requests with 204 + all required CORS headers.
Middleware uses the same (KlHttpRequest *, KlHttpResponse *, void *) signature. Return 0 to continue, non-zero to short-circuit:
Logging middleware:
Auth middleware:
Request context passing (middleware → handler):
/* are prefix matches: /api/* matches /api, /api/users, /api/users/123/* are exact matches: /health matches only /health"*" matches any HTTP method; "GET" also matches HEAD requestsRegister a WebSocket endpoint and get bidirectional communication:
The WebSocket server module handles frame parsing, masking, and protocol details. The handler receives callbacks for each message; use kl_ws_server_send_text() or kl_ws_server_send_binary() to reply.
Server handlers can be sync (return immediately with a response set) or async (suspend the connection for later resumption). KEEL provides two primitives for async handlers: KlWatcher (generic FD callbacks) and KlAsyncOp (connection suspension). Together they allow handlers to park a connection, perform work asynchronously, and resume when done, without stalling the event loop.
The watcher callback runs on the event loop thread, making it safe to call kl_async_complete() which re-registers the connection FD and drives the state machine forward.
KlWakeup is the portable signal channel: a raw pipe(2) is not watchable on Windows (WSAPoll and IOCP watch sockets only), so kl_wakeup_open() gives you a pipe where a pipe works and a loopback socket pair where it does not. kl_wakeup_signal() is the one call a foreign thread may make; everything else, including kl_async_complete(), belongs in the watcher callback.
on_resume does not have to set anything. If it returns having only built the response (on kl_http_conn_response(op->conn)), kl_async_complete() sends it.
KlThreadPool bridges blocking work (SQLite queries, file I/O, DNS, crypto) and the single-threaded event loop. Submit work items from the event loop, execute on worker threads, resume connections via pipe wakeup.
Each KlWorkItem has three callbacks:
| Callback | Thread | Purpose |
|---|---|---|
work_fn | Worker | Execute blocking work |
done_fn | Event loop | Resume connection (called via pipe watcher) |
cancel_fn | Event loop | Cleanup for items still queued at shutdown (may be NULL) |
Thread safety is guaranteed by construction: workers never touch the event loop directly. They push completed items to a done queue and write a byte to a pipe; the pipe watcher fires on the event loop thread and calls done_fn. Backpressure: submit() returns -1 when the queue is full.
KEEL includes both sync (blocking) and async (event-driven) HTTP/1.1 clients with TLS support. The sync client is a single function call for simple use cases. The async client uses KlEventCtx (not KlHttpServer), so it works standalone; no server required.
Sync (blocking):
Async (event-driven):
The URL parser (kl_url_parse) handles http://, https://, ws://, wss://, IPv6 [::1]:port, default ports, and rejects CRLF injection.
Zero-copy file responses via sendfile(2):
Uses sendfile(2) on Linux and macOS, with TCP_CORK coalescing on Linux for optimal throughput.
Write directly to the socket via chunked transfer encoding; zero intermediate buffering:
The KlHttpResponseWriteFn signature (int (*)(void *ctx, const char *data, size_t len)) is designed to be compatible with streaming JSON writers.
The router returns 200 (match), 405 (path matched, wrong method), or 404 (not found).
KEEL stamps each connection with a monotonic clock on every I/O event. A periodic sweep (every ~400ms) closes connections that have been idle longer than read_timeout_ms and sends a 408 Request Timeout response. This protects against slow-loris attacks and abandoned connections without affecting active transfers.
Set a callback in KlHttpServerConfig and KEEL calls it after each response is fully sent. The callback receives the full request (method, path, headers), response status, body size, and wall-clock duration in milliseconds. Users implement their own formatting (JSON, CLF, custom). NULL = no logging, zero overhead.
The allocator interface passes size to free and old_size to realloc; enabling arena and pool allocators that don't store per-allocation metadata.
Ships with llhttp (default). Swap by setting KlHttpServerConfig.parser:
Implement the 3-function KlHttp1RequestParser vtable (parse, reset, destroy) for any backend. The response parser (KlHttp1ResponseParser) uses the same pattern for the HTTP client.
KEEL doesn't vendor any TLS library. Bring your own backend (BearSSL, LibreSSL, OpenSSL, rustls-ffi) by implementing the 7-function KlTls vtable:
The vtable interface (handshake, read, write, shutdown, pending, reset, destroy) wraps the transport layer. Everything above it (parser, router, middleware, body readers, handlers) works identically on plaintext and TLS connections.
When TLS is active, sendfile(2) falls back to pread + TLS write (encryption requires userspace access to plaintext). All other response modes (buffered, streaming) work transparently.
KEEL deliberately does not own your sandbox policy; that's an application concern. The server separates initialization (bind/listen) from the event loop (accept/read/write), so you can lock down syscalls and filesystem access between the two:
On Linux, use the pledge polyfill (seccomp-bpf + Landlock) for the same API. The key insight: KEEL's init/run split makes this natural; no library changes needed.
The benchmark suite runs 4 endpoints against a dedicated bench server:
| Endpoint | What it measures |
|---|---|
GET /hello | Baseline: minimal JSON, no routing params, no middleware |
GET /users/:id | Router: param extraction + snprintf response |
GET /mw/hello | Middleware: same response through 2 pass-through middleware |
POST /echo | Body reading: KlHttpBufReader + echo body back |
Sample results (Apple M1 Max, single thread, 100 connections, kqueue):
| Endpoint | Req/sec | Avg Latency | p99 |
|---|---|---|---|
GET /hello (baseline) | 111,650 | 0.89ms | 1.13ms |
GET /users/42 (route params) | 109,112 | 0.91ms | 1.15ms |
GET /mw/hello (middleware chain) | 111,247 | 0.89ms | 1.14ms |
POST /echo (body reading) | 109,370 | 0.90ms | 1.15ms |
Route params, middleware, and body reading add no measurable overhead; all within ~2% of the baseline. No GC pauses. No goroutine scheduling. No async runtime overhead. Just kqueue → read → write.
| Platform | Backend | Build |
|---|---|---|
| macOS / BSD | kqueue (edge-triggered) | make |
| Linux | epoll (edge-triggered) | make |
| Linux 5.6+ | io_uring (completion, SQE/CQE) | make BACKEND=iouring |
| Any POSIX | poll (level-triggered) | make BACKEND=poll |
| Windows (MinGW) | WSAPoll (readiness) or IOCP (completion) | make BACKEND=wsapoll / BACKEND=iocp |
| Windows (native MSVC) | WSAPoll or IOCP, same PAL and Make graph | source scripts/msvc-env.sh then make CC=cl |
| Any POSIX host | pollcomp (portable completion double) | make BACKEND=pollcomp |
| Linux (musl/Alpine) | epoll (edge-triggered) | make |
| Cosmopolitan (APE) | poll (auto-selected) | make CC=cosmocc |
| Bare-metal + lwIP | poll (via lwIP BSD sockets) | make BACKEND=poll + -DKL_NO_SIGNAL |
| Bare-metal, no OS sockets | lwIP raw NO_SYS callback provider | see Frontier providers |
| UEFI firmware (pre-boot) | EFI_TCP4/UDP4 completion provider (freestanding) | see Frontier providers |
The io_uring backend is completion-native: it submits SQEs and reaps CQEs (zero-copy splice for file responses, registered send buffers), rather than polling for readiness. The earlier IORING_OP_POLL_ADD readiness adapter was retired after benchmarks showed it ~2–2.3× slower than the completion backend. Requires liburing-dev.
The poll backend is a universal POSIX fallback that works on any platform with poll(2). It enables Cosmopolitan C support (Actually Portable Executables that run on Linux, macOS, Windows, FreeBSD, OpenBSD, NetBSD from a single binary). When CC=cosmocc is detected, the Makefile automatically selects the poll backend.
For bare-metal targets (STM32, ESP32, etc.), link against lwIP or picoTCP; their BSD socket compatibility layers provide all the POSIX functions Keel uses (accept, read, write, close, poll, getaddrinfo). Compile with -DKL_NO_SIGNAL to disable POSIX signal handling, and exclude thread_pool.c from the build if no RTOS is available. For environments with no OS sockets at all (a raw lwIP NO_SYS stack, or UEFI firmware before an OS exists) see Frontier providers.
The socket + completion axes are abstract enough to host stacks that have no OS sockets and no libc. These providers live under integrations/ as bring-your-own adapters: the core libkeel (or a freestanding archive) is unchanged; you inject the provider at runtime. They are more experimental than the mainstream backends and are labeled with their exact maturity.
| Provider | What it is | Maturity |
|---|---|---|
| lwIP BSD sockets | Keel over lwIP's POSIX-compatible socket layer (integrations/platform/lwip, event_lwip.c) | Shipped, BYO |
**lwIP raw (NO_SYS)** | Keel over lwIP's raw callback API: a completion provider (event_lwip_raw.c) that runs the whole HTTP stack with no OS sockets, no threads. Serves HTTP over loopback, ASan/UBSan/LSan-clean, CI-gated | Shipped, BYO |
| UEFI EFI_TCP4/UDP4 | A freestanding completion provider (integrations/platform/uefi) that runs a stock async KlHttpClient inside UEFI firmware, before any OS: no epoll, no OS sockets, no errno, no libc. Plaintext + HTTPS (CA + hostname + certificate-validity-time verified over EFI Runtime Services GetTime, real EFI_RNG entropy) + DNS over EFI_UDP4, with ExitBootServices-clean teardown | Client proven + hardened on QEMU/OVMF (adversarially reviewed for EFI completion-token lifetime; see docs/archive/phases/phase10_uefi_feasibility_design.md). A UEFI server is scoped but not built (docs/archive/phases/phase10_uefi_server_design.md) |
The through-line: KlHttpClient is genuinely model-blind: the same client code runs unchanged over io_uring, IOCP, the pollcomp double, lwIP raw, and EFI tokens (including TLS via the socket-BIO seam and DNS via the built-in resolver). Adding a provider is a socket-axis + completion-axis exercise; the protocol code does not change. This validates the axis separation the way nothing else can: by hosting the stack somewhere with no operating system underneath it.
The unit and integration suites cover every module and both event axes. Each tests/test_*.c and tests/protocols/<family>/test_*.c compiles to a standalone utest.h binary, auto-discovered by the Makefile. A full make test on the default readiness backend currently runs 110 suites, 1,585 individual tests. Counts move as suites are added, so make test is the authority; the per-backend gate inventory is in docs/operations/testing.md.
| Area | Suites | Tests | Covers |
|---|---|---|---|
| Event core | 10 | 64 | Readiness loop (init/add/mod/del/wait) and the completion driver, capability negotiation, the event-provider vtable, watcher lifecycle and ABA safety, async suspend/resume/deadline/cancel, the IOCP engine, io_uring SQE failure paths |
| Socket seam | 8 | 110 | KlSocketProvider vtable, POSIX and Winsock runtimes, first-use socket-runtime init, KlSockAddr conversion, handle-type convention, UNIX-domain sockets and path-node lifecycle (POSIX + Windows) |
| Stream & listener | 8 | 160 | KlStream read/close/single-shot and the transport contract oracle, KlListener accept lifecycle, the connect_op Happy Eyeballs connect machine, public transport surface |
| Datagram | 15 | 273 | KlDatagram open/socket config/lifecycle/confirmed-detachment close, recv + classification, send + fixed-slot queue, recvmmsg/sendmmsg batching, multicast, cmsg (pktinfo/TOS/GRO), live loopback |
| Runtime services | 16 | 204 | Allocator (default + validating), C11 atomics policy, timers, thread pool, cross-thread wakeup, drain backpressure, URL parser, error codes, version drift, kl_cstr, file-IO vtable, decompression, resolver cache, TLS vtable |
| HTTP/1.1 | 41 | 562 | Request/response/router/connection/integration, sync + async client with pooling, streaming, proxy, redirects and Happy Eyeballs, CORS, SSE, TLS, body readers, multipart streaming, chunked TE, parser vtables, server state + stats, timeouts, early-reject drain, compression, peer address + mTLS cert |
| HTTP/2 | 5 | 61 | Server sessions, streams, routing, ALPN, GOAWAY, body limits; client session vtable, stream tracking, hostname verification, overflow guards |
| WebSocket | 5 | 95 | Frame parsing, masking, opcodes, fragments, close handshake, unmasked rejection; client encoding, handshake, auto-ping, overflow guards |
| DNS | 1 | 47 | Built-in async resolver: dual A+AAAA, EDNS0, cookies, resolv.conf search/ndots, /etc/hosts, TCP fallback, retransmit and timeout |
| PROXY protocol | 1 | 9 | v1/v2 header parsing + CIDR trust matching |
| Total | 110 | 1,585 |
An HTTP server at this level is mostly syscalls, pointer arithmetic, and state machines. C is a natural fit: direct writev/sendfile/epoll_wait access, zero-copy pointers into read buffers, explicit memory layout, no runtime. One vendored dependency (llhttp), 2-second clean builds, runs on everything from io_uring to bare-metal MCUs.
The tradeoff is real: C has no borrow checker, no bounds-checked slices, no RAII. We compensate with defense-in-depth:
malloc, no fragmentation)SIZE_MAX/2 overflow guards on all arithmetic, bounds checks at system boundariespledge()/unveil() sandboxing, -D_FORTIFY_SOURCE=2 -fstack-protector-strongThis is adequate for a focused ~32K LOC library with thorough testing, but it's not a language-level guarantee. If you're evaluating Keel and memory safety is your primary concern, that's a legitimate reason to look elsewhere.
KEEL is a transport library: it handles sockets, parsing, routing, and response serialization. Everything above the HTTP layer is an application concern:
access_log callback with method, path, status, body size, and duration; you bring the formatter. Hull provides a structured JSON logger middleware.kl_http_request_header() / kl_http_response_header() for the headers; your application handles 304 logic. Hull handles conditional responses for static assets.examples/static_files.c for the pattern. Hull auto-serves embedded or filesystem static assets with MIME detection.hull.middleware.csrf with automatic token generation and validation.Idempotency-Key header. Hull provides hull.middleware.idempotency with configurable TTL and response caching.The general principle: if it requires policy decisions that vary between applications, it belongs in application code, not in the transport library. KEEL provides the hooks (middleware, body readers, access log callback); you provide the policy.
Three embedded C HTTP libraries compared.
| Keel | Mongoose | GNU libmicrohttpd | |
|---|---|---|---|
| License | MIT | GPLv2 / Commercial | LGPLv2.1+ |
| LOC | ~32K | ~33K | ~19K |
| Architecture | 35+ independent modules on 3 orthogonal axes | Monolithic amalgam | Monolithic |
| Maturity | New (2025–2026) | 20+ years (NASA, Siemens, Samsung) | GNU project, 18+ years (NASA, Sony, systemd) |
| HTTP/2 | Server + client | No | No |
| Event backends | readiness (epoll, kqueue, WSAPoll, poll) + completion (io_uring, IOCP) | select/poll only | select, poll, epoll |
| Router + middleware | Built-in with :param capture | None (DIY if/else) | None (single callback) |
| HTTP client | Sync + async + streaming + H2 | Basic client | Server only |
| Allocator | Runtime vtable (bring-your-own) | Compile-time macros | None (raw malloc) |
| TLS | Pluggable vtable, any backend | Built-in TLS 1.3 + pluggable | GnuTLS only |
| Compression | Pluggable vtable (gzip + extensible) | No | No |
| Threading | Single-threaded + thread pool | Single-threaded | 4 modes incl. thread-per-connection |
| Bare-metal MCU | lwIP BSD sockets, lwIP raw (no OS sockets), or UEFI firmware | Built-in TCP/IP stack | Requires OS networking |
| Cosmopolitan C | Supported (APE binaries) | No | No |
| Tests | Comprehensive (ASan/UBSan) | ~4K LOC tests | Fewer relative to size |
Choose Keel when you want MIT licensing, HTTP/2, a built-in router/middleware/client, and pluggable everything. Choose Mongoose when you're targeting bare-metal MCUs with no OS, need a built-in TCP/IP stack, or need battle-tested maturity. Choose libmicrohttpd when you need multi-threaded request handling, independently audited security, or wide distro packaging.
GitHub Actions runs on every push and PR against main:
WIN_TEST_SUITES, TCP/datagram/DNS smokes, installed-artifact gatescl/lib build + suites on WSAPoll and IOCP, public-API consumer link, no-MinGW-import assertionNO_SYS provider under ASan/UBSan/LSanSeparate workflows run CodeQL, OpenSSF Scorecard, a Doxygen docs publish, the site deploy, and a benchmark on push to main (informational, not gating).
MIT