http.server: the server loop as a package
Status: built (§7).
examples/api/api.chocarried the whole loop – accept, read, parse, frame, pipeline, back-pressure, idle sweep – with the application’s routes inside it. A second server would copy 600 lines. This extracts the loop;docs/server.mdstays the account of what the loop does and costs, this says where it lives and why it has this shape. §11 (byte-fed mode, so the loop can sit behind TLS) is built too, andexamples/https_hellois the HTTPS server made of it andpackages/tls. So is §12 (streaming request bodies andExpect: 100-continue): a body larger than the buffer, in pieces, with the same back-pressure and two timers of its own.
1. What was asked
Take the loop out of the example so a server is its routes and a main. It is
the piece cancho-web would build on, and the first package that imports
std (package-system.md §4.8).
2. The obvious shape does not type-check
The first design is serve(heap, router, handler, ...): the loop calls a
handler it was given. Lex has function values (function-values.md), so this
looks free. It is not, and the reason is measured rather than assumed:
fn drive[&k](heap: &!k Heap, h: fn(&k [byte]) -> [] int) -> [heap] int {
let b = box_slice(heap, 8, byte_of(0));
borrow mut b as &!bw in { let c = contents(bw); n = h(c[0..4]); }
is refused – bw does not outlive k, so a reference valid for the first
cannot be used where the second is expected. A function value’s type may
mention only regions already in scope where it is written
(function-values.md §4.2, “a type quantified over its own regions (rank 2) is
refused”), and the request the loop would hand a handler lives in a buffer the
loop borrowed itself. The handler’s parameters would need a region the caller
cannot name. So a callback cannot receive a view of the loop’s own buffers, and
copying every request into an owned value to get round that costs an
allocation per request on the path the benchmark measures.
3. The shape that does: the loop is the application’s
The package owns the state and the work that has no business being rewritten – sockets, framing, pipelining, back-pressure, timeouts – and returns control at the one place the application has something to say:
let srv = server.open(heap, poller, listener, size, chunk, idle);
while true {
srv = server.wait(heap, srv, clock, listener, 1000); // I/O, once
while (k = server.next(heap, srv)) >= 0 { // a request is in hand
// server.head / parsed / body: views of the loop's buffers
// ... the application builds its answer ...
server.respond(srv, answer); // send, or queue
}
}
The views are ordinary borrows of the Server (&s), valid inside the
borrow that reads them – which is what a rank-1 region system is good at, and
what a callback is not. The application’s own state is its own variables, which
a callback with no closures could not reach at all (function-values.md §4.1).
What stays in the package: everything drain, emit and serve_on did. What
moves out: the route table and the handler, which were never the loop’s.
4. The contract
opentakes thePollerand the listener and registers the listener; the server owns thePollerfrom then on andcloseends it.waitis the one place that blocks (at mosttimeout_ms). It accepts, reads, writes what was queued, sweeps idle connections, and leaves a list of connections that hold input. It takes and returns theServerby value because accepting replaces the connection table (std.conns.putgrows it).nextanswers the slot of a connection holding one complete request, parsed and (if chunked) decoded, or-1when none is left. Refusals the loop owns – 400 for a request the parser rejects, 413/431 for one that cannot fit – are answered insidenextand never reach the application.respondappends the answer to the current connection: straight to the kernel if nothing is queued ahead of it, otherwise to the connection’s output buffer, in order. Once a connection has output waitingnexttakes no more requests from it: back-pressure is the loop’s, not the application’s.- The application must call
nextuntil-1before the nextwait. A request skipped would sit unanswered;waittherefore finishes any connectionnextleft half-visited and re-queues unvisited ones rather than losing them.
5. What does not change
The behaviour. Every test in conformance/api.rs runs against the migrated
examples/api unchanged, and the throughput and tail-latency figures in
server.md are re-measured (§7).
6. What it does not do
Streaming bodies, Expect: 100-continue, TLS and more than one core are
server.md §6’s list and remain the list. Handlers see a request whole.
(TLS: §11 is the byte-fed mode that lets a TLS terminator drive the loop. Streaming a response is stream there. Streaming a request, and Expect, are §12: a server that calls limits gets them; one that does not still sees a request whole and ignores Expect.)
7. Built
packages/http-server/server.cho (module http.server, published with vcs publish --std into packages/http-server/.cancho-vcs), and
examples/api/api.cho rewritten onto it: the example is its routes, its
handlers and a 60-line run loop, and consumes the package through
examples/api/server.lock like every other package consumer.
The tests. All 21 conformance/api.rs tests pass unchanged against the
migrated server – keep-alive, pipelining, split requests, slow readers,
vanishing clients, chunked and oversized bodies, refusals, idle timeouts. The
contract’s one clause api does not follow (next until -1 before the next
wait) has its own test over tests/programs/server_one_per_round.cho: four
connections, six pipelined requests each, one next per wait. It found a
bug the first time it ran: the connection next was part-way through was
finished (its input compacted) but never queued again, and no input would
arrive to wake it, so five of its six requests were never answered. wait now
re-queues it when requests remain buffered. Without that fix the test fails;
with the unvisited-connections shift broken it fails too (both checked by
mutation). A second test holds the checked-in store to the checked-in source,
so an edit that is not re-published is red.
Throughput. Same machine, same session, the old and new binaries
alternated, server pinned to core 0, kload 2x16 connections on cores 2-3:
| requests a second (three 5 s rounds) | |
|---|---|
before (loop inside api.cho) | 71,446 70,979 73,180 / 73,926 70,432 71,683 |
after (http.server) | 81,516 78,185 76,806 / 76,566 76,640 76,755 |
It is not slower; it measured about 7% faster. Why was not investigated, and the two series overlap in one round, so the claim is “no regression”, not “a speed-up”.
What it cost the application. The borrow nesting moved. Reading a
request is a borrow srv as &sr around the handler, sending is a
borrow mut srv as &!sw around respond, and the answer lives in a buffer of
the application’s own between them. The application’s variables (the router,
the parameter slots, the output buffer) are plain locals, which is what a
callback could not have given it.
Left over. wait takes and returns the Server by value, because
std.conns.put consumes the table to grow it. A put that works in place on a
table made with its final capacity would make wait take &!s Server like the
rest; it is a change to std.conns and nothing here asked for it. The package
publishes every declaration in its file, helpers included (vcs publish
cannot yet publish only the pub ones), so the lock pins the interface and
the rest is reachable but not depended on.
8. Reply helpers added after a real service used it
cancho-web’s examples/users (a CRUD API over this package and cancho-schema)
needed three things the package did not have, each now here:
reply_as(heap, out, status, content_type, body, keep, extra).replyhard-codedapplication/json, so an RFC 9457application/problem+jsonanswer had to be assembled by hand.reply/reply_withare nowreply_aswith that type, and nothing that called them changes.reply_empty(heap, out, status, keep, extra), a204/304with noContent-Lengthand noContent-Type(std.http.respond_no_content), so aDELETEcan answer204.json.put_fragment(instd.json, not here) for assembling a response from stored JSON.
conformance/http_server.rs::replies_of_any_type_and_bodiless_ones_keep_the_connection_framed
sends four pipelined requests down one connection – JSON, 204, text/plain with
an extra header, JSON – and checks the exact bytes: a wrong frame on the bodiless
one would shift every answer after it. The store was republished (a changed body is
refused by incremental publish, so it is regenerated), and examples/api/server.lock
re-pins it with the two new names.
9. What a page endpoint found in std.buffer
cancho-web’s GET /users?limit=20 answered 40,000 requests a second against 99,900 for a
hand-written C server (cancho-web/docs/benchmarks.md), the widest gap of its four workloads.
Two causes, one in the application and one here, measured one after the other on the same
machine in the same session (page of 20 users, 1.8 KB, server on core 0, kload on cores 2-3,
three 5 s rounds each):
| requests a second | |
|---|---|
each stored user re-parsed by json.put_fragment | 39,404 40,073 40,051 |
| the page spliced as bytes (the users are the program’s own canonical output) | 59,830 64,873 63,353 |
… and buffer.append storing in one pass | 70,931 70,144 70,944 |
The first is the application’s, not this repository’s: it was validating text it had itself
rendered. The second is std.buffer.append, which was push once per byte, and push checks the
capacity and rebuilds the Buffer every time. Appending n bytes now makes room once and stores
n times (tests/lex/buffer_test.cho checks it at every capacity boundary and fails under two
deliberate mutations). Cost per extra user in a page was about 0.36 microseconds, roughly 4 ns a
byte, before the change, which is what two byte-at-a-time copies of a response (into the page, then
into the reply) would cost; that reading was not profiled.
Left: both copies remain byte loops. bulk-io.md §4 names the primitive that would fix it for the
whole library (a slice-to-slice copy) and leaves open whether that is the compiler’s or the
backend’s to do; this change does not decide it.
10. Holding a request, and handles of the application’s own
A request whose answer is not ready – it waits on a database – used to have one choice: block in
next’s caller until the answer came, and stop every other connection. Two additions let the
loop go on, and neither makes the server know what it is waiting on (cancho-pg/docs/nonblocking.md
is the first user).
hold and answer. hold(srv) takes the request next handed over and answers a ticket
instead of leaving it to respond; the loop goes on to the next request. answer(srv, ticket, bytes) is respond for that request, at any later time. What the server does meanwhile:
- A held connection is not read from once its buffer is full (the rest of what the client sent stays in the kernel), and no request behind the held one is handed out until it is answered: the answers must leave in the order the requests came, so the next request on that connection waits. Requests already buffered behind it are served after the answer without waiting for new input.
- It is not closed for being idle: the answer is the application’s to give, and so is giving up
(an application that wants a deadline answers with
504). - A ticket is a slot and a generation of that slot (
ticket % 2048,ticket / 2048; the generation increases every time a connection takes the slot).answeranswers-1and writes nothing for a ticket that was answered already, or whose connection is gone and whose slot may now be someone else’s. The-1is the whole protection against writing an answer to the wrong client, so it is checked, not trusted.
poller, first_token, foreign_count, foreign. poller(srv) is the server’s own
poller. The application registers its own handles in it (std.conns.watch with a token from
first_token(srv) up: 0 is the listener, 1 to limit the connections), so that one wait serves
them and the connections together. What wait reports for those tokens is not acted on; it is
listed by foreign_count/foreign as (token, readiness) pairs, valid until the next wait, for
the application to read from the handle and answer tickets. The list is 64 pairs; a pair beyond
that is dropped, which is safe only for handles watched level-triggered (the poller reports them
again next time), which std.conns.watch is.
tests/programs/server_hold.cho is the application used in conformance/http_server.rs: it
watches a connection to the test and answers one held request for every byte the test writes.
| test | what it fixes |
|---|---|
a_held_request_does_not_stop_the_loop_and_what_follows_it_waits_its_turn | other clients are answered while one is held; a request pipelined behind it, and one written later, wait and come out in order after it |
held_requests_are_released_one_each_and_oldest_first | three held requests; each release answers one |
a_held_connection_with_a_full_buffer_waits_without_reading_and_loses_nothing | 2,500 requests (about 80 KB) behind a held one, 5 buffers’ worth: all answered, in order |
a_held_request_is_not_the_idle_timeouts_to_close | held for 12 s, the idle timeout being 9 |
a_ticket_answers_once_and_never_reaches_a_connection_that_took_the_slot | the same ticket twice; and, after the connection is gone and another holds a request in its slot, the old ticket does not answer the new one |
Nine single-edit mutations of the new code, each published to a scratch store and run against all of the tests above; every one is caught:
| mutation | first test to fail |
|---|---|
answer does not check the generation | a_ticket_answers_once... |
answer does not check that the request is held | a_ticket_answers_once... |
| the generation is not increased when a slot is taken again | a_ticket_answers_once... |
| a held connection with a full buffer is still read | a_held_connection_with_a_full_buffer... |
| a held request is produced again when more input arrives | a_held_request_does_not_stop_the_loop... |
| the idle sweep closes held connections | a_held_request_is_not_the_idle_timeouts_to_close |
| foreign events are not counted | five tests |
| the ready queue is not compacted when full | answering_more_than_the_table_has_rooms... |
| no requeue of requests buffered behind an answered one | two tests |
The first attempt left three of the nine standing (the held-twice one, the full-buffer one and the idle one); the last three tests, and the “nothing was held twice” check in the first, were written for them. The queue-compaction mutation survived a further round until a test ran the server with buffers of 64 MiB (room for four connections, so a ready queue of four).
One flake was found and fixed on the way: a release byte written before the server had registered the request it was meant for was read and lost, so the request was never released. The test application now keeps a byte as a credit until there is a held request to spend it on.
11. Serving it over TLS: the byte-fed mode
Status: built (this section was a proposal when
examples/tls_echomerged, PR #346; building it corrected the proposal in the places marked Corrected).packages/tlshas a server (tls-server.md), andexamples/https_helloputs it, and this package, in one program: HTTP/1.1 over TLS 1.3 with keep-alive, pipelining, a 64 MiB streamed body and a client that stops reading, checked against curl,openssl s_clientand Python’sssl(§11.6), at a measured cost (§11.7).
11.1 What stopped it
A TLS terminator’s plaintext does not come from a socket: it comes out of tls.recv, and the answer goes into tls.send. This package
could not be the plaintext side, for a precise reason: it never saw a connection’s bytes except through a socket it held itself.
waitowned theListener’s readiness andaccept_allcalledtcp_acceptandconns.put: every connection the server knew was aConnin its ownconns.Table.- Input reached a connection’s buffer in one place,
step, byconns.read. Output left in three:emit(the fast path),step’s write of what was queued, and the refusalsproducesends throughemit.shutclosed theConn;settlere-registered it with the poller.
The one way that works with no change is a loopback relay (the TLS side connecting to this server’s own listener on 127.0.0.1 for each
client): it doubles the sockets, copies every byte once more each way, gives the HTTP side 127.0.0.1 for every client and adds
net_out to the program’s row. It was not built, and it is not what the broker or the gateway should copy.
11.2 The API as built
A server opened with open_bytes has no listener and no sockets: the application owns them and moves the bytes. Everything that is not
about where bytes come from is the socket server’s code, unchanged (next, head, parsed, body, respond, hold, answer,
close, connections, the reply*/failure* helpers).
server.open_bytes(heap, size, out, idle, max) -> Opening // Opening::Ok(Server) | Opening::Failed(errno)
k = server.attach(srv, now_ms) // a connection: its slot, -1 when full
n = server.room(srv, k) // how many bytes input takes now; 0 is back-pressure
n = server.input(srv, k, bytes, now_ms) // bytes from the peer; n taken (n <= room), -1 for a dead slot
server.end_input(srv, k) // the peer will send no more (half-close, close_notify)
server.ready(srv, now_ms) // `wait` without the poller: requeue, sweep idle; how many are queued
k = server.next(heap, srv) ... // unchanged, with head / parsed / body / respond / hold / answer
n = server.output(srv, k, out, now_ms) // what the server would have written: up to len(out) bytes
n = server.pending(srv, k); n = server.space(srv, k) // bytes of answer waiting; bytes of answer room left
b = server.closing(srv, k); server.detach(srv, k) // the server is done with k; free the slot
n = server.stream(srv, ticket, bytes) // part of a held request's answer; n taken (n <= space)
open_bytes | size is each connection’s input buffer (a request, head and body, must fit in it); out the answer each may have waiting (at least 512); idle seconds without progress before it ends; max connections at most (at least 1). Memory is max * (size + out) and 16 words of state a connection, all allocated here; nothing grows. Failed(22) for a size, out or max out of range, else the errno of the one kernel object it makes (below) |
attach | takes a free slot, or answers -1. now_ms starts its idle clock |
input | copies at most room bytes into the connection’s buffer and queues it for next. It never takes more than room, so the application keeps the rest and asks again: this is the back-pressure. Dead or ended slots answer -1 |
ready | what wait does either side of its poller_wait: connections next did not reach, and the one it was part-way through, are queued again; every second, connections with no progress for more than idle seconds end |
output | moves up to len(out) bytes of the waiting answer to the caller (the bytes for tls.send) and counts it as progress. When the last byte of an answer that ends the connection goes, the connection ends |
closing | the connection ended: Connection: close was answered, the peer ended and everything is answered, it went idle, an answer did not fit, or what it sent was refused (400, 413, 431). What is left is the application’s: close_notify, close the socket, detach |
detach | frees the slot, ended or not (the socket failed, the application gave up). A request held for it is forgotten; its ticket answers -1 for ever, even when the slot is taken again (the generation of §10) |
stream | with hold: the request stays held, nothing behind it is given out, and the idle timeout spares it while nothing is waiting to be sent. answer(ticket, "") ends it. This is how an answer larger than out is sent |
Corrected, against the proposal:
open_bytes(heap, size, chunk, idle)isopen_bytes(heap, size, out, idle, max).chunklimits a socket write and means nothing without one.out, the bound on buffered output, was the constant 64 KiB; a gateway wants it, and the number of connections, as parameters (the TLS engine holds about 40 KiB a connection beside them). The socket server’sopenis unchanged and uses the same fields (osizeis 65,536 for it).- It returns
Opening, and it is not “no Poller”.Corehas aPollerfield the socket path registers handles in, and aresfield cannot be absent without making every use of it amatch. A byte-fed server therefore holds one kernel poller object (one file descriptor on Linux) that it never registers anything in;closecloses it. That object is the only thing that can fail, hence the enum.waiton a byte-fed server returns it unchanged without blocking;poller,first_tokenandforeignare meaningless on one. - The clock is an argument, not a
Clock.ready(heap, srv, clock)would have had the package read the clock, andattachandinput(which stamp progress) would have had to read it too. The caller reads its clock once a turn and gives the milliseconds to every call that moves a byte or looks at time (§11.4). room,end_input,pending,spaceandstreamare additions.input“how many fit; 0 is back-pressure” cannot be used without knowing beforetls.recvhow much to ask for (the plaintext is in the engine; it has no way to hand some back), so there isroom. A half-close needs a call (§11.4).spaceandpendinglet a caller size an answer to what the connection can take.streamis the one real addition: the proposal had no way to send an answer bigger than the output room, and the example’s/big/<n>, and the gateway’s proxied bodies, are exactly that.- It is about 330 lines, not a hundred (444 added, 75 removed, of which about 60 are code moved out of
accept_all,serve_eventsandwaitinto functions both paths call). - The effect rows are the socket path’s.
nextis[heap, conn_write, poll]because the refusals it sends go throughemit, and the language has no polymorphism over effects (AGENTS.md§8). A byte-fed program therefore reportsconn_writeandpollincancho authoritythough no socket is written through them. It does not widen the report of a program that has sockets, which a terminator does; a program with none would want a second set of names, which nothing has asked for. - A server cannot be seen to be the wrong kind in the type.
attach,ready,streamand the rest answer -1 (or do nothing) on a socket server, andwaitdoes nothing on a byte-fed one; neither can trap.
11.3 How the two paths share the code
There is one parser, one framer, one pipeline and one back-pressure rule. produce (parse, frame, refuse), finish, advance,
deliver_to / respond, hold / answer and the idle sweep are the functions the socket path always used, and a byte-fed server calls
the same ones. What differs is where bytes cross a boundary, and each of those places is one test of core.fed:
| socket server | byte-fed server | |
|---|---|---|
| bytes in | step’s conns.read into the connection’s buffer | input copies into the same buffer |
| bytes out | emit: conns.write to the kernel if nothing is queued, the rest into pends | emit skips the write: it all goes into pends; output drains it |
shut | closes the Conn, frees the slot | marks the slot ended (closing); detach frees it |
settle | re-registers the Conn with the poller | nothing |
| accepting | accept_all | attach |
| time | wait reads the clock | the caller’s now_ms |
Four pieces were taken out of the socket path’s functions so that both call them (init_slot from accept_all, enqueue from
serve_events, begin_round from wait, sweep_idle from serve_events). The socket path’s behaviour is the same, and the 21
api tests and the 9 http_server tests pass unchanged. Four things did change on it, and are listed because “unchanged” is a claim
the tests only partly carry:
- The idle sweep spares a held connection only if nothing is waiting to be sent. A socket server never holds with output waiting
(
producehands out nothing while output waits), so for it the condition is the old one. - A connection queued for
nextis queued throughenqueue, which compacts a full queue first (answeralready did;serve_eventsdid not, and could in principle have written past the end). - Slot word 15, which the socket path did not use, is the peer’s end (
end_input); it starts at 0. - The identity of every function that mentions
Serverchanged (Corehas four more fields), soexamples/api/server.lockandtests/programs/server_hold.lockwere re-pinned and the store republished. No signature changed; a consumer pinned to the old store keeps working on it.
scripts/publish_packages.py now publishes and checks packages/http-server too (it did x509 and tls only; the store is flat, packages/http-server/.cancho-vcs,
which the locks pin). Republishing removes the old store’s files and writes the new: a changed body is refused by an incremental publish, as §8 found.
11.4 What the semantics are
Time. The package never reads a clock. attach, input, output and ready are given now_ms, any monotonic milliseconds, and a connection’s
last progress is the time of the last byte in or out, in whole seconds, as for a socket. A byte-fed connection that no one feeds and
no one drains is idle after idle seconds, which ready notices (at most once a second, from the now_ms it is given). The caller’s TLS
handshake and close timeouts are its own; https_hello’s server idle is its own plus two seconds, so its sweep, which also drives the connection through close_notify,
acts first.
Back-pressure shows up in three numbers and one rule. room(k) is the bytes of input the server will take: 0 while an answer is
waiting (a client that does not take its answers is not read), and 0 once the connection is ending. pending(k) and space(k) are the answer waiting and the room for
more, and pending + space = out. The rule is the socket server’s own: nothing is handed out by next for a connection with output
waiting, so with a client that takes nothing the server holds one answer, and the connection’s input buffer (size), and no more.
An answer that does not fit the room is refused whole (nothing is half sent) and the connection ends, as a socket’s does when the client
has not read a buffer’s worth: respond answers -1 and closing is true. A caller that has more than space to say holds the
request and streams it.
A half-close and an error. The peer’s end (a TCP FIN, or its close_notify) is end_input(k): what it sent is still answered, in order,
and then the connection ends; a request it did not finish is dropped, as for a socket. A socket that fails, a TLS alert, a connection
the application gives up on: detach(k). The application learns the server wants to end a connection from closing(k), and always ends
it by detach. Both can happen with answers waiting: end_input waits for them to be taken; detach drops them.
Pipelining is the socket server’s: requests in the buffer are served in order, one at a time, because a connection with an answer waiting is not given another; the next
ready or output that empties the answer queues it again. A request split across any number of inputs is answered once, when next says it is whole.
The memory a connection can make the server hold is size + out bytes and 16 words, fixed at open_bytes. Nothing is allocated per connection or per request
by the package; the application allocates what it builds an answer in. In https_hello a connection also has four 16 KiB buffers and the TLS engine’s slot (§11.6).
Limits that are refusals, with their tags (the status, and failure’s message, which the client sees; none reaches the application): a request head that fills
size without ending, 431 request head too large; a body that cannot fit, 413 request too large; a malformed request or an
ambiguous length, 400 with std.http’s message. After any of them the connection ends once the answer is taken.
11.5 Tests and mutants
tests/packages/http_server_bytes_test.cho (19 cancho test tests, run on both backends by conformance/http_server_bytes.rs) is the byte-fed API without a
socket, which is the point of the mode: a length body, a chunked body, three pipelined requests with a fourth cut short, a request fed one byte at a time in
each framing (answered once, after its last byte and not before), every refusal and its status, half-closes (after a request, after nothing, in the middle of one, with an
answer waiting, with a request held), a client that takes nothing (twenty pipelined requests, one answer held, input refused, pending never past out), an answer over the room
(by 1 byte and by 100, and one that fits exactly), idle on the caller’s clock (progress by input and by output, the boundary second), slots (full, freed, reused, a freed one
skipped over), a held request streamed in pieces and a ticket that never outlives its connection, a stalled stream ended by the idle timeout, the ready queue (its overflow, a slot
freed while queued, the connection in hand detached), answers of no bytes, ready giving back a visit’s room, every bad slot and ticket, and 8 seeds of 3,000 random operations that
must not trap or break a bound (pending <= out, room <= size).
scripts/http_server_bytes_mutants.py applies 90 single-edit mutations to the new code and the shared code it reaches, one at a time, to server.cho and runs those
tests: 79 are killed and 11 survive, each argued equivalent in the script (ten because the line they change is redundant with another or unobservable: detach and init_slot both clear a slot’s buffered count, shut clears a flag that detach clears again; and one, stream refusing a socket server, because cancho test cannot open a socket server, which needs a Listener). The first run killed 74 of 91 and showed 11 survivors that were not equivalent: no test detached the connection next had in hand, none stopped at the second the idle limit is reached, none took output and then waited past the limit, none gave an answer one byte over the room, none ended a peer while its request was held, none answered with no bytes, and none looked at the room ready gives back; each has a test now (CI runs it).
11.6 examples/https_hello
examples/https_hello/ is tls_echo’s program with http.server where the echo was: hello.cho (the options, main), loop.cho (the loop), app.cho (the routes). tls_echo’s
loop was split so that the two share it (below). Its authority report is tls_echo’s, label for label (conformance/https_hello.rs pins it: no foreign code, bounded).
socket -> tls.feed -> tls.recv -> rcv buffer -> server.input requests
server.output -> app buffer -> tls.send -> tls.take -> socket answers
Each stage reads only when the stage after it has emptied, so the same rule that bounds the server bounds the whole path: a client that stops reading its answers
stops server.room, which stops tls.recv, which stops tls.feed, which stops reading the socket. A connection whose request is held with a full input buffer is not even
watched. A turn is: wait; pump every connection the poller named; then rounds of {ready, answer up to 64 requests, give the streams what room they have, pump every
connection that moved}, at most 16 rounds before returning to the poller (with no wait if work remains).
The routes: GET / (hello), GET /hello/<name>, POST /echo (a JSON body is returned inside {"method":..,"path":..,"bytes":..,"json":<body>}; anything else is a 400),
GET /big/<n> (n bytes, a to z repeating, up to 1 GiB: the request is holded, the head streamed, and the body given as the connection takes it), 404 and 405 with Allow.
What moved to a shared file. examples/tls_echo/echo.cho and tls_echo.cho held the options, the per-slot state, the log, the handshake bounds (admit), accept_all, writing
the engine’s bytes, end_slot, the reload and the command line (about 450 lines). A second program needed all of it, and the duplication check refuses a copy, so it is
examples/tls_echo/front.cho (module tls_front) now: every function is byte for byte what it was, with pub added (and 4 more slots in stride).
echo.cho keeps pump, close_with, sweep, stop_all and run, which touch the plaintext. main and serve remain in each program (they name their loop; a third
program would move them: link-time selection of the module that holds run would do it).
scripts/https_hello_test.py (15 cases; CI’s tls-assurance job runs them):
| case | what it checks |
|---|---|
curl | curl (OpenSSL build), HTTP/1.1 over TLS 1.3: one request; 100 requests on one connection (%{num_connects} adds up to 1); a JSON POST echoed; a 404 and a 405; a 100,000-byte streamed body byte for byte. Skipped, saying so, where curl is a SecureTransport build and cannot be given the test CA |
openssl | openssl s_client: a request, a keep-alive one after it on the same connection, three pipelined in one write (answers in order), then Connection: close, answered and ended with close_notify |
http | Python http.client: 2,000 requests on one connection, each answer checked; the JSON echo with every escape, non-ASCII and nesting; a body that is not JSON (400); a chunked request body; HTTP/1.0 answered and closed |
pipelined | 300 requests in one write, then 300 with length and chunked bodies and a 404 every third, all in one write: 600 answers, in order, and the connection serves after |
many | 200 connections at once (Python ssl, one thread each), 10 requests each |
big | /big/<n> for 16 sizes from 0 to 64 MiB (the boundaries of the 16 KiB record and the 64 KiB output room among them): every byte checked, the connection serves after each; a request pipelined behind a big one is answered in order; a length past 1 GiB, not a number, negative or empty is refused and the connection goes on |
stalled | a client asks for 1 GiB and reads 100 bytes: the server’s resident memory does not grow past 4 MB beside it, another client is served throughout (the slowest of its requests is reported), and the stalled one is ended after --idle; the server serves after |
slow | half a request and silence: ended after --idle, and the clients beside it are served |
ended | a connection ended by Connection: close and then written to every 50 ms (each write refused by the closed socket) beside a keep-alive client making 40 requests: every one is answered and no other connection is closed. The writes come from a thread of their own (below) |
reload, bound, full, idle, shutdown | tls_echo’s cases, over HTTP: a connection open before SIGHUP keeps its certificate and keeps being served, a later one gets the renewed one, a refused reload leaves it; --handshakes 2 held by two silent peers delays an honest one rather than refusing it; a fifth connection over --connections 4 is closed at once; an idle keep-alive connection is sent close_notify; SIGTERM with 10 open keep-alive connections sends close_notify to each and exits 0 |
hostile | 1,500 connections each sending a mangled request (flipped bytes, cuts, bare LFs, a 100 KB target, a thousand headers, JSON 5,000 deep, lengths past 2^64 or negative, chunk sizes past the end, 1,000 NULs): the server is alive after every one and serves after the last |
Results. 14 of 14 on linux-aarch64 (Docker, with curl 8.5 built on OpenSSL 3.0.13), 13 of 14 on macOS (its curl is SecureTransport and the case says so); tls_echo_test.py still passes 8 of 8 on both after front.cho.
conformance/https_hello.rs (64 connections through tls_many, a reload taken and one refused, a stop, the authority pin) and conformance/http_server_bytes.rs run in cargo test on both CI targets; cargo test --workspace on linux-aarch64 in Docker passes everything but the two tests that need a git checkout, as before this change.
A false report, and why ended writes from its own thread. It was reported that bytes arriving on an ended connection made the server close a different one: a Python
client with D and H in one thread, D ended by Connection: close and then written to every 50 ms, saw H’s next request fail with BrokenPipeError within 0.1 s, the server alive
and logging no close of H (it logged conn 2 closed tls-peer-closed only afterwards, when http.client closed H on the error). Measured on macOS, Python 3.14.5 with OpenSSL 3.6.4,
the server is not involved:
- when D’s
conn 1 closedis logged its descriptor is gone (lsof), so D’s later bytes reach only the kernel, which answers them with a reset to D. The server’s descriptor for H staysESTABLISHED, nothing arrives on H’s socket, and a turn of the loop never sees them; - H in a second process, beside the same D written to the same way: 10 of 10 requests answered;
- the same two clients against a plain-Python TLS 1.3 server (
ssl, one thread per connection, D closed after one read, H echoed): H’s write right after D’s refused one raisesBrokenPipeError, the one after that succeeds; - calling OpenSSL’s
ERR_clear_error(throughctypes, on thelibcryptothat_ssllinks) between D’s refused write and H’s makes every write on H succeed.
The cause is the client’s: OpenSSL keeps its error queue per thread, D’s refused SSL_write leaves an EPIPE on it, and CPython’s _ssl reports that entry as the
failure of the next write on another socket in the same thread. There was nothing to correct in loop.cho or front.cho. The ended case keeps the report’s sequence, with D’s
writes on their own thread: the unchanged server passes it 5 of 5. To check that the case can fail, end_slot was made to close the next slot too (a wrong-slot close,
the bug the report suspected): the case failed (ConnectionResetError on H’s next request). That change was not kept.
11.7 Cost
python3 scripts/https_hello_test.py <https_hello> --cost <seconds> --kload kload --tload tload --plain api (CI’s tls-assurance job runs it): the closed-loop keep-alive load of docs/server.md §5 (benches/server/kload.c:
2 threads of 16 connections, one request outstanding on each, three rounds), plain against examples/api (the socket path of this same package, GET /users/42, a short JSON answer) and,
through benches/server/tload.c, the same loop over TLS 1.3 against https_hello (GET /hello/42, a 99-byte answer with its head: a different route, so the application’s own work is not the same, and small beside what TLS adds).
The server is pinned to one core and the load to two others. The script also reads the server’s CPU time from /proc and divides it by the requests.
Measured on two machines, both with the LLVM backend and OpenSSL 3.0.13 as the client: the x86-64 CI runner (a shared virtual machine, as tls-server.md §11.4’s 5.5 ms a handshake was), whose three rounds agree to 0.5%; and Docker Desktop’s Linux VM on an Apple M4 Max (aarch64 Linux 6.8, 6 vCPUs, other containers running), which is not a quiet machine: its rates moved by up to 1.5 times between two runs of the same command, so there the CPU a request is the figure to read:
| requests a second, three rounds | the server’s CPU a request | |
|---|---|---|
x86-64, CI’s ubuntu-latest runner (the PR’s first green run), 5 s rounds | ||
plain, examples/api | 178,451 179,056 178,473 | 5.59 microseconds |
TLS 1.3, https_hello | 42,614 42,313 42,086 | 23.40 microseconds |
| aarch64, Docker Desktop’s Linux VM on an Apple M4 Max | ||
plain, examples/api | 345,024 317,966 253,457 (10 s rounds); 406,611 260,736 262,227 (5 s rounds, an earlier run) | 2.68 microseconds |
TLS 1.3, https_hello | 97,548 74,635 84,708 (10 s rounds); 84,732 133,024 133,420 (5 s rounds, an earlier run) | 9.77 microseconds |
So over TLS this server costs 3.6 times (aarch64) to 4.2 times (x86-64) the CPU a request of the plain one, and serves as many times fewer requests a second from one core: about 42,000 a second of 99-byte
answers on the CI’s x86-64 core, against 178,000 plain, and 75,000 to 130,000 against 250,000 to 400,000 on the aarch64 VM. The 7 microseconds (aarch64) or 18 (x86-64) a request that TLS adds is a record decrypted, a record encrypted, the engine’s and the loop’s copies, and the loop pumping a connection twice a request (§11.6).
It was not profiled and that is the next place to look if the figure matters: the stages copy a request’s bytes several times each way (the socket into inq, tls.recv into rcv, input into the server’s buffer; an answer into pends, output into app, tls.take into pend) and server.output
moves what is left of an answer to the front of its buffer on every call. A body streamed with /big/<n> ran at 83 MB a second on CI’s x86-64, 104 MB a second on the aarch64 VM and 245 MB a second on the Mac itself, outside Docker, each checked byte for byte.
Not measured: more than 32 connections, handshakes (tls-server.md §11.4’s 3.0 ms on the M4 and 5.5 ms on CI’s x86-64 are unchanged).
11.8 What cancho-gateway needs next
What the gateway (an HTTP/1.1 reverse proxy that terminates HTTPS) can build on today, and what it still lacks. In the order it will hit them:
- Streaming a request body. Built, §12. (It was: a request is handed over whole, so one larger than
sizeis refused with a 413, and a proxy forwarding an upload would have to buffer it.)limitsopts in; the held request’s ticket then givesbody_part,body_take,body_state,body_totalandproceed(100 Continue), with the sameroomrule as answers, and refusals with rule tags. What the gateway still has to do with it is in §12.14. - Chunked responses.
streamsends bytes; a proxied answer of unknown length needsTransfer-Encoding: chunkedframing on the way out (and de-chunking on the way in from the upstream).std.httphasdechunkand no chunk writer. - The peer’s address.
Corrected (std.connsdoes not give itconn-peer.md):conns.peer(table, slot)answers it (std.addr.Peer, withtextfor a log line and anX-Forwarded-Forentry andkeyfor a per-address bound), andexamples/tls_echologs it and bounds by it;examples/https_hellologs it (the lines arefront.cho’s). What is not done ispackages/http-server’s use of it: theX-Forwarded-Forheader, and the access log, are the gateway’s change after this one. - Upstream connections in the same loop. The gateway’s own sockets to its upstreams go in the poller it owns (a byte-fed server has none of its own;
https_hello’sloop.chois the place), withhold/answerfor the request waiting on the upstream (§10) andstreamfor the answer’s body.packages/http-requestis blocking; a non-blocking client is the missing piece, as it was forcancho-pg. (Built:http-client.md,packages/http-clientandexamples/http_fetch_nb; what the gateway still needs of it is §10.7 there.) - Upgrade.
Upgrade: websocketandCONNECTneed a connection to stop being parsed once the 101 is sent and become a byte tunnel:detachfrees the HTTP side’s slot, but the bytes already buffered behind the request are the gateway’s to recover (head/bodyviews end with the request). - Two timeouts. Built, §12.8:
head_msis a deadline for a head (a trickle does not extend it) andbody_msthe longest the server waits for more of a body; both answer408with a rule tag.idleis still the keep-alive connection’s. (It was:idlewas one number for a keep-alive connection waiting for its next request and a client taking a minute to send a head.) - ALPN. The engine selects a protocol (
--alpn); the loop could offerhttp/1.1and refuseh2, which it does not speak. Nothing is done with the negotiated value yet. - More than 1,024 connections (
max_connections) and more than one core (a second process onSO_REUSEPORT, not tried). - A byte-fed program’s authority report lists
conn_writeandpoll(§11.2). If the gateway’s reviewers want it without them, the package needs a second set of names whose rows have no socket in them; nothing has asked yet.
12. Streaming a request body, and Expect: 100-continue
Status: built (this section was committed as a design first, with two measured numbers; building it corrected it in the places marked Corrected, collected in §12.12; the tests, mutants and measurements are §12.13). It was the first item of §11.8 and
server.md§6’s: a proxy forwarding an upload cannot buffer it.examples/https_hellohas the upload routes, andcancho-gateway’s item 6 (a header timeout of its own) is §12.8.
12.1 What stopped it, measured
A request is handed to the application whole or not at all. produce waits until head + body is in the connection’s size-byte input buffer, and refuses what can never fit:
tests/packages/http_server_bytes_test.cho pins it (POST with Content-Length: 1000 into size 128 is 413 request too large; a chunked body that outgrows the buffer is the same
413), and on the socket path the same code runs. Three consequences for a reverse proxy, each from that one rule:
- an upload larger than
sizecannot be proxied at all (andsizeis memory times connections:input_budgetcaps the sum at 256 MiB); - a client that sends
Expect: 100-continue(measured, curl 8.7.1 against a server that reads the head and then nothing:-Tor--data-binaryof a 3 MiB file sends it and then waits 1.0 s before sending the body anyway; a 10 KB body sends noExpect) cannot be told to go ahead or refused before it sends the body, so a refusal costs the whole upload and the proxy cannot say “no” to a 1 GiBPUTcheaply; - the only timeout is
idle, one number for a keep-alive connection waiting for its next request and a client taking a minute to send a head or a body (cancho-gatewaydocs/design.md§5 wants a header timeout of its own, a408; §11.8 item 6).
The per-byte cost of the machine this design needs. The chunked decoder below is a state machine run once over each byte, in place. Measured on this machine (Apple M4 Max, LLVM backend,
cancho build --std; a 64,000-byte buffer, 4,096 passes = 262 MB, a two-state machine that drops every CR and copies the rest, byte by byte): 134 ms, 1.96 GB/s, 0.51 ns a byte; the same
loop as a plain forward copy of the buffer within itself is turned into a memmove by LLVM (2 ms for the 262 MB), so a content-length body costs no copy at all and a chunked one costs about
half a nanosecond a byte. The TLS path measured 83 MB/s (§11.7), so the framing is not where an upload’s time will go, and this design does not trade clarity for speed in it.
12.2 The shape: a pull, and it is the request’s ticket that pulls
Three shapes were considered.
- A callback per piece (
serve(.., on_head, on_body_part, on_end)). Refused for the reason of §2, unchanged: a piece is a view of a buffer the loop borrowed itself, a function value’s type cannot name that region, and the application’s own state would have to be reachable from a closure the language does not have. nextanswering the same request again for every piece (the request in hand with abodythat grows). The request in hand is one request pernext, and a proxy has thousands of uploads in flight, each waiting on its own upstream socket; “in hand” has no place to remember which. It would also givebodytwo meanings.- The held request’s ticket (§10) pulls its own body. Chosen.
holdalready is “this request goes on while I do other things, name it with a ticket, and the server will not give out anything behind it”. An upload is exactly that: the application takes the head fromnext, decides (refuses it, orholds it), and then, whenever it can use bytes (its upstream socket is writable) it asks the ticket for the next piece. The views are borrows of theServer, as every view is.
So the API is five new calls on a ticket and one on the request in hand, and the existing calls do not change:
server.limits(srv, max_body, piece, head_ms, body_ms) -> int // opt in: 0, or -1 for a bound under 1; before this, nothing below happens and §12.4's first row holds
server.streaming(srv) -> bool // the request in hand has a body that is still arriving (or does not fit)
t = server.hold(srv) // unchanged. For a streaming request it also lets go of the head: copy what you need first
server.proceed(srv, t) -> int // "I will read the body": answers 100 Continue if the client asked for it and it was not sent. 1 sent, 0 nothing to send, -1 the request is over
p = server.body_part(srv, t) -> &[byte] // the next piece: at most `piece` bytes that have arrived and not been taken; empty when none now
n = server.body_take(srv, t, n) -> int // the application has used n bytes of the piece it saw: n, or -1 (n past the piece, or the request is over)
s = server.body_state(srv, t) -> int // 0 more is coming, 1 all of it has arrived (bytes may still wait in the buffer), -1 the request is over: stop
n = server.body_total(srv, t) -> int // bytes of the body received so far, decoded
The end of a body is body_state == 1 with body_part empty: no separate body_end call, because “all arrived” and “all taken” are two facts and a proxy that is slow to take needs both
(it has seen the end of the stream and still owes the upstream a piece). body_state == -1 is the one answer for every way a request ends under the application’s feet: the server refused it
(framing, size, a timeout), the connection went (a reset, the peer’s end in the middle of the body, detach), or the ticket was answered. The application finds out by asking, in the same loop that gives
streamed answers what room they have (feed_streams in examples/https_hello/app.cho), because there is no callback to be told by; §12.9 says what the server has done by then.
Why opt-in (limits). A server that never calls it is, in every byte it reads and writes, the server of §11: the 413, the ignored Expect, idle as the only timer. The api and http_server
tests and every consumer pinned to the store stay as they are. A server that calls it has told the loop what its bounds are, and the loop can then do what a bound makes possible
(hand over a head early, refuse by Expect, time a head). The four numbers are bounds, so they are parameters and not defaults the package chose: max_body the most body bytes a request may have
(the Content-Length, or the decoded total), piece the most body_part shows at once (clipped to size), head_ms and body_ms the two timers of §12.8.
12.3 When next hands over a head early
With limits, produce parses the head as before and then looks at the body:
| the request | what next does |
|---|---|
no body (Content-Length 0 or absent, not chunked) | as ever |
Content-Length above max_body | refuses 413 (body.too-large) now: before any 100, before the client sends a byte of it |
Content-Length and the whole body is in the buffer, and head + body fit size | as ever: head, parsed, body, respond. streaming is false |
Content-Length and any of it has not arrived (or head + body do not fit size) | hands over the head at once; streaming is true |
| chunked, and the framing in the buffer reaches the final CRLF (§12.4) | as ever: the body is decoded in place, body is the whole decoded body, streaming is false |
| chunked, and it does not | hands over the head at once; streaming is true |
an Expect other than 100-continue (on any request) | refuses 417 (expect.unsupported) |
What a request that fits does, exactly. Without limits: everything it did. With limits: a request whose head and entire body are in the buffer when its head is parsed is
handed over whole, with the same head, parsed, body and respond and the same bytes in the answer; what is different is a request that fits size and whose body arrives in a later
read, which was held back until whole and is now handed over as soon as its head is complete. That is deliberate and is the only change: it is what lets the application see Expect before the
client has sent anything, and an application that opted in already handles streaming. A fed-at-once request and the same bytes fed one at a time therefore reach the application in different forms; both
are tested to produce the same body bytes and the same answer.
An application that wants no streaming for small bodies reads the body from body_part anyway: body_part, body_take and body_state work for a request that streaming called false too,
once it is held (a whole body is “all arrived” at once), so one loop over pieces serves both forms. (A chunked body held whole is still in the buffer where it was decoded; the legacy chunk scratch
decoded is no longer used by a server with limits: the decoding is in place, §12.4.)
12.4 Framing
Content-Length. The head’s length, parsed and refused by std.http as today (400 for a malformed, repeated-and-different or with-Transfer-Encoding length). The server counts: remaining = length - bytes already in the buffer. Input is never taken past the end of the body (room is min(free space, remaining), and the socket path reads only that many), so what follows a body on the wire is never mixed
into it, and a request pipelined behind a streamed body is simply the next bytes the caller offers once remaining is 0.
Transfer-Encoding: chunked. std.http.parse accepts exactly chunked and refuses every other coding and a Content-Length beside it, as today. The decoder is a byte-at-a-time state machine
whose state lives in the connection’s words, so a body fed one byte at a time is decoded once, not again from the start at every byte as std.http.dechunk does. It decodes in place: decoded bytes are written
over the framing that carried them (the write position never passes the read position), so the buffer holds [decoded, not yet taken][nothing else] while a body is arriving, and a chunk of any size needs no
buffer of its own. States: size (1 to 8 hex digits, leading zeros count), extension, the LF of a size line, data, the CR and LF after data, then after the zero chunk a trailer section and the final CRLF.
- Chunk extensions are accepted and ignored (RFC 9112 §7.1.1: a recipient MUST ignore ones it does not recognise), within bounds: at most 256 bytes after the
;, each a tab or a visible ASCII or high byte, never a control character or a bare CR or LF (body.chunk-extension).std.http.dechunkrefuses them; the gateway’sframing.chunk-extensiondoes too. This server does not, because the framing is its own and an extension that cannot be told apart from data is not a smuggling vector when it is parsed by the same machine that finds the end of the chunk; an application that wants them refused can refuse them in its own code (it sees none: they are ignored here). A switch for refusing them was not built; nothing has asked for one. - Trailers are accepted, checked for bytes that could confuse a parser, and discarded. After the zero chunk: zero or more lines, each of bytes with no control character (a tab is allowed) up to a CRLF, then a blank line. Their
name: valuesyntax is not checked, because nothing reads them: what matters is that no bare LF, NUL or CR hides in one. At most 4,096 bytes of them, counted from the first byte of the first trailer line to the final CR (body.trailer-too-large,431); a malformed line isbody.trailer. The application never sees them: a proxy re-frames the body it forwards, so a trailer is dropped as RFC 9110 §6.5.2 allows.body_stateis 1 only after the blank line. - Bounds.
max_bodyon the decoded total, checked when a chunk’s size line is read (size + total > max_bodyrefuses at once, before the chunk’s bytes), so a hostilefffffff0is refused without waiting for it. Framing overhead per chunk is bounded by the 8 digits, 256 extension bytes and four CRLF bytes, each chunk carrying at least one body byte: at most about 270 wire bytes for each body byte, and time-bounded bybody_mslike any other byte.
Every refusal, with its rule tag and status. The tag is sent in an X-Rule header beside failure’s {"error": ...} (a server without limits sends what it sent before, with no X-Rule). All close the connection after the answer.
| rule | status | when |
|---|---|---|
body.too-large | 413 | Content-Length over max_body; decoded total over max_body |
body.chunk-size | 400 | a size line with no hex digit first, or more than 8 digits |
body.chunk-framing | 400 | anything but CRLF where CRLF belongs: a bare LF, a size line with something after its digits, data not followed by CRLF |
body.chunk-extension | 400 | an extension over 256 bytes, or with a control character |
body.trailer | 400 | a control character (a tab apart) in a trailer line, a trailer line or the final line not ended by CRLF |
body.trailer-too-large | 431 | more than 4,096 bytes of trailers |
timeout.head | 408 | §12.8 |
timeout.body | 408 | §12.8 |
expect.unsupported | 417 | an Expect header with any member other than 100-continue |
head.too-large, head.malformed | 431, 400 | what §11.4 already refuses (a head that fills size; std.http’s refusals): tagged only when limits is on |
(no answer) body.truncated | the peer ends or the connection resets mid-body: nothing to answer to; the request is over (-1) |
A refusal found while reading (framing, size) is recorded by input/step and answered by the next next: input has no heap to build an answer in and its effect row stays [] (a widening would break
every caller). The request is over from the instant it is recorded (body_state is -1, room is 0), the answer is queued by the next that follows, and the connection ends once it is sent.
12.5 Expect: 100-continue
- Only HTTP/1.1, only for a request with a body that is streaming. On a 1.0 request, on a request without a body, and on a request whose body had already arrived whole, it is ignored (RFC 9110 §10.1.1: the client has sent it, or has no body to wait to send).
- The server never sends
100 Continueby itself. The application sends it byproceed(t), after it has decided it wants the body: after its route and its limits, after its upstream accepted the connection, after a quota check. A gateway that refuses by route or size answers the head with a final status and sends no100, and the client does not send the body. proceedwritesHTTP/1.1 100 Continue\r\n\r\ninto the connection’s output (it fits:outis at least 512 and nothing of this request’s has been queued), once. It answers 0 afterwards, if the request had noExpect, and if the application has already started its answer (streamwas called): a100after the first byte of a final response is an error on the wire.417for anExpectheader any of whose values is not100-continue(case-insensitively;100-continue, xtoo), bynext, before the application sees the request.- A client that sends the body without waiting (curl waits one second, others do not wait) simply has its body buffered up to
size, taken bybody_partas usual, and is never sent a100.
12.6 Back-pressure in both directions
In. The rule of §11.4, unchanged and shared: a byte is read only when there is room for it. room(k) is the free space in the connection’s buffer plus the bytes the application has taken from
the front of it (they are moved out of the way, once, by the next input that needs the room), for a streaming body bounded by what the body still owes (Content-Length) — and
0 while an answer is waiting to go out, while the connection is ending, and while a refusal is recorded. A piece the application has not taken therefore stays in the buffer, the buffer fills, room is 0, the
caller stops calling tls.recv (or the socket path stops being watched), and the client’s TCP window closes. A proxy passes its upstream’s pressure to the client by simply not calling body_take.
The most a streaming connection holds is the same size + out of §11.4 whatever max_body is; a 1 GiB upload through a 16 KiB buffer is 16 KiB of the server’s memory, which the live test measures.
Out. Unchanged too, and it applies while a body is still arriving: an answer waiting to be sent stops room, so a client that sends a body and does not read the response it is already getting (an
early refusal, 100 Continue, a streamed echo) stops being read, and a stalled sender and a stalled reader are both timed (§12.8). body_take re-arms the socket server’s read interest (settle) when it makes room;
a byte-fed caller finds the room by asking room.
12.7 Pipelining after a streamed body
A request behind a streamed body is held back for two reasons that are already the rule: nothing is handed out for a connection while a request is held (§10), and room never takes bytes past the end of a
Content-Length body. For a chunked body, the machine stops at the final CRLF and the bytes after it are the next request’s, moved down to follow the body in the buffer. When the streamed request is answered, whatever of its body
the application did not take is dropped (the body has fully arrived, or the connection is closing, §12.9) and the buffered next request is given out by the next next, without waiting for new input.
12.8 The timeouts
Both are in milliseconds, on the caller’s clock (the one given to attach, input, output, ready; wait reads its own). Both answer 408 (timeout.head, timeout.body) with the connection closing after,
unless the application has already begun its answer, in which case the connection is closed with nothing more sent. They are in addition to idle, which is unchanged: a keep-alive connection with no request in progress ends by it.
head_ms: a deadline for the head, not an idle limit. It starts whennextfirst looks at a request whose head is incomplete, and ends when the head is complete; a client that sends a byte everyhead_ms - 1milliseconds does not extend it (that is the slow-loris shape: many connections, each held open by a trickle). It does not run while the connection has an answer waiting (the server is not reading then).body_ms: the longest the server waits for the client to send more of a body. It starts when the head is handed over, restarts when a body byte arrives, when the application takes a piece, and when it saysproceed; it does not run while the buffer is full and the application has not taken what is in it, nor while a client that sentExpect: 100-continuehas not been told to go ahead, nor while an answer waits to be sent (the client is not reading): waiting for the application is the application’s to time (a gateway has its upstream timeouts), not the client’s fault. A client that sends one byte everybody_ms - 1milliseconds is not stopped by this alone; each such byte is progress by definition. The bound on that case is the application’s total deadline for the request, whichholdalways allowed (§10: “an application that wants a deadline answers with 504”), andmax_bodybounds the bytes.- The scan for expired timers runs at most every 50 ms, over the connection table.
ready/waitdo it; expiry only records the refusal (as in §12.4);nextsends it.
12.9 Answering before the body has been read
The application may respond/answer a streaming request at any time: a refusal to the head, an 413 after counting bytes, an upstream that answered early. What the server does with the connection afterwards:
- The body had all arrived (
body_state1): the untaken bytes are dropped and the connection carries on, keep-alive as the request asked. - The body had not started (an
Expectrequest, no100sent, no body byte received or taken): the client was never told to send it, so the connection carries on keep-alive. If the client sends the body anyway its bytes are the next “request” and are refused as one (400), which closes; no worse than any client that pipelines garbage. - Otherwise the connection closes once the answer has gone (as if it had said
Connection: close, whatever the application’s head said, because the server cannot rewrite an answer it was handed). The alternative, reading and discarding the rest, is a decision about someone else’s bandwidth that the application can take by taking the pieces and dropping them before it answers; the server does not drain by itself because a 1 GiBPUTrefused at 100 MB would otherwise cost the server 900 MB of reading to keep one connection. The client may see a reset before it has read the answer if it is still sending when the connection closes: that is a property of closing a TCP connection with unread data, and the lingering close that avoids it belongs to the program that owns the socket. - While the answer is streamed (
hold+stream) and the body is still coming, both flow (§12.6, “Out”); if the body then fails, the answer cannot be replaced by a refusal, so the connection is closed.
A refusal the server makes mid-body (§12.4, §12.8) while the application holds the request: the request is over (body_state -1, answer/stream -1), the refusal is queued unless the application had started
its answer, and the connection ends. The application’s ticket is not reusable (§10’s generation).
12.10 The memory bound per connection
size bytes of input buffer, out bytes of answer, and 32 words of state (stride goes from 16 to 32): max * (size + out) and 256 bytes a connection, fixed at open/open_bytes, as §11.4. A streaming body adds nothing: it is
the input buffer. The decoded scratch of the legacy chunk path is not used by a server with limits and is allocated as before (a socket server or one without limits still needs it).
12.11 One implementation for both paths
The socket path and the byte-fed path differ in where bytes cross a boundary (§11.3) and that is all they will differ in:
classify a head, Expect, refuse | produce, one function |
| count a body, run the chunk machine | ingest, called by step (socket) and input (fed) after they have put bytes in the buffer; one function |
| how much may be read | readable, called by step, settle and room |
| the timers | sweep_timers, called by sweep_idle’s two callers (wait’s serve_events, ready) |
hold, answer, deliver_to, end of request, body_*, proceed | one function each, on the connection’s state |
step (socket) reads at most readable bytes and calls ingest on what it got; input (fed) takes at most readable and calls ingest. The duplication test would refuse a copy of either.
12.12 What building found, and what it corrected
Corrected marks a sentence above that was changed in place. Each of these was found by a test, and the test is named.
enqueuecould write past the ready queue (a bug in §11’s shared code, not in the new).answerended a held connection’s turn withfinish, which cleared its “queued” flag even wheninputhad added an entry for it while it was held, so the nextinputqueued it a second time; a caller that feeds and answers without callingnextbetween (the new tests do; a socket server’s loop never) filled a queue ofmaxentries with copies of one connection and the next store trapped.answernow keeps the flag. (test_a_length_body_larger_than_the_buffer_arrives_byte_for_byte, 3 uploads on a server withmax2.)- A request with no body, with
limits, kept the previous request’s head length (classifyreturned before settingstate[7..10]), so the connection’s next request began at the wrong byte. (test_requests_pipelined_behind_a_streamed_body_are_served_in_order.) - A socket server closed a connection whose buffer was “full” of bytes the application had already taken (
step’sst[p] >= sizecheck ran before the roombody_takegave back counted). It asksreadablenow, andreclaimmoves what is left to the front only when a read needs the room. Corrected: §12.6 saidbody_takefrees the room; it makes it available, and the move happens at the next read. (uploads_arrive_byte_for_byte_over_sockets..., the first run.) finishmoved a connection’s buffer to the front without moving a streaming request’s body start with it, when the application left a streaming request in hand at the end of a round. (test_a_streaming_request_left_in_hand_when_the_round_ends_keeps_its_body.)- The body timer ran while a client that had sent
Expect: 100-continuewas waiting for the application’s decision, and did not restart when the application saidproceed. It does not run then, and restarts. Corrected: §12.8 now says so. (test_a_stalled_body_times_out_but_a_slow_application_does_not.) - A refusal recorded by a timer or by
end_inputwas sent bynext, butexamples/https_hello’s loop only moved a connection’s answer to its TLS slot when input had arrived for it, so a408sat in the server’s output for ever. The loop’s sweep pumps a connection that has something waiting (server.pending). (scripts/https_hello_test.py halfbody.) examples/https_hello’s/echoneeded its body whole, and a body that arrives in two reads is now handed over streaming (§12.3 says so; reading the existinghttpandpipelinedcases, which send chunked bodies to/echoin several writes, showed the example would have refused them). The example holds such a request, leaves its body in the server’s buffer and answers when the body has all come, or refuses it with a 413 if the buffer fills first (answer_wholeinapp.cho): the recipe for an application that wants a bounded body whole. The other way, a server that waits for a body that fits, was considered and not built: it needs a “handed over late” state (the head stays valid while the body arrives) for little gain, sincesizeis already the threshold between the two.pieceis not clipped tosize: a larger one is the same, because the buffer holds no more. Corrected in §12.2. The clipping line was equivalent under every test.- The timers’ scan is at most every 50 ms, so a timer fires up to 50 ms after it is due (
test_a_head_has_a_deadline_that_trickling_does_not_extend...pins it). Corrected: §12.8 said “at most every 50 ms” already; the tests are what made the consequence a sentence. - Not built: a switch that refuses chunk extensions; draining or lingering on an early answer; a per-route
max_body(the application answers413itself); a counter of discarded trailer lines (word 28 is spare). packages/http-server/server.chowas 1,990 lines of the 2,000 allowed when this was written (1,999 after §13: corrected), incancho fmt’s form (formatting::the_repository_is_formattedholds it to that). The streaming code is about 640 of them. To fit, the refusal table is one string (rule), trailer lines are checked for control bytes but not forname: valuesyntax, and the chunk machine’s fixed-byte states share one function (want);std.http.hex_valueis nowpubso the server does not carry a copy (the duplication check found it). The next addition to this file needs the package split into two modules (Coreand the loop in one, the streaming in another importing it): a change to how the package is published (its store is flat, and the locks ofexamples/apiandtests/programspin it), not to this one.- Found in
examples/https_hello, not fixed here: a client that keeps sending bytes after the server has ended its connection makes the server reset a different, healthy connection (reproduced onmainat #360: aConnection: closerequest, then bytes on the same socket, while another client is served). Its cause is not known; a task to find and fix it was filed. The new cases avoid triggering it (a stalled client stops sending when it is answered408).
12.13 Tests, mutants and measurements
Without a socket (tests/packages/http_server_body_test.cho and http_server_body_flow_test.cho, with their helpers in body_support.cho; cancho test on both backends through conformance/http_server_body.rs): 22 tests.
| test | what it fixes |
|---|---|
| a length body larger than the buffer | sizes around the buffer (256) and the piece (64) up to 5,000 bytes, fed 1, 7, 64, 100 and 4,096 bytes at a time and pulled 1, 13 and 64 at a time: every byte, body_total, the answer, the connection serves after |
| a chunked body with extensions and trailers | 4 body sizes x 4 chunk sizes x extension/trailer combinations x 3 feed steps x 2 pull sizes (384 uploads), byte for byte |
| a request cut at every byte | a length request, a chunked one with an extension and a trailer and a chunked one in one chunk, cut at every point of the request: the same body and one answer, whole or streamed |
| requests pipelined behind a streamed body | a 700-byte body (length and chunked) then GET and POST, fed 1 to 4,096 bytes at a time: bodies and answers in order |
| a request that fits | answered with the same bytes with or without limits (five requests, HTTP/1.0 and Connection: close among them); a whole chunked body with an extension and a trailer is decoded (a server without limits refuses it) with the next request moved down behind it; a whole request held is read from its ticket |
Expect | 100 sent once and only when the application proceeds; a refusal before the body sends no 100 and keeps the connection (and closes it once the client has started sending, or sent some of the body with the head); a refusal in hand without hold; 417 for four kinds of unsupported expectation; the token is case-insensitive |
| every fault | 18 chunked wires, a length over the maximum, a whole body over it, a head too large and a malformed one, each fed one byte at a time and at once, refused with its status, message and rule, the request over from the instant it is read, the ticket refused before and after the refusal is sent |
| the chunk machine against a corpus | 50 wires, accepted (with the body they decode to) or refused (with the rule), at two feed steps; the bounds on both sides of the line (extension 256 and 257 bytes, trailers 4,096 and 4,097, a body of exactly 100) |
| the timers | the head deadline (the boundary millisecond, a trickle, a scan within 50 ms of the last, a keep-alive connection that is not timed, the clock of the answer being taken); the body stall (the boundary, a body that has all arrived, a client told to wait, a full buffer nobody takes, an answer waiting, an answer begun) |
| back-pressure | a piece not taken stops the reading; the room returns exactly as bytes are taken; an answer waiting stops it however much room there is |
| answering early | all arrived (kept, the rest dropped, what follows served), part of a length or chunked body (closes), in hand, streamed both ways |
| the peer’s end | mid-body (the request is over, nothing answered, what waits is dropped), an answer on its way is not, in hand |
| tickets | two uploads at once, stale generations, bad tickets, detach |
| a streaming request left in hand | the round ends with it unanswered and unheld: its body is where it was |
no limits | nothing changes: the 413, Expect ignored, no X-Rule; the new calls refuse |
| random | 8 seeds: 40 requests (none, length, chunked) split and consumed at random, byte for byte; 8 seeds x 3,000 random operations with hostile fragments and bad tickets must not trap or break a bound |
With sockets (tests/programs/server_upload.cho and conformance/http_server_upload.rs, in cargo test): 6 tests that build a server program from the package’s source and talk to it over loopback: uploads of a length and chunked body with 100 Continue, pipelining, keep-alive and four uploads at once; a refusal before the body, every fault by its rule,
417, an early answer; a client that stalls in a head or a body, and the others served; back-pressure (a slow application: the client’s writes block, the test asserts under 64 MiB of 256 were taken) and clients that leave; a server that visits connections late, so a fault wait found waits through turns in which the client sends more; and a connection that reads while its neighbour’s buffer holds untaken bytes.
Against real clients (scripts/https_hello_test.py, CI’s tls-assurance job; 18 of 18 on linux-aarch64 in Docker, curl 8.5 on OpenSSL 3.0.13), four new cases over TLS 1.3:
| case | result |
|---|---|
upload | POST /upload of 0, 1, 2, 16,383 to 65,537 bytes, 1, 16, 128 MiB and 1 GiB, each with a length and chunked: byte count and SHA-256 equal to what was sent, 2,338 MiB hashed at 142 MiB/s with both ends hashing; the server’s RSS 1,944 -> 2,060 KB over all of it; chunks of 1 to 1,000,003 bytes with extensions and trailers; an upload, a request and an upload pipelined in one write |
expect | the 100 came 44 ms into an upload that was waiting for it; /upload/refuse answered 413 with no 100 and the connection served after; 417; --max-body refusing a length by its head and a chunked body while it was sent; curl -X POST -T file -H 'Expect: 100-continue' --expect100-timeout 10 uploaded 900,000 bytes in 0.06 s, and the same to /upload/refuse uploaded 0 bytes (%{size_upload}); openssl s_client uploaded 300,000 |
halfbody | half a head, half a length body, half a chunked body and a head a byte at a time: 408 with the rule at 1.05 to 1.11 s for --read-timeout 1000; 27 requests beside them, the slowest 41 ms; a steady upload that took 3 s was not timed out |
mangled | 1,500 connections each with a mangled upload (flipped bytes, cuts, bad chunk sizes, long extensions and trailers, lengths that lie, Expect heads): the server alive after every one, RSS 1,792 -> 2,932 KB, and a good upload still hashes |
macOS (aarch64, curl on SecureTransport so the curl part is skipped, and says so): the same cases, uploads to 128 MiB at 98 MiB/s. CI’s x86-64 run (the tls-assurance job on #369, GitHub’s ubuntu-latest, curl on OpenSSL 3.0.13): 18 of 18 cases; upload 2,338 MiB hashed correctly at 124 MiB/s with the server’s RSS 4,292 -> 4,420 KB; expect waited 43 ms for the upload with its 100, curl -T uploaded 900,000 bytes in 0.02 s and a refused one 0 bytes;
halfbody answered 408 at 1.05 to 1.10 s with 27 requests served beside, the slowest 41 ms; mangled RSS 2,140 -> 5,244 KB over 1,500; both mutant runs (232 and the byte-fed 90) as stated; its cost step gave 4.42 microseconds of server CPU a request plain and 18.49 over TLS (a different runner from §11.7’s 5.59 and 23.40: the comparison that means something is the alternated one below).
Mutants. scripts/http_server_body_mutants.py (CI runs it): 232 single-edit mutations of the new code and of the shared lines that now call it, 215 killed and 17 argued equivalent in the script, none surviving. The first run killed 155 of 230 (12 old texts did not match, the rest survived); the third left 22 survivors, 19 of which are argued equivalent in the script and 3 needed tests (the clock that output keeps, a ticket asked after the refusal is queued
and before it is sent, the message of every refusal); between them the tests added for survivors were the timer boundaries, a body that has all arrived or a client told to wait not being timed, Expect untouched or touched by bytes that came with the head, a request in hand looked at twice, and the corpus’s bare LF where a CR belongs. The mutants are compared as tokens, so cancho fmt’s layout does not matter. 9 more are of the lines only the socket path runs (python3 scripts/http_server_body_mutants.py ... --socket, by hand: each builds the server program): the first run killed 7 of 9, the two survivors needed a server that visits connections late and a slow
neighbour, and 9 of 9 are killed. The byte-fed mode’s own 90 (http_server_bytes_mutants.py) still give 79 killed and 11 argued equivalent, as §11.5 says.
Cost. The socket path with no limits is the same: examples/api, kload, one core, three 5 s rounds, the CPU a request read from /proc (Docker Desktop’s Linux VM on an Apple M4 Max with other containers running, so the rates moved by 1.5x between rounds and the CPU is the figure to read): 3.01 and 3.24 microseconds before, 3.10 and 3.17 after. https_hello over TLS (tload), which now calls
limits and so classifies every request: before 11.17, 9.16, 11.76 and 9.02 microseconds in four alternated runs (mean 10.3), the same example with the limits call taken out 10.52, 10.72, 10.07 and 10.32 (10.4), after 10.17, 9.85, 9.43 and 7.87 (9.3): no difference this machine can see. The memory a connection can make the server hold is as §11.4: size + out and 32 words (it was 16), whatever the body.
Upload throughput is above (142 MiB/s on that VM with SHA-256 at both ends; 98 MiB/s on the Mac).
12.14 What cancho-gateway still needs
What the gateway can build on now, and what it still lacks, in the order it will hit them (§11.8 has the rest, unchanged except where marked):
- The proxy loop over a ticket.
limits, then for a requeststreamingreports:hold, read the head and copy what the upstream needs beforehold(it lets go of the head),proceedwhen the upstream has accepted (or the route’s policy has), and each turnbody_part-> write to the upstream as far as it takes ->body_takewhat it took. An upstream that takes nothing is a client that is not read: it needs no code.body_state1 and an emptybody_partis the end; -1 means stop and drop the upstream. A chunked upstream request is framed by the gateway from the pieces (the server discards extensions and trailers).examples/https_hello/app.cho’sfeed_uploadsis the loop with a hash where the upstream write goes. - Per-route bounds.
max_bodyis the server’s one number. A route’s own is the application’s: answer413from the head (before any100) or after counting. There is no per-routehead_msorbody_ms. - A request’s total deadline is still the application’s (§12.8):
body_msstops a client that goes quiet, not one that sends a byte everybody_ms - 1ms. - No lingering close, no drain (§12.9): a request answered while its body is in flight closes the connection once the answer is sent; the client may see a reset before it has read it. A gateway that wants to keep the connection after refusing a body in flight takes the pieces and drops them before answering, up to its own bound.
- No readiness list. The application asks each ticket it holds (
body_state/body_part) in the loop that already gives streams their room; with thousands of idle uploads that is thousands of calls a turn, each a few loads. A “tickets with news” list likeforeignwould be the change; nothing has asked. - Chunked responses, the peer’s address, upstream connections in the same loop,
Upgrade, ALPN, more than 1,024 connections, the second set of effect names: §11.8’s items 2, 3, 4, 5, 7, 8 and 9, unchanged.packages/http-requestis being made non-blocking by another change. packages/http-server/server.chohas 1 line left (was 10 in §12.12; §13 took nine). The next thing added to it splits the package.examples/https_hellohas a bug that predates this change (a client that keeps sending after its connection was ended resets another connection, §12.12) which a gateway on the same loop would inherit: filed.
13. An extra header without its CRLF, and what a refused one answers
The bug. failure_with(.., 405, .., "Allow: GET") – the header written as one writes a
header, with no CRLF – died with SIGILL, and with it every connection (found building services with
a model, cancho-code#58; two models wrote it that way, cancho#515). reply_with, reply_as,
failure_with and reply_empty hand extra to std.http.respond_head_with /
respond_no_content, which trap unless valid_extra(extra); the doc comments said the lines look
like Allow: GET\r\n, and a missing terminator was a trap, not an error.
What valid_extra accepted (read before changing it): zero or more name: value lines, a name of
token characters, a value of the characters a value may hold or spaces and tabs, each line ended by
CRLF, and nothing else. conformance/http.rs::a_misused_router_or_response_traps_instead_of_going_on
pinned five refusals: a bare LF in a line, a bare CR, a line with no colon, a blank line, and
(extra-without-its-line-ending) a missing final CRLF.
The decision, two rules.
- The final CRLF is optional, in
std.httpitself:"Allow: GET"and"Allow: GET\r\n"give the same bytes, as do"A: 1\r\nB: 2"and"A: 1\r\nB: 2\r\n". A missing terminator is not a safety question – the writer supplies the one it needs – so it is the one case that moved from refused to accepted, and it moved for every caller, not only the server’s: one rule is easier to hold than “complete in the package, trap in the library”. A lone CR or LF at the end ("X: a\n","X: a\r") is still a bare one and still refused, as are a NUL, a line withoutname: valueand a blank line.extra-without-its-line-endingwas removed from the trap list because its claim was what changed; the guard tests now include the trailing-bare-LF and trailing-bare-CR forms. - What is still refused answers 500 in the server’s
reply*, and the server keeps serving.reply_as(soreply_with,failure_with) andreply_emptyteststd.http.valid_extra(nowpub) first and, on a refusal, answer500 Internal Server Error,{"error":"internal error"}(std.http.respond_internal_error), no extra headers, on the keep-alive setting the caller gave. A header string is an application bug; it must not kill the process.std.http.respond_head_withandrespond_no_contentthemselves keep trapping on a refusedextra: they are the lower layer with a documented precondition, a direct caller who has not checked has the bug the trap is there to find, and the one caller that must not die (this package) checks first. A trap in the library and a 500 in the package are the same rule at two altitudes.
reply_empty with a status other than 204 or 304 and reply_as with a content type holding a CR, LF
or NUL or a status outside 100-999 still trap: they are not header strings an application builds from a
route, and this change did not widen to them (nothing has asked).
Tests. tests/lex/http_test.cho (both backends, through json.rs): the same bytes with and without the
CRLF, on respond_head_with and respond_no_content; valid_extra against 16 inputs including a NUL;
the fixed 500. conformance/http.rs: the trap list above, now with the trailing bare LF/CR.
conformance/http_server.rs::an_extra_header_without_its_crlf_is_sent_and_a_refused_one_answers_500 runs a
server with /bare (failure_with 405 Allow: GET), and a refused extra to reply_with, failure_with
and reply_empty, pipelined on one connection followed by an ordinary request: exact bytes, and the
process alive. Against origin/main it failed (empty read: the process died on the first request); the
three mutations below each turned a gate red. The store was republished (scripts/publish_packages.py) and the locks of
examples/api and tests/programs/server_hold.lock re-pinned.
Mutations (each applied alone, the gate run, then undone):
| mutation | red |
|---|---|
the CRLF completion removed (extra_open always false) | test_a_missing_final_crlf_on_extra_is_supplied; the server test (the 405 head runs into the body) |
the 500 fallback removed from reply_as and reply_empty (store republished, locks re-pinned) | the server test: the /bare answer arrives, then the process traps on /bad and the connection closes |
| the guard loosened to take a bare LF as a line end | test_valid_extra_refuses_everything_unsafe, a_misused_router_or_response_traps_instead_of_going_on (extra-bare-line-feed), the server test |