A Capra-style HTTP/1.1 server for jolt (Clojure on Chez Scheme) — no
JVM, no Java NIO. It layers an incremental HTTP/1.1 parser and a Ring-shaped
handler contract over jolt-tcp's jolt.net-backed readiness reactor.
This is to Capra what jolt-tcp is to teensyp: a native reimplementation of the adapter over jolt-tcp's owned sockets. It replaces ring-chez-adapter, which read each request into a String, served one request per connection, and always closed.
Supports HTTP/1.1 only: keep-alive, pipelining, chunked request and response bodies, streaming request bodies, file responses, and async handlers.
(require '[jolt.http.server :as http])
(defn handler [_request]
{:status 200
:headers {"Content-Type" "text/plain; charset=utf-8"}
:body "Hello World"})
(def server (http/run-server handler :port 3000))
;; ... later ...
(http/stop-server server)Options can be supplied as variadic arguments or as a map. The server also works
with with-open.
The process will not exit on its own. jolt-http loads core.async (for request body streaming), which starts non-daemon threads. End a
-mainwith(System/exit 0).
Ring-shaped. Synchronous by default; pass :async? true for 3-arity
(fn [request respond raise] ...) handlers.
The request map carries :request-method, :uri, :query-string, :headers
(lower-cased, repeated headers joined with commas), :scheme, :protocol,
:server-port, :server-name, :remote-addr and :body.
Ring specifies :body as an InputStream. jolt cannot provide one — its proxy
is just reify, so InputStream cannot be subclassed, and jolt's io coercions
reject a reified stream. :body is therefore a jolt.http.body/RequestBody:
(require '[jolt.http.body :as body])
(defn handler [request]
{:status 200
:headers {}
:body (str "you sent: " (body/body-string (:body request) "UTF-8"))})(body-recv body)— block for the next chunk as a byte-array,nilat end.(body-bytes body)— block for the whole body as one byte-array.(body-string body charset)— block for the whole body, decoded.
Bodies stream: chunks are handed over as they arrive, and a consumer that falls behind applies backpressure through TCP flow control rather than buffering without limit.
String, byte-array, java.io.File, a seq of strings, or nil. Strings, byte
arrays and files are sent with a Content-Length; seqs and other types are
chunked.
Extend jolt.http.body/StreamableBody (the ring.core.protocols analogue) to
support your own type:
(extend-protocol body/StreamableBody
MyType
(write-body-to-sink [this _response sink]
(let [bs (.getBytes (render this) "UTF-8")]
(body/sink-write! sink bs 0 (alength bs)))))write-body-to-sink is synchronous: it must finish producing the body before
returning and must not retain the sink. The adapter closes the sink exactly once
after the call returns; a custom implementation may close it early because
close is idempotent. :async? controls Ring handler completion, not body
production. A future asynchronous producer needs an explicit completion and
cancellation SPI rather than retaining this blocking sink.
Each sink write blocks on jolt-tcp's outcome-bearing completion. A native write
failure therefore throws back through write-body-to-sink/respond and retires
the connection; it cannot strand the producing handler on a success-only
callback. An async handler may catch that exception in the task which called
respond.
Note that protocol dispatch on java.io.File does not work on jolt (both
extend-protocol and extend fall through to Object), so files are dispatched
by an explicit instance? test internally.
| Key | Description | Default |
|---|---|---|
:async? |
Whether to use 3-arity asynchronous Ring handlers | false |
:control-queue-size |
The max number of queued control events | 32 |
:error-handler |
An async Ring handler called on uncaught exceptions | 500 response |
:error-logger |
A function that takes an exception and logs it | prints |
:executor |
Borrowed executor for handler calls | fixed pool |
:pool-size |
Size of the default handler pool | 32 |
:port |
The port number to listen on | 80 |
:read-buffer-size |
Read buffer size; bounds one request/header line | 8K |
:max-header-bytes |
Maximum aggregate header-section bytes incl. CRLF | 64K |
:max-header-count |
Maximum number of request header fields | 100 |
:recv-buffer-size |
The receive buffer size (i.e. the SO_RCVBUF option) | |
:remote-addr |
Optional override for the actual peer address | peer address |
:response-buffer-size |
The size of the buffer used for the response | 32K |
:reuse-address? |
The SO_REUSEADDR socket option | false |
:server-name |
Optional override for the actual local address | local address |
:shutdown-executor? |
Adopt and stop a supplied executor | false |
:stream-queue-size |
Request body chunks buffered before backpressure | 8 |
:write-buffer-size |
The write buffer size in bytes | 128K |
:write-queue-size |
The maximum number of writes that can be queued | 64 |
The adapter validates message syntax strictly rather than guessing, because nearly every "lenient" HTTP parse is a request-smuggling primitive: it lets a front-end and a back-end disagree about where one request ends and the next begins. The following are rejected with a 400 (or 501 where noted):
Content-Lengthtogether withTransfer-Encoding(RFC 9112 6.3).- Repeated
Content-Lengthwith differing values; a non-numeric, signed, hex-prefixed, empty, or signed-64-bit-overflowingContent-Length. - A
chunk-sizethat is not1*HEXDIG— no+,-,0x,_or surrounding whitespace, and not empty. - Any
Transfer-Encodingother than exactlychunked(501), includingchunked, chunkedand,chunked. - Whitespace between a field name and its colon (RFC 9112 5.1).
- Obsolete line folding, i.e. a header line starting with whitespace (5.2).
- A bare CR anywhere in the request line or a header line (2.2).
- A line terminated by a bare LF rather than CRLF, anywhere in the message. RFC 9112 2.2 permits a recipient to accept a lone LF in the request line and header fields, and 7.1 permits nothing of the sort in chunked framing. Both are rejected here: jolt-http is an origin server that will usually sit behind a front end, and a front end that requires CRLF while the origin does not frames a different message out of the same bytes — the same reasoning that makes a bare CR a 400.
- A field name that is not a token, and a control character in a field value.
- A request method that is not a token (RFC 9110 9.1).
- More than one
Hostfield, or none (RFC 9112 3.2). - Any HTTP version other than
HTTP/1.1(505). - A header section over
:max-header-bytesor:max-header-count(431). The exact configured boundary is accepted; the next byte or field is rejected. - EOF in a partial request line, header section, fixed-length body, chunk, or trailer produces one 400 and closes. EOF while idle closes silently.
On the response side:
- A
HEADresponse carries the header block aGETwould, and no body. 1xxand204never carryContent-LengthorTransfer-Encoding, even if the handler supplies them;304carries them but no body.- A response header whose name is not a token, or whose value contains CR, LF or NUL, is refused and turned into a 500 rather than emitted. This prevents HTTP response splitting (CWE-113) when a handler puts unescaped input into a header.
- A non-three-digit integer status, unrepresentable
Content-Length, or transfer encoding other than exactlychunkedis likewise replaced with a safe 500 before any response byte is written. Repeated identical content lengths collapse to one canonical field; valid chunked transfer encoding wins over and removes content length. - A leading empty line before a request line is ignored (RFC 9112 2.2).
- Chunk extensions are ignored and the trailer section is consumed, so neither can be misread as the start of the next request.
Expect: 100-continuegets an interim100 Continueonce the headers are known to be acceptable; any other expectation is a 417 (RFC 9110 10.1.1).- A client that half-closes its write side after sending — send request,
shutdown(SHUT_WR), read reply — still gets its response, and the connection is then released rather than held open for a request that cannot arrive.
The one deliberate deviation is that an HTTP/1.0 request is answered with 505
rather than 400, including when it carries Transfer-Encoding: chunked. Like
Capra, this adapter supports HTTP/1.1 only, so the version is the more accurate
complaint. h1spec reports this as its single failure; everything else in its
suite passes (32/33 in --strict mode).
The test suite covers these directly. The rule set was drawn from cispa/http-conformance (MIT), the suite from Who's Breaking the Rules? (ACM AsiaCCS 2024), and from the parsing-discrepancy classes catalogued by the HTTP Garden project, which found 100+ such bugs in widely deployed servers. HTTP Garden is GPLv3 and nothing is copied from it — the tests here cover the bug classes it documents, with payloads written from the RFC grammar.
Conformance is also checked with h1spec, which runs against a live server:
joltc run dev/h1spec-server.clj & # or any server you like
h1spec --strict 127.0.0.1:8080
The rules above are pinned by fixed payloads and explored generatively, so
coverage comes from classes of input rather than from a list of remembered
examples. Three layers, all folded into joltc -M:test:
- Pure properties (
jolt.http.body-property-test) — the RFC-1123 date arithmetic against an independently written civil-date algorithm, and the writer/sink layer driven at generated buffer capacities down to one byte. - Protocol properties (
jolt.http.protocol-property-test) — the state machine driven in-process through ateensyp.server/Socketfake, so hundreds of generated message streams run in the time a handful of loopback cases take. Framing invariance under every write split, aggregate header boundaries, Content-Length and chunked agreement, response metadata fail-closed behavior, every truncated EOF prefix, HEAD equalling GET, pipelining, response splitting, and a mutation fuzz asserting every answer is well formed and terminal. A stateful model generates whole request sequences on one connection. - Loopback properties (
jolt.http.server-property-test) — what the fake cannot reach: the reactor's send loop, write-credit accounting, backpressure on a real sender, and a genuine half-close.
Expected values come from an HTTP/1.1 response reader in
jolt.http.http-model, written from RFC 9112 rather than derived from the
parser — a property whose oracle is the code under test proves nothing.
All eight loopback properties run in the default gate. The two rapid-churn
properties formerly exposed an fd-reuse race in jolt-tcp; they remain available
through run-known-flaky-properties! as a compatibility helper for focused
stress.
The bounded inline-response control-capacity proof records the source facts, Z3 counterexample query and semantic controls behind the synchronous pipelining regression. The bounded response-validation, terminal-EOF, and sink-finalization proofs record the fail-closed models, known-bug controls, non-vacuity witnesses, and their implementation-test boundaries.
-
Request bodies are a
RequestBodyprotocol value, not anInputStream(see above). Response bodies use theStreamableBodyprotocol in place ofring.core.protocols. -
No Ring dependency — there is no Ring on jolt, so the protocols are defined here.
-
Buffers are
teensyp.buffer/Bufferrather thanjava.nio.ByteBuffer, and charsets are name strings ("UTF-8"), notCharsetobjects. -
The response buffer is per connection, not per thread: jolt has no usable
ThreadLocalconstructor. jolt-tcp calls a connection's handler arities serially, so it needs no locking. -
The
Dateheader is computed here (jolt's core has nojava.time) and cached for the current second. -
No
:direct-read-buffer?— jolt has no direct buffers. -
The default executor is an explicitly bounded pool. Capra uses a virtual-thread-per-task executor, but jolt has no M:N virtual threads. In v0.4.15 the cached, virtual-thread, and work-stealing constructor shims all map to a fixed 32-worker implementation, so none provides Capra's scaling semantics. A separate high-concurrency native-I/O workload has ended in Chez's
nonrecoverable invalid memory reference, but that runtime safety case still needs a minimal reproducer. This server therefore keeps configurable bounded policy and queues excess requests; raise:pool-sizedeliberately for blocking workloads.This does not deadlock even though a handler may block waiting for one of its own writes: jolt-tcp runs write-completion callbacks on a separate executor, so the callback that releases a blocked handler always has a thread. (Sharing one pool deadlocks at exactly
pool-sizeconcurrent streaming responses — that is why jolt-tcp grew a:callback-executor.)An executor created by jolt-http is transferred to jolt-tcp and shut down as part of deterministic server cleanup. A supplied
:executoris borrowed by default; pass:shutdown-executor? trueto transfer its ownership explicitly. -
Connection metadata is truthful.
:server-portcomes from the bound local endpoint, including the kernel-selected port after:port 0;:server-nameand:remote-addrdefault to the actual numeric local and peer addresses. Their options remain explicit overrides for proxy deployments.
JOLT_PWD="$PWD" /path/to/casselc-jolt/bin/joltc -A:test -m hegel.install
JOLT_PWD="$PWD" /path/to/casselc-jolt/bin/joltc -M:testRuns a framework-less acceptance suite plus three generative layers (jolt-hegel) over real loopback TCP, driving raw sockets so that malformed requests, split requests and pipelining can be exercised exactly. Scenarios are ported from Capra's test suite. Each scenario runs under a 60-second watchdog, because the loopback client uses a blocking recv with no socket timeout and a lost response would otherwise hang the run instead of failing it.
Run a subset by naming scenarios:
JOLT_PWD="$PWD" /path/to/casselc-jolt/bin/joltc \
-M:test "pipelining" "keep-alive"The current reviewed core baseline is
ecf7728f15d8b8f858327c47dbd8b751eb36798c. deps.edn pins jolt-tcp at
81cfa68cc71f91d67da36d68143f3679e25277c2, which transitively pins jolt-net
at eabf9067f32d0f4c1673b5d84c24484943ea75c5.
Progress is also written to /tmp/jolt-http-test-progress.log; jolt block-buffers
stdout when it is redirected, so on a hang that file is the only record of how
far the run got.
EPL-2.0 OR GPL-2.0-or-later, matching Capra and teensyp.