Skip to content

Streams - #2

Open
MDA2AV wants to merge 43 commits into
mainfrom
streams
Open

Streams#2
MDA2AV wants to merge 43 commits into
mainfrom
streams

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Sep 8, 2026

Copy link
Copy Markdown
Owner

No description provided.

…ds through the reader

io/pipe.h: the reader hands out received bytes as one contiguous span at a time - in
place in the kernel's buffer when they lie within one, gathered into the consumer's
buffer when they span or when kept bytes must stay contiguous - with read/examine/
drop/keep/run_begin/release/copy; it holds at most two kernel buffers, one with kept
bytes of earlier runs, one with the live bytes. The writer is a slab with reserve/
advance/write/flush/send. Both suspend the coroutine when they must wait.

The engine now reads through the reader: read_head parses the live span in place and
keeps the head (zero copy when it arrived in one receive), the chunk parser walks
spans, ioma_body_all keeps its bytes, streamed reads copy straight from the kernel's
buffer, and release at the end of a request replaces the pipelining carry-over. The
16 KB request buffer is the reader's gathering buffer.

Public: ioma_pipe with ioma_run_pipes for raw TCP handlers, on the same runner as
ioma_run. tests/pipe-server.c is a line echo on it, tests/pipes.py drives it (split
lines, two lines in a packet, a line spanning kernel buffers, one too long); smoke
gains a head split across three sends and a body arriving after its head.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
conn.h is what a user of a connection needs: RX_QUEUE, rx_item, the recv state, struct
conn, and the awaits (await_recv, await_send, ioma__await_item). proactor.h keeps the
worker: the ring tunables, struct proactor, run and spawn. The pipe and the HTTP plane
include conn.h; run.c takes proactor.h as well. The loop-side plumbing conn.c and
proactor.c share stays in io/internal.h.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
Its tunables, BUF_COUNT and BUF_SIZE with the power-of-two assert, its struct - ring,
slab, local tail, the dirty and returned flags that used to be loose fields of the
proactor - and its operations with the module prefix: init, return, publish,
unregister, unmap, and ioma__bufring_at for a buffer's bytes. The proactor embeds one;
conn.c, pipe.c and proactor.c address it through it. internal.h keeps only what the
plane's files share.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…'s header

RX_MASK and CONN_POOL_MAX join RX_QUEUE in conn.h, with conn.c's loop-facing exports
(conn_new, conn_main, arm_recv, on_recv, pool_drain) in a section of their own, the way
bufring.h declares its loop-facing init and publish. ioma__sqe, the worker's SQE claim,
is in proactor.h. What is left in internal.h is what the loop and the operations share
and neither owns: the user_data tags, UD, op_t, and the trace switch.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
A header per module that exports something, as in the I/O plane: engine.h declares
ioma__serve for the runner, router.h declares ioma__router_build and ioma__dispatch for
the runner and the engine. internal.h is down to ioma__hexval, shared by the percent
decoder and the chunk-size parser. api.c and run.c export nothing private and get no
header. Each file includes the standard headers it uses.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…tion, await_recv gone

The pipewriter has a head and a tail now: front(n) reserves bytes just before the
pending data, back(n) just after it, and one flush sends the span. The HTTP reply's
lead and slack are the writer's - the head and a chunk's size line go in front, a
chunk's CRLF behind - and the chunked terminator rides the final send instead of a
send of its own. The engine no longer owns a slab or calls await_send; ioma_response
loses buf/cap/len, and ioma_reserve / ioma_advance on the context are the way to
format straight into the reply.

The connection's coroutine builds the pipe, so every handler receives one: ioma__serve
takes a pipe, ioma_run_pipes passes the user's function through, and run.c's wrapper
is gone. await_recv had no callers left and is deleted; the reader's primitive,
ioma__await_item, is the one way bytes leave the queue.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
include/ for the public header, lib/ for the library, tests/ and playground/ for what
links it. Build files, the tidy config and the docs follow; the sources' includes are
relative to the include path and did not change.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
A forward-only writer, the shape of .NET's Utf8JsonWriter: object, array, end, key,
string, cstr, int, uint, double, bool, null and raw, each putting its bytes straight
into a sink - the reply (content-type set for you), a raw pipe, or a memory buffer -
with no tree and no allocation. Strings are escaped as they are copied, a safe run at
a time; doubles take the shortest of 15, 16 or 17 significant digits that reads back
the same, decimal point forced to '.'; nan and inf become null. Nesting and the commas
each level owes are two bits per level; too deep, a full buffer or a gone peer fail
the writer and every later call.

Unit tests cover escaping, every number edge, empty containers, a top-level scalar,
the depth limit and a full buffer. The fixture serves GET /json/:id and GET /json/big,
whose 3000 objects stream out chunked while the writer keeps going.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
The C sibling of ioxide, the .NET library this design started from, gets a name that
says so and stays four letters: ioxd_ and IOXD_ prefixes, <ioxd.h>, libioxd, ioxd.pc,
the ioxd CMake package, IOXD_WORKERS and IOXD_PORT, the ioxd: log prefix, the server
header the fixture sends. Mechanical throughout; nothing else changed.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
IOXD_JSON_STRUCT(name, FIELDS) expands a field list twice - into the struct's members
and into a name_to_json function - so the two cannot drift. A line is the field's
kind, its type (or the nested struct's name) and its name: VALUE picks the writer by C
type through _Generic; OBJECT calls the nested struct's function; OPTIONAL is a pointer
that comes out as null when NULL; ARRAY and OBJECTS loop over a count field. Plain
text substitution: the generated function is the code one would write by hand, with
no table and nothing at runtime. IOXD_JSON_WRITER makes only the function, for a
struct declared elsewhere; IOXD_JSON_FIELD and IOXD_JSON_VALUE serve hand-written
documents. A null char pointer serializes as null.

The hello example's GET /users/:id fills a struct - nested address, optional billing,
an array of tags, an array of order objects - and serializes it with one call; unit
tests cover every kind, empty and null cases, and the field macro; a strict-C11
consumer compiles the macros without a warning.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
slice.h (slices, conversions, key/value parsing), http.h (request, response, context,
body, reply, ioxd_run), router.h (groups, endpoints, middleware, the script macros),
json.h (the writer, structs described once) and pipe.h (a connection as a pipe). Each
stands on its own under strict C11 and includes what it needs; ioxd.h includes them
all. Installed as <prefix>/include/ioxd.h plus <prefix>/include/ioxd/*.h, so a
consumer still writes #include <ioxd.h> with -I<prefix>/include; pkg-config, the
Makefile and CMake follow.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
Every worker opens its own SO_REUSEPORT socket on each port; an accept CQE carries
its listener, and a connection remembers the port it came in on and the port's TLS
store - the hook the TLS prologue will use. ioxd_listen(port, tls) before the run adds
a listener, at most eight; ioxd_run's own port joins as a plain one, and 0 there means
only the added ones. The fixture serves a second plain port and the smoke suite hits
it.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…ificate store

A TLS listener's connection runs a prologue before its handler: OpenSSL's TLS 1.3
handshake through memory BIOs, reading ciphertext from the pipe and flushing its
flights through it; the keylog callback catches the traffic secrets; HKDF-Expand-Label
makes key and IV; TCP_ULP "tls" and SOL_TLS TLS_TX / TLS_RX go in through the ring
(SOCKET_URING_OP_SETSOCKOPT, so the registered file table needs no fd). From then on
the kernel does every record and nothing above the pipe knows. The receive handoff is
exact: the multishot recv is paused, whatever it already delivered is decrypted in
userspace record by record - the tail of a split record fetched straight from the
socket - the plaintext injected into the reader, TLS_RX installed at the sequence
consumed, the recv resumed. One suite (TLS_AES_128_GCM_SHA256), no tickets, no 0-RTT;
a client that cannot do that fails the handshake.

The store loads <dir>/<host>/{cert,key}.pem per hostname, `default` for no SNI or no
match, `_.example.com` for a wildcard; the ClientHello callback picks the context;
ioxd_tls_reload swaps a reference-counted table under handshakes in flight. make TLS=0
builds without OpenSSL.

Also: await_send no longer sets MSG_WAITALL - kernel TLS refuses it and the loop
finishes short sends anyway. Tests: self-signed certificates from tests/mkcerts.sh,
the fixture listening on a third port, and smoke checks through Python's ssl for the
default certificate, SNI, an unknown name, a POST body over kernel RX, keep-alive, a
3000-object streamed reply over kernel TX, and a refused TLS 1.2 client.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…, a 1 MB upload over TLS

tests/tls_early.py (tlslite-ng, skipped when it is not installed) holds the client's
writes back to send the request in the same TCP write as its Finished - whole, and with
the record cut in two with a pause, so the server must decrypt what the kernel already
delivered and fetch the tail of a split record before kernel RX takes over - then two
early requests with the second split, a close_notify, and a corrupted record. make
check runs it with TLS_PYTHON.

The limits, at their boundaries, as known and to be grown later: a 15 KB request head
served and one over 16 KB a 431; a 16000-byte body read whole and 16400 a 413; the 16
reply headers (15 beside the middleware's) with the next refused and the reply still
served; a 3 KB reply header served and a 5 KB one closing the connection; 32 of 40
query parameters kept; 70 request headers a 400. Over TLS: a 1 MB upload streamed
through kernel RX in 256 reads. The raw exchange helper decodes chunked replies.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…uzzer as a make target

A TLS connection now ends with a close_notify alert sent as a control record through
the kernel (sendmsg with TLS_SET_RECORD_TYPE, over the ring), so clients see a clean
close; the wire test checks it. The fixture's default certificate is RSA - what most
clients and test suites assume - with sni.test on ECDSA so both key types serve. make
check-tlsfuzzer runs the six tlsfuzzer TLS 1.3 scripts that apply to a one-suite,
TLS 1.3-only server; TLS.md records what the rest probe and why they fail by design,
and the one real limit: a control record from the peer ends the connection without an
alert of ours. The prologue's split-record scratch moved off the coroutine stack.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…client's Finished is not lost

The handshake loop wrote every delivered byte into the read BIO. When the
client's Finished and its first application record arrived in one segment,
the record vanished into OpenSSL: the drain found nothing, kernel RX was
installed one sequence number behind, and the connection hung. Records are
now assembled one at a time in the prologue's own scratch, during the
handshake and during the drain alike, so what follows the Finished stays in
the reader; a record cut by the pause is completed from the socket. The
drain stops at a close_notify and refuses a post-handshake message it cannot
follow; the early plaintext is bounded by what the reader can take.

The early-data test held the client's Finished through send() while tlslite
sends it through sendall(), so its three coalescing cases never coalesced;
the wrapper holds both now and the cases fail on the old code.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
… cannot serve is refused

A reload carries a host that failed to load forward by sharing its old SSL_CTX, and the
loop after it then called SSL_CTX_set_client_hello_cb(ctx, on_client_hello, new_table) on
every context of the new table - re-pointing, with no lock, a context worker threads are
reading and that a handshake already in flight was created from. A client sitting on its
ClientHello was answered out of a table it never started on, and out of freed memory once
that table was dropped. The callback goes on in context_for now, once, with a NULL
argument, and finds the table through the SSL's ex_data instead: the prologue calls
ioxd__tls_bind right after SSL_new and holds a reference to that table for as long as the
SSL lives. Nothing writes to a context after it is published.

A loaded certificate is checked against the clock - X509_cmp_time on notBefore and
notAfter - and one that is not valid yet or has run out is a load failure like any other,
so the host keeps what was serving. `default` is required: without it ioxd_tls_new fails
instead of promoting whatever readdir returned first, and a reload that loses it keeps the
previous fallback by host name or is refused whole. ioxd_tls_reload holds a reload mutex
over all of it and a reference to the table it reads, so it no longer walks a table it
does not own and no longer races another reload.

Smaller things: host_eq folds only A-Z, so CR no longer matches `-` and DEL `_`; one
trailing root dot is dropped, so `sni.test.` finds sni.test; the SNI extension must be
exactly the one host_name entry we read (name_len + 3 == list_len, list_len + 2 ==
ext_len); ssl_error reports ERR_peek_last_error, the outermost reason, and clears the
queue behind it; calloc and strdup are checked; snprintf truncation, S_ISREG on both
files, and a warning for a key.pem readable past its owner; errno.h and limits.h come in
directly. ioxd_tls_free joins the public header, with the note that it is not for a store
a listener still uses; the TLS=0 stubs keep up.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
… it; a reply's head follows the request and the status; copied headers

Request side (RFC 9112): a Content-Length is digits and nothing else, two
of them must agree, Transfer-Encoding must end in chunked and name no other
coding (501 otherwise), the two framings never come together, a folded
header line is refused, an HTTP/1.1 request carries exactly one Host, every
Connection line counts with close winning, an absolute-form target is
routed by its path, a chunk size line keeps to the grammar, trailers are
bounded, and a query that does not fit its arrays is a 400 or 414 rather
than a partial view. Each was a request-smuggling vector behind a proxy.

Reply side: no body after HEAD or on a 1xx, 204 or 304, no framing header
on a 1xx or 204, a status outside three digits goes out as 500, a declared
Content-Length is held to (corrected when buffered whole, cut and closed
when streamed), a body left unread past the drain limit closes, and an
Expect: 100-continue gets its interim reply on the first body read - or a
close when the handler never reads.

ioxd_header and ioxd_content_type copy into a per-reply arena (the head was
serialized after the handler's frame was gone) and refuse control bytes,
non-token names and the engine's own headers. ioxd_content_type and
ioxd_content_length return whether they applied. ioxd_run checks that the
application's ioxd_ctx is the library's, since the limits that size it are
compile-time constants.

The pipe reader pins a kernel buffer whose kept run it moves, so a kept
pointer stays valid until release; drop and keep are clamped to the live
bytes, the writer's advance to the slab; a raw handler's unsent slab is
flushed at close. ioxd_cstr refuses an embedded NUL, %00 stays literal.

The public headers under include/ioxd/ were never committed: a bare
"ioxd" in .gitignore matched the directory. tests/conformance.py drives all
of it at the wire level; the fixture routes it needs follow with the
test-suite merge.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…inst its level

A double was formatted with whatever decimal point LC_NUMERIC named and mended one
byte afterwards, so a locale whose point is several bytes - fa_IR, ps_AF - turned
every double into invalid JSON, quietly: "0.1" came out as 0x30 0x2e 0xab 0x31. The
per-double localeconv() handed back a process-wide static besides, read from every
worker. The numbers now go through a locale_t made once under pthread_once, worn with
uselocale() around the snprintf/strtod pair and handed straight back, and the writer
fails if that locale cannot be made. A float has a path of its own - 6 to 9
significant digits, round-tripped with strtof - so 0.1f prints as 0.1 and not as the
double it was promoted to; _Generic routes float: to it.

What a call may do is checked now: a key belongs in an object, one per value; a value
inside an object has to follow a key; an end needs something open and the pair it
closes complete. Each refusal marks the writer failed, where an unbalanced end used to
return false silently and the writer kept accepting. ioxd_json_done() answers the
other half - nothing failed, nothing left open - so a missing end is no longer
invisible. Also: the depth is checked before the separator is written, so a document
one level too deep leaves no comma behind; IOXD_JSON_DEPTH is 63 with a static_assert
under it, since level 64 aliased the root's bit through depth & 63; an empty
ioxd_json_raw fails rather than emitting "[,]"; and a run the sink refuses whole is
asked for again halved, down to 64 bytes, since the reply and the pipe both refuse a
reserve larger than their slab.

In the header: IOXD_JSON_FIELD's answer goes through a static inline, so a field
written for its effect is a statement and not a value the compiler sees discarded; the
USER_FIELDS example no longer shows a // note before a line continuation, which would
swallow the lines after it, and points at playground/hello/main.c for the block-comment
form; and it says that strings go out byte for byte, invalid UTF-8 included, so
untrusted input has to be validated first.

The unit tests cover each of these. The locale one tries ps_AF, fa_IR, de_DE and fr_FR
and prints a note instead when the box has none of them.

include/ioxd/json.h enters the tree here: .gitignore's `ioxd` line, meant for an old
binary at the root, also matched include/ioxd/, so none of the split headers were ever
committed and this one no longer matches the json.c beside it.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…e machine's stranded corners

Stopping used to abandon whatever was open: parked coroutines, their stacks, the conn_t
and the socket, and then it unmapped the recv slab while multishot recvs could still be
in flight. A worker now drains instead - it cancels the accepts by name, asks the kernel
to cancel everything else with one ASYNC_CANCEL_ANY, ends by hand the recvs parked on
-ENOBUFS that hold no operation to cancel, and keeps running the loop, ignoring the stop
flag, until the last connection has closed itself or two seconds are up. Parked
coroutines wake with errors, handlers unwind, stacks go back to the pool; if any are
still open the slab stays mapped and the log says so. Twenty idle keep-alive connections
now go from SIGINT to "0 still open" in under 100 ms.

conn.c's pause/resume had corners that stranded a coroutine for good. The -ENOBUFS
branch ignored c->pausing, so a TLS prologue parked in recv_pause was never woken under
buffer starvation, and ignored c->eof, so a connection the queue-full policy had already
finished was parked on the starved list instead of ending. recv_pause reported success
with the input already ended and recv_resume armed a recv on a dead connection; both say
so now, and resume during a drain ends the input rather than arming an operation nothing
will cancel. A positive result without IORING_CQE_F_BUFFER used a buffer id that was
never set. The queue-full policy staged a cancel per arrival while the queue stayed
full: one flag now bounds it to one in flight. A terminal -EIO on a TLS listener is the
peer's control record, which TLS.md calls the end of input, so it reads as a clean close
rather than an error. Closing a socket goes through an IORING_OP_CLOSE on both paths -
the plain close(2) raced SQEs already staged for that same descriptor.

A persistent accept error (-ENFILE when the file table is full, -EMFILE, -ENOMEM,
-ENOBUFS) was an unbounded re-arm spin with one log line per turn. The listener now
stalls and is re-armed once p->live has dropped, or once a second in case the shortage
was someone else's, and it does not accept past the registered-file ceiling in the first
place; the error log is rate-limited and counts what it stands for. With an eight-slot
table, forty clients: eight served, two log lines, and everything works again when they
let go.

ioxd__sqe discarded uring_submit's return, so on -EBUSY - the CQ overflowing, the kernel
refusing submissions - all sixteen retries were identical and the worker aborted. The CQ
head is now published per entry, so a handler resuming inline always has room for its
completions, and the retry enters with GETEVENTS to flush the backlog; the abort, if it
comes, names the errno. CQ overflow was invisible: sq_off.flags is mapped, an overflow
forces an enter that flushes it, and the count is in the stop line.

uring_init left a half-built struct behind on an mmap failure - sqe_mem was MAP_FAILED,
which passes `if (ring->sqe_mem)` - so uring_exit double-unmapped and closed a stale fd;
the struct is emptied on every failure path and only owns an fd once both mappings are
up. Also: fd 0 is a legal descriptor, the -EINVAL retry no longer claims every -EINVAL
was NO_SQARRAY, RING_FDS returns a count that must be 1 before up.offset means anything,
sq_array is null under NO_SQARRAY instead of aliasing sq_head, and the compat block
covers the flags and the getevents arg that older headers lack.

The guard below a coroutine stack was one 4 KB page under frames of 25 KB and 10 KB - a
deep frame would have stepped clean over it into the neighbour. It is 64 KB of PROT_NONE
now (address space only), and STACK_SIZE is 128 KB against a measured 44 KB of use.
swap_ctx and the coro_* API are hidden, so nothing outside the library can interpose the
scheduler; the switch carries CFI, so gdb and perf unwind through it; and coro_resume's
loop-only rule is checked in every build rather than compiled out with -DNDEBUG.

run.c never cleared g_stop and never removed the port it added, so a second ioxd_run was
a silent no-op with doubled listeners; both are undone before it returns, and it returns
non-zero when a worker's ring died. A failed pthread_create now stops and joins the
workers already running instead of leaking them and their allocations.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…be re-armed

ioxd__recv_resume now says whether it re-armed; a false return means the
peer is gone or the worker is draining, so the handler is not run.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…aptures

A 405's allow header now names every method the path has. The walk latched only the
first end-of-path node, so a path that ends a static route and a capture route both -
"/users/new" beside "/users/:id" - answered with one node's methods and hid the other's:
DELETE /users/new said "allow: POST" while GET /users/new was served. Every such node is
remembered, and the union of their methods, each once and in registration order, is built
into a small arena in the request (route_arena), which outlives the handler whether or not
ioxd_header copies. The per-node allow strings are gone with it. HEAD is served by the GET
of a path that has none of its own (RFC 9110 9.3.2) and is listed behind that GET in the
allow header, so DELETE /health answers "GET, HEAD".

A group's prefix and what follows it are joined by a '/' when neither side brings one:
ioxd_get(api, "users", h) under "/api" registered "/apiusers" and now registers
"/api/users"; a repeated slash still counts once. Captured values arrive percent-decoded
into that same arena ("/users/a%2Fb" gives "a/b"), while the matching itself stays on the
raw bytes, so an encoded '/' cannot split a segment - and a value that does not fit the
arena is handed over raw. Method, path and prefix are copied at registration, so a caller
may pass temporaries and the method is no longer compared against the caller's memory for
the life of the process.

ioxd_group_use, ioxd_use, ioxd_endpoint_use and ioxd_default now carry ioxd_route's
"registered after ioxd_run started" guard: middleware added once the workers are running
raced them, and an endpoint's was dropped in silence. ioxd_next_run advances its cursor in
place, so a middleware that calls it twice no longer replays the rest of the chain - the
second call runs nothing. An IOXD_GROUP block left early by break, return or goto used to
leave the group open and nest every later registration inside it; the block's variable now
carries __attribute__((cleanup)) where the compiler has it, and ioxd_run reports any group
still open. Seventeen middleware on an IOXD_GROUP compiled with a warning and dropped the
last one - the slot is an undefined sentinel now, a compile error like the endpoint form's.

tests/router_test.c drives ioxd__dispatch with a context built by hand: the allow union,
the prefix joining, %2F and %65 in a capture, HEAD on GET, the group-depth diagnostic, the
guards, and a second ioxd_next_run. make check runs it after the unit test. The smoke
suite's allow check follows HEAD onto GET.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
# Conflicts:
#	include/ioxd/http.h
#	include/ioxd/router.h
…ainst, assertions that can fail

The sequence make check ran inline is now tests/run-suites.sh, which CMake's check
target and its ctest entries run as well, so it lives in one place. It keeps the
fixture's output in a log instead of /dev/null, takes its exit status after the
kill -INT, and fails - printing the log - when a worker reported an error or stopped
with connections still open. The TLS suites are gated on the TLS port answering a
socket probe rather than on a certificate file existing, so make check TLS=0 skips
them with a line instead of a traceback. The fixture serves a copy of tests/certs, so
a suite may rewrite what it serves.

make check-tiny builds the fixture and the objects under it with BUF_COUNT=8,
BUF_SIZE=64 and RX_QUEUE=4 into obj-tiny/ and runs the stress suite against it on
8410, where every request empties the buffer group: the -ENOBUFS parking, the re-arm
and the queue-full path are exercised for real. check-all is check plus check-tiny.
make tidy runs clang-tidy over the library with the flags the objects are built with,
and .clang-tidy enables concurrency-*.

Assertions that a hang would have passed now say what they mean: the read helpers
return whether the peer really closed, and the oversized-header, 5 KB reply header,
quit and long-line cases assert a close rather than a swallowed timeout. The flood
without reading asserts the server stopped taking it, and within a time bound, instead
of check(..., True).

New coverage: HEAD of a buffered, a declared-length and a chunked route, each asking
for the GET reply's head and no body (all three fail until the engine routes HEAD);
ioxd_content_length and ioxd_flush through GET /declared, checked byte-exact and
followed by another request on the same connection; ioxd_reserve and ioxd_advance
through GET /raw, and a reserve past the slab; ioxd_pipe_copy, keep, release, write
and send through the pipe fixture's copy and hold commands; the allow header of a path
with two methods; //health and /api//admin/stats; a group registered with prefix "";
a wildcard _.example.com certificate by SNI; and a TLS reload - new files for a host,
POST /tls/reload, the new certificate served and the connection in flight unharmed.
mkcerts.sh makes the wildcard host and remakes any certificate within three days of
expiry.

The build: TLS=0/1 is a dependency at last - the effective flags are written to
obj/flags whenever they differ and every object depends on it, so make TLS=0 lib after
a normal build rebuilds instead of linking -DIOXD_TLS=1 objects without -lssl. The
library is compiled with -fstack-clash-protection (the coroutine guard page needs the
probes) and libioxd.so is linked through cmake/ioxd.map, so nm -D shows ioxd_* and
nothing else. cmake/ioxdConfig.cmake.in carries IOXD_TLS and find_dependency(OpenSSL 3)
when it is on, so find_package(ioxd) works downstream: verified with a consumer built
against an installed prefix, and with pkg-config --libs --static.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
… any enter made inside one

Publishing per entry cost measurable throughput; what the mid-batch flush
needs is only that the head be current before it enters, so the loop counts
the entries it has taken and ioxd__sqe publishes them first.

The conformance suite reads a HEAD reply as ending at its blank line, and
files a Transfer-Encoding that names identity with the unimplemented
codings (501).

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
…ild, a tidy target, the CMake package fixed

Resolved against the router branch (its test binary joins the check
target). The fixture answers HEAD, 204/304, reflected headers and a
declared length for tests/conformance.py, which runs against the same
fixture as the smoke suite; expectations that changed by design follow
the engine: a 5 KB header is refused by ioxd_header rather than breaking
the reply, forty query parameters are a 400, HEAD rides on GET in the
allow list, and a request without Host is not HTTP/1.1. strerror goes
through one helper so the thread-safety note lives in one place.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
README lists the real prerequisites (gcc 14, OpenSSL 3, the TLS opt-out)
and every check target; ARCHITECTURE follows the code again - the file
map, the 128 KB stacks and their guard, the tag table, the loop and its
drain, the pipe's pinning, the engine's framing and reply rules, the
router's HEAD and allow union, the JSON writer's float path and level
rules; PERF names the knobs' files and records the hardening's cost;
TLS.md separates what is built from the watcher that is planned and
describes the record-at-a-time handshake; STREAMS lists the shipped
verbs; DESIGN.md is marked as the historical v1 record; FILES.md says
none of it exists yet.

Claude-Session: https://claude.ai/code/session_013wYnJvEFUjKEpGLkyTLt9P
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant