diff --git a/.clang-tidy b/.clang-tidy index 16e220a..08bb449 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,12 +1,15 @@ -# clang-tidy configuration for libioma. CLion picks this file up (it prefers a project .clang-tidy -# over its own settings); from the shell, with the compile flags the Makefile uses: +# clang-tidy configuration for libioxd. CLion picks this file up (it prefers a project .clang-tidy +# over its own settings); from the shell, `make tidy` runs it with the flags the objects are built +# with, which is # -# clang-tidy src/io/*.c src/http/*.c tests/server.c -- -std=gnu23 -D_GNU_SOURCE -Iinclude -Isrc -Ithird_party/picohttpparser +# clang-tidy lib/io/*.c lib/http/*.c tests/server.c -- -std=gnu23 -D_GNU_SOURCE -Iinclude -Ilib -Ithird_party/picohttpparser # # (CLion's bundled clang-tidy ships without clang's builtin headers; give it gcc's with -# --extra-arg=-isystem/usr/lib/gcc/x86_64-linux-gnu/13/include, or it cascades phantom warnings.) +# --extra-arg=-isystem$(gcc -print-file-name=include), or it cascades phantom warnings - which is +# what `make tidy TIDY=/bin/clang/linux/x64/bin/clang-tidy` does for you.) # -# The checks that matter for a C library, minus the ones that only add noise here: the Annex K +# The checks that matter for a C library - the threading ones included, since the workers share a +# certificate store and a stop flag - minus the ones that only add noise here: the Annex K # "memcpy is insecure" nag (glibc has no *_s functions), include-what-you-use over deliberately # transitive private headers, the pragma-once portability nag, int-to-pointer casts that ARE the # tagged user_data design, and the readability checks that argue with idiomatic C. @@ -14,6 +17,7 @@ Checks: > -*, bugprone-*, clang-analyzer-*, + concurrency-*, misc-*, performance-*, portability-*, @@ -36,4 +40,4 @@ Checks: > -misc-no-recursion CheckOptions: bugprone-signed-bitwise.IgnorePositiveIntegerLiterals: true -HeaderFilterRegex: '(include|src)/.*' +HeaderFilterRegex: '(include|lib)/.*' diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..eaa24ef --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,39 @@ +# The manual, on GitHub Pages: built from the headers by manual/build.py on every push, and +# deployed with the Pages actions (the repository's Pages source is "GitHub Actions"). +name: manual + +on: + push: + branches: [main, streams] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - name: build the manual from the headers + run: python3 manual/build.py + - uses: actions/upload-pages-artifact@v3 + with: + path: manual + + deploy: + needs: build + runs-on: ubuntu-24.04 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 7a5013e..f0fe0e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,19 @@ obj/ +obj-tiny/ *.o # libraries and examples -libioma.a -libioma.so -libioma.so.* -ioma-hello -ioma.pc +libioxd.a +libioxd.so +libioxd.so.* +libioxd-tiny.a +ioxd-hello +ioxd.pc # old binaries -ioma -ioma-tiny -ioma-trace +/ioxd +ioxd-tiny +ioxd-trace stackful stackful-tiny stackful-trace @@ -25,5 +27,10 @@ compile_commands.json # IDE .idea/ -tests/ioma-test-server -tests/ioma-unit +tests/ioxd-test-server +tests/ioxd-test-server-tiny +tests/ioxd-unit +tests/ioxd-pipe-server +tests/ioxd-router-test +tests/certs/ +__pycache__/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 819596c..bce54dd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ -# How libioma works +# How libioxd works -libioma is an HTTP/1.1 server library in C. It runs one worker per CPU core, each worker owns +libioxd is an HTTP/1.1 server library in C. It runs one worker per CPU core, each worker owns its own io_uring, and every connection is served by a small coroutine that suspends while the kernel does the I/O. There are no locks and nothing is shared between workers. @@ -8,18 +8,25 @@ kernel does the I/O. There are no locks and nothing is shared between workers. process ├─ worker 0 (thread pinned to CPU 0) ├─ worker 1 (CPU 1) ... │ ├─ io_uring one ring, one syscall per batch - │ ├─ SO_REUSEPORT listener the kernel spreads connections across workers + │ ├─ SO_REUSEPORT listeners one per port; the kernel spreads connections across workers │ ├─ provided buffer ring where the kernel puts received bytes - │ ├─ coroutine per connection runs serve(): parse → handler → reply + │ ├─ coroutine per connection runs ioxd__serve(): parse → handler → reply │ └─ pools: conn objects, coroutine stacks ``` -`include/ioma.h` is the whole public API. Under `src/` there are two planes, one concern per file: -`io/` is the I/O plane - `uring.c` (the ring), `coro.c` + `switch_x86_64.S` (coroutines), -`bufring.c` (the buffer ring), `conn.c` (a connection and its awaits), `proactor.c` (the worker -loop), with `proactor.h` as the interface the other plane uses - and `http/` is the HTTP plane - +`include/ioxd.h` is the whole public API, an umbrella over `include/ioxd/`: `slice.h` (slices and +conversions), `http.h` (request, response, context, body, reply, `ioxd_run`), `router.h` (groups, +endpoints, middleware, the script macros), `json.h` (the writer and `IOXD_JSON_STRUCT`), `pipe.h` +(a connection as a pipe) and `tls.h` (a certificate store), one per concern, each usable on its +own. Under `lib/` there are three planes, one concern per file: `io/` is the I/O plane - +`uring.c` (the ring), `coro.c` + `switch_x86_64.S` (coroutines), `bufring.c` (the buffer ring), +`conn.c` (a connection and its awaits), `pipe.c` (the reader and the writer), `proactor.c` (the +worker loop), with `proactor.h` as the interface the other planes use; `http/` is the HTTP plane - `engine.c` (parse, body, reply, the serve loop), `router.c` (routes and middleware), `api.c` -(handler helpers), `run.c` (`ioma_run`). Each plane has an `internal.h` for what its files share. +(handler helpers), `run.c` (`ioxd_bind`, `ioxd_run`, `ioxd_run_pipes`); and `tls/` is the TLS +prologue - `store.c` (certificates, SNI, reload) and `handshake.c` (the OpenSSL handshake and the +handoff to the kernel). `json/json.c` is the JSON writer, which depends on neither plane. Each +`io/` and `http/` each have an `internal.h` for what their files share and no module owns; `tls/` has none - `store.h` and `handshake.h` are what its two files need from each other, and the runner includes the latter. --- @@ -29,7 +36,7 @@ io_uring is two ring buffers shared with the kernel. You write **submission entr "do this") into one ring, the kernel writes **completion entries** (CQEs, "this finished, result N") into the other. One syscall, `io_uring_enter`, both submits and waits. -libioma talks to it directly (`uring.c`), the way ioxide does, with three setup flags: +libioxd talks to it directly (`uring.c`), the way ioxide does, with three setup flags: - **SINGLE_ISSUER** — only this thread submits, so the kernel skips its SQ locking. - **DEFER_TASKRUN** — completion work runs batched inside our `enter` call, never as an interrupt @@ -64,7 +71,7 @@ the handler has consumed it, the worker hands it back by writing its id into the Returns are staged and the ring tail is published once per loop iteration, so a batch of returned buffers costs one atomic store. If the ring runs dry a recv ends with `-ENOBUFS`; the worker logs it -(once a second at most, with counts, so a starved ring is visible and `BUF_COUNT` can be raised) and the connection is +(once a second at most, with counts, so a starved ring is visible and `recv_buffers` can be raised) and the connection is parked and re-armed as soon as any buffer comes back. --- @@ -76,8 +83,8 @@ A **stackful coroutine** is a function running on its own stack that can pause i variables intact. That is what lets a handler be written as plain sequential code: ```c -n = await_recv(c, buf, sizeof buf); /* pauses here until data arrives */ -await_send(c, reply, len); /* pauses here until the send completes */ +ioxd_pipe_read(pipe, &live); /* pauses here until data arrives */ +ioxd_pipe_send(pipe, reply, len); /* pauses here until the send completes */ ``` ### The switch @@ -90,8 +97,10 @@ nanoseconds. ### Creating one -`coro_create` maps a 64 KB stack with an unmapped **guard page** below it (a stack overflow faults -instead of corrupting a neighbour). The `coro_t` descriptor sits at the top of that same block. +`coro_create` maps a 128 KB stack with a 64 KB unmapped **guard** below it (a stack overflow faults +instead of corrupting a neighbour; one page would not do, since `ioxd__conn_main`'s frame is 25 KB +and `ioxd__serve`'s 10 KB, and a frame that big could step clean over it). `PROT_NONE` pages cost +address space and no RSS. The `coro_t` descriptor sits at the top of that same block. Then it forges a first frame: six zeros and a return address pointing at `coro_entry`. The first `swap_ctx` into it pops the zeros and "returns" into `coro_entry`, which calls the coroutine's function. When the function returns, `coro_entry` marks it done and yields for the last time. @@ -101,40 +110,58 @@ function. When the function returns, `coro_entry` marks it done and yields for t - Only the **loop** resumes coroutines. A coroutine never resumes another; it *spawns* one, which goes on a ready list the loop drains. Two thread-locals track the current coroutine and the loop's saved stack pointer, and that is the whole scheduler. -- A finished coroutine's stack goes into a per-worker **pool** (guard page still armed) and is - reused by the next connection, so churn pays no `mmap`/`munmap`. +- A finished coroutine's stack goes into a per-worker **pool** (guard still armed) and is + reused by the next connection, so churn pays no `mmap`/`munmap` and no cross-core TLB shootdown. + Past `CORO_POOL_MAX` idle stacks the extra ones are unmapped; the cap bounds what is kept warm, + not how many coroutines may run. --- ## 3. The proactor (the worker loop) -Each worker thread runs `proactor_run`: pin to a CPU, create the ring and buffer ring, open the -listener, arm the multishot accept, then loop: +Each worker thread runs `proactor_run`: pin to a CPU, create the ring and buffer ring, open one +socket per listener, arm each multishot accept, then loop: ``` loop: + stop? the flag is read once: begin_drain, then run until the last connection ends run_ready start coroutines spawned since last time rearm_starved re-arm recvs that hit -ENOBUFS, if buffers came back publish buffer returns staged during the last batch enter submit everything staged, wait for >= 1 completion (the one syscall) - dispatch handle every CQE in the batch; handlers resume inline - advance publish the CQ head once + dispatch read the CQ tail once, then copy out and handle one CQE at a time; + handlers resume inline + advance publish the CQ head once, for everything taken this batch + rearm_stalled a close may have made room for an accept that ran out of it ``` Handlers run *inside* dispatch, on their own stacks. A resumed handler that reaches `await_send` stages a SEND SQE and parks; the SQE rides the next `enter` together with the rest of the batch. +Each CQE is copied out of the ring before its handler runs, since the handler may need the slot: +`ioxd__sqe` enters again when the SQ fills mid-batch, and it publishes the CQ head first, so the +kernel has somewhere to put what that enter completes. The head is published once per batch +otherwise, which is the one barrier the batch costs. + +An accept that runs out of descriptors, file slots or memory is not re-armed at once - that would +spin - so its listener stalls; `rearm_stalled` arms it again once a connection has closed, or once +a second, whichever comes first. ### Routing a completion -Every SQE carries a 64-bit `user_data`. libioma stores a pointer in it with a 3-bit **tag** in the +Every SQE carries a 64-bit `user_data`. libioxd stores a pointer in it with a 3-bit **tag** in the low bits (everything it points at is 8-byte aligned): | tag | points at | meaning | |---|---|---| -| `OP` | an `op_t` on the awaiting coroutine's stack | a one-shot op (send) finished: resume that coroutine | +| `IGNORE` (0) | – | a cancel acknowledgement | +| `OP` | an `op_t` on the awaiting coroutine's stack | a one-shot op (send, recv-exact, setsockopt, sendmsg) finished: resume that coroutine | | `RECV` | the connection | multishot recv delivered data, or ended | -| `ACCEPT` | the worker | a new connection | -| `IGNORE` | – | a cancel acknowledgement | +| `ACCEPT` | the `struct listener` | a new connection on that port | +| `CLOSE` | – | a socket closed; only a failure is news | +| `DRAIN` | the worker | the shutdown's one blanket cancel: its result says whether the kernel knew it | + +`IGNORE` is the zero tag on purpose: an SQE whose `user_data` was never set then dispatches as +"nobody waits for this" instead of as an op with a null pointer. The `op_t` can live on the coroutine's stack because a parked coroutine's frame is frozen: its address stays valid for exactly as long as the operation is in flight. @@ -144,123 +171,275 @@ address stays valid for exactly as long as the operation is in flight. Accept → take a `conn_t` from the pool → arm its multishot recv → spawn its handler coroutine. The connection has **two owners**, the handler coroutine and the armed recv, so `refs` starts at 2. -Data CQEs are queued on the connection (`rx`, a small ring of slices) and wake the handler if it is -parked in `await_recv`. When the handler returns, `conn_close` cancels the recv, returns any unread +Data CQEs are queued on the connection (`rx`, a small ring of filled buffers) and wake the handler +if it is parked in the pipe's reader. When the handler returns, `conn_close` cancels the recv, returns any unread buffers, closes the fd and drops its ref. The recv's ref drops when its terminal CQE arrives (`-ECANCELED`, or the peer's FIN). At zero refs the `conn_t` goes back to the pool. A connection is therefore never recycled while a completion for it is still coming. ### The two awaits -`await_recv` copies the next queued slice into the caller's buffer, returns the provided buffer to -the ring when the slice is fully consumed, and parks if the queue is empty. `await_send` stages a -SEND SQE (`MSG_WAITALL`, so the kernel finishes short sends itself) and parks until its CQE. +`ioxd__await_item` hands the next queued buffer over whole - the pipe's reader owns it from then +until it returns it to the ring - and parks if the queue is empty. `await_send` stages a SEND SQE +(the loop around it finishes short sends; kernel TLS refuses `MSG_WAITALL`) and parks until its CQE; the pipe's +writer is the only caller. Nothing above the pipe touches either. The TLS prologue adds three more +that nothing else uses: `ioxd__recv_pause` and `ioxd__recv_resume` stop and restart the multishot +recv, `ioxd__recv_exact` reads an exact count straight from the socket, and `ioxd__setsockopt` +programs it - all over the ring, all suspending like any await. + +### Stopping + +The stop flag is read once. `begin_drain` cancels each armed accept by name (they must stop even on +a kernel without `CANCEL_ANY`), marks every listener stalled so nothing re-arms one, stages a single +`ASYNC_CANCEL` with `IORING_ASYNC_CANCEL_ANY` that takes every recv and every send a coroutine is +parked on, and ends by hand the recvs parked on `-ENOBUFS`, which hold no operation to cancel. If +the kernel refuses the blanket cancel, the worker falls back to cancelling connections one at a +time. Then the loop keeps running: the parked awaits fail with `-ECANCELED`, the handlers unwind, +`conn_close` returns the stacks and the fds, and the loop leaves once `live == 0` or a two-second +grace period is up. A connection accepted in the window before the cancel landed is closed unserved. +If any are still open at the deadline, the ring goes but the buffer slab stays mapped: the kernel +may still hold a buffer for a recv, and leaking the slab at exit beats unmapping it underneath. ### Shared nothing -Nothing crosses workers: each has its own ring, listener, buffers, pools and coroutines. The +Nothing crosses workers: each has its own ring, sockets, buffers, pools and coroutines. The kernel balances connections over the listeners (`SO_REUSEPORT`), and worker *i* is pinned to the *i*-th CPU the process is allowed on, so it is correct under a container cpuset. --- -## 4. HTTP - -`serve()` in `http.c` is the coroutine every connection runs. Per request: +## 4. Pipes: the reader and the writer + +The connection's bytes reach the HTTP engine, or a handler of your own under `ioxd_run_pipes`, +through a pipe (`io/pipe.h`; `ioxd_pipe` in the public header): a reader over the buffers the +kernel filled and a writer over a slab. Every call that has to wait suspends the coroutine and +the loop resumes it on the completion, so one piece of code drives any connection the I/O plane +runs - the shape of .NET's PipeReader and PipeWriter, with coroutines in place of tasks. + +**The reader** hands out received bytes as one contiguous span at a time. The kernel delivers +them as provided buffers, 2 KB each, and the reader keeps them as such: when everything live +lies in one buffer - a whole request head in one receive, the common case - `read` returns that +buffer's bytes in place, no copy. Only when the bytes span buffers, or when the consumer keeps +bytes it wants contiguous, are they gathered into the consumer's buffer (`IOXD_PIPE_GATHER`, +16 KB). Six verbs drive it besides `read`: `examine` (looked at n bytes without consuming them, so +the next `read` waits for more instead of returning the same incomplete head), `drop` +(consume), `keep` (consume, but the bytes stay where they are, contiguous with the run and valid +until `release`), `release` (forget every kept byte; the live ones stay) and `copy` (the plain read +into a buffer of your own), with `run_begin` and `run` below them. A run is a stretch of +kept bytes: the head is one and a whole-read body another; `run_begin` freezes the head's so +the body's can move without it. + +Kept bytes are what the request model is made of, so `keep` never invalidates a pointer it +handed out. Bytes kept in place stay in the kernel buffer they arrived in, and `run_begin` **pins** +that buffer: it is not returned to the ring when the live bytes move on, but held until `release`. +Only one buffer can be pinned, so a run that would need a second is copied into the gathering +buffer instead, and `ioxd_pipereader_run` says where it ended up. At most two kernel buffers are +held at a time, the pinned one and the live one, so a slow handler pins one 2 KB buffer per request +in flight; the starvation log says when `recv_buffers` should grow. The counts are clamped rather than +trusted: `drop` consumes at most what is live, `keep` refuses an n past it (and refuses, as +`IOXD_PIPE_FULL`, kept plus live bytes that would outgrow the gathering buffer). + +**The writer** is the slab: `reserve` n bytes to format into directly and `advance` by what was +written - never past the room that was there, whatever was claimed - `write` to copy in (data +larger than the slab goes straight from the caller's memory), `flush` to send (a suspension), +`send` for both. In front of the slab is a lead and behind it a slack, so a frame's front and back +go out in the same send as the data between them: the HTTP reply puts its head and a chunk's size +line in the lead and the chunk's CRLF in the slack, and sends through the same await; a raw pipe +writes straight and never touches either. Whatever a handler leaves in the slab is flushed when +the pipe closes, so a raw handler that just wrote and returned still has its bytes sent. + +## 5. HTTP + +`ioxd__serve` in `lib/http/engine.c` is the coroutine every connection runs - behind the TLS +prologue when the listener has a certificate store. Per request: 1. **Parse** with picohttpparser, straight into `req.headers` (the layouts match, so there is no copy). `-2` means "incomplete": read more and parse again, so a request split at any byte works. -2. **Body, on demand**: one pass over the headers picks out `Content-Length`, - `Transfer-Encoding` and `Connection` (a length test rejects almost every header before a byte - is compared), but the body stays on the wire. `ioma_body_all` reads it whole into the request - buffer, a chunked one decoded down over its own raw bytes; `ioma_body_read_until` streams it, any - size, filling the caller's buffer; `ioma_body_read_next_chunk` hands over one chunk exactly as the - sender framed it. All three chunked paths share one small parser (a raw stage after the head, a - size-line reader, a data mover) that survives a split at any byte. Whatever a handler leaves - unread is drained after it returns, up to a limit, past which the reply says close. -3. **Keep-alive**: HTTP/1.1 unless `Connection: close`; HTTP/1.0 only with `Connection: keep-alive`. -4. **Dispatch** a context to the middleware chain and the route (section 5). The context holds + `-1` is a 400. A head that outgrows the reader's 16 KB is a 431. The parsed head is `keep`t, so + every slice in the request points at bytes that stay put, and `run_begin` closes it off so the + body's kept bytes can be a run of their own. +2. **Check the framing** before a handler ever sees the request, because a proxy in front that + reads it differently is how one request smuggles another (RFC 9112 6.1). A `Content-Length` is + decimal digits and nothing else; repeated, its values must agree; both a `Content-Length` and a + `Transfer-Encoding` is a 400. A `Transfer-Encoding` must end in `chunked` and name no other + coding - another coding is a 501, a `chunked` that is not last (across every line, since the + lines join into one list) or an empty list is a 400. A folded continuation line (obs-fold) + reaches the engine with no name and is refused rather than interpreted. HTTP/1.1 needs exactly + one `Host`, HTTP/1.0 at most one. Every `Connection` line is read and its tokens counted, with + `close` winning over `keep-alive`. An `Expect` other than `100-continue` is a 417. An + absolute-form target loses its scheme and authority (RFC 9112 3.2.2) before the path and query + are split off. A query with more parameters than fit is a 400, one whose decoded bytes outgrow + the arena a 414 - never a request acted on in part. Anything refused is answered and the + connection closes. +3. **Body, on demand**: the pass that lower-cases the header names is the one that picks out + `Content-Length`, `Transfer-Encoding`, `Host`, `Connection` and `Expect` (a switch on the name + length rejects nearly every header before a byte is compared), but the body stays on the wire. + `ioxd_body_all` keeps it whole in the reader - in place after the head when it all arrived in + one receive, gathered otherwise, a chunked one slid together chunk by chunk; a + `Content-Length` past the reader's buffer is a 413; `ioxd_body_read_until` streams it, any + size, filling the caller's buffer; `ioxd_body_read_next_chunk` hands over one chunk exactly as the + sender framed it. All three chunked paths share one small parser (a size-line reader, a data + mover, the CRLF after each chunk) that survives a split at any byte. A size line is held to the + grammar - hex digits, then the CRLF, or blanks and a `;` with an extension after it - and + trailers are dropped unread and bounded at `IOXD_TRAILER_MAX`, so a peer cannot hold the + connection open with an endless one; anything else is a 400. With `Expect: 100-continue` the + first of these reads sends `100 Continue` ahead of anything the handler buffered. Whatever a + handler leaves unread is drained after it returns, up to `IOXD_DRAIN_MAX`, past which the reply + says close; a body an expecting client was never asked for is not waited for at all - the reply + goes out and the connection closes. +4. **Keep-alive**: HTTP/1.1 unless `Connection: close`; HTTP/1.0 only with `Connection: keep-alive`. +5. **Dispatch** a context to the middleware chain and the route (section 6). The context holds the request and the response; the response holds the reply being shaped (status, content - type, headers) and the write slab: an 8 KB buffer with room reserved in front of it for the - head. -5. **Write**: the handler calls `ioma_write` / `ioma_printf`; bytes land in the buffer. If it - fills, the framework sends what it has — head first, framed chunked on HTTP/1.1 or until close - on HTTP/1.0 (or with a length the handler declared) — and the handler suspends on that send. -6. **Finish**: after the chain returns, whatever is buffered goes out. The usual case is that + type, headers); the bytes go into the pipe's writer, an 8 KB slab with a lead in front of it + for the head and a chunk's size line, and slack behind it for a chunk's CRLF. +6. **Write**: the handler calls `ioxd_write` / `ioxd_printf`, or reserves slab bytes with + `ioxd_reserve` and says how many it used with `ioxd_advance`. If the slab fills, the framework + sends what it has — head first, framed chunked on HTTP/1.1 or until close on HTTP/1.0 (or with + a length the handler declared) — and the handler suspends on that send. +7. **Finish**: after the chain returns, whatever is buffered goes out. The usual case is that everything fit: the head (with `Content-Length`, serialized by `memcpy` of precomposed pieces - plus a small integer writer, no `snprintf`) is copied into the reserve right before the body + plus a small integer writer, no `snprintf`) is copied into the lead right before the body and the reply is one send. Every header name goes out lower-cased, the engine's and the handler's alike; HTTP/1.1 treats names case-insensitively and HTTP/2 requires lowercase, so - one spelling is the only one there is. A streamed reply gets its terminating chunk. Then loop; leftover - bytes of a pipelined next request are carried over. + one spelling is the only one there is. A streamed reply gets its terminating chunk. Then loop; + `release` gives this request's bytes back and leftover bytes of a pipelined next one stay. + +What goes on the wire follows the request and the status, not only what the handler wrote. A reply +to HEAD, and a 1xx, 204 or 304, carries no body however much was written (RFC 9112 6.3) - HEAD +keeps the `Content-Length` its GET would have had, while a 1xx and a 204 carry no framing header at +all. A status outside 100-999 goes out as 500. A declared `Content-Length` is held to: a reply that +was buffered whole is corrected to what was actually written, a stream is cut at the declared byte +and the reply fails there, and one that falls short closes the connection so the client can see it +was cut. Whether the reply says `connection: close` because of an undrainable body is settled with +the head, before any of it is sent. Because the head is built at the first send, middleware can shape headers and status until then, -and `head_sent` tells a handler when that moment has passed. - -Everything in a request is a slice (pointer + length) into the read buffer, valid only during -the handler. Three key/value arrays hang off it, read directly: `headers` (names lower-cased once -at parse time, so a plain compare works), `params` (the query, split and percent-decoded into a -small per-request arena only when a value needs it, otherwise a zero-copy view), and `route_params` (the -`:name` captures the router filled in, in pattern order). A `scratch` arena is there for building -a body (`ioma_textf`). +and `head_sent` tells a handler when that moment has passed. `ioxd_header` copies the name and the +value into the response's own arena as the line they will become, so temporaries are fine; it +lower-cases the name and refuses one that is not an HTTP token, a value with a control byte (no +response splitting), and the three headers the engine owns - `content-length`, +`transfer-encoding`, `connection` - while `content-type` through it is taken as `ioxd_content_type`. + +Everything in a request is a slice (pointer + length) into the bytes the reader kept, valid only +during the handler. Three key/value arrays hang off it, read directly: `headers` (names lower-cased +once at parse time, so a plain compare works), `params` (the query, split and percent-decoded into +a per-request arena only when a value needs it, otherwise a zero-copy view), and `route_params` +(the `:name` captures the router filled in, in pattern order). There are three arenas and no more: +that query arena on the serve coroutine's stack, `req.route_arena` (`IOXD_ROUTE_ARENA`) where the +router decodes captures and writes a 405's `allow` value, and `res.head` (`IOXD_RESP_HEAD_CAP`) +where the reply's added header lines and a copied content type live. --- -## 5. Router and middleware +## 6. Router and middleware Endpoints are registered in groups before the workers start: a group is a path prefix plus middleware, groups nest, and the root (`NULL`) is the group with no prefix whose middleware -`ioma_use` adds. `ioma_run` resolves the whole table once, and after that it is read-only, so +`ioxd_use` adds. `ioxd_run` resolves the whole table once, and after that it is read-only, so every worker reads it without a lock. The resolution turns each endpoint's full path (the prefixes of its groups, outermost first, then -its own path) into a segment tree: a node per static segment, plus at most one capture child per +its own path) into a segment tree. The parts are joined with a `/` between them only when neither +side brought one, so a group `/api` and a path `users` make `/api/users` and a repeated slash +counts once. The tree is a node per static segment, plus at most one capture child per node for a `:name` segment, with the endpoints at a node kept one per method. A request is one walk down the tree along its path segments. The static child is tried before the capture, so a static segment wins at any depth, and the walk backs up to the capture when the static branch comes to nothing - including when it reaches the end without the request's method, so a static -path with only a GET lets a POST fall through to a capture route that has one. Captured segments -land in `req->route_params`, named from the endpoint that matched. A path the tree knows without -the method is a 405 with an `allow` header; a path it does not know goes to the fallback -(`ioma_default`, a plain 404 unless replaced). Nothing is scanned and nothing is compiled per -request; the tree is the map. +path with only a GET lets a POST fall through to a capture route that has one. A node with a GET +and no HEAD of its own answers HEAD with that GET (RFC 9110 9.3.2): the handler still sees `HEAD` +as the method, and the engine drops the body it writes. Captured segments land in +`req->route_params`, named from the endpoint that matched and percent-decoded into the request's +arena when they need it (a `/users/a%2Fb` captures `a/b`), left raw when they do not fit. + +A path the tree knows without the method is a 405 whose `allow` header is the union of the methods +reachable at that path, not one route's: a path can end more than one route - `/users/new` also +ends the `/users/:id` of a capture route - so the walk remembers every end-of-path node it reached +and the header lists each of their methods once, in registration order, with `HEAD` written after +a GET that has no HEAD of its own since that is what would answer it. A path the tree does not know +goes to the fallback (`ioxd_default`, a plain 404 unless replaced). Nothing is scanned and nothing +is compiled per request; the tree is the map. Middleware is an onion: each layer receives the context and a `next`; it does work, calls -`ioma_next_run` to continue, and can do more on the way back out, or it replies and returns to +`ioxd_next_run` to continue, and can do more on the way back out, or it replies and returns to short-circuit the request. Each endpoint's chain is flattened at resolution - the root's middleware, then each group's from outermost to innermost, then the endpoint's own - into one -array, so dispatch is a call through it with no walking of groups. The fallbacks run behind the -root's middleware only. +array, so dispatch is a call through it with no walking of groups. Both fallbacks run behind the +root's middleware only, so a 405's `allow` header names methods a group's own middleware would +otherwise have gated. -The `IOMA_` macros are the same registrations as a script: `IOMA_GROUP(prefix, middleware...)` +The `IOXD_` macros are the same registrations as a script: `IOXD_GROUP(prefix, middleware...)` opens a group for the block that follows (a run-once `for`, the group popped when it ends), -`IOMA_GET(path, handler, middleware...)` and its siblings register into the open group, and -`IOMA_USE` adds middleware to it. The middleware lists travel in small structs ended by a null, +`IOXD_GET(path, handler, middleware...)` and its siblings register into the open group, and +`IOXD_USE` adds middleware to it. The middleware lists travel in small structs ended by a null, so every argument is type-checked and a wrong signature is a compile error. -## 6. One keep-alive request, end to end +## 7. One keep-alive request, end to end 1. Bytes arrive. The kernel copies them into a provided buffer and posts a `RECV` CQE. 2. `enter` returns; dispatch queues the slice on the connection and resumes its coroutine. -3. `await_recv` copies the slice into `serve`'s buffer and returns the buffer to the ring. -4. picohttpparser parses; the route runs; the response is serialized into the head buffer. +3. The reader hands `ioxd__serve` the slice in place - no copy; the head is `keep`t there, so the + buffer stays pinned to the request until it is done. +4. picohttpparser parses; the framing is checked; the route runs; the head is serialized into the + slab's lead, right in front of the body the handler wrote. 5. `await_send` stages a SEND SQE and yields back to the loop. 6. The loop finishes the batch and calls `enter` once: the SEND is submitted and the loop waits. -7. The SEND CQE (`OP` tag) resumes the coroutine; `serve` loops to `await_recv` and parks. +7. The SEND CQE (`OP` tag) resumes the coroutine; `release` gives the request's bytes back, the + buffer goes to the ring, and the next `read` parks. Two switches in, two out — tens of nanoseconds. The cost of a request is the kernel's, not ours. --- +## 8. JSON, written as you go + +`lib/json/json.c` is a forward-only writer, the shape of .NET's `Utf8JsonWriter`: `ioxd_json_object`, +`ioxd_json_key`, `ioxd_json_int`, `ioxd_json_string`, `ioxd_json_end` and so on, each putting its +bytes straight into a sink - the reply through `ioxd_reserve`/`ioxd_advance`, 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; integers go through a digit loop; a double takes the shortest of 15, 16 or 17 +significant digits that `strtod` reads back as the same value, and a float the shortest of 6 to 9 +that `strtof` reads back as the same float, so `0.1f` is written `0.1` and not the wider double it +would be promoted to. Both are formatted under a private "C" locale - made once for the process, +worn by the thread for the `snprintf` and the read-back and handed straight back - since a locale +like `fa_IR` spells the decimal point in several bytes and mending one afterwards is not enough. + +Nesting and the commas it owes are two bits per level - one saying the level has a value already, +one saying it closes with `}` rather than `]` - so a document deeper than `IOXD_JSON_DEPTH` fails +cleanly, and so does one whose calls do not make a document: a key needs an object under it and no +key already waiting for its value, a value inside an object needs a key in front of it, and an +`end` needs something open and no pending key. The first such call fails, the rest are dropped, so +checking the last one is enough; `ioxd_json_done` is the check after it - nothing failed and +nothing is left open. A reply larger than the slab streams out chunked +while the writer keeps going, which is the whole point of writing as you go. + +A struct can be described once and serialized with one call: `IOXD_JSON_STRUCT(user, USER_FIELDS)` +expands a field list twice, into the struct's members and into a `user_to_json` function, so the +two cannot drift. A line is the field's kind - `VALUE`, `OBJECT`, `OPTIONAL`, `ARRAY`, `OBJECTS` - its +type and its name; scalars pick their writer by C type through `_Generic`, nested structs call +their own function, arrays loop over a count field. It is text substitution all the way down: the +generated function is the code one would write by hand, with no table and nothing at runtime. + ## Tunables -| name | default | what | +The first six are set at run time, per worker, with `ioxd_configure(&(ioxd_config){ ... })` +before `ioxd_run` (`ioxd/config.h`; a zero field keeps its default, a bad value is refused with a +line on stderr); the build-time name is the default. The rest are build-time only. + +| `ioxd_config` field | default (`-D` name) | what | +|---|---|---| +| `ring_entries` | 4096 (`RING_ENTRIES`) | SQ depth (CQ is twice that); a power of two, at most 32768 | +| `recv_buffers` × `recv_buffer_size` | 4096 × 2 KB (`BUF_COUNT`, `BUF_SIZE`) | provided recv buffers per worker: a power of two at most 32768, and 64 B to 1 MB each | +| `stack_size` | 128 KB (`STACK_SIZE`) | per coroutine, above a 64 KB guard (`CORO_GUARD`); at least 64 KB | +| `idle_stacks`, `idle_connections` | 512, 1024 (`CORO_POOL_MAX`, `CONN_POOL_MAX`) | idle stacks / conns kept warm per worker (not connection limits) | + +| build-time only | default | what | |---|---|---| -| `RING_ENTRIES` | 4096 | SQ depth (CQ is twice that) | -| `BUF_COUNT` × `BUF_SIZE` | 4096 × 2 KB | provided recv buffers per worker | | `RX_QUEUE` | 64 | slices a connection may hold undelivered | -| `STACK_SIZE` | 64 KB | per coroutine, plus a guard page | -| `CORO_POOL_MAX`, `CONN_POOL_MAX` | 512, 1024 | idle stacks / conns kept warm per worker (not connection limits) | -| `IOMA_REQ_CAP` | 16 KB | a request must fit here, else 413/431 | +| `FIXED_FILES` | 16384 | registered file slots per worker, and so its connection ceiling; 0 disables the table | +| `IOXD_PIPE_GATHER` | 16 KB | the reader's gathering buffer: a head must fit here (else 431) and so must a body read whole (else 413) | +| `IOXD_PIPE_LEAD` / `IOXD_PIPE_CAP` / `IOXD_PIPE_SLACK` | 512 / 8 KB / 8 | the writer's slab and the room in front of and behind it | -Override any of them with `-D` at build time. +The library's own limits - `IOXD_MAX_HEADERS` and the rest of the `IOXD_MAX_*` in `ioxd/http.h` - +are neither: they lay out the context, so `ioxd_run` checks that the application and the library +agree and refuses to start otherwise. diff --git a/CMakeLists.txt b/CMakeLists.txt index 1899ce0..a4b07c8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,11 @@ -# CMake build for the ioma library plus the playground examples. +# CMake build for the ioxd library plus the playground examples. # # Consume it from another CMake project either as a subdirectory: -# add_subdirectory(ioma) -# target_link_libraries(myapp PRIVATE ioma::ioma) +# add_subdirectory(ioxd) +# target_link_libraries(myapp PRIVATE ioxd::ioxd) # or, after `cmake --install`, via find_package: -# find_package(ioma REQUIRED) -# target_link_libraries(myapp PRIVATE ioma::ioma) +# find_package(ioxd REQUIRED) +# target_link_libraries(myapp PRIVATE ioxd::ioxd) cmake_minimum_required(VERSION 3.21) # PROJECT_IS_TOP_LEVEL, find_program(NO_CACHE) # The compiler: unless one is given (-DCMAKE_C_COMPILER, CC in the environment, a toolchain file), @@ -13,32 +13,32 @@ cmake_minimum_required(VERSION 3.21) # PROJECT_IS_TOP_LEVEL, find_program(NO_C # and the code is C23, which needs gcc 14. Decided before project(), where CMake fixes the # compiler; the make build does the same. if(NOT CMAKE_C_COMPILER AND NOT DEFINED ENV{CC} AND NOT CMAKE_TOOLCHAIN_FILE) - set(ioma_newest_gcc "") - set(ioma_newest_gcc_version 0) + set(ioxd_newest_gcc "") + set(ioxd_newest_gcc_version 0) foreach(candidate gcc gcc-14 gcc-15 gcc-16) unset(candidate_path) find_program(candidate_path ${candidate} NO_CACHE) if(candidate_path) execute_process(COMMAND ${candidate_path} -dumpfullversion OUTPUT_VARIABLE candidate_version OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) - if(candidate_version VERSION_GREATER ioma_newest_gcc_version) - set(ioma_newest_gcc ${candidate_path}) - set(ioma_newest_gcc_version ${candidate_version}) + if(candidate_version VERSION_GREATER ioxd_newest_gcc_version) + set(ioxd_newest_gcc ${candidate_path}) + set(ioxd_newest_gcc_version ${candidate_version}) endif() endif() endforeach() - if(ioma_newest_gcc) - set(CMAKE_C_COMPILER ${ioma_newest_gcc}) + if(ioxd_newest_gcc) + set(CMAKE_C_COMPILER ${ioxd_newest_gcc}) endif() endif() -project(ioma VERSION 0.1.0 LANGUAGES C ASM) +project(ioxd VERSION 0.1.0 LANGUAGES C ASM) set(CMAKE_C_STANDARD 23) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_EXTENSIONS ON) # gnu23: _GNU_SOURCE, statement exprs if(CMAKE_C_COMPILER_ID STREQUAL "GNU" AND CMAKE_C_COMPILER_VERSION VERSION_LESS 14) - message(FATAL_ERROR "libioma is C23 and needs gcc 14 or newer; this is gcc ${CMAKE_C_COMPILER_VERSION}. " + message(FATAL_ERROR "libioxd is C23 and needs gcc 14 or newer; this is gcc ${CMAKE_C_COMPILER_VERSION}. " "Configure with -DCMAKE_C_COMPILER=gcc-14 (in CLion: Settings > Build > Toolchains).") endif() set(CMAKE_EXPORT_COMPILE_COMMANDS ON) @@ -52,84 +52,140 @@ find_package(Threads REQUIRED) include(GNUInstallDirs) # --- the library (static by default; -DBUILD_SHARED_LIBS=ON for a .so) --- -add_library(ioma - src/io/uring.c - src/io/coro.c - src/io/switch_x86_64.S - src/io/bufring.c - src/io/conn.c - src/io/proactor.c - src/http/engine.c - src/http/api.c - src/http/router.c - src/http/run.c +add_library(ioxd + lib/io/uring.c + lib/io/coro.c + lib/io/switch_x86_64.S + lib/io/bufring.c + lib/io/conn.c + lib/io/proactor.c + lib/io/pipe.c + lib/http/engine.c + lib/http/api.c + lib/http/router.c + lib/http/run.c + lib/json/json.c + lib/tls/store.c + lib/tls/handshake.c third_party/picohttpparser/picohttpparser.c) -add_library(ioma::ioma ALIAS ioma) +add_library(ioxd::ioxd ALIAS ioxd) -target_include_directories(ioma +target_include_directories(ioxd PUBLIC $ - $ + $ PRIVATE - src + lib third_party/picohttpparser) -target_compile_definitions(ioma PRIVATE _GNU_SOURCE) -target_compile_options(ioma PRIVATE -Wall -Wextra) +target_compile_definitions(ioxd PRIVATE _GNU_SOURCE) +# TLS: OpenSSL for the handshake only; the kernel does the records (TLS.md). +option(IOXD_TLS "TLS listeners (needs OpenSSL 3)" ON) +if(IOXD_TLS) + find_package(OpenSSL 3 REQUIRED) + target_compile_definitions(ioxd PRIVATE IOXD_TLS=1) + target_link_libraries(ioxd PUBLIC OpenSSL::SSL OpenSSL::Crypto) +else() + target_compile_definitions(ioxd PRIVATE IOXD_TLS=0) +endif() +target_compile_options(ioxd PRIVATE -Wall -Wextra) # Fat LTO objects when supported: the archive stays plain-linkable, and consumers that link with # -flto get cross-file inlining (the demo below does). include(CheckCCompilerFlag) -check_c_compiler_flag("-flto -ffat-lto-objects" IOMA_HAVE_FAT_LTO) -if(IOMA_HAVE_FAT_LTO) - target_compile_options(ioma PRIVATE -flto -ffat-lto-objects) - target_link_options(ioma PRIVATE -flto) +check_c_compiler_flag("-flto -ffat-lto-objects" IOXD_HAVE_FAT_LTO) +if(IOXD_HAVE_FAT_LTO) + target_compile_options(ioxd PRIVATE -flto -ffat-lto-objects) + target_link_options(ioxd PRIVATE -flto) +endif() +# A coroutine's stack ends at a guard page, which only stops a frame that touches every page as it +# grows: stack clash protection makes the compiler emit those probes. +check_c_compiler_flag(-fstack-clash-protection IOXD_HAVE_STACK_CLASH) +if(IOXD_HAVE_STACK_CLASH) + target_compile_options(ioxd PRIVATE -fstack-clash-protection) +endif() +# A shared build exports ioxd_* and nothing else, through the version script the make build uses. +set(IOXD_VERSION_SCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/cmake/ioxd.map) +if(BUILD_SHARED_LIBS) + target_link_options(ioxd PRIVATE -Wl,--version-script,${IOXD_VERSION_SCRIPT}) + set_target_properties(ioxd PROPERTIES LINK_DEPENDS ${IOXD_VERSION_SCRIPT}) endif() -target_link_libraries(ioma PUBLIC Threads::Threads) -set_target_properties(ioma PROPERTIES VERSION ${PROJECT_VERSION} SOVERSION 0) +target_link_libraries(ioxd PUBLIC Threads::Threads) +set_target_properties(ioxd PROPERTIES VERSION ${PROJECT_VERSION} SOVERSION 0) # Vendored parser: silence its warnings, they are not ours to fix. set_source_files_properties(third_party/picohttpparser/picohttpparser.c PROPERTIES COMPILE_OPTIONS "-w") -# --- playground examples (off when ioma is a subproject) --- -option(IOMA_EXAMPLES "Build the playground examples" ${PROJECT_IS_TOP_LEVEL}) -if(IOMA_EXAMPLES) - add_executable(ioma-hello playground/hello/main.c) - target_link_libraries(ioma-hello PRIVATE ioma::ioma) - add_executable(ioma-test-server tests/server.c) - target_link_libraries(ioma-test-server PRIVATE ioma::ioma) - add_executable(ioma-unit tests/unit.c) - target_link_libraries(ioma-unit PRIVATE ioma::ioma) +# --- playground examples (off when ioxd is a subproject) --- +option(IOXD_EXAMPLES "Build the playground examples" ${PROJECT_IS_TOP_LEVEL}) +if(IOXD_EXAMPLES) + add_executable(ioxd-hello playground/hello/main.c) + target_link_libraries(ioxd-hello PRIVATE ioxd::ioxd) + add_executable(ioxd-test-server tests/server.c) + target_link_libraries(ioxd-test-server PRIVATE ioxd::ioxd) + add_executable(ioxd-pipe-server tests/pipe-server.c) + target_link_libraries(ioxd-pipe-server PRIVATE ioxd::ioxd) + add_executable(ioxd-unit tests/unit.c) + target_link_libraries(ioxd-unit PRIVATE ioxd::ioxd) + add_executable(ioxd-router-test tests/router_test.c) + target_link_libraries(ioxd-router-test PRIVATE ioxd::ioxd) + # The suites, through the one script `make check` runs, so the sequence lives in one place. + # The three that talk to the HTTP fixture share one entry, and so one fixture on one port, as + # they do under make: a fixture bound to a port the suite before it filled with TIME_WAIT + # connections has some of its new ones reset. Both entries bind fixed ports, so neither may + # run beside anything else. enable_testing() - add_test(NAME unit COMMAND ioma-unit) - if(IOMA_HAVE_FAT_LTO) - target_compile_options(ioma-hello PRIVATE -flto) - target_link_options(ioma-hello PRIVATE -flto) - target_compile_options(ioma-test-server PRIVATE -flto) - target_link_options(ioma-test-server PRIVATE -flto) + set(IOXD_CHECK_PORT 8099 CACHE STRING "first port the fixture listens on; it takes the two after it") + set(IOXD_PIPE_PORT 8102 CACHE STRING "port the pipe fixture listens on") + set(IOXD_TLS_PYTHON python3 CACHE STRING "a python with tlslite-ng, for tests/tls_early.py") + add_test(NAME unit COMMAND ioxd-unit) + add_test(NAME router COMMAND ioxd-router-test) + add_test(NAME suites COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/tests/run-suites.sh + --suite smoke --suite conformance --suite stress --suite tls + --port ${IOXD_CHECK_PORT} + --server $ + --tls-python ${IOXD_TLS_PYTHON} + --work ${CMAKE_CURRENT_BINARY_DIR}/check/suites) + add_test(NAME pipes COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/tests/run-suites.sh + --suite pipes + --pipe-port ${IOXD_PIPE_PORT} + --pipe-server $ + --work ${CMAKE_CURRENT_BINARY_DIR}/check/pipes) + set_tests_properties(suites pipes PROPERTIES RUN_SERIAL TRUE) + add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + DEPENDS ioxd-unit ioxd-router-test ioxd-test-server ioxd-pipe-server + USES_TERMINAL) + if(IOXD_HAVE_FAT_LTO) + target_compile_options(ioxd-hello PRIVATE -flto) + target_link_options(ioxd-hello PRIVATE -flto) + target_compile_options(ioxd-test-server PRIVATE -flto) + target_link_options(ioxd-test-server PRIVATE -flto) endif() endif() -# --- install + export so find_package(ioma) works --- -install(TARGETS ioma EXPORT iomaTargets +# --- install + export so find_package(ioxd) works --- +install(TARGETS ioxd EXPORT ioxdTargets ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}) -install(FILES include/ioma.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/ioma) -install(EXPORT iomaTargets - NAMESPACE ioma:: - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ioma - FILE iomaTargets.cmake) +install(FILES include/ioxd.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +install(DIRECTORY include/ioxd DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +install(EXPORT ioxdTargets + NAMESPACE ioxd:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ioxd + FILE ioxdTargets.cmake) -# A config file that re-finds the public Threads dependency, then loads the exported targets. +# A config file that re-finds the public dependencies - Threads, and OpenSSL when this build has +# TLS, which it is told through IOXD_TLS - then loads the exported targets. include(CMakePackageConfigHelpers) configure_package_config_file( - ${CMAKE_CURRENT_SOURCE_DIR}/cmake/iomaConfig.cmake.in - ${CMAKE_CURRENT_BINARY_DIR}/iomaConfig.cmake - INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ioma) + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/ioxdConfig.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/ioxdConfig.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ioxd) write_basic_package_version_file( - ${CMAKE_CURRENT_BINARY_DIR}/iomaConfigVersion.cmake + ${CMAKE_CURRENT_BINARY_DIR}/ioxdConfigVersion.cmake VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion) install(FILES - ${CMAKE_CURRENT_BINARY_DIR}/iomaConfig.cmake - ${CMAKE_CURRENT_BINARY_DIR}/iomaConfigVersion.cmake - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ioma) + ${CMAKE_CURRENT_BINARY_DIR}/ioxdConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/ioxdConfigVersion.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ioxd) diff --git a/DESIGN.md b/DESIGN.md index ee6120e..2f6892a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,6 +1,14 @@ # ioxide's TCP core, read for a minimal stackful C runtime -> Note: this is the design record for **ioma** (github.com/MDA2AV/ioma). Paths like `ioxide/...` and `ringzero/...` refer to the author's sibling repos (github.com/MDA2AV/ioxide, github.com/MDA2AV/ringzero); ioma itself is the `stackful/`→`ioma/` runtime described in section 3 and shipped in this repository. +> **Historical.** This is the v1 design record for **ioxd** (github.com/MDA2AV/ioxd): the reading +> of ioxide's TCP core and the ~500-line runtime planned from it, written before any of it was +> built. It does **not** describe what shipped. The v1 sketched here uses liburing, one-shot +> accept/recv/send, no provided buffers and files named `worker.c`/`listener.c`; the library has +> raw io_uring with no liburing, multishot accept and recv over a provided buffer ring, and the +> layout in [`ARCHITECTURE.md`](ARCHITECTURE.md), which supersedes this document. Kept for the +> reasoning - why a stackful coroutine removes half of ioxide's machinery, and the pitfalls in +> section 4, which still hold. Paths like `ioxide/...` and `ringzero/...` are the author's sibling +> repos (github.com/MDA2AV/ioxide, github.com/MDA2AV/ringzero). Scope: `ioxide/src/ioxide` — `io_uring/Ring.cs`, `Native/*`, `Reactor/*`, `Reactor/Transport/Tcp/*`, diff --git a/FILES.md b/FILES.md new file mode 100644 index 0000000..94f707d --- /dev/null +++ b/FILES.md @@ -0,0 +1,45 @@ +# Static files over the ring (design, branch `streams`) + +**Status.** None of this is built: there is no `ioxd_files_*` in the library, no tail capture in +the router and no `lib/io/watch.c`. This is the design, written down before the work. + +The model is ioxide.file's: small, hot files served from a baked, immutable snapshot; large files +as positional ring reads or, better, spliced from the descriptor straight into the socket - no +thread pool either way, and with kernel TLS the splice is encrypted on its way through. + +## The snapshot + +`ioxd_files_new("/srv/www")` walks the root once and builds a table keyed by URL path. Files up to +a threshold (256 KB) get their whole response baked: status line, `content-type` from the +extension, `content-length`, `etag` (inode, size, mtime), `last-modified`, then the body, in one +block, so serving one is a lookup and one send with nothing formatted. Larger files keep an open +descriptor and their `statx`; serving one is the head from the snapshot and the body by +`IORING_OP_SPLICE` from the descriptor through a pipe pair into the socket, in slices the writer +paces, or by positional `IORING_OP_READ` into the reply slab where splice is not wanted. Conditional +requests (`if-none-match`, `if-modified-since`) answer 304 from the snapshot alone. Ranges and +precompressed `.gz`/`.br` siblings are second-round work. + +The router grows a tail capture, `/static/*path`, so a handler is one line: + + IOXD_GET("/static/*path", assets); /* ioxd_files_serve(ctx, files, ctx->req.route_params[0].value) */ + +## Knowing a file changed + +Watching the file itself misses the case that matters: an atomic replace is a `rename` over the +old name, a new inode, and the old watch dies with the old one. So the watch is on directories, +one per directory in the tree (inotify watches are cheap; an asset tree of a few hundred +directories is nothing), and the events that mean "the content under this name is different" are +`IN_CLOSE_WRITE` (a write in place finished), `IN_MOVED_TO` (the atomic replace landed), +`IN_CREATE` and `IN_DELETE`. The descriptor would be read as an ordinary ring read by the same +control coroutine that watches certificates - one facility, `lib/io/watch.c`, with two clients. +Neither the watcher nor that file exists yet; the certificate store's own rotation watcher is +planned in the same terms (TLS.md), and today it reloads only when the application says so. + +Events are debounced, then the changed entries are confirmed with `statx` against the snapshot +(inode, size, mtime), re-baked, and a new snapshot is published: an atomic pointer with a +reference count, so a request that is mid-send from a baked block keeps the old snapshot alive +until its send completes, and the old snapshot is freed when the last lease drops. That is +ioxide.file's lease-safe reload, driven by the watch instead of only by `Reload()`, which stays +for deployments that swap the tree whole. `fanotify` could watch a whole mount with one +descriptor but needs privileges; inotify per directory does not. The fallback for filesystems +without events is the same `statx` sweep on a timer. diff --git a/Makefile b/Makefile index 769db64..d9ef087 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,15 @@ -# ioma - a static and shared library plus the playground examples that link it. +# ioxd - a static and shared library plus the playground examples that link it. # -# make build libioma.a, libioma.so and the examples +# make build libioxd.a, libioxd.so and the examples # make lib just the libraries -# make install install libs, headers (under /include/ioma) and ioma.pc +# make check the unit test and the suites, against the fixture servers +# make check-tiny the stress suite against a build with the buffers starved on purpose +# make check-all both +# make tidy clang-tidy over the library +# make install install libs, ioxd.h and ioxd/*.h (under /include) and ioxd.pc # sudo make install PREFIX=/usr/local # -# Downstream then builds with: cc app.c $(pkg-config --cflags --libs ioma) -o app +# Downstream then builds with: cc app.c $(pkg-config --cflags --libs ioxd) -o app # The compiler: unless CC is given, the newest gcc on the PATH (a distro's `gcc` is often older # than a `gcc-NN` installed beside it). The code is C23, which needs gcc 14 or newer. @@ -15,7 +19,7 @@ CC := $(if $(CC),$(CC),gcc) endif STD := $(shell $(CC) -std=gnu23 -x c -c /dev/null -o /dev/null 2>/dev/null && echo -std=gnu23) ifeq ($(STD),) -$(error $(CC) does not know -std=gnu23: libioma is C23 and needs gcc 14 or newer (make CC=gcc-14)) +$(error $(CC) does not know -std=gnu23: libioxd is C23 and needs gcc 14 or newer (make CC=gcc-14)) endif AR ?= ar # Fat LTO objects when the compiler supports them: the archive stays linkable by anyone (it also @@ -24,98 +28,191 @@ AR ?= ar LTO := $(shell $(CC) -Werror -flto -ffat-lto-objects -x c -c /dev/null -o /dev/null 2>/dev/null && echo -flto -ffat-lto-objects) CFLAGS ?= -O3 -g $(LTO) WARN := -Wall -Wextra $(STD) -CPP := -D_GNU_SOURCE -Iinclude -Isrc -Ithird_party/picohttpparser -HDRS := $(wildcard include/*.h src/*/*.h) +# A coroutine's stack ends at a guard page, which only stops a frame that touches every page as it +# grows: stack clash protection makes the compiler emit those probes. +HARDEN := $(shell $(CC) -Werror -fstack-clash-protection -x c -c /dev/null -o /dev/null 2>/dev/null && echo -fstack-clash-protection) +CPP := -D_GNU_SOURCE -Iinclude -Ilib -Ithird_party/picohttpparser +# TLS: OpenSSL for the handshake only; the kernel does the records (TLS.md). make TLS=0 leaves it out. +TLS ?= 1 +ifeq ($(TLS),1) +CPP += -DIOXD_TLS=1 +LIBS := -lssl -lcrypto +else +CPP += -DIOXD_TLS=0 +LIBS := +endif +HDRS := $(wildcard include/*.h include/ioxd/*.h lib/*/*.h) PTHREAD := -pthread VERSION := 0.1.0 -SONAME := libioma.so.0 +SONAME := libioxd.so.0 PREFIX ?= /usr/local LIBDIR := $(PREFIX)/lib -INCDIR := $(PREFIX)/include/ioma +INCDIR := $(PREFIX)/include PCDIR := $(LIBDIR)/pkgconfig -UNITS := io/uring io/coro io/bufring io/conn io/proactor http/engine http/api http/router http/run +UNITS := io/uring io/coro io/bufring io/conn io/proactor io/pipe http/engine http/api http/router http/run json/json tls/store tls/handshake OBJ := $(addprefix obj/,$(addsuffix .o,$(UNITS))) obj/io/switch_x86_64.o obj/picohttpparser.o PICOBJ := $(addprefix obj/pic/,$(addsuffix .o,$(UNITS))) obj/pic/io/switch_x86_64.o obj/pic/picohttpparser.o -EXAMPLES := ioma-hello -TESTSRV := tests/ioma-test-server -UNIT := tests/ioma-unit +EXAMPLES := ioxd-hello +TESTSRV := tests/ioxd-test-server +PIPESRV := tests/ioxd-pipe-server +UNIT := tests/ioxd-unit +ROUTER := tests/ioxd-router-test +# The linker version script: the .so exports ioxd_* and nothing else. +MAP := cmake/ioxd.map + +# Every flag an object is built with, in a file. TLS=0/1 - or a different CC or CFLAGS - changes +# what the objects must be, and a stamp they all depend on is what makes that a build dependency: +# the recipe rewrites it only when it differs, so an unchanged build stays untouched. +FLAGS := $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $(LIBS) -.PHONY: all lib examples check clean install uninstall +.PHONY: all lib examples check check-tiny check-all tidy manual clean install uninstall force all: lib examples -lib: libioma.a libioma.so +lib: libioxd.a libioxd.so + +force: +obj/flags: force + @mkdir -p $(@D) + @printf '%s\n' '$(FLAGS)' | cmp -s - $@ || printf '%s\n' '$(FLAGS)' > $@ -libioma.a: $(OBJ) +libioxd.a: $(OBJ) $(AR) rcs $@ $^ -libioma.so: $(PICOBJ) - $(CC) $(CFLAGS) -shared -Wl,-soname,$(SONAME) -o $@ $^ $(PTHREAD) +# The version script keeps the library's own names out of the dynamic symbol table: what a +# consumer may bind to is ioxd_*, and nothing else (nm -D libioxd.so says so). +libioxd.so: $(PICOBJ) $(MAP) + $(CC) $(CFLAGS) -shared -Wl,-soname,$(SONAME) -Wl,--version-script,$(MAP) -o $@ $(PICOBJ) $(PTHREAD) $(LIBS) -# --- static objects (used by libioma.a and the examples) --- -obj/%.o: src/%.c $(HDRS) +# --- static objects (used by libioxd.a and the examples) --- +obj/%.o: lib/%.c $(HDRS) obj/flags @mkdir -p $(@D) - $(CC) $(CFLAGS) $(WARN) $(CPP) $(PTHREAD) -c $< -o $@ -obj/io/switch_x86_64.o: src/io/switch_x86_64.S + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) -c $< -o $@ +obj/io/switch_x86_64.o: lib/io/switch_x86_64.S obj/flags @mkdir -p $(@D) $(CC) $(CFLAGS) $(CPP) -c $< -o $@ -obj/picohttpparser.o: third_party/picohttpparser/picohttpparser.c +obj/picohttpparser.o: third_party/picohttpparser/picohttpparser.c obj/flags @mkdir -p $(@D) - $(CC) $(CFLAGS) -w -Ithird_party/picohttpparser -c $< -o $@ + $(CC) $(CFLAGS) -w $(HARDEN) -Ithird_party/picohttpparser -c $< -o $@ -# --- position-independent objects (used by libioma.so) --- -obj/pic/%.o: src/%.c $(HDRS) +# --- position-independent objects (used by libioxd.so) --- +obj/pic/%.o: lib/%.c $(HDRS) obj/flags @mkdir -p $(@D) - $(CC) $(CFLAGS) $(WARN) $(CPP) $(PTHREAD) -fPIC -c $< -o $@ -obj/pic/io/switch_x86_64.o: src/io/switch_x86_64.S + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) -fPIC -c $< -o $@ +obj/pic/io/switch_x86_64.o: lib/io/switch_x86_64.S obj/flags @mkdir -p $(@D) $(CC) $(CFLAGS) $(CPP) -fPIC -c $< -o $@ -obj/pic/picohttpparser.o: third_party/picohttpparser/picohttpparser.c +obj/pic/picohttpparser.o: third_party/picohttpparser/picohttpparser.c obj/flags @mkdir -p $(@D) - $(CC) $(CFLAGS) -w -fPIC -Ithird_party/picohttpparser -c $< -o $@ + $(CC) $(CFLAGS) -w $(HARDEN) -fPIC -Ithird_party/picohttpparser -c $< -o $@ # --- examples link the static library --- examples: $(EXAMPLES) # Link the static archive directly so the example runs in-tree without installing the .so. -ioma-hello: playground/hello/main.c libioma.a - $(CC) $(CFLAGS) $(WARN) $(CPP) $(PTHREAD) $< libioma.a -o $@ $(PTHREAD) +ioxd-hello: playground/hello/main.c libioxd.a + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) # --- tests: the unit test, then the fixture server with both suites against it --- -$(TESTSRV): tests/server.c libioma.a - $(CC) $(CFLAGS) $(WARN) $(CPP) $(PTHREAD) $< libioma.a -o $@ $(PTHREAD) -$(UNIT): tests/unit.c libioma.a - $(CC) $(CFLAGS) $(WARN) $(CPP) $(PTHREAD) $< libioma.a -o $@ $(PTHREAD) +$(TESTSRV): tests/server.c libioxd.a + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) +$(UNIT): tests/unit.c libioxd.a + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) +$(PIPESRV): tests/pipe-server.c libioxd.a + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) +$(ROUTER): tests/router_test.c libioxd.a + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(PTHREAD) $< libioxd.a -o $@ $(PTHREAD) $(LIBS) CHECK_PORT ?= 8099 -check: $(TESTSRV) $(UNIT) - @./$(UNIT) || exit 1; \ - IOMA_WORKERS=2 IOMA_PORT=$(CHECK_PORT) ./$(TESTSRV) >/dev/null 2>&1 & pid=$$!; \ - for i in $$(seq 1 50); do ss -ltn | grep -q ":$(CHECK_PORT) " && break; sleep 0.1; done; \ - python3 tests/smoke.py $(CHECK_PORT); s=$$?; python3 tests/stress.py $(CHECK_PORT); t=$$?; \ - kill -INT $$pid; wait $$pid 2>/dev/null; exit $$((s | t)) +PIPE_PORT ?= 8102 # the fixture takes CHECK_PORT and the two after (plain, TLS) +TLS_PYTHON ?= python3 # a python with tlslite-ng, for tests/tls_early.py (skips itself otherwise) +TLSFUZZER ?= # a tlsfuzzer checkout, for `make check-tlsfuzzer` (TLS_PYTHON must have its requirements) +# The sequence itself is tests/run-suites.sh, so CMake's `check` target runs exactly this one. +check: $(TESTSRV) $(UNIT) $(PIPESRV) $(ROUTER) + @./$(ROUTER) || exit 1; \ + sh tests/run-suites.sh --port $(CHECK_PORT) --pipe-port $(PIPE_PORT) \ + --unit ./$(UNIT) --server ./$(TESTSRV) --pipe-server ./$(PIPESRV) --tls-python $(TLS_PYTHON) + +# --- the same stress suite, against a build starved on purpose --- +# 8 x 64 B receive buffers (ioxd_configure, through the fixture's environment) and a 4-deep +# per-connection queue (a build-time constant, so a second object directory): every request +# empties the buffer group, so recvs park on -ENOBUFS and are re-armed as handlers give buffers +# back, and the queue overflows at the first stall. +TINY := -DRX_QUEUE=4 # the buffers come from the environment: ioxd_configure at run time +TINYOBJ := $(addprefix obj-tiny/,$(addsuffix .o,$(UNITS))) obj-tiny/io/switch_x86_64.o obj-tiny/picohttpparser.o +TINYSRV := tests/ioxd-test-server-tiny +TINY_PORT ?= 8410 + +obj-tiny/%.o: lib/%.c $(HDRS) obj/flags + @mkdir -p $(@D) + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(TINY) $(PTHREAD) -c $< -o $@ +obj-tiny/io/switch_x86_64.o: lib/io/switch_x86_64.S obj/flags + @mkdir -p $(@D) + $(CC) $(CFLAGS) $(CPP) $(TINY) -c $< -o $@ +obj-tiny/picohttpparser.o: third_party/picohttpparser/picohttpparser.c obj/flags + @mkdir -p $(@D) + $(CC) $(CFLAGS) -w $(HARDEN) -Ithird_party/picohttpparser -c $< -o $@ +libioxd-tiny.a: $(TINYOBJ) + $(AR) rcs $@ $^ +$(TINYSRV): tests/server.c libioxd-tiny.a + $(CC) $(CFLAGS) $(WARN) $(HARDEN) $(CPP) $(TINY) $(PTHREAD) $< libioxd-tiny.a -o $@ $(PTHREAD) $(LIBS) + +check-tiny: $(TINYSRV) + @IOXD_RECV_BUFFERS=8 IOXD_RECV_BUFFER_SIZE=64 sh tests/run-suites.sh --suite stress --port $(TINY_PORT) --server ./$(TINYSRV) --work obj-tiny/check + +# Everything: the default build's suites, then the starved build's. +check-all: check check-tiny + +# --- clang-tidy over the library with the flags its objects are built with (.clang-tidy holds the +# checks). CLion's bundled binary ships without clang's builtin headers, so gcc's own are handed +# to it: make tidy TIDY=/bin/clang/linux/x64/bin/clang-tidy --- +TIDY ?= clang-tidy +TIDY_ARGS ?= --extra-arg=-isystem$(shell $(CC) -print-file-name=include) +TIDY_SRC := $(wildcard lib/*/*.c) tests/server.c tests/pipe-server.c tests/unit.c +tidy: + $(TIDY) $(TIDY_ARGS) $(TIDY_SRC) -- $(STD) $(CPP) $(PTHREAD) + +# --- the manual: man-page style HTML for every public header, generated from the headers --- +manual: + python3 manual/build.py # --- pkg-config --- -ioma.pc: ioma.pc.in - sed -e 's|@PREFIX@|$(PREFIX)|g' -e 's|@VERSION@|$(VERSION)|g' $< > $@ +ioxd.pc: ioxd.pc.in obj/flags + sed -e 's|@PREFIX@|$(PREFIX)|g' -e 's|@VERSION@|$(VERSION)|g' -e 's|@LIBS@|$(LIBS)|g' $< > $@ # --- install / uninstall --- -install: lib ioma.pc - install -d $(DESTDIR)$(LIBDIR) $(DESTDIR)$(INCDIR) $(DESTDIR)$(PCDIR) - install -m644 libioma.a $(DESTDIR)$(LIBDIR)/ - install -m755 libioma.so $(DESTDIR)$(LIBDIR)/libioma.so.$(VERSION) - ln -sf libioma.so.$(VERSION) $(DESTDIR)$(LIBDIR)/$(SONAME) - ln -sf $(SONAME) $(DESTDIR)$(LIBDIR)/libioma.so - install -m644 include/ioma.h $(DESTDIR)$(INCDIR)/ - install -m644 ioma.pc $(DESTDIR)$(PCDIR)/ - @echo "installed ioma $(VERSION) to $(PREFIX)" +install: lib ioxd.pc + install -d $(DESTDIR)$(LIBDIR) $(DESTDIR)$(INCDIR)/ioxd $(DESTDIR)$(PCDIR) + install -m644 libioxd.a $(DESTDIR)$(LIBDIR)/ + install -m755 libioxd.so $(DESTDIR)$(LIBDIR)/libioxd.so.$(VERSION) + ln -sf libioxd.so.$(VERSION) $(DESTDIR)$(LIBDIR)/$(SONAME) + ln -sf $(SONAME) $(DESTDIR)$(LIBDIR)/libioxd.so + install -m644 include/ioxd.h $(DESTDIR)$(INCDIR)/ + install -m644 include/ioxd/*.h $(DESTDIR)$(INCDIR)/ioxd/ + install -m644 ioxd.pc $(DESTDIR)$(PCDIR)/ + @echo "installed ioxd $(VERSION) to $(PREFIX)" uninstall: - rm -f $(DESTDIR)$(LIBDIR)/libioma.a $(DESTDIR)$(LIBDIR)/libioma.so* - rm -rf $(DESTDIR)$(INCDIR) - rm -f $(DESTDIR)$(PCDIR)/ioma.pc + rm -f $(DESTDIR)$(LIBDIR)/libioxd.a $(DESTDIR)$(LIBDIR)/libioxd.so* + rm -rf $(DESTDIR)$(INCDIR)/ioxd $(DESTDIR)$(INCDIR)/ioxd.h + rm -f $(DESTDIR)$(PCDIR)/ioxd.pc clean: - rm -rf obj libioma.a libioma.so $(EXAMPLES) $(TESTSRV) $(UNIT) ioma.pc + rm -rf obj obj-tiny libioxd.a libioxd.so libioxd-tiny.a $(EXAMPLES) $(TESTSRV) $(PIPESRV) $(TINYSRV) $(UNIT) $(ROUTER) ioxd.pc + +# tlsfuzzer's TLS 1.3 conformance scripts that apply to a TLS 1.3-only, one-suite server; the +# fixture must be up on CHECK_PORT with IOXD_CERTS (as `make check` runs it). Expected to pass: +# the rest of the suite probes AES-256, TLS 1.2 fallback and alerts we do not send (TLS.md). +TLSFUZZER_SCRIPTS := conversation zero-length-data record-padding unrecognised-groups keyshare-omitted rsa-signatures +.PHONY: check-tlsfuzzer +check-tlsfuzzer: $(TESTSRV) + @[ -n "$(TLSFUZZER)" ] || { echo "set TLSFUZZER= (git clone https://github.com/tlsfuzzer/tlsfuzzer)"; exit 2; } + @[ -f tests/certs/default/cert.pem ] || sh tests/mkcerts.sh tests/certs >/dev/null + @IOXD_WORKERS=2 IOXD_PORT=$(CHECK_PORT) IOXD_CERTS=tests/certs ./$(TESTSRV) >/dev/null 2>&1 & pid=$$!; \ + for i in $$(seq 1 50); do ss -ltn | grep -q ":$$(($(CHECK_PORT) + 2)) " && break; sleep 0.1; done; \ + rc=0; for t in $(TLSFUZZER_SCRIPTS); do \ + (cd $(TLSFUZZER) && $(abspath $(TLS_PYTHON)) scripts/test-tls13-$$t.py -h 127.0.0.1 -p $$(($(CHECK_PORT) + 2)) >/dev/null 2>&1) \ + && echo "ok tlsfuzzer test-tls13-$$t" || { echo "FAIL tlsfuzzer test-tls13-$$t"; rc=1; }; \ + done; kill -INT $$pid; wait $$pid 2>/dev/null; exit $$rc diff --git a/PERF.md b/PERF.md index e930506..33bf8cf 100644 --- a/PERF.md +++ b/PERF.md @@ -1,6 +1,6 @@ # Performance notes -What has been tried on libioma, what it measured, and what is left. Numbers are saturated +What has been tried on libioxd, what it measured, and what is left. Numbers are saturated keep-alive throughput of the HttpArena baseline handler with the server pinned to 4 cores of an i9-14900K and the load on other cores, unless stated otherwise. Run-to-run noise is about ±1%, so treat anything under that as "no change". Connection churn is the same load with one request per @@ -28,15 +28,24 @@ profiles score. | Registered ring fd (`IORING_REGISTER_RING_FDS`) | not measurable | | Registered file table: direct accept into slots, recv/send/close by index | keep-alive not measurable; churn +3%; connections no longer consume process fds | | PGO (`-fprofile-generate`, train, `-fprofile-use`) on top of LTO | +1.5–2% | -| Request model: the query split into `params` eagerly, header names lower-cased at parse (8 bytes a step) so handlers compare with plain `ioma_slice_eq` | about −1% each; the price of direct data access | +| Request model: the query split into `params` eagerly, header names lower-cased at parse (8 bytes a step) so handlers compare with plain `ioxd_slice_eq` | about −1% each; the price of direct data access | +| Parse straight from the provided buffer when a whole request sits in one (the pipe's reader keeps it in place; the copy happens only for a request that spans receives) | neutral, as predicted: the copy it removes was under 1% | | gcc 14 and C23 (was gcc 13 and gnu11) | neutral: 1.23M vs 1.22M req/s keep-alive and equal churn, wrk and oha, three interleaved rounds on 4 reactors. The standard changes what the compiler accepts, not the code it emits | +| The review hardening: the request framing checked before a handler sees it, reply headers copied and validated into the response's arena, and the loop's own bookkeeping | about −1%: 1.258M → 1.245M req/s keep-alive, medians of interleaved wrk rounds on 4 reactors. Paid for correctness; the CQ head is still published once per batch | + +The `-D` switches: `FIXED_FILES=0` disables the registered file table and `NO_REG_RING` the +registered ring fd - both features fall back at runtime on kernels that lack them anyway. The rest +are the tunables' defaults, each defined where it is used: `BUF_SIZE` and `BUF_COUNT` in +`lib/io/bufring.h`, `RX_QUEUE` and `CONN_POOL_MAX` in `lib/io/conn.h`, `CORO_POOL_MAX` and +`CORO_GUARD` in `lib/io/coro.h` and `lib/io/coro.c`, `RING_ENTRIES`, `STACK_SIZE` and +`FIXED_FILES` itself in `lib/io/proactor.h`. An application sets the ring, the buffers, the stack +and the pools per worker at run time instead, with `ioxd_configure` (`ioxd/config.h`), so a +deployment tunes them without rebuilding the library; `RX_QUEUE` and `FIXED_FILES` stay build-time. -The `-D` switches: `FIXED_FILES=0` disables the file table, `NO_REG_RING` the registered ring fd, -`BUF_SIZE`/`BUF_COUNT`/`RING_ENTRIES`/`RX_QUEUE`/`STACK_SIZE`/`CORO_POOL_MAX`/`CONN_POOL_MAX` are A recv that finds the ring empty is logged, at most once a second per worker, with counts: -`ioma: [w3] recv found no provided buffer N times ...: raise BUF_COUNT`. That line is the signal to raise -`-DBUF_COUNT` (a power of two, up to 65536; each buffer is `BUF_SIZE` bytes of the per-worker slab). -in `src/io/proactor.h`. Both ring features fall back at runtime on kernels that lack them. +`ioxd: [w3] recv found no provided buffer N times (M in total, K connections parked): raise +BUF_COUNT`. That line is the signal to raise `-DBUF_COUNT` (a power of two, at most 32768 - the +kernel refuses a ring of 65536 entries; each buffer is `BUF_SIZE` bytes of the per-worker slab). ### How to do PGO @@ -60,10 +69,10 @@ At saturation the per-request cost is about 3 µs on the i9 and about 15 µs on and nearly all of it is the kernel: the multishot recv delivery, the send, the TCP stack. The userspace work (parse, route, serialize, two coroutine switches in and two out) is well under a microsecond. That is why the remaining wins are in the low single digits: the peers pay the same -kernel cost, and libioma, libreactor and libxev land within a couple of percent of each other. +kernel cost, and libioxd, libreactor and libxev land within a couple of percent of each other. The 8-CPU saturation profile is the one place where every percent still matters: at the offered -500K req/s libioma sits at ~100% CPU on those cores, and queueing latency explodes near full +500K req/s libioxd sits at ~100% CPU on those cores, and queueing latency explodes near full utilisation, so a 2% cut in CPU per request buys far more than 2% in latency. ## Not worth it here @@ -78,6 +87,5 @@ utilisation, so a 2% cut in CPU per request buys far more than 2% in latency. ## Still open -- **Parse straight from the provided buffer** when a whole request sits in one, skipping the copy in `await_recv`. Small. - **Connection steering** (`SO_INCOMING_CPU`, reuseport BPF) so a connection is served by the worker on its NIC queue's CPU. Real on a NIC, irrelevant on the loopback the benchmark uses. - **Profiling on the benchmark host itself** with `perf`, to see the split of that 15 µs. This is the one thing that could reveal something not visible from the i9. diff --git a/README.md b/README.md index fad479f..30988a9 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,80 @@ -# libioma +# libioxd -An HTTP/1.1 server library in C. One worker per core, io_uring underneath with no liburing, and a +An HTTP/1.1 server library in C, the sibling of [ioxide](https://github.com/MDA2AV/ioxide), the .NET +library this design started from. One worker per core, io_uring underneath with no liburing, and a stackful coroutine per connection, so an endpoint is a plain function that takes a request and returns a response. Modelled on [ioxide](https://github.com/MDA2AV/ioxide)'s TCP core. How the runtime works is in [`ARCHITECTURE.md`](ARCHITECTURE.md). +## Manual + +The API is documented as man pages at [mda2av.github.io/libioxd](https://mda2av.github.io/libioxd/) (the `manual/` directory, deployed by a workflow on every push): one page per public header +(`ioxd_http(3)`, `ioxd_router(3)`, ...), generated from the headers themselves by `make manual`, an +overview in `ioxd(7)`, and every public name in one list; `manual/index.html` is the same site, offline. + ## Build -Run `make` in the repo root. It produces `libioma.a`, `libioma.so` and the demo server -`ioma-hello`. CMake works too. Requirements: Linux 6.x, x86-64, gcc 14 or newer: the code is C23. On Ubuntu 24.04 -`sudo apt install gcc-14`; make and CMake pick the newest gcc they find unless told otherwise. +Run `make` in the repo root. It produces `libioxd.a`, `libioxd.so` and the demo server +`ioxd-hello`. CMake works too. Requirements: Linux 6.x, x86-64, gcc 14 or newer (the code is C23), +and OpenSSL 3 with its headers, which the TLS handshake links against. On Ubuntu 24.04 +`sudo apt install gcc-14 libssl-dev`; make and CMake pick the newest gcc they find unless told +otherwise. TLS is built by default: `make TLS=0`, or CMake's `-DIOXD_TLS=OFF`, leaves it out and +with it the OpenSSL dependency, and `ioxd_certs_load` then returns NULL with a line saying so. ## Run -`./ioma-hello` is the smallest server: two routes, `GET /hello/:name` and a `POST /repeat/:times` -that reads the body and streams it back, one worker per core on port 8080. Ctrl-C stops it. A server exercising every feature of the request and response model +`./ioxd-hello` is the smallest server: three routes, `GET /hello/:name`, a `GET /users/:id` that writes JSON, and a `POST /repeat/:times` +that reads the body and streams it back, one worker per core on port 8080; given a directory of certificates (`sh tests/mkcerts.sh certs && ./ioxd-hello certs`) it serves the same routes over TLS on 8443 too. Ctrl-C stops it. A server exercising every feature of the request and response model is `tests/server.c`, the fixture the test suites run against. ## Use it in your project Install with `make install` (set `PREFIX` to choose where), then build against it with pkg-config -(`ioma`) or CMake (`find_package(ioma)`, link `ioma::ioma`). Adding the repo as a CMake -subdirectory works as well. Include `ioma.h`. Compile and link your program with `-flto` and the +(`ioxd`) or CMake (`find_package(ioxd)`, link `ioxd::ioxd`). Adding the repo as a CMake +subdirectory works as well. Include `ioxd.h`. Compile and link your program with `-flto` and the compiler inlines your handlers into the engine (the library ships fat LTO objects); it is worth about two percent. An endpoint is a function that receives a context holding the request and the response. Everything in the request is a slice (pointer and length); headers, query parameters and route parameters are key/value arrays on it that you read directly, and the body is read only when you -ask: `ioma_body_all` reads it whole, `ioma_body_read_until` streams it, `ioma_body_read_next_chunk` hands over +ask: `ioxd_body_all` reads it whole, `ioxd_body_read_until` streams it, `ioxd_body_read_next_chunk` hands over one chunk at a time, and what you leave unread is drained. -Everything arrives as slices, bytes with a length; `ioma_to_int`, `ioma_to_double`, `ioma_to_bool` and -the `ioma_slice_*` helpers compare and convert them without copying, and fail instead of guessing. -Set the status and content type on the response, add headers with `ioma_header`, and write the -body into its slab with `ioma_write`, `ioma_text` or `ioma_printf`; the framework sends the head -in front of it, in one send when it fits and streamed when it does not. Endpoints live in groups: a group is a path prefix plus middleware, groups nest, and `ioma_get(api, "/users/:id", user)` +Everything arrives as slices, bytes with a length; `ioxd_to_int`, `ioxd_to_double`, `ioxd_to_bool` and +the `ioxd_slice_*` helpers compare and convert them without copying, and fail instead of guessing. +Set the status and content type on the response, add headers with `ioxd_header`, and write the +body into its slab with `ioxd_write`, `ioxd_text` or `ioxd_printf`; the framework sends the head +in front of it, in one send when it fits and streamed when it does not. Endpoints live in groups: a group is a path prefix plus middleware, groups nest, and `ioxd_get(api, "/users/:id", user)` under a group at `/api` answers at `/api/users/:id`, wrapped by the middleware of every group above it; the root -is `NULL`, with `ioma_use` for middleware on everything. `ioma_run` resolves it all once into a segment tree and flat -chains, so a request costs one walk and no scan, then serves with a worker count (zero means one per core) and a port. -The same registrations read as a script with the `IOMA_GET`, `IOMA_GROUP` and `IOMA_USE` macros, a group's block +is `NULL`, with `ioxd_use` for middleware on everything. `ioxd_run` resolves it all once into a segment tree and flat +chains, so a request costs one walk and no scan, then serves with a worker count (zero means one per core) over every port +bound before it with `ioxd_bind(port, NULL)` - or `ioxd_bind(port, store)` for TLS, the store from +`ioxd_certs_load("")`, a directory of `/cert.pem` and `key.pem` ([`TLS.md`](TLS.md)). +The runtime's knobs - the ring, the receive buffers, the coroutine stacks, the pools, per worker - are set +before the run with `ioxd_configure`; a zero field keeps the build's default. +Underneath, a connection is a pipe: `ioxd_run_pipes` hands a handler of your own the reader and writer the +HTTP engine uses, for raw TCP, with the same suspend-and-resume. A JSON reply is written as you go with +the `ioxd_json` writer, the shape of .NET's Utf8JsonWriter: no tree, no allocation, streamed as the slab fills; a struct +described once with `IOXD_JSON_STRUCT` serializes with one call, nested objects and arrays included. The same registrations read as a script with the `IOXD_GET`, `IOXD_GROUP` and `IOXD_USE` macros, a group's block nesting the routes below it; the hello example and `tests/server.c` are written that way. `playground/hello/main.c` is a complete example. ## Tests and limits -`make check` builds the fixture server, runs `tests/smoke.py` and `tests/stress.py` against it -and stops it. HTTP/1.1 only, no TLS, a request head and a body read whole up to 16 KB (streamed -bodies have no limit). MIT licensed. +`make check` builds the fixtures and runs the router test, then `tests/run-suites.sh`: the unit +test, then the smoke, conformance, stress and early-TLS suites against one HTTP fixture - one +fixture for all four, since a port the suite before it left full of `TIME_WAIT` connections resets +some of the next one's - then the pipe suite against the pipe fixture. `make check-tiny` runs the +stress suite alone against the fixture starved on purpose - eight 64-byte receive buffers through +`ioxd_configure`, and a build with `-DRX_QUEUE=4` - so every request empties the buffer group; +`make check-all` runs both. `make tidy` +runs clang-tidy over the library, and `make check-tlsfuzzer TLSFUZZER=` runs six of +tlsfuzzer's TLS 1.3 scripts against the fixture. The suites need python3 and the `openssl` command +(`tests/mkcerts.sh` makes the certificates); `tests/tls_early.py` needs tlslite-ng and skips itself +without it, so pass a python that has it as `make check TLS_PYTHON=...`; tlsfuzzer needs its own +requirements in that python; `make tidy` needs clang-tidy. CMake runs the same script: a `check` +target over ctest, with `unit`, `router`, `suites` and `pipes` entries. + +HTTP/1.1 only. TLS 1.3, terminated in the kernel after an OpenSSL handshake, one cipher suite, no +tickets and no client certificates ([`TLS.md`](TLS.md)). A request head, and a body read whole, must +fit 16 KB; streamed bodies have no limit. MIT licensed. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..0efb2e6 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,177 @@ +# The review of 2026-09-09 + +Sixteen independent reviewers, one per area, read the whole tree at commit 1f00207 (branch +`streams`, the state after TLS shipped) with the brief "only what you verified by reading the +code; quote the line". Their findings were consolidated, verified again where the fix depended on +it, and fixed in the commits that follow 1f00207. This is the record: what was found, what was +done about it, and what was deliberately left. + +Severity is the reviewer's, kept even where the fix turned out to be small. "Fixed" means the +code changed and a test exercises the new behaviour; the test suites are named in README.md. + +## The ones that mattered most + +- **A request sent in the same segment as the client's TLS Finished was lost** (critical). The + handshake loop wrote every delivered byte into OpenSSL's read BIO; an application record that + arrived with the Finished vanished into it, the drain found nothing, and kernel RX was installed + one sequence number behind - the connection hung. The test meant to catch this held the + Finished through `send()`, but tlslite sends it through `sendall()`, so its three coalescing + cases never coalesced. Fixed: one record per feed, the drain assembles records in its own + scratch; the test holds both calls and fails on the old code. `lib/tls/handshake.c`, + `tests/tls_early.py`. +- **The public headers were never in the repository.** `.gitignore` had a bare `ioxd` line for + an old binary; it also matched `include/ioxd/`, so `slice.h`, `http.h`, `router.h`, `json.h`, + `pipe.h` and `tls.h` were never committed and a fresh clone did not build. Found by the fix + agents, not the reviewers. Fixed: `/ioxd`, headers added. +- **Request smuggling through the framing headers** (critical, four ways). `Content-Length` + stopped at the first non-digit and did not check overflow (`+5`, `0x5`, `5abc`, 2^64+5 all + read as something else), duplicates were last-wins, `Transfer-Encoding` was last-wins and + matched `chunked` anywhere in the list, both framings together were accepted, a folded + continuation line was skipped, Host was never checked. Every one verified live as a desync + behind a proxy. Fixed with 400 (501 for a transfer coding we do not implement) and close; + `tests/conformance.py` sends each. `lib/http/engine.c`. +- **Response splitting through `ioxd_header`** (critical). Values went on the wire byte for + byte, and a percent-decoded `%0d%0a` in a query value became a second header line. Fixed: + names must be tokens, values may hold no control byte, the engine's own headers cannot be set, + and both are copied into a per-reply arena - the pointers used to be read after the handler's + frame was gone, which sent stack garbage. `lib/http/api.c`, `include/ioxd/http.h`. +- **HEAD, 204 and 304 carried bodies** (critical), and `HEAD` of a GET route was a 405. Fixed: + the router serves HEAD from GET, the engine sends the head alone (Content-Length as the GET + would have had; no framing header on a 1xx or 204). `lib/http/router.c`, `lib/http/engine.c`. +- **A kept pointer from `ioxd_pipe_keep` could dangle** (critical, public pipe API only). A run + started in a fresh kernel buffer was copied out by `gather` and the buffer went back to the + ring, where another connection's recv refilled it. Fixed: the buffer is pinned until release. + `lib/io/pipe.c`. +- **A paused recv was stranded under buffer starvation** (high, three reviewers). The + `-ENOBUFS` completion never looked at `pausing`, so a TLS handshake that hit starvation parked + its coroutine forever, leaking the connection. Fixed, along with the rest of the pause/resume + corners (eof, closed, a stale cancel). `lib/io/conn.c`. +- **Shutdown abandoned every live connection** (high). The loop exited on the stop flag, parked + coroutines never unwound, their stacks and sockets leaked, and the recv slab was unmapped while + recvs could still land in it; `ioxd_run` returned 0 and could not be called again. Fixed: a + drain (accepts cancelled, one `ASYNC_CANCEL_ANY`, run until nothing is live or 2 s pass), the + stop flag and listener table reset per run, a worker failure returned. `lib/io/proactor.c`, + `lib/http/run.c`. +- **A reload could free a table a stalled handshake still used** (critical, not yet reachable: + nothing called reload). The ClientHello callback's argument was re-pointed on an `SSL_CTX` + shared between two tables. Fixed: the table rides on the SSL. Also: certificates are checked + for validity dates, a missing `default` is an error, reloads serialise. `lib/tls/store.c`. + +## By area + +### I/O plane (uring, bufring, conn, proactor, coro) + +| Sev | Finding | Status | +|---|---|---| +| high | mmap failure in `uring_init` left the struct populated; `uring_exit` double-freed | fixed | +| high | persistent accept error (-ENFILE with the file table full) spun re-arming at 100 % CPU | fixed: stalled listeners re-arm when connections drop or after a second; the table is a ceiling | +| high | `ioxd__sqe` ignored `uring_submit`'s -EBUSY and aborted the process | fixed: the CQ head is published before a mid-batch enter, which retries with GETEVENTS | +| high | slab unmapped with recvs in flight; live connections abandoned at stop | fixed: the drain | +| high | one 4 KB guard page below 25 KB frames, no stack probes in our own flags | fixed: 64 KB guard, `-fstack-clash-protection`, 128 KB stacks | +| high | `swap_ctx` and `coro_*` exported and interposable from the .so | fixed: hidden, plus a version script exporting `ioxd_*` only | +| medium | CQ overflow invisible | fixed: the flag forces an enter; overflows counted in the stop line | +| medium | starved sweep re-armed every parked connection per returned buffer | fixed: at most as many as were returned, FIFO | +| medium | fatal enter error retired one worker silently | fixed: stop set, `ioxd_run` non-zero | +| medium | no CFI in the context switch; loop-only invariant was an `assert` | fixed | +| medium | `BUF_COUNT` bound one power of two too permissive; compat `#ifndef`s missing for the newer flags | fixed | +| medium | pooled stacks never trimmed | left: warm stacks are the point of the pool (`CORO_POOL_MAX` bounds it); noted in coro.c | +| medium | a stack per accepted connection before any handler runs | left: pooled, and admission is now bounded by the file table | +| low | TAG_OP was 0 (a forgotten user_data dispatched as a null op); direct close untagged; `fd > 0`; banner snprintf; dangling `starved` | fixed | +| low | MXCSR/x87 not switched; CET off for the whole program by the switch's missing note | documented in coro.h and switch_x86_64.S | +| low | setsockopt fallback dead under registered files | documented: kernel TLS needs `SOCKET_URING_OP_SETSOCKOPT` (6.7+) or `FIXED_FILES=0` | + +### Pipes + +| Sev | Finding | Status | +|---|---|---| +| critical | kept pointers dangled after `gather` | fixed: the buffer is pinned | +| high | `inject` with a run held in `cur` broke the live-in-buf invariant | fixed | +| high | the gathering buffer (16 KB) is smaller than a maximal TLS record | fixed on the TLS side: records are assembled in the prologue's scratch, never in the reader | +| medium | `drop` past the live bytes, `keep` of more than is live, `advance` past the slab | fixed: clamped | +| low | `avail` collapsed error and empty; `inject` ignored a sticky error; a raw handler's unsent slab was dropped at close | fixed | + +### HTTP engine, request side + +| Sev | Finding | Status | +|---|---|---| +| critical | `Content-Length` parse, duplicates, `Transfer-Encoding` last-wins, both framings | fixed: 400/501 and close | +| high | obs-fold skipped; Host never checked | fixed | +| medium | trailers unbounded; absolute-form targets 404; `Connection` last-wins | fixed | +| low | bare whitespace after a chunk size; `int` overflow on huge reads; bytes copied lost on a chunk-end error | fixed | +| low | `ioxd_kv_parse` dropped pairs silently past its limits | fixed: `truncated` out-parameter; the engine answers 400/414 | + +### HTTP engine, reply side + +| Sev | Finding | Status | +|---|---|---| +| critical | header values unvalidated; HEAD/204/304 bodies | fixed | +| high | handler-set `content-length`/`transfer-encoding` reached the wire; a declared length never reconciled | fixed: refused; buffered replies get the real length, streams are cut at it and close when short | +| medium | framing headers on 1xx/204; status not validated; no `100 Continue`; drain cap decided after the head froze | fixed | +| low | a head past its cap closed silently; `ioxd_advance` unbounded; OOM in `ioxd_printf` not sticky | fixed | + +### Router + +| Sev | Finding | Status | +|---|---|---| +| high | the 405 `allow` list omitted methods reachable through a capture route | fixed: the union of every path node reached, HEAD after GET | +| high | four registration functions lacked the after-`ioxd_run` guard | fixed | +| medium | `break` inside `IOXD_GROUP` left the group open; prefix and path glued without a slash; 17 middleware silently dropped one; captures raw while `params` are decoded | fixed | +| low | caller strings kept by pointer; `ioxd_next_run` twice replayed the chain; allow behind group middleware | fixed / fixed / documented | + +### Slices, conversions, helpers + +| Sev | Finding | Status | +|---|---|---| +| critical | `ioxd_header` accepted CRLF (see above) | fixed | +| medium | `%00` decoded to a NUL and `ioxd_cstr` reported success on it | fixed: `%00` stays literal, `ioxd_cstr` refuses an embedded NUL | +| low | NULL C strings crashed; a locale failure fell back to the process locale; 128-byte numbers refused | fixed / fixed / left (documented) | + +### JSON + +| Sev | Finding | Status | +|---|---|---| +| high | a multi-byte decimal point corrupted every double; an unbalanced `end` went unreported | fixed: a private C locale; `failed` set; `ioxd_json_done` | +| medium | no level rules (a key in an array, a value without a key); `float` printed as a double; the header example's `//` swallowed macro lines | fixed | +| low | depth 64 aliased the root level; a trailing comma on a too-deep open; empty `raw`; refused reserves not retried | fixed | + +### TLS + +| Sev | Finding | Status | +|---|---|---| +| critical | early data lost with the Finished (see above); the reload callback argument | fixed | +| high | early plaintext of 64 KB could not be delivered after the keys were installed; a split record could be spliced wrongly | fixed: bounded by the reader, assembled in scratch | +| high | no validity-period check on load | fixed | +| medium | close_notify in the drain window lost the request before it; a KeyUpdate desynchronised silently; reload raced itself; the fallback host followed `readdir` order | fixed | +| low | SNI case-fold matched control bytes; a trailing dot; error queue leftovers; no `ioxd_tls_free`; key file permissions | fixed | + +### Public API and documentation + +| Sev | Finding | Status | +|---|---|---| +| critical | the limits that size `ioxd_ctx` could be redefined by an application | fixed: `ioxd_run` passes `sizeof(ioxd_ctx)` and the library refuses a mismatch | +| high | `ioxd_header` pointer lifetime; strings kept by the router | fixed: copied | +| medium | `ioxd_run` one-shot; `pthread_create` failure path; comments naming members and functions that do not exist; the umbrella header's example did not compile | fixed | +| medium | README said "no TLS", lacked the prerequisites; `IOXD_REQ_CAP`; the rotation watcher described as built; DESIGN.md claiming to describe what shipped | fixed in the docs pass | + +### Tests and build + +| Sev | Finding | Status | +|---|---|---| +| critical | no negative framing tests at all | fixed: `tests/conformance.py` | +| high | an assertion that could not fail; the starvation test never starved; the fixture's exit status discarded; `TLS=0` not a build dependency; the CMake package unusable downstream | fixed: `check-tiny`, `tests/run-suites.sh`, an `obj/flags` stamp, `find_dependency(OpenSSL)` | +| medium | assertions that passed on a hang; untested public functions; wildcard SNI and reload untested; `ctest` ran one test | fixed | +| low | no `make tidy`; `concurrency-*` off | fixed | + +## What the reviewers confirmed correct + +The io_uring memory ordering, the two-owner reference count on a connection, the buffer-ring +contract and every buffer's path back to the ring, the context switch and its forged frame, the +chunked framing of replies, the front/back arithmetic of the writer, the key schedule and nonce +split of the TLS handoff, the SNI parser under ASan, the JSON escaping and number formatting, and +the conversions under four million fuzzed inputs. The defects were at the edges: failure paths, +shutdown, what a handler may hand the engine, and the framing a peer declares. + +## Cost + +The hardening costs about 1 % on the saturated keep-alive benchmark (PERF.md). It is the price +of checking what a request declares before acting on it. diff --git a/STREAMS.md b/STREAMS.md new file mode 100644 index 0000000..a482d9b --- /dev/null +++ b/STREAMS.md @@ -0,0 +1,116 @@ +# Streams and pipes over the I/O plane (design, branch `streams`) + +**Status.** Steps 1 to 4 below are on the branch: `io/pipe.h` (reader, writer), the HTTP engine +reading through the reader with the in-place fast path, and the public `ioxd_pipe` with +`ioxd_run_pipes`, a line-echo fixture and its tests. The reader's verbs ended up as read / +examine / drop / keep / release / copy, with run_begin and run below them, rather than one +`advance(consumed, examined)`, because kept bytes - the request model's slices - need to stay +put, which Pipelines has no notion of. TLS arrived as a prologue over the same pipe rather than +as a second transport (TLS.md), so no vtable was needed. Open, to talk about: the public surface +(run_begin/run stay private for now); outbound connections (`ioxd_connect` on +IORING_OP_CONNECT, the same pipe over a socket to a database or a peer); QUIC, and whether that +wants a vtable; letting the live bytes sit in a second kernel buffer while a run continues in +the first, so streamed bodies never copy twice. + +## Why + +Before this work the I/O plane offered two primitives, a recv await and a send await, and the +HTTP engine built everything else on them: a 16 KB request buffer it filled and parsed, a reply +slab it appended to and flushed. Both are streams in all but name. Naming them, and moving them below the +HTTP engine, gives three things: + +1. a transport-independent surface, so a raw TCP server, a WebSocket upgrade or a TLS layer + later do not need to know the engine; +2. the reply side formalised as a writer: reserve space in the slab, write into it, advance, + flush - what the arena handler already does by hand; +3. the request side as a reader over the kernel-filled provided buffers, which is where the one + copy left on the hot path lives (provided buffer -> request buffer) and where a zero-copy + parse of the common single-segment head becomes possible. + +The shape is System.IO.Pipelines: Kestrel is transport -> PipeReader/PipeWriter -> parser, and +this runtime has the same layering, with coroutines instead of tasks. "Async" here means "may +suspend the coroutine"; the caller does not see it. + +## The pieces + +**`ioxd_pipewriter`** - the slab with a head reserve, a tail, and a connection to flush to. + + void *ioxd_pipewriter_reserve(w, size_t n); /* n bytes at the tail, flushing first if they do not fit */ + void ioxd_pipewriter_advance(w, size_t n); /* the caller wrote n of them */ + int ioxd_pipewriter_write (w, data, n); /* copy in (reserve + memcpy + advance) */ + int ioxd_pipewriter_flush (w); /* send what is in the slab; suspends */ + int ioxd_pipewriter_send (w, data, n); /* write + flush */ + +The HTTP response keeps its head-building on top (first flush builds the head into the lead). +`ioxd_write`, `ioxd_printf`, `ioxd_flush` become thin calls; `ioxd_reserve`/`ioxd_advance` are new +on the context, for handlers that format straight into the reply. + +**`ioxd_pipereader`** - the connection's received bytes, one contiguous span at a time. + + int ioxd_pipereader_read (r, ioxd_slice *live); /* the live bytes, contiguous, once some are unexamined; waits for more otherwise; 0 at EOF, <0 on error */ + void ioxd_pipereader_examine (r, size_t n); /* looked at n of them: do not hand back the same bytes, wait for more */ + void ioxd_pipereader_drop (r, size_t n); /* consume n (clamped to what is live); the kernel buffer goes back once nothing is left in it */ + const char *ioxd_pipereader_keep (r, size_t n); /* consume n but leave them where they are, contiguous with the run; where they are, or NULL: no room, or n past the live bytes */ + void ioxd_pipereader_run_begin(r); /* freeze the run in progress; the next keep starts another */ + ioxd_slice ioxd_pipereader_run (r); /* the run in progress, wherever it ended up */ + void ioxd_pipereader_release (r); /* forget every kept byte; the live ones stay */ + int ioxd_pipereader_copy (r, void *dst, size_t n); /* the Stream-style read: up to n bytes into dst */ + +Why one span and not a list of segments. io_uring hands data over as provided buffers, each a +pointer and a length, and the reader keeps them as such internally (the rx queue does already). +But every consumer needs contiguous bytes: picohttpparser takes one buffer, the chunk parser +takes one buffer, and the request model hands handlers `ioxd_slice`s, which are contiguous by +definition - a header value split across two provided buffers cannot be a slice without a +copy. A segment-aware parser would not remove that copy, only move it, and the parser would be +ours to write and to keep fast. So the reader coalesces, and it does so lazily: + +- when everything buffered lies in one provided buffer - a whole request head in one receive, + the common case - `read` returns that buffer's bytes in place: zero copy, the buffer stays + owned by the connection until `drop` consumes past it; +- when the data spans buffers, `read` copies the pieces into the connection's gathering buffer + (16 KB, on the coroutine's stack: no allocation) and returns that. Spanning requests pay the + copy they pay today; nothing else does. + +That is Kestrel's `IsSingleSegment` fast path, kept inside the reader rather than in every +parser. Pipelines' one `advance(consumed, examined)` became two verbs, `drop` and `examine`, plus +`keep` for the third case Pipelines has no name for: `drop` releases buffers, `examine` keeps +`read` from returning the same incomplete head twice - it suspends until more arrives instead - and +`keep` consumes without moving, because the request model's slices have to stay valid. Kept bytes +form a run; `run_begin` closes one off and pins the kernel buffer it sits in, so pointers already +handed out stay good until `release`. + +**`ioxd_pipe`** - one connection's reader and writer together, what a handler of a non-HTTP +protocol receives (`include/ioxd/pipe.h` for the public verbs, `lib/io/pipe.h` for the rest). + +## The HTTP engine on top + +- `read_head`: `reader_read` gives a span; `phr_parse_request` runs on it. In the common case + that is the provided buffer itself - no copy - and the request's slices point into it; the + head is `keep`t, so the buffer stays owned by the connection until the reply is finished, when + `release` returns it. A head that spans receives arrives coalesced in the gathering buffer. +- Body reads (`ioxd_body_all`, `read_until`, `read_next_chunk`) run the chunk parser on spans + the same way; the whole read wants contiguous output and gets it from the same coalescing. +- Pipelining falls out: the bytes after a request are simply not consumed. +- The reply: unchanged behaviour, on the writer. + +## Costs and gates + +- Holding a provided buffer for the life of a request (instead of copying and returning it at + once) means a slow handler pins one 2 KB buffer per in-flight request. 4096 per worker; the + -ENOBUFS parking already handles exhaustion. Measure under the 500k profile. +- An extra indirection per read/write is nothing next to a syscall, but the reader's segment + bookkeeping must not add branches to the single-segment fast path. Gate: keep-alive and churn + on 4 reactors within noise of main, then the arena profiles. +- No vtable in the first cut: one transport. TLS or a test transport can come as a compile-time + layer or a later vtable once there is a second implementation to justify it. + +## Order of work + +1. Writer: reserve/advance/write/flush/send in the io plane; the response on it; + `ioxd_reserve`/`ioxd_advance` public. Small, no behaviour change, measurable. +2. Reader with `copy` only (Stream semantics) and the engine's `read_head` and body stage on it, + still copying. Same behaviour, same numbers expected. Removes the recv await from the engine. +3. Spans + examine/drop/keep, then the zero-copy single-buffer path. This is the + step with a payoff and the risk; it gets the fragmentation validator and the raw-socket + smoke tests. +4. `ioxd_pipe` public, with a raw TCP entry point, once the HTTP engine is a clean client of it. diff --git a/TLS.md b/TLS.md new file mode 100644 index 0000000..e40bc26 --- /dev/null +++ b/TLS.md @@ -0,0 +1,140 @@ +# TLS: one way, in the kernel (design, branch `streams`) + +**Status.** Built: `lib/tls/store.c` (the store, SNI, `ioxd_certs_reload`, `ioxd_certs_free`) and +`lib/tls/handshake.c` (the prologue and the handoff); `ioxd_bind(port, ioxd_certs_load(dir))`. +Built by default; `make TLS=0` or `-DIOXD_TLS=OFF` leaves it out, and `ioxd_certs_load` then says so +and returns NULL. Verified by the smoke suite through +Python's `ssl` (default certificate, SNI, the `_.example.com` wildcard, an unknown name, a POST +body and a 1 MB upload through kernel RX, keep-alive, a 3000-object reply through kernel TX, a +refused TLS 1.2 client, and a reload while it serves), by +`tests/tls_early.py` with tlslite-ng at the wire level (the request in the same TCP write as the +client's Finished, whole and with its record cut in two with a pause; two early requests; a +close_notify; a corrupted record), by six of tlsfuzzer's TLS 1.3 scripts (`make check-tlsfuzzer`), and +by a testssl.sh scan (TLS 1.3 only, forward-secret AEAD only). The certificates the suites use come +from `tests/mkcerts.sh`, which makes three short-lived self-signed hosts - `default` (RSA), +`sni.test` and the wildcard `_.example.com` (ECDSA) - and remakes one that is within three days of +expiring. + +Kernel TLS programs the socket with `setsockopt`, and the plane sends that over the ring +(`IORING_OP_URING_CMD` with `SOCKET_URING_OP_SETSOCKOPT`): under the registered file table, which +is the default, a connection is a slot index and not a descriptor `setsockopt(2)` could be called +on. So a TLS listener wants a kernel that knows that command, 6.7 or newer - or a build with +`-DFIXED_FILES=0`, where the plain call is the fallback. + +Known limits, all deliberate for now: +one suite, TLS_AES_128_GCM_SHA256 - AES-256-GCM and ChaCha20-Poly1305 are one table entry each in +the store and one key-size case in the handoff; no tickets, no 0-RTT, no client certificates; the +server sends close_notify when it closes, but a control record from the peer after the handoff +(an alert, a KeyUpdate, an over-long record) ends the connection without an alert of ours, since +the plain recv only reports it as an error - seeing record types would mean recvmsg with control +data on the multishot recv. Still open: the rotation watcher below (FILES.md shares it), and +letting `ctx->req` carry the negotiated server name and the fact that the connection is TLS, for +handlers and redirects - neither is built. + +## The shape + +Kernel TLS handles the record layer of an ordinary socket - encrypt on send, decrypt on receive - +but not the handshake. So a TLS connection is a plain connection with a prologue: + +1. **Handshake in userspace, over the pipe.** OpenSSL runs a TLS 1.3 server handshake through + memory BIOs on the connection's coroutine: ciphertext from the pipe's reader goes into the read + BIO, `SSL_do_handshake`, the write BIO is drained into the pipe's writer and flushed, repeat on + `WANT_READ`. Handshake bytes ride the same ring as everything else and the handshake suspends + like any await. Nothing above the pipe knows it happened. +2. **Keys into the socket.** The keylog callback hands us the TLS 1.3 traffic secrets (server for + TX, client for RX); RFC 8446 HKDF-Expand-Label makes the key and the IV; `setsockopt(TCP_ULP, + "tls")` attaches the ULP and `setsockopt(SOL_TLS, TLS_TX / TLS_RX)` installs them. Secrets are + zeroed once programmed. This is ioxide.certs's handoff, in C. +3. **Both directions in the kernel from then on.** The pipe's multishot recv delivers plaintext into + the provided buffers; the writer's sends are encrypted by the kernel; `splice` from a file + descriptor is encrypted too, which is what makes zero-copy file serving over TLS possible. + The HTTP engine, the reader and the writer are untouched. + +"One way" means exactly this: TLS 1.3, one AEAD suite the kernel knows (TLS_AES_128_GCM_SHA256; +AES-256-GCM and ChaCha20-Poly1305 are one table entry each), no session tickets (a ticket would +advance the record sequence after the handshake and break the handoff), no 0-RTT, no client +certificates in the first cut. No userspace record path exists, so there is nothing to fall back +to: a client that cannot do this suite fails the handshake. + +**The receive handoff, precisely.** A TLS 1.3 client may send its first request in the same +segment as its Finished, so when the handshake completes the reader may hold ciphertext past it. +OpenSSL is therefore fed exactly one record per call all the way through the handshake: a whole +record is assembled out of the reader and written into the read BIO, and nothing more, so what the +client sent behind its Finished is still in the reader when the handshake ends rather than +swallowed by the BIO. + +Kernel RX can only start at a record boundary in the socket's own queue, so before installing RX +the prologue stops the multishot recv and drains what is left. The drain assembles records into a +scratch buffer of its own, one at a time, never waiting: while records are there they go through +OpenSSL and the plaintext is kept aside; when nothing delivered is left, the socket is at a record +boundary and the drain is done. The tail of a record the pause cut in two is fetched straight from +the socket (`ioxd__recv_exact`). The plaintext is then handed to the reader as if it had just +arrived, and `TLS_RX` is installed with the record sequence those records consumed. + +Three things end the connection there, each with a line saying which: a record that is not +application data (an alert, or a handshake message), a post-handshake message OpenSSL wants to +answer - a KeyUpdate, seen as bytes appearing in the write BIO, which the handoff cannot follow - +and early plaintext larger than the reader can hold, since the plaintext has to fit the reader's +gathering buffer (`IOXD_PIPE_GATHER`, 16 KB). A client's close_notify during the drain is not a +failure: the records before it are still served, and the connection then ends normally. + +After the handoff the kernel owns the records, and a control record from the peer - close_notify, +or a KeyUpdate the kernel cannot honour - surfaces as `-EIO` on the recv, which the connection +treats as end of input. Browsers do not send KeyUpdate; that is the accepted limit. + +## Certificates: files on disk, SNI, a default, a reload + +- **Layout.** A directory per listener: `//cert.pem` (the chain) and `key.pem`, plus + `/default/` for no SNI or no match. Matching is exact hostname, then one wildcard level + (`*.example.com` as the directory name `_.example.com`). `default` is required: without it there + is nothing to answer a name we do not have, so `ioxd_certs_load` says so and returns NULL. Only a + reload may go without one, and only by carrying the host that was already answering forward. +- **What a host must be to load.** The key has to match the certificate and the chain has to parse, + and the certificate's validity dates are checked as well: one that has expired, or that does not + start until later, is a load failure like any other. A key file readable past its owner is + logged, once per host per load. +- **SNI.** `SSL_CTX_set_client_hello_cb` reads the server-name extension, looks the host up, and + switches the connection's context with `SSL_set_SSL_CTX`. Unknown or absent: the default. The + callback goes on a context once, when it is built, and finds the table through the SSL that its + handshake holds a reference to - not through the context - because a reload shares a carried-over + context between two tables, and a published `SSL_CTX` is never written to again. +- **Reload.** `ioxd_certs_reload(tls)` reads the directory again and switches to what it holds. It is + manual: nothing watches the tree, and nothing sends it a signal. Reloads serialise against each + other, and the table is reference-counted - every handshake takes a reference to the table it + started with - so a handshake in flight finishes on the certificate it began with and the old + table is freed when the last reference drops. A host that fails to load keeps the context it was + serving, so an expired replacement changes nothing but a line on stderr; the host answering for + unmatched SNI keeps answering. It returns -1 when nothing at all could be loaded, and what was + serving still is. `ioxd_certs_free(tls)` gives the store back once no handshake holds a table of + it: for a store never listened on, or for after `ioxd_run` has returned. + +## Where it plugs in + +Ports are bound one by one, each plain or with a store, and every worker opens all of them, so +plain and TLS coexist and more than one port can be served (ioxide's multi-port): + + ioxd_certs *tls = ioxd_certs_load("/etc/ioxd/certs"); // reads the tree + ioxd_bind(8080, NULL); // plain + ioxd_bind(8443, tls); // TLS + ioxd_run(0); // workers over every bound port + +Per connection, `ioxd__conn_main` runs the pipe's handler, and `run.c` puts the prologue in front +of it when the listener has a TLS store; a close_notify goes out when the handler is done. The +dependency is libssl at handshake time; the data path is the kernel's. The test fixture +(`tests/server.c`) serves `IOXD_CERTS` on the port two above its own and exposes +`POST /tls/reload`, which is how the smoke suite rewrites `sni.test`'s files, checks that the old +certificate is still served until the reload, and checks that a connection open across it keeps +answering. + +## Planned: rotation without a restart + +Not built. One control coroutine on worker 0 would watch the directory tree +with inotify - `IN_CLOSE_WRITE` for a file rewritten in place, `IN_MOVED_TO` for the atomic +replace (`rename` over the old file; a watch on the file itself would be lost with the inode), +`IN_CREATE`/`IN_DELETE` for hosts added and removed - reading the inotify descriptor as an +ordinary ring read. Events would be debounced (500 ms: editors and certbot touch a directory +several times), then each changed host confirmed with `statx` (inode, size, mtime against what is +loaded) before the reload the store already does. A `statx` sweep every 60 s is the safety net for +filesystems where inotify is silent (NFS, some bind mounts), and a SIGHUP would force one for +deployments that prefer to say when. `ioxd_certs_reload` is what all of that would call; today it is +the whole of it, and the application decides when. FILES.md shares the same watch facility. diff --git a/cmake/iomaConfig.cmake.in b/cmake/iomaConfig.cmake.in deleted file mode 100644 index 3dddfbe..0000000 --- a/cmake/iomaConfig.cmake.in +++ /dev/null @@ -1,7 +0,0 @@ -@PACKAGE_INIT@ - -# ioma links pthreads publicly, so a consumer must re-find it before the targets load. -include(CMakeFindDependencyMacro) -find_dependency(Threads) - -include("${CMAKE_CURRENT_LIST_DIR}/iomaTargets.cmake") diff --git a/cmake/ioxd.map b/cmake/ioxd.map new file mode 100644 index 0000000..92cb4d7 --- /dev/null +++ b/cmake/ioxd.map @@ -0,0 +1,8 @@ +/* The dynamic symbol table of libioxd.so: the public names and nothing else. Everything the + * library uses internally - the vendored parser included - stays local, so a consumer can neither + * bind to it nor have one of its own names interposed on it. Both build systems pass this to the + * linker with -Wl,--version-script. */ +{ + global: ioxd_*; + local: *; +}; diff --git a/cmake/ioxdConfig.cmake.in b/cmake/ioxdConfig.cmake.in new file mode 100644 index 0000000..59fb25c --- /dev/null +++ b/cmake/ioxdConfig.cmake.in @@ -0,0 +1,13 @@ +@PACKAGE_INIT@ + +# What ioxd links publicly has to be found again before its targets load: pthreads always, and +# OpenSSL when the library was built with TLS - the option below is what that build decided. +set(IOXD_WITH_TLS @IOXD_TLS@) + +include(CMakeFindDependencyMacro) +find_dependency(Threads) +if(IOXD_WITH_TLS) + find_dependency(OpenSSL 3) +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/ioxdTargets.cmake") diff --git a/include/ioma.h b/include/ioma.h deleted file mode 100644 index fbf4c52..0000000 --- a/include/ioma.h +++ /dev/null @@ -1,279 +0,0 @@ -/* - * ioma.h - the libioma API: what you write endpoints against. The one header that is installed. - * - * Every request gets a context: the request, the response, and a slot for your own data. The - * context is passed to each middleware and to the handler, and any of them may read the request - * and shape the response. The request is all the data, as slices into the read buffer; its body - * is read from the wire only when asked for (whole, or streamed), and whatever is left unread is - * drained after the handler. The response holds the write slab: a handler writes the body into - * it, and the framework sends the head in front - in one send when it fits, streamed when not. - * The handler runs on the connection's coroutine, so a read or a write that has to touch the - * wire simply suspends it until the I/O completes. - * - * static void user(ioma_ctx *ctx) { - * ioma_slice id = ctx->req.route_params[0].value; // the :id of "/users/:id" - * ioma_printf(c, "user %.*s\n", (int)id.len, id.p); - * } - * int main(void) { - * ioma_route("GET", "/users/:id", user); - * return ioma_run(0, 8080); // one worker per core - * } - */ -#pragma once - -#include -#include -#include - -#ifndef IOMA_MAX_HEADERS -#define IOMA_MAX_HEADERS 64 /* request headers kept */ -#endif -#ifndef IOMA_MAX_PARAMS -#define IOMA_MAX_PARAMS 32 /* query parameters kept */ -#endif -#ifndef IOMA_MAX_ROUTE_PARAMS -#define IOMA_MAX_ROUTE_PARAMS 8 /* :name captures a route pattern may have */ -#endif -#ifndef IOMA_MAX_RESP_HEADERS -#define IOMA_MAX_RESP_HEADERS 16 /* headers a reply may add */ -#endif - -/* A slice: pointer + length, the C span. Not NUL-terminated. */ -typedef struct { const char *p; size_t len; } ioma_slice; - -/* One key/value pair of slices: a header, a query parameter, a route parameter. */ -typedef struct { ioma_slice key, value; } ioma_kv; - -/* ── the request ───────────────────────────────────────────────────────────────────────── */ - -/* All the data of a request, as slices into the connection's read buffer (decoded parameters - * into a per-request arena), valid only until the handler returns. Read the arrays directly; - * header names are lower-cased, so compare them with lowercase literals. The body is not here - * until you ask for it: ioma_body reads it whole, ioma_body_read streams it. */ -typedef struct ioma_request { - ioma_slice method; /* "GET", "POST", ... */ - ioma_slice target; /* raw request target: path plus any query */ - ioma_slice path; /* the path, query stripped */ - ioma_slice query; /* raw text after '?', undecoded */ - int minor_version; /* 0 or 1 for HTTP/1.0 or 1.1 */ - - ioma_kv headers[IOMA_MAX_HEADERS]; /* names lower-cased, values as received */ - size_t n_headers; - ioma_kv params[IOMA_MAX_PARAMS]; /* query parameters, percent-decoded */ - size_t n_params; - ioma_kv route_params[IOMA_MAX_ROUTE_PARAMS]; /* the :name captures, in pattern order */ - size_t n_route_params; - - size_t content_length; /* what the head declared; 0 if nothing */ - bool chunked; /* the body is chunked: length unknown */ - ioma_slice body; /* the whole body, once ioma_body read it */ - bool keep_alive; /* computed from version + Connection */ -} ioma_request; - -/* ── the response ──────────────────────────────────────────────────────────────────────── */ - -/* The reply being shaped, and the write slab. Body bytes wait in buf[0, len); a full slab is - * sent and emptied, and the head (status, content type, headers) goes out in front of the first - * send - after the chain when everything fit, earlier when the body streams - and is frozen from - * then on (head_sent). You may write into buf + len yourself and advance len, up to cap. */ -typedef struct ioma_response { - int status; /* 200 by default */ - ioma_slice content_type; /* "text/plain" by default; any slice */ - ioma_kv headers[IOMA_MAX_RESP_HEADERS]; /* added with ioma_header */ - size_t n_headers; - bool close; /* close the connection after this reply */ - bool head_sent; - size_t content_length; /* declared with ioma_content_length */ - bool has_length; - - char *buf; /* the write slab */ - size_t cap, len; - - bool chunked, failed; /* private: how a stream is framed; peer gone */ -} ioma_response; - -/* ── the context ───────────────────────────────────────────────────────────────────────── */ - -typedef struct ioma_ctx { - ioma_request req; - ioma_response res; - void *user; /* free slot: middleware hands data to the handler */ - void *priv; /* the engine's own state */ -} ioma_ctx; - -typedef void (*ioma_handler)(ioma_ctx *ctx); - -/* Middleware runs around the handler (the onion model): shape the context, call ioma_next_run to - * run the rest of the chain and then the endpoint, then act on the result - or write a reply and - * return WITHOUT calling ioma_next_run to short-circuit (auth failure, cache hit). */ -typedef struct ioma_next ioma_next; -typedef void (*ioma_mw)(ioma_ctx *ctx, ioma_next *next); -void ioma_next_run(ioma_ctx *ctx, ioma_next *next); - -/* ── the body ──────────────────────────────────────────────────────────────────────────── */ - -/* The whole body, read into the request buffer once and returned as a slice (also req.body). It - * must fit the buffer (16 KB by default): otherwise the slice is empty and res.status is 413, - * which becomes the reply - a handler that streams its reply should check and stop. Not after - * one of the reads below. */ -ioma_slice ioma_body_all(ioma_ctx *ctx); - -/* The next bytes of the body into dst, reading until n are there or the body ends. Returns the - * count (less than n only at the end), 0 once it is all consumed, -1 on error (the connection - * then closes after the reply). Any size of body, nothing kept in the engine. */ -int ioma_body_read_until(ioma_ctx *ctx, void *dst, size_t n); - -/* The next chunk of a chunked body, exactly as the sender framed it, into dst: the rest of the - * current chunk when a read stopped inside one, else the next whole one. Returns its length, 0 at - * the last chunk, -1 on error - a chunk larger than cap is a 413 - or when the body is not - * chunked. */ -int ioma_body_read_next_chunk(ioma_ctx *ctx, void *dst, size_t cap); - -/* ── the reply ─────────────────────────────────────────────────────────────────────────── */ - -/* Body writes into the slab. When it fills, it is sent - head first - and the body streams from - * then on: chunked on HTTP/1.1, until close on HTTP/1.0, or with the length declared below. - * Return 0, or -1 once the peer is gone (further writes are ignored). */ -int ioma_write (ioma_ctx *ctx, const void *data, size_t len); -int ioma_text (ioma_ctx *ctx, const char *s); /* a C string */ -int ioma_printf(ioma_ctx *ctx, const char *fmt, ...) __attribute__((format(printf, 2, 3))); /* formatted, into the slab */ - -/* Shape the head. Only before it is sent: ioma_header returns false afterwards. */ -bool ioma_header (ioma_ctx *ctx, const char *name, const char *value); /* both stay valid until sent; the name is sent lower-cased */ -void ioma_content_type (ioma_ctx *ctx, const char *type); -void ioma_content_length(ioma_ctx *ctx, size_t n); /* stream a large body with a known length */ -int ioma_flush (ioma_ctx *ctx); /* send what is in the slab now (starts streaming) */ - -/* ── slices ────────────────────────────────────────────────────────────────────────────── */ - -/* Everything a request carries is a slice: bytes with a length, not NUL-terminated, valid until - * the handler returns. These compare and convert one without copying it. */ -bool ioma_slice_eq (ioma_slice s, const char *cstr); /* exact */ -bool ioma_slice_eq_ci (ioma_slice s, const char *cstr); /* ASCII case-insensitive */ -bool ioma_slice_starts_with(ioma_slice s, const char *prefix); -bool ioma_slice_ends_with (ioma_slice s, const char *suffix); -ioma_slice ioma_slice_trim (ioma_slice s); /* no leading/trailing space, tab, CR, LF */ - -/* A NUL-terminated copy in buf, for whatever wants a C string. False when it did not fit: buf - * then holds what fit, still terminated (cap 0 writes nothing). */ -bool ioma_cstr(ioma_slice s, char *buf, size_t cap); - -/* Conversions. The whole slice must be the value - nothing around it, nothing after it - and a - * number that does not fit the type fails. On failure *out is left alone and false comes back, - * so "0" and "not a number" cannot be confused. Integers: an optional '-' and decimal digits. - * Doubles: also a fraction and an exponent ("2.5", ".5", "1e-3"); never inf, nan or hex; too - * large fails, too small rounds towards zero. - * Booleans: true/false, 1/0, yes/no, on/off, any case. */ -bool ioma_to_int (ioma_slice s, int *out); -bool ioma_to_i64 (ioma_slice s, int64_t *out); -bool ioma_to_u64 (ioma_slice s, uint64_t *out); -bool ioma_to_double(ioma_slice s, double *out); -bool ioma_to_bool (ioma_slice s, bool *out); - -/* Parse "k=v&k2=v2" - a query string, a form body - into out, up to cap pairs. Keys and values - * that need it ('+', %XX) are decoded into arena and point there; the rest are views of s. - * Returns the pair count. A pair that does not fit the arena is skipped. */ -size_t ioma_kv_parse(const char *text, size_t len, ioma_kv *out, size_t cap, char *arena, size_t arena_cap); - -/* ── routing ───────────────────────────────────────────────────────────────────────────── */ - -/* Endpoints live in groups, and groups nest. A group is a path prefix plus middleware: an - * endpoint "/users" in a group "/api" under a group "/v1" answers at "/v1/api/users", wrapped by - * the middleware of every group above it, outermost first, then its own. NULL as the group is - * the root: no prefix, and the middleware given to ioma_use. - * - * Register everything before ioma_run, from the main thread. ioma_run resolves it once: every - * endpoint's full path into a segment tree and its middleware into one flat chain, which the - * workers then share read-only. A request costs one walk down the tree - no scan, no regex - and - * one call through its chain. */ -typedef struct ioma_group ioma_group; -typedef struct ioma_endpoint ioma_endpoint; - -ioma_group *ioma_group_new(ioma_group *parent, const char *prefix); /* "/api"; "" for middleware only */ -void ioma_group_use(ioma_group *group, ioma_mw mw); /* wraps everything below it */ - -/* An endpoint: method matched exactly; path matched by segment below the group's prefix, with - * :name captures ("/users/:id") landing in req.route_params. A static segment beats a capture at - * any depth, and a static path that lacks the method falls through to a capture route that has - * it. A trailing slash is tolerated. */ -ioma_endpoint *ioma_route(ioma_group *group, const char *method, const char *path, ioma_handler fn); -void ioma_endpoint_use(ioma_endpoint *endpoint, ioma_mw mw); /* wraps this one only */ - -/* The verbs, for short: ioma_get(api, "/users/:id", user). */ -static inline ioma_endpoint *ioma_get (ioma_group *g, const char *path, ioma_handler fn) { return ioma_route(g, "GET", path, fn); } -static inline ioma_endpoint *ioma_post (ioma_group *g, const char *path, ioma_handler fn) { return ioma_route(g, "POST", path, fn); } -static inline ioma_endpoint *ioma_put (ioma_group *g, const char *path, ioma_handler fn) { return ioma_route(g, "PUT", path, fn); } -static inline ioma_endpoint *ioma_patch (ioma_group *g, const char *path, ioma_handler fn) { return ioma_route(g, "PATCH", path, fn); } -static inline ioma_endpoint *ioma_delete(ioma_group *g, const char *path, ioma_handler fn) { return ioma_route(g, "DELETE", path, fn); } - -/* Root middleware: every request, the fallbacks included. */ -void ioma_use(ioma_mw mw); -/* The fallback when no path matches (a built-in 404 by default). A path that matches without the - * method gets a built-in 405 with an allow header. */ -void ioma_default(ioma_handler fn); - -/* ── the same, as a script ─────────────────────────────────────────────────────────────── */ - -/* Registration as a block-structured script: a current group, which the block after IOMA_GROUP - * sets (the root outside any block), endpoints registered into it, with their own middleware - * listed after the handler, and IOMA_USE adding middleware to it - so a group's middleware is - * either listed after its prefix or added with IOMA_USE inside its block. Plain functions - * underneath, so everything is type-checked; a group's block runs exactly once (do not break out - * of it). - * - * IOMA_USE(log); - * IOMA_GET("/", home); - * IOMA_GROUP("/api", api_header) { - * IOMA_GET("/ping", ping); - * IOMA_GROUP("/admin", require_token) { - * IOMA_GET("/stats", stats, timing); - * } - * } - */ -#define IOMA_MAX_MW 16 /* middleware per group and per endpoint */ -struct ioma_group_args { const char *prefix; ioma_mw mws[IOMA_MAX_MW + 1]; }; /* +1: the ending null */ -struct ioma_endpoint_args { const char *path; ioma_handler fn; ioma_mw mws[IOMA_MAX_MW + 1]; }; - -/* What the macros call: the current group's stack and an endpoint with a middleware list. */ -ioma_group *ioma__group_begin(struct ioma_group_args args); -ioma_group *ioma__group_end(void); -ioma_group *ioma__group_current(void); -ioma_endpoint *ioma__endpoint(const char *method, struct ioma_endpoint_args args); - -/* The argument lists become the structs above. An argument count picks the expansion, so the - * middleware list always has its own braces and no macro is ever invoked with an empty variadic - * part: clean under -Wall -Wextra -pedantic, in C11 and later. */ -#define IOMA__CAT2(a, b) a##b -#define IOMA__CAT(a, b) IOMA__CAT2(a, b) -#define IOMA__PICK(_1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, name, ...) name -#define IOMA__RW(path, fn, ...) (struct ioma_endpoint_args){ (path), (fn), { __VA_ARGS__, NULL } } -#define IOMA__RB(path, fn) (struct ioma_endpoint_args){ (path), (fn), { NULL } } -#define IOMA__ROUTE_ARGS(...) IOMA__PICK(__VA_ARGS__, IOMA__RW, IOMA__RW, IOMA__RW, IOMA__RW, IOMA__RW, IOMA__RW, \ - IOMA__RW, IOMA__RW, IOMA__RW, IOMA__RW, IOMA__RW, IOMA__RW, IOMA__RW, IOMA__RW, \ - IOMA__RW, IOMA__RW, IOMA__RB, IOMA__RB, IOMA__RB)(__VA_ARGS__) -#define IOMA__GW(prefix, ...) (struct ioma_group_args){ (prefix), { __VA_ARGS__, NULL } } -#define IOMA__GB(prefix) (struct ioma_group_args){ (prefix), { NULL } } -#define IOMA__GROUP_ARGS(...) IOMA__PICK(__VA_ARGS__, IOMA__GW, IOMA__GW, IOMA__GW, IOMA__GW, IOMA__GW, IOMA__GW, \ - IOMA__GW, IOMA__GW, IOMA__GW, IOMA__GW, IOMA__GW, IOMA__GW, IOMA__GW, IOMA__GW, \ - IOMA__GW, IOMA__GW, IOMA__GW, IOMA__GB, IOMA__GB)(__VA_ARGS__) - -#define IOMA_GROUP(...) \ - for (ioma_group *IOMA__CAT(ioma__block_, __LINE__) = ioma__group_begin(IOMA__GROUP_ARGS(__VA_ARGS__)); \ - IOMA__CAT(ioma__block_, __LINE__); IOMA__CAT(ioma__block_, __LINE__) = ioma__group_end()) -#define IOMA_USE(mw) ioma_group_use(ioma__group_current(), (mw)) -#define IOMA_ROUTE(method, ...) ioma__endpoint((method), IOMA__ROUTE_ARGS(__VA_ARGS__)) -#define IOMA_GET(...) IOMA_ROUTE("GET", __VA_ARGS__) -#define IOMA_POST(...) IOMA_ROUTE("POST", __VA_ARGS__) -#define IOMA_PUT(...) IOMA_ROUTE("PUT", __VA_ARGS__) -#define IOMA_PATCH(...) IOMA_ROUTE("PATCH", __VA_ARGS__) -#define IOMA_DELETE(...) IOMA_ROUTE("DELETE", __VA_ARGS__) -#define IOMA_DEFAULT(fn) ioma_default(fn) - -/* ── run ───────────────────────────────────────────────────────────────────────────────── */ - -/* Start `workers` proactor threads (<= 0: one per core) serving HTTP on `port`, and block until - * SIGINT/SIGTERM. Returns 0 on clean shutdown. */ -int ioma_run(int workers, int port); - -/* The reason phrase for a status code ("OK", "Not Found", ...); "Unknown" if unlisted. */ -const char *ioma_reason(int status); diff --git a/include/ioxd.h b/include/ioxd.h new file mode 100644 index 0000000..97958a3 --- /dev/null +++ b/include/ioxd.h @@ -0,0 +1,32 @@ +/* + * ioxd.h - the libioxd API: what you write endpoints against. Include this one; it brings in the + * parts under ioxd/, one per concern, which are installed beside it. + * + * Every request gets a context: the request, the response, and a slot for your own data. The + * context is passed to each middleware and to the handler, and any of them may read the request + * and shape the response. The request is all the data, as slices into the read buffer; its body + * is read from the wire only when asked for (whole, or streamed), and whatever is left unread is + * drained after the handler. The response holds the write slab: a handler writes the body into + * it, and the framework sends the head in front - in one send when it fits, streamed when not. + * The handler runs on the connection's coroutine, so a read or a write that has to touch the + * wire simply suspends it until the I/O completes. + * + * static void user(ioxd_ctx *ctx) { + * ioxd_slice id = ctx->req.route_params[0].value; // the :id of "/users/:id" + * ioxd_printf(ctx, "user %.*s\n", (int)id.len, id.p); + * } + * int main(void) { + * IOXD_GET("/users/:id", user); // or ioxd_route(NULL, "GET", "/users/:id", user) + * ioxd_bind(8080, NULL); // plain; ioxd_bind(8443, ioxd_certs_load("certs")) for TLS + * return ioxd_run(0); // one worker per core + * } + */ +#pragma once + +#include "ioxd/slice.h" /* slices, conversions, key/value parsing */ +#include "ioxd/config.h" /* the runtime's knobs, set before the run */ +#include "ioxd/http.h" /* request, response, context, body, reply, ioxd_run */ +#include "ioxd/router.h" /* groups, endpoints, middleware; the script macros */ +#include "ioxd/json.h" /* JSON written as you go; structs described once */ +#include "ioxd/pipe.h" /* a connection as a pipe, for other protocols */ +#include "ioxd/tls.h" /* certificates, for a TLS listener */ diff --git a/include/ioxd/config.h b/include/ioxd/config.h new file mode 100644 index 0000000..2ae4299 --- /dev/null +++ b/include/ioxd/config.h @@ -0,0 +1,24 @@ +/* + * ioxd/config.h - the runtime's knobs: the ring, the receive buffers, the coroutine stacks and + * the pools, per worker. Set once before ioxd_run; the build's values are the defaults. + */ +#pragma once + +#include + +/* Every field is per worker, and a zero field keeps its default. The receive buffers are what + * the kernel delivers into, so their count bounds how much may be in flight before recvs park + * on -ENOBUFS (the log says "raise recv_buffers" when that happens) and their size bounds what + * one delivery holds; a worker's slab is count x size bytes. */ +typedef struct ioxd_config { + unsigned ring_entries; /* submission queue entries, the completion queue twice that; a power of two, at most 32768; 4096 */ + unsigned recv_buffers; /* provided receive buffers; a power of two, at most 32768; 4096 */ + unsigned recv_buffer_size; /* bytes in each, 64 to 1 MB; 2048 */ + size_t stack_size; /* a connection's coroutine stack, above a 64 KB guard; at least 64 KB; 128 KB */ + unsigned idle_stacks; /* stacks kept warm for the next connections; 512 */ + unsigned idle_connections; /* connection records kept warm; 1024 */ +} ioxd_config; + +/* Apply a configuration to the runs that follow. -1, with the reason on stderr, when a value is + * refused - nothing changes then. ioxd_configure(&(ioxd_config){ 0 }) is the defaults. */ +int ioxd_configure(const ioxd_config *config); diff --git a/include/ioxd/http.h b/include/ioxd/http.h new file mode 100644 index 0000000..4d03da8 --- /dev/null +++ b/include/ioxd/http.h @@ -0,0 +1,187 @@ +/* + * ioxd/http.h - the request, the response, the context a handler receives, the body read on + * demand, the reply written as you go, and the run. + */ +#pragma once + +#include +#include +#include + +#include "ioxd/slice.h" + + +/* The limits that size a request and a reply. They are build-time constants of the LIBRARY: + * an application may not redefine them, since they lay out the context the library allocates - + * ioxd_run checks that the two sides agree and refuses to start otherwise. A request past a + * limit is answered 400 (more headers than fit, more query parameters than fit) or 414 (more + * decoded query than fits its arena); a reply past one is refused by the call that adds to it. */ +#ifndef IOXD_MAX_HEADERS +#define IOXD_MAX_HEADERS 64 /* request headers; more is a 400 */ +#endif +#ifndef IOXD_MAX_PARAMS +#define IOXD_MAX_PARAMS 32 /* query parameters; more is a 400 */ +#endif +#ifndef IOXD_MAX_ROUTE_PARAMS +#define IOXD_MAX_ROUTE_PARAMS 8 /* :name captures a route pattern may have */ +#endif +#ifndef IOXD_MAX_RESP_HEADERS +#define IOXD_MAX_RESP_HEADERS 16 /* headers a reply may add */ +#endif +#ifndef IOXD_RESP_HEAD_CAP +#define IOXD_RESP_HEAD_CAP 3072 /* bytes the added headers may serialize to */ +#endif +#ifndef IOXD_ROUTE_ARENA +#define IOXD_ROUTE_ARENA 256 /* per-request bytes the router decodes into */ +#endif + +/* ── the request ───────────────────────────────────────────────────────────────────────── */ + +/* All the data of a request, as slices into the connection's read buffer (decoded parameters + * into a per-request arena), valid only until the handler returns. Read the arrays directly; + * header names are lower-cased, so compare them with lowercase literals. The body is not here + * until you ask for it: ioxd_body_all reads it whole, ioxd_body_read_until streams it. + * The engine has already checked the framing (RFC 9112): a Content-Length that is not a plain + * number, conflicting duplicates, a Transfer-Encoding with anything but a final "chunked", + * both fields together, a folded header line, or an HTTP/1.1 request without exactly one Host + * never reach a handler - they are answered 400 (501 for a transfer coding we do not + * implement) and the connection closes. */ +typedef struct ioxd_request { + ioxd_slice method; /* "GET", "POST", ... */ + ioxd_slice target; /* raw request target: path plus any query */ + ioxd_slice path; /* the path, query stripped */ + ioxd_slice query; /* raw text after '?', undecoded */ + int minor_version; /* 0 or 1 for HTTP/1.0 or 1.1 */ + + ioxd_kv headers[IOXD_MAX_HEADERS]; /* names lower-cased, values as received */ + size_t n_headers; + ioxd_kv params[IOXD_MAX_PARAMS]; /* query parameters, percent-decoded */ + size_t n_params; + ioxd_kv route_params[IOXD_MAX_ROUTE_PARAMS]; /* the :name captures, percent-decoded */ + size_t n_route_params; + + size_t content_length; /* what the head declared; 0 if nothing */ + bool chunked; /* the body is chunked: length unknown */ + ioxd_slice body; /* the whole body, once ioxd_body_all read it */ + bool keep_alive; /* computed from version + Connection */ + bool expect_continue; /* "Expect: 100-continue": the body waits for the interim reply the first body read sends */ + char route_arena[IOXD_ROUTE_ARENA]; /* private: the router's per-request scratch */ +} ioxd_request; + +/* ── the response ──────────────────────────────────────────────────────────────────────── */ + +/* The reply being shaped. Body bytes wait in the connection's write slab (ioxd_write, + * ioxd_printf, ioxd_reserve); a full slab is sent and emptied, and the head (status, content + * type, headers) goes out in front of the first send - after the chain when everything fit, + * earlier when the body streams - and is frozen from then on (head_sent). + * What goes on the wire follows the request and the status, not only the handler: a reply to + * HEAD, a 1xx, 204 or 304 carries no body whatever was written (HEAD keeps the Content-Length + * the GET would have had; 1xx and 204 carry no framing header at all), a status outside + * 100-999 goes out as 500, and a declared Content-Length that the writes do not match is + * corrected when the reply was buffered whole and closes the connection when it streamed. */ +typedef struct ioxd_response { + int status; /* 200 by default */ + ioxd_slice content_type; /* "text/plain" by default; assign a slice that outlives the handler (a literal), or ioxd_content_type copies one */ + ioxd_kv headers[IOXD_MAX_RESP_HEADERS]; /* added with ioxd_header: copies, names lower-cased */ + size_t n_headers; + bool close; /* close the connection after this reply */ + bool head_sent; + size_t content_length; /* declared with ioxd_content_length */ + bool has_length; + + bool chunked, failed; /* private: how a stream is framed; peer gone */ + size_t body_sent; /* private: body bytes sent so far */ + size_t head_len; /* private: the added headers, serialized */ + char head[IOXD_RESP_HEAD_CAP]; +} ioxd_response; + +/* ── the context ───────────────────────────────────────────────────────────────────────── */ + +typedef struct ioxd_ctx { + ioxd_request req; + ioxd_response res; + void *user; /* free slot: middleware hands data to the handler */ + void *priv; /* the engine's own state */ +} ioxd_ctx; + +typedef void (*ioxd_handler)(ioxd_ctx *ctx); + +/* Middleware runs around the handler (the onion model): shape the context, call ioxd_next_run to + * run the rest of the chain and then the endpoint, then act on the result - or write a reply and + * return WITHOUT calling ioxd_next_run to short-circuit (auth failure, cache hit). */ +typedef struct ioxd_next ioxd_next; +typedef void (*ioxd_mw)(ioxd_ctx *ctx, ioxd_next *next); +void ioxd_next_run(ioxd_ctx *ctx, ioxd_next *next); + +/* ── the body ──────────────────────────────────────────────────────────────────────────── */ + +/* The whole body, read into the request buffer once and returned as a slice (also req.body). It + * must fit the buffer (16 KB by default): otherwise the slice is empty and res.status is 413, + * which becomes the reply - a handler that streams its reply should check and stop. Not after + * one of the reads below. A malformed chunked body is a 400 the same way. + * With "Expect: 100-continue" the first of these reads answers "100 Continue" before waiting; + * a handler that never reads such a body gets its reply sent and the connection closed. */ +ioxd_slice ioxd_body_all(ioxd_ctx *ctx); + +/* The next bytes of the body into dst, reading until n are there or the body ends. Returns the + * count (less than n only at the end), 0 once it is all consumed (or for n == 0), -1 on error + * (the connection then closes after the reply). Any size of body, nothing kept in the engine. */ +int ioxd_body_read_until(ioxd_ctx *ctx, void *dst, size_t n); + +/* The next chunk of a chunked body, exactly as the sender framed it, into dst: the rest of the + * current chunk when a read stopped inside one, else the next whole one. Returns its length, 0 at + * the last chunk, -1 on error - a chunk larger than cap is a 413 - or when the body is not + * chunked. */ +int ioxd_body_read_next_chunk(ioxd_ctx *ctx, void *dst, size_t cap); + +/* ── the reply ─────────────────────────────────────────────────────────────────────────── */ + +/* Body writes into the slab. When it fills, it is sent - head first - and the body streams from + * then on: chunked on HTTP/1.1, until close on HTTP/1.0, or with the length declared below. + * Return 0, or -1 once the reply failed: the peer is gone, or a format could not be written + * (further writes are ignored either way). */ +int ioxd_write (ioxd_ctx *ctx, const void *data, size_t len); +/* Or write into the slab directly: reserve n bytes (flushing first when they do not fit; nullptr + * once the peer is gone or n exceeds the slab) and advance by what was written - never more + * than reserved; advance clamps to the room that was there. */ +void *ioxd_reserve(ioxd_ctx *ctx, size_t n); +void ioxd_advance(ioxd_ctx *ctx, size_t n); +int ioxd_text (ioxd_ctx *ctx, const char *s); /* a C string */ +#if defined(__GNUC__) || defined(__clang__) +int ioxd_printf(ioxd_ctx *ctx, const char *fmt, ...) __attribute__((format(printf, 2, 3))); /* formatted, into the slab */ +#else +int ioxd_printf(ioxd_ctx *ctx, const char *fmt, ...); +#endif + +/* Shape the head, only before it is sent: each returns false afterwards. ioxd_header copies the + * name (sent lower-cased) and the value, so temporaries are fine; it also returns false for a + * name that is not an HTTP token, a value with a control byte (CR, LF, NUL: no response + * splitting), a header the engine owns (content-length, transfer-encoding, connection), when + * the table or its IOXD_RESP_HEAD_CAP bytes are full. "content-type" through it sets the + * content type. */ +bool ioxd_header (ioxd_ctx *ctx, const char *name, const char *value); +bool ioxd_content_type (ioxd_ctx *ctx, const char *type); /* copied */ +bool ioxd_content_length(ioxd_ctx *ctx, size_t n); /* stream a large body with a known length */ +int ioxd_flush (ioxd_ctx *ctx); /* send what is in the slab now (starts streaming) */ + +/* ── run ───────────────────────────────────────────────────────────────────────────────── */ + +/* Bind a port: plain HTTP when tls is NULL, TLS 1.3 terminated in the kernel otherwise, with the + * certificate store from ioxd_certs_load (TLS.md). Every bound port serves the same routes; bind as + * many as you need (at most 8), then run. -1 if refused: a bad port, or the table is full. */ +typedef struct ioxd_certs ioxd_certs; +int ioxd_bind(int port, ioxd_certs *certs); + +/* Start `workers` proactor threads (<= 0: one per core) serving HTTP on every bound port, and + * block until SIGINT/SIGTERM. Returns 0 on clean shutdown, non-zero when nothing was bound, when a + * port could not be opened, when a worker failed, or when the limits above differ between this + * header and the library (the context would not match). May be called again after it returns; + * the ports stay bound. */ +int ioxd__run(int workers, size_t ctx_size); +static inline int ioxd_run(int workers) +{ + return ioxd__run(workers, sizeof(ioxd_ctx)); +} + +/* The reason phrase for a status code ("OK", "Not Found", ...); "Unknown" if unlisted. */ +const char *ioxd_reason(int status); diff --git a/include/ioxd/json.h b/include/ioxd/json.h new file mode 100644 index 0000000..018f06c --- /dev/null +++ b/include/ioxd/json.h @@ -0,0 +1,144 @@ +/* + * ioxd/json.h - JSON written as you go, and a struct described once, serialized with one call. + */ +#pragma once + +#include +#include +#include + +#include "ioxd/http.h" + +struct ioxd_pipe; /* ioxd/pipe.h */ + +/* ── JSON, written as you go ───────────────────────────────────────────────────────────── */ + +/* A forward-only JSON writer, the shape of .NET's Utf8JsonWriter: no tree, no allocation. The + * bytes go straight into the reply - or a raw pipe, or a buffer - escaped as they are written, + * and stream out as the slab fills. Nesting and commas are tracked, so a handler just says what + * it means: + * + * ioxd_json j = ioxd_json_reply(ctx); // content-type: application/json + * ioxd_json_object(&j); + * ioxd_json_key(&j, "id"); ioxd_json_int(&j, id); + * ioxd_json_key(&j, "name"); ioxd_json_string(&j, name); + * ioxd_json_key(&j, "tags"); ioxd_json_array(&j); + * ioxd_json_cstr(&j, "new"); + * ioxd_json_end(&j); + * ioxd_json_end(&j); + * if (!ioxd_json_done(&j)) { ... } // whole: nothing failed, nothing open + * + * Strings are emitted byte for byte, with only '"', '\' and the control characters escaped: + * invalid UTF-8 goes out exactly as it came in, so untrusted input has to be validated first. + * Every call returns false once the sink is gone (the peer left; the buffer is full), the nesting + * passed IOXD_JSON_DEPTH, or the call had no place in the document - a key outside an object, a + * value where a key was due, an end with nothing open. The rest is then dropped, so checking the + * last call is enough; check done() after it to catch an end that was never written. */ +#define IOXD_JSON_DEPTH 63 /* levels: one bit of each mask below apiece */ +typedef struct ioxd_json { + enum { + IOXD_JSON_TO_REPLY, + IOXD_JSON_TO_PIPE, + IOXD_JSON_TO_MEM, + } kind; + + union { + ioxd_ctx *ctx; + struct ioxd_pipe *pipe; + struct { char *p; size_t cap, *len; } mem; + } to; + + uint64_t has_value; /* per level: a value is there, so a comma is due */ + uint64_t is_object; /* per level: it closes with '}' rather than ']' */ + unsigned depth; + bool after_key; /* the next value follows a key: no comma */ + bool failed; +} ioxd_json; + +ioxd_json ioxd_json_reply(ioxd_ctx *ctx); /* into the reply; sets its content type */ +ioxd_json ioxd_json_pipe (struct ioxd_pipe *pipe); /* into a raw pipe's slab */ +ioxd_json ioxd_json_mem (char *buf, size_t cap, size_t *len); /* into memory; *len is what was written */ + +bool ioxd_json_object(ioxd_json *j); /* { */ +bool ioxd_json_array (ioxd_json *j); /* [ */ +bool ioxd_json_end (ioxd_json *j); /* } or ], whichever is open */ +bool ioxd_json_done (ioxd_json *j); /* nothing failed, all closed */ +bool ioxd_json_key (ioxd_json *j, const char *name); /* "name": */ +bool ioxd_json_string(ioxd_json *j, ioxd_slice s); /* "...", escaped */ +bool ioxd_json_cstr (ioxd_json *j, const char *s); /* NULL is null */ +bool ioxd_json_int (ioxd_json *j, int64_t v); +bool ioxd_json_uint (ioxd_json *j, uint64_t v); +bool ioxd_json_double(ioxd_json *j, double v); /* the shortest that reads back the same; nan and inf become null */ +bool ioxd_json_float (ioxd_json *j, float v); /* the same, read back as a float: 0.1f is 0.1 */ +bool ioxd_json_bool (ioxd_json *j, bool v); +bool ioxd_json_null (ioxd_json *j); +bool ioxd_json_raw (ioxd_json *j, ioxd_slice json); /* already JSON: copied as is */ + +/* A value by its C type, and a key with one: the _Generic picks ioxd_json_int for the integer + * types, _uint for the unsigned ones, _float and _double for those two, _bool, _cstr for a char + * pointer, _string for a slice. */ +#define IOXD_JSON_VALUE(j, x) _Generic((x), \ + bool: ioxd_json_bool, \ + char: ioxd_json_int, signed char: ioxd_json_int, short: ioxd_json_int, \ + int: ioxd_json_int, long: ioxd_json_int, long long: ioxd_json_int, \ + unsigned char: ioxd_json_uint, unsigned short: ioxd_json_uint, unsigned: ioxd_json_uint, \ + unsigned long: ioxd_json_uint, unsigned long long: ioxd_json_uint, \ + float: ioxd_json_float, double: ioxd_json_double, \ + char *: ioxd_json_cstr, const char *: ioxd_json_cstr, \ + ioxd_slice: ioxd_json_string)((j), (x)) + +/* A key and its value in one line. The answer goes through a function so that a field written for + * its effect - `IOXD_JSON_FIELD(j, "n", n);` - is a plain statement and not a value the compiler + * sees discarded, while `if (IOXD_JSON_FIELD(j, "n", n))` still reads it. */ +static inline bool ioxd__json_wrote(bool ok) { return ok; } +#define IOXD_JSON_FIELD(j, name, x) \ + ioxd__json_wrote(ioxd_json_key((j), (name)) && IOXD_JSON_VALUE((j), (x))) + +/* A struct described once, serialized with one call. The description is a list of fields, each + * line its kind, its C type (or, for a nested struct, that struct's name) and its name: + * + * #define USER_FIELDS(X) \ + * X(VALUE, int64_t, id) \ + * X(VALUE, const char *, name) \ + * X(OBJECT, address, address) \ + * X(OPTIONAL, address, billing) \ + * X(ARRAY, const char *, tags, n_tags) \ + * X(OBJECTS, order, orders, n_orders) + * IOXD_JSON_STRUCT(user, USER_FIELDS) + * + * VALUE is a scalar, written by its C type; OBJECT a nested struct held by value; OPTIONAL a + * pointer to one, null where the pointer is NULL; ARRAY scalars and the field holding their + * count; OBJECTS the same for nested structs. IOXD_JSON_STRUCT defines the struct and the + * function - struct user, and user_to_json(ioxd_json *, const struct user *); IOXD_JSON_WRITER + * only the function, for a struct declared elsewhere with the same fields. A nested struct's own + * IOXD_JSON_STRUCT comes first. Counts are size_t; arrays are pointers to their first element. + * A note beside a field is written as a block comment, the way playground/hello/main.c writes + * them: a // one would run on through the backslash and swallow the lines after it. */ +#define IOXD_JSON_STRUCT(name, FIELDS) \ + struct name { FIELDS(IOXD__JSON_MEMBER) }; \ + IOXD_JSON_WRITER(name, FIELDS) +#define IOXD_JSON_WRITER(name, FIELDS) \ + static inline bool name##_to_json(ioxd_json *j, const struct name *v) \ + { \ + ioxd_json_object(j); \ + FIELDS(IOXD__JSON_WRITE) \ + return ioxd_json_end(j); \ + } + +/* What each kind of line becomes: a member, and a piece of the writer. */ +#define IOXD__JSON_MEMBER(kind, ...) IOXD__JSON_MEMBER_##kind(__VA_ARGS__) +#define IOXD__JSON_MEMBER_VALUE(type, field) type field; +#define IOXD__JSON_MEMBER_OBJECT(sname, field) struct sname field; +#define IOXD__JSON_MEMBER_OPTIONAL(sname, field) const struct sname *field; +#define IOXD__JSON_MEMBER_ARRAY(type, field, count) type *field; size_t count; +#define IOXD__JSON_MEMBER_OBJECTS(sname, field, count) const struct sname *field; size_t count; +#define IOXD__JSON_WRITE(kind, ...) IOXD__JSON_WRITE_##kind(__VA_ARGS__) +#define IOXD__JSON_WRITE_VALUE(type, field) ioxd_json_key(j, #field); IOXD_JSON_VALUE(j, v->field); +#define IOXD__JSON_WRITE_OBJECT(sname, field) ioxd_json_key(j, #field); sname##_to_json(j, &v->field); +#define IOXD__JSON_WRITE_OPTIONAL(sname, field) ioxd_json_key(j, #field); if (v->field) sname##_to_json(j, v->field); else ioxd_json_null(j); +#define IOXD__JSON_WRITE_ARRAY(type, field, count) ioxd_json_key(j, #field); ioxd_json_array(j); \ + for (size_t i_ = 0; i_ < v->count; i_++) { IOXD_JSON_VALUE(j, v->field[i_]); } \ + ioxd_json_end(j); +#define IOXD__JSON_WRITE_OBJECTS(sname, field, count) ioxd_json_key(j, #field); ioxd_json_array(j); \ + for (size_t i_ = 0; i_ < v->count; i_++) { sname##_to_json(j, &v->field[i_]); } \ + ioxd_json_end(j); diff --git a/include/ioxd/pipe.h b/include/ioxd/pipe.h new file mode 100644 index 0000000..5a4a830 --- /dev/null +++ b/include/ioxd/pipe.h @@ -0,0 +1,47 @@ +/* + * ioxd/pipe.h - a connection as a pipe: a reader over the bytes the kernel received and a + * writer over a slab, for handlers of protocols other than HTTP. + */ +#pragma once + +#include + +#include "ioxd/slice.h" + +/* ── pipes ─────────────────────────────────────────────────────────────────────────────── */ + +/* A connection as a pipe: a reader over the bytes the kernel received and a writer over a slab. + * Every call that must wait suspends the connection's coroutine, and the worker's loop resumes + * it on the completion, so a handler reads and writes in straight-line code. The HTTP engine is + * one such handler; ioxd_run_pipes runs one of yours on raw TCP connections instead. */ +typedef struct ioxd_pipe ioxd_pipe; +typedef void (*ioxd_pipe_handler)(ioxd_pipe *pipe); +int ioxd_run_pipes(int workers, ioxd_pipe_handler fn); /* like ioxd_run, over the ports ioxd_bind bound, without HTTP */ + +/* Reading. The live bytes are the ones received and not yet consumed, always handed out as one + * contiguous span - in place in the kernel's buffer when they lie within one. read returns 1 + * with them once some are unexamined, otherwise it waits for more; examine says how many were + * looked at without being consumed, so the next read waits for more rather than returning the + * same bytes; drop consumes (never more than is live); keep consumes but leaves the bytes where + * they are, contiguous with earlier kept bytes and valid until release - it returns where they + * are, or NULL when they would not fit the pipe's buffer (the pipe is then FULL) or n is more + * than is live; copy is the plain read into your own buffer. read and copy return 0 at the end + * of input, IOXD_PIPE_GONE on a dead peer, IOXD_PIPE_FULL when kept plus live bytes would + * exceed the pipe's buffer (16 KB). A handler that returns with bytes still in the writer's + * slab has them sent before the connection closes. */ +#define IOXD_PIPE_GONE (-1) +#define IOXD_PIPE_FULL (-2) +int ioxd_pipe_read (ioxd_pipe *pipe, ioxd_slice *live); +void ioxd_pipe_examine(ioxd_pipe *pipe, size_t n); +void ioxd_pipe_drop (ioxd_pipe *pipe, size_t n); +const char *ioxd_pipe_keep (ioxd_pipe *pipe, size_t n); +void ioxd_pipe_release(ioxd_pipe *pipe); +int ioxd_pipe_copy (ioxd_pipe *pipe, void *dst, size_t n); + +/* Writing: a slab, sent on flush. reserve n bytes to write into directly and advance by what was + * written, or write to copy in; send is write then flush. -1 once the peer is gone. */ +void *ioxd_pipe_reserve(ioxd_pipe *pipe, size_t n); +void ioxd_pipe_advance(ioxd_pipe *pipe, size_t n); +int ioxd_pipe_write (ioxd_pipe *pipe, const void *data, size_t n); +int ioxd_pipe_flush (ioxd_pipe *pipe); +int ioxd_pipe_send (ioxd_pipe *pipe, const void *data, size_t n); diff --git a/include/ioxd/router.h b/include/ioxd/router.h new file mode 100644 index 0000000..604f845 --- /dev/null +++ b/include/ioxd/router.h @@ -0,0 +1,127 @@ +/* + * ioxd/router.h - groups, endpoints and middleware, and the same as a script. + */ +#pragma once + +#include + +#include "ioxd/http.h" + +/* ── routing ───────────────────────────────────────────────────────────────────────────── */ + +/* Endpoints live in groups, and groups nest. A group is a path prefix plus middleware: an + * endpoint "/users" in a group "/api" under a group "/v1" answers at "/v1/api/users", wrapped by + * the middleware of every group above it, outermost first, then its own. NULL as the group is + * the root: no prefix, and the middleware given to ioxd_use. A prefix and what follows it are + * joined by a '/' when neither side brings one ("/api" and "users" is "/api/users"), and a + * repeated slash counts once. + * + * Register everything before ioxd_run, from the main thread; methods, paths and prefixes are + * copied, so temporaries are fine, and anything registered once ioxd_run has started is ignored + * with a line on stderr. ioxd_run resolves it once: every endpoint's full path into a segment + * tree and its middleware into one flat chain, which the workers then share read-only. A request + * costs one walk down the tree - no scan, no regex - and one call through its chain. */ +typedef struct ioxd_group ioxd_group; +typedef struct ioxd_endpoint ioxd_endpoint; + +ioxd_group *ioxd_group_new(ioxd_group *parent, const char *prefix); /* "/api"; "" for middleware only */ +void ioxd_group_use(ioxd_group *group, ioxd_mw mw); /* wraps everything below it */ + +/* An endpoint: method matched exactly, except that HEAD is answered by the GET of a path that + * has no HEAD of its own; path matched by segment below the group's prefix, with :name captures + * ("/users/:id") landing in req.route_params. A static segment beats a capture at any depth, and + * a static path that lacks the method falls through to a capture route that has it. A trailing + * slash is tolerated. Segments are matched as they arrive on the wire, so an escape in a static + * segment does not match it, but a captured value is handed over percent-decoded ("/users/a%2Fb" + * captures "a/b") - raw in the rare case that it does not fit IOXD_ROUTE_ARENA. */ +ioxd_endpoint *ioxd_route(ioxd_group *group, const char *method, const char *path, ioxd_handler fn); +void ioxd_endpoint_use(ioxd_endpoint *endpoint, ioxd_mw mw); /* wraps this one only */ + +/* The verbs, for short: ioxd_get(api, "/users/:id", user). */ +static inline ioxd_endpoint *ioxd_get (ioxd_group *g, const char *path, ioxd_handler fn) { return ioxd_route(g, "GET", path, fn); } +static inline ioxd_endpoint *ioxd_post (ioxd_group *g, const char *path, ioxd_handler fn) { return ioxd_route(g, "POST", path, fn); } +static inline ioxd_endpoint *ioxd_put (ioxd_group *g, const char *path, ioxd_handler fn) { return ioxd_route(g, "PUT", path, fn); } +static inline ioxd_endpoint *ioxd_patch (ioxd_group *g, const char *path, ioxd_handler fn) { return ioxd_route(g, "PATCH", path, fn); } +static inline ioxd_endpoint *ioxd_delete(ioxd_group *g, const char *path, ioxd_handler fn) { return ioxd_route(g, "DELETE", path, fn); } + +/* Root middleware: every request, the fallbacks included. */ +void ioxd_use(ioxd_mw mw); +/* The fallback when no path matches (a built-in 404 by default). A path that matches without the + * method gets a built-in 405 whose allow header lists every method that path has, on a capture + * route too. Both run behind the root's middleware only, so that allow header names methods a + * group's own middleware would otherwise have gated. */ +void ioxd_default(ioxd_handler fn); + +/* ── the same, as a script ─────────────────────────────────────────────────────────────── */ + +/* Registration as a block-structured script: a current group, which the block after IOXD_GROUP + * sets (the root outside any block), endpoints registered into it, with their own middleware + * listed after the handler, and IOXD_USE adding middleware to it - so a group's middleware is + * either listed after its prefix or added with IOXD_USE inside its block. Plain functions + * underneath, so everything is type-checked; a group's block runs exactly once, and leaving it + * early - break, return, goto - still closes the group. + * + * IOXD_USE(log); + * IOXD_GET("/", home); + * IOXD_GROUP("/api", api_header) { + * IOXD_GET("/ping", ping); + * IOXD_GROUP("/admin", require_token) { + * IOXD_GET("/stats", stats, timing); + * } + * } + */ +#define IOXD_MAX_MW 16 /* middleware per group and per endpoint */ +struct ioxd_group_args { const char *prefix; ioxd_mw mws[IOXD_MAX_MW + 1]; }; /* +1: the ending null */ +struct ioxd_endpoint_args { const char *path; ioxd_handler fn; ioxd_mw mws[IOXD_MAX_MW + 1]; }; + +/* What the macros call: the current group's stack and an endpoint with a middleware list. */ +ioxd_group *ioxd__group_begin(struct ioxd_group_args args); +ioxd_group *ioxd__group_end(void); +void ioxd__group_pop(ioxd_group **open); +ioxd_group *ioxd__group_current(void); +ioxd_endpoint *ioxd__endpoint(const char *method, struct ioxd_endpoint_args args); + +/* The argument lists become the structs above. An argument count picks the expansion, so the + * middleware list always has its own braces and no macro is ever invoked with an empty variadic + * part: clean under -Wall -Wextra -pedantic, in C11 and later. */ +#define IOXD__CAT2(a, b) a##b +#define IOXD__CAT(a, b) IOXD__CAT2(a, b) +#define IOXD__PICK(_1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, name, ...) name +#define IOXD__RW(path, fn, ...) (struct ioxd_endpoint_args){ (path), (fn), { __VA_ARGS__, NULL } } +#define IOXD__RB(path, fn) (struct ioxd_endpoint_args){ (path), (fn), { NULL } } +#define IOXD__ROUTE_ARGS(...) IOXD__PICK(__VA_ARGS__, IOXD__RW, IOXD__RW, IOXD__RW, IOXD__RW, IOXD__RW, IOXD__RW, \ + IOXD__RW, IOXD__RW, IOXD__RW, IOXD__RW, IOXD__RW, IOXD__RW, IOXD__RW, IOXD__RW, \ + IOXD__RW, IOXD__RW, IOXD__RB, IOXD__RB, IOXD__RB)(__VA_ARGS__) +#define IOXD__GW(prefix, ...) (struct ioxd_group_args){ (prefix), { __VA_ARGS__, NULL } } +#define IOXD__GB(prefix) (struct ioxd_group_args){ (prefix), { NULL } } +/* IOXD__TOO_MANY_GROUP_MW is left undefined on purpose: a 17th middleware lands on it and does + * not compile, rather than overflowing the list and losing the last one quietly. The endpoint + * form fails on its own - a 17th middleware there is taken for the macro name. */ +#define IOXD__GROUP_ARGS(...) IOXD__PICK(__VA_ARGS__, IOXD__TOO_MANY_GROUP_MW, \ + IOXD__GW, IOXD__GW, IOXD__GW, IOXD__GW, IOXD__GW, IOXD__GW, \ + IOXD__GW, IOXD__GW, IOXD__GW, IOXD__GW, IOXD__GW, IOXD__GW, IOXD__GW, IOXD__GW, \ + IOXD__GW, IOXD__GW, IOXD__GB, IOXD__GB)(__VA_ARGS__) + +/* A break, a return or a goto out of the block skips the loop's increment, so where the compiler + * has __attribute__((cleanup)) the pop is hung on the block's variable and runs on every way out; + * a block that ended on its own has already popped and nulled it. Elsewhere the plain form + * stands, and a group still open at ioxd_run is reported. */ +#ifdef __GNUC__ +#define IOXD_GROUP(...) \ + for (ioxd_group *IOXD__CAT(ioxd__block_, __LINE__) __attribute__((cleanup(ioxd__group_pop))) \ + = ioxd__group_begin(IOXD__GROUP_ARGS(__VA_ARGS__)); \ + IOXD__CAT(ioxd__block_, __LINE__); IOXD__CAT(ioxd__block_, __LINE__) = ioxd__group_end()) +#else +#define IOXD_GROUP(...) \ + for (ioxd_group *IOXD__CAT(ioxd__block_, __LINE__) \ + = ioxd__group_begin(IOXD__GROUP_ARGS(__VA_ARGS__)); \ + IOXD__CAT(ioxd__block_, __LINE__); IOXD__CAT(ioxd__block_, __LINE__) = ioxd__group_end()) +#endif +#define IOXD_USE(mw) ioxd_group_use(ioxd__group_current(), (mw)) +#define IOXD_ROUTE(method, ...) ioxd__endpoint((method), IOXD__ROUTE_ARGS(__VA_ARGS__)) +#define IOXD_GET(...) IOXD_ROUTE("GET", __VA_ARGS__) +#define IOXD_POST(...) IOXD_ROUTE("POST", __VA_ARGS__) +#define IOXD_PUT(...) IOXD_ROUTE("PUT", __VA_ARGS__) +#define IOXD_PATCH(...) IOXD_ROUTE("PATCH", __VA_ARGS__) +#define IOXD_DELETE(...) IOXD_ROUTE("DELETE", __VA_ARGS__) +#define IOXD_DEFAULT(fn) ioxd_default(fn) diff --git a/include/ioxd/slice.h b/include/ioxd/slice.h new file mode 100644 index 0000000..d718e3f --- /dev/null +++ b/include/ioxd/slice.h @@ -0,0 +1,51 @@ +/* + * ioxd/slice.h - a slice, bytes with a length, and a key/value pair of them: what every request + * carries. Compare and convert them without copying; parse a query string or a form body. + */ +#pragma once + +#include +#include +#include + +/* A slice: pointer + length, the C span. Not NUL-terminated. */ +typedef struct { const char *p; size_t len; } ioxd_slice; + +/* One key/value pair of slices: a header, a query parameter, a route parameter. */ +typedef struct { ioxd_slice key, value; } ioxd_kv; + +/* ── slices ────────────────────────────────────────────────────────────────────────────── */ + +/* Everything a request carries is a slice: bytes with a length, not NUL-terminated, valid until + * the handler returns. These compare and convert one without copying it. */ +bool ioxd_slice_eq (ioxd_slice s, const char *cstr); /* exact */ +bool ioxd_slice_eq_ci (ioxd_slice s, const char *cstr); /* ASCII case-insensitive */ +bool ioxd_slice_starts_with(ioxd_slice s, const char *prefix); +bool ioxd_slice_ends_with (ioxd_slice s, const char *suffix); +ioxd_slice ioxd_slice_trim (ioxd_slice s); /* no leading/trailing space, tab, CR, LF */ + +/* A NUL-terminated copy in buf, for whatever wants a C string. False when it did not fit: buf + * then holds what fit, still terminated (cap 0 writes nothing) - and false when the slice holds + * a NUL of its own, which would end the C string early ("secret.txt%00.png" is not a PNG). */ +bool ioxd_cstr(ioxd_slice s, char *buf, size_t cap); + +/* Conversions. The whole slice must be the value - nothing around it, nothing after it - and a + * number that does not fit the type fails. On failure *out is left alone and false comes back, + * so "0" and "not a number" cannot be confused. Integers: an optional '-' and decimal digits. + * Doubles: also a fraction and an exponent ("2.5", ".5", "1e-3"); never inf, nan or hex; too + * large fails, too small rounds towards zero. + * Booleans: true/false, 1/0, yes/no, on/off, any case. */ +bool ioxd_to_int (ioxd_slice s, int *out); +bool ioxd_to_i64 (ioxd_slice s, int64_t *out); +bool ioxd_to_u64 (ioxd_slice s, uint64_t *out); +bool ioxd_to_double(ioxd_slice s, double *out); +bool ioxd_to_bool (ioxd_slice s, bool *out); + +/* Parse "k=v&k2=v2" - a query string, a form body - into out, up to cap pairs. Keys and values + * that need it ('+', %XX) are decoded into arena and point there; the rest are views of s. A + * malformed %XX and %00 stay as written. Returns the pair count; *truncated (may be NULL) is set + * when a pair was left out - past cap, or not fitting the arena - so the caller can refuse the + * request rather than act on part of it. Every pair is returned, duplicates included, in order: + * pick a policy (first or last) and keep to it. */ +size_t ioxd_kv_parse(const char *text, size_t len, ioxd_kv *out, size_t cap, char *arena, size_t arena_cap, + bool *truncated); diff --git a/include/ioxd/tls.h b/include/ioxd/tls.h new file mode 100644 index 0000000..674344b --- /dev/null +++ b/include/ioxd/tls.h @@ -0,0 +1,26 @@ +/* + * ioxd/tls.h - TLS 1.3, terminated in the kernel after an OpenSSL handshake (TLS.md): a store of + * certificates to listen with. + */ +#pragma once + +#include "ioxd/http.h" + +/* A store from a directory: //cert.pem (the chain) and key.pem for each hostname, + * `default` for no SNI or no match, `_.example.com` for *.example.com. `default` is required - + * without it nothing can answer a name we do not have. NULL, with the reason on stderr, when + * nothing loads or the build has no TLS. Then: ioxd_bind(port, store). */ +ioxd_certs *ioxd_certs_load(const char *dir); + +/* Read the directory again and switch to what it holds. A host that fails to load - unreadable, + * mismatched, not valid yet, expired - keeps its old certificate, and the host answering for + * unmatched SNI keeps answering. Safe while serving: handshakes in flight finish on the table + * they started with, and reloads serialise against each other. 0, or -1 when nothing could be + * loaded at all, in which case what was serving still is. */ +int ioxd_certs_reload(ioxd_certs *certs); + +/* Give the store back: its certificates and the store itself, once the last handshake holding a + * table of it has finished. Not while a listener still uses it - every TLS connection takes a + * reference through the store - so this is for a store that was never listened on, or for after + * ioxd_run has returned. NULL is a no-op. */ +void ioxd_certs_free(ioxd_certs *certs); diff --git a/ioma b/ioma new file mode 100755 index 0000000..4190ebf Binary files /dev/null and b/ioma differ diff --git a/ioma-hello b/ioma-hello new file mode 100755 index 0000000..1a397eb Binary files /dev/null and b/ioma-hello differ diff --git a/ioma.pc.in b/ioxd.pc.in similarity index 59% rename from ioma.pc.in rename to ioxd.pc.in index 7291be4..0605779 100644 --- a/ioma.pc.in +++ b/ioxd.pc.in @@ -1,11 +1,12 @@ prefix=@PREFIX@ exec_prefix=${prefix} libdir=${exec_prefix}/lib -includedir=${prefix}/include/ioma +includedir=${prefix}/include -Name: ioma +Name: ioxd Description: HTTP/1.1 server framework on a thread-per-core io_uring runtime Version: @VERSION@ -URL: https://github.com/MDA2AV/ioma -Libs: -L${libdir} -lioma -pthread +URL: https://github.com/MDA2AV/libioxd +Libs: -L${libdir} -lioxd -pthread +Libs.private: @LIBS@ Cflags: -I${includedir} -pthread diff --git a/src/http/api.c b/lib/http/api.c similarity index 56% rename from src/http/api.c rename to lib/http/api.c index e89fd41..a7ed536 100644 --- a/src/http/api.c +++ b/lib/http/api.c @@ -4,19 +4,22 @@ * Nothing here touches the runtime. */ #include "http/internal.h" +#include "ioxd/http.h" +#include "ioxd/slice.h" #include #include #include #include #include +#include #include #include /* ── slices ────────────────────────────────────────────────────────────────────────────── */ /* Is the slice exactly this C string? */ -bool ioma_slice_eq(ioma_slice s, const char *cstr) +bool ioxd_slice_eq(ioxd_slice s, const char *cstr) { size_t n = strlen(cstr); return n == s.len && (n == 0 || memcmp(s.p, cstr, n) == 0); @@ -29,7 +32,7 @@ static unsigned char lower(unsigned char c) } /* Is the slice this C string, ignoring ASCII case? */ -bool ioma_slice_eq_ci(ioma_slice s, const char *cstr) +bool ioxd_slice_eq_ci(ioxd_slice s, const char *cstr) { size_t n = strlen(cstr); if (n != s.len) @@ -41,14 +44,14 @@ bool ioma_slice_eq_ci(ioma_slice s, const char *cstr) } /* Does the slice begin with this C string? */ -bool ioma_slice_starts_with(ioma_slice s, const char *prefix) +bool ioxd_slice_starts_with(ioxd_slice s, const char *prefix) { size_t n = strlen(prefix); return n <= s.len && (n == 0 || memcmp(s.p, prefix, n) == 0); } /* Does the slice end with this C string? */ -bool ioma_slice_ends_with(ioma_slice s, const char *suffix) +bool ioxd_slice_ends_with(ioxd_slice s, const char *suffix) { size_t n = strlen(suffix); return n <= s.len && (n == 0 || memcmp(s.p + s.len - n, suffix, n) == 0); @@ -61,7 +64,7 @@ static bool is_space(char c) } /* The slice without leading and trailing whitespace. */ -ioma_slice ioma_slice_trim(ioma_slice s) +ioxd_slice ioxd_slice_trim(ioxd_slice s) { while (s.len && is_space(s.p[0])) { s.p++; @@ -72,8 +75,9 @@ ioma_slice ioma_slice_trim(ioma_slice s) return s; } -/* A NUL-terminated copy of the slice in buf; false when it did not all fit. */ -bool ioma_cstr(ioma_slice s, char *buf, size_t cap) +/* A NUL-terminated copy of the slice in buf; false when it did not all fit, or when the slice + * holds a NUL itself - the copy would read as a shorter string to whatever takes it. */ +bool ioxd_cstr(ioxd_slice s, char *buf, size_t cap) { if (cap == 0) return false; @@ -81,7 +85,7 @@ bool ioma_cstr(ioma_slice s, char *buf, size_t cap) if (n) memcpy(buf, s.p, n); buf[n] = '\0'; - return n == s.len; + return n == s.len && memchr(buf, '\0', n) == nullptr; } /* ── conversions ───────────────────────────────────────────────────────────────────────── */ @@ -104,13 +108,13 @@ static bool digits_to_u64(const char *p, size_t n, uint64_t limit, uint64_t *out } /* An unsigned 64-bit integer. */ -bool ioma_to_u64(ioma_slice s, uint64_t *out) +bool ioxd_to_u64(ioxd_slice s, uint64_t *out) { return digits_to_u64(s.p, s.len, UINT64_MAX, out); } /* A signed 64-bit integer: an optional '-' and digits. */ -bool ioma_to_i64(ioma_slice s, int64_t *out) +bool ioxd_to_i64(ioxd_slice s, int64_t *out) { bool negative = s.len > 0 && s.p[0] == '-'; uint64_t limit = negative ? (uint64_t)INT64_MAX + 1 : (uint64_t)INT64_MAX; @@ -125,10 +129,10 @@ bool ioma_to_i64(ioma_slice s, int64_t *out) } /* An int: a signed 64-bit integer that fits one. */ -bool ioma_to_int(ioma_slice s, int *out) +bool ioxd_to_int(ioxd_slice s, int *out) { int64_t v; - if (!ioma_to_i64(s, &v) || v < INT_MIN || v > INT_MAX) + if (!ioxd_to_i64(s, &v) || v < INT_MIN || v > INT_MAX) return false; *out = (int)v; return true; @@ -145,7 +149,7 @@ static void make_c_locale(void) /* A double: digits with an optional fraction and exponent; strtod does the rounding. Too large * fails; too small rounds towards zero, like every JSON parser. */ -bool ioma_to_double(ioma_slice s, double *out) +bool ioxd_to_double(ioxd_slice s, double *out) { char text[128]; if (s.len == 0 || s.len >= sizeof text) @@ -160,9 +164,11 @@ bool ioma_to_double(ioma_slice s, double *out) memcpy(text, s.p, s.len); text[s.len] = '\0'; pthread_once(&c_locale_once, make_c_locale); + if (!c_locale) + return false; /* no "C" locale: never the process one, which may read "2.5" as 2 */ char *end; errno = 0; - double v = c_locale ? strtod_l(text, &end, c_locale) : strtod(text, &end); + double v = strtod_l(text, &end, c_locale); if (end != text + s.len || (errno == ERANGE && (v == HUGE_VAL || v == -HUGE_VAL))) return false; *out = v; @@ -170,16 +176,16 @@ bool ioma_to_double(ioma_slice s, double *out) } /* A boolean: true/false, 1/0, yes/no, on/off in any case. */ -bool ioma_to_bool(ioma_slice s, bool *out) +bool ioxd_to_bool(ioxd_slice s, bool *out) { static const char *const yes[] = { "true", "1", "yes", "on" }; static const char *const no[] = { "false", "0", "no", "off" }; for (size_t i = 0; i < 4; i++) { - if (ioma_slice_eq_ci(s, yes[i])) { + if (ioxd_slice_eq_ci(s, yes[i])) { *out = true; return true; } - if (ioma_slice_eq_ci(s, no[i])) { + if (ioxd_slice_eq_ci(s, no[i])) { *out = false; return true; } @@ -189,7 +195,8 @@ bool ioma_to_bool(ioma_slice s, bool *out) /* ── key/value parsing ─────────────────────────────────────────────────────────────────── */ -/* Percent-decode [s, s+n) into dst ('+' becomes a space, a malformed %XX is kept as is). +/* Percent-decode [s, s+n) into dst ('+' becomes a space; a malformed %XX, and %00 - a NUL + * would end the value early for every C string function - are kept as they are). * Never longer than the input; returns the decoded length. */ static size_t decode(const char *src, size_t len, char *dst) { @@ -198,8 +205,8 @@ static size_t decode(const char *src, size_t len, char *dst) if (src[i] == '+') { dst[out++] = ' '; } else if (src[i] == '%' && i + 2 < len) { - int hi = ioma__hexval((unsigned char)src[i + 1]), lo = ioma__hexval((unsigned char)src[i + 2]); - if (hi >= 0 && lo >= 0) { + int hi = ioxd__hexval((unsigned char)src[i + 1]), lo = ioxd__hexval((unsigned char)src[i + 2]); + if (hi >= 0 && lo >= 0 && !(hi == 0 && lo == 0)) { dst[out++] = (char)(hi * 16 + lo); i += 2; } else { @@ -213,7 +220,7 @@ static size_t decode(const char *src, size_t len, char *dst) } /* Decode a slice into the arena and point it there; false when it would not fit. */ -static bool decode_into(ioma_slice *slice, char *arena, size_t arena_cap, size_t *used) +static bool decode_into(ioxd_slice *slice, char *arena, size_t arena_cap, size_t *used) { if (*used + slice->len > arena_cap) return false; @@ -224,12 +231,19 @@ static bool decode_into(ioma_slice *slice, char *arena, size_t arena_cap, size_t return true; } -/* "k=v&k2=v2" into pairs; see http.h. One pass per pair finds '=' and '&' and notes whether - * either side needs decoding, so the common undecoded pair is a view and costs a short scan. */ -size_t ioma_kv_parse(const char *text, size_t len, ioma_kv *out, size_t cap, char *arena, size_t arena_cap) +/* "k=v&k2=v2" into pairs; see slice.h. One pass per pair finds '=' and '&' and notes whether + * either side needs decoding, so the common undecoded pair is a view and costs a short scan. + * *truncated (may be NULL) says whether a pair was left out: past cap, or not fitting the arena. */ +size_t ioxd_kv_parse(const char *text, size_t len, ioxd_kv *out, size_t cap, char *arena, size_t arena_cap, + bool *truncated) { size_t used = 0, count = 0, start = 0; - while (start < len && count < cap) { + bool lost = false; + while (start < len) { + if (count == cap) { + lost = true; + break; + } size_t end = start, eq_at = len; bool key_needs_decode = false, value_needs_decode = false; for (; end < len && text[end] != '&'; end++) { @@ -242,54 +256,146 @@ size_t ioma_kv_parse(const char *text, size_t len, ioma_kv *out, size_t cap, cha } if (end > start) { /* skip empty pairs ("&&") */ bool has_eq = eq_at < end; - ioma_slice key = { text + start, (has_eq ? eq_at : end) - start }; - ioma_slice value = { has_eq ? text + eq_at + 1 : text + end, has_eq ? end - eq_at - 1 : 0 }; + ioxd_slice key = { text + start, (has_eq ? eq_at : end) - start }; + ioxd_slice value = { has_eq ? text + eq_at + 1 : text + end, has_eq ? end - eq_at - 1 : 0 }; size_t mark = used; bool ok = (!key_needs_decode || decode_into(&key, arena, arena_cap, &used)) && (!value_needs_decode || decode_into(&value, arena, arena_cap, &used)); - if (ok) - out[count++] = (ioma_kv){ key, value }; - else + if (ok) { + out[count++] = (ioxd_kv){ key, value }; + } else { used = mark; /* skip the pair, give its arena back */ + lost = true; + } } start = end + 1; } + if (truncated) + *truncated = lost; return count; } /* ── shaping the reply ─────────────────────────────────────────────────────────────────── */ -/* Add a header to the reply. false once the head is on the wire, or when the table is full. */ -bool ioma_header(ioma_ctx *ctx, const char *name, const char *value) +/* An HTTP token character (RFC 9110): what a field name is made of. */ +static bool is_tchar(unsigned char c) { - ioma_response *res = &ctx->res; - if (res->head_sent || res->n_headers == IOMA_MAX_RESP_HEADERS) + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c != 0 && strchr("!#$%&'*+-.^_`|~", c) != nullptr); +} + +/* A field value may hold anything but a control byte: no CR or LF (they would end the line + * and start another: response splitting), no NUL, no other C0 byte except a tab, no DEL. */ +static bool valid_field_value(const char *v, size_t n) +{ + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)v[i]; + if ((c < 0x20 && c != '\t') || c == 0x7f) + return false; + } + return true; +} + +/* The bytes of the head arena a copied content type takes: it sits at the far end, so the + * serialized lines can grow from the front without it in their way. */ +static size_t content_type_reserved(const ioxd_response *res) +{ + uintptr_t p = (uintptr_t)res->content_type.p, lo = (uintptr_t)res->head, hi = lo + sizeof res->head; + return p >= lo && p < hi ? res->content_type.len : 0; +} + +/* n bytes appended to the reply's head arena, or nullptr when they do not fit. */ +static char *head_room(ioxd_response *res, size_t n) +{ + if (n > sizeof res->head - content_type_reserved(res) - res->head_len) + return nullptr; + char *at = res->head + res->head_len; + res->head_len += n; + return at; +} + +/* Bytes into the arena at *at, which moves past them: a line is assembled from its parts. */ +static void put_bytes(char **at, const void *src, size_t n) +{ + memcpy(*at, src, n); + *at += n; +} + +/* Add a header to the reply: copied into the head arena as its serialized line, the name + * lower-cased, and remembered in headers[] as slices into that line. False once the head is on + * the wire, when the table or the arena is full, when the name is not a token or the value has + * a control byte, and for the headers the engine writes itself - "content-type" is taken as + * ioxd_content_type would. */ +bool ioxd_header(ioxd_ctx *ctx, const char *name, const char *value) +{ + ioxd_response *res = &ctx->res; + if (!name || !value) + return false; + size_t name_len = strlen(name), value_len = strlen(value); + if (res->head_sent || res->n_headers == IOXD_MAX_RESP_HEADERS || name_len == 0) + return false; + for (size_t i = 0; i < name_len; i++) + if (!is_tchar((unsigned char)name[i])) + return false; + if (!valid_field_value(value, value_len)) return false; - res->headers[res->n_headers++] = (ioma_kv){ { name, strlen(name) }, { value, strlen(value) } }; + if (name_len == 12 && strncasecmp(name, "content-type", 12) == 0) + return ioxd_content_type(ctx, value); + if ((name_len == 14 && strncasecmp(name, "content-length", 14) == 0) + || (name_len == 17 && strncasecmp(name, "transfer-encoding", 17) == 0) + || (name_len == 10 && strncasecmp(name, "connection", 10) == 0)) + return false; + char *line = head_room(res, name_len + 2 + value_len + 2); + if (!line) + return false; + char *at = line; + for (size_t i = 0; i < name_len; i++) + *at++ = (char)lower((unsigned char)name[i]); + put_bytes(&at, ": ", 2); + put_bytes(&at, value, value_len); + put_bytes(&at, "\r\n", 2); + res->headers[res->n_headers++] = (ioxd_kv){ { line, name_len }, { line + name_len + 2, value_len } }; return true; } -/* Set the content type from a C string (a slice can be assigned to res.content_type directly). */ -void ioma_content_type(ioma_ctx *ctx, const char *type) +/* Set the content type from a C string: a copy in the head arena (a slice that outlives the + * handler can be assigned to res.content_type directly). False once the head is sent, or for a + * value with a control byte, or when the arena is full. */ +bool ioxd_content_type(ioxd_ctx *ctx, const char *type) { - ctx->res.content_type = (ioma_slice){ type, strlen(type) }; + ioxd_response *res = &ctx->res; + if (!type || res->head_sent) + return false; + size_t n = strlen(type); + if (!valid_field_value(type, n)) + return false; + if (n > sizeof res->head - res->head_len) + return false; + char *copy = res->head + sizeof res->head - n, *at = copy; /* at the far end, past the lines */ + put_bytes(&at, type, n); + res->content_type = (ioxd_slice){ copy, n }; + return true; } -/* Declare the body length, so a body larger than the slab streams with Content-Length. */ -void ioma_content_length(ioma_ctx *ctx, size_t n) +/* Declare the body length, so a body larger than the slab streams with Content-Length. False + * once the head is sent. */ +bool ioxd_content_length(ioxd_ctx *ctx, size_t n) { + if (ctx->res.head_sent) + return false; ctx->res.content_length = n; ctx->res.has_length = true; + return true; } -/* Write a C string. */ -int ioma_text(ioma_ctx *ctx, const char *s) +/* Write a C string (NULL writes nothing). */ +int ioxd_text(ioxd_ctx *ctx, const char *s) { - return ioma_write(ctx, s, strlen(s)); + return s ? ioxd_write(ctx, s, strlen(s)) : 0; } /* The reason phrase for a status code; "Unknown" if unlisted. */ -const char *ioma_reason(int status) +const char *ioxd_reason(int status) { switch (status) { case 200: return "OK"; diff --git a/lib/http/engine.c b/lib/http/engine.c new file mode 100644 index 0000000..6c1a142 --- /dev/null +++ b/lib/http/engine.c @@ -0,0 +1,1091 @@ +/* + * engine.c - the HTTP/1.1 engine: parse a request head with picohttpparser, run the middleware + * chain and the endpoint against a context, read the body on demand (whole or streamed) through + * the connection's pipe (io/pipe.h) and drain + * what was left, then send what was written through the same pipe. All of it runs on the + * connection's coroutine, so a read or a flush simply suspends it and the loop resumes it. + */ +#include "http/engine.h" +#include "http/internal.h" +#include "http/router.h" +#include "io/pipe.h" +#include "picohttpparser.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifndef IOXD_PARAM_CAP +#define IOXD_PARAM_CAP 2048 /* per-request arena for percent-decoded query parameters */ +#endif +#ifndef IOXD_HEAD_CAP +#define IOXD_HEAD_CAP 4096 /* a serialized reply head must fit here */ +#endif +#ifndef IOXD_DRAIN_MAX +#define IOXD_DRAIN_MAX (1024UL * 1024) /* unread body discarded after a handler before we close instead */ +#endif +#ifndef IOXD_TRAILER_MAX +#define IOXD_TRAILER_MAX 4096 /* bytes of chunked trailers taken before the body is a 400 */ +#endif + +/* serve() hands req.headers to picohttpparser as its header array: a kv (two slices) must lay + * out exactly like a phr_header (name, name_len, value, value_len). */ +static_assert(sizeof(ioxd_kv) == sizeof(struct phr_header), "ioxd_kv must mirror phr_header"); +static_assert(offsetof(ioxd_kv, key) == offsetof(struct phr_header, name) && + offsetof(ioxd_slice, len) == offsetof(struct phr_header, name_len) && + offsetof(ioxd_kv, value) == offsetof(struct phr_header, value), + "ioxd_kv must mirror phr_header"); + +/* The engine's per-request state, behind ctx->priv. */ +struct serve_state { + struct ioxd_pipe *pipe; /* the connection: the head is kept in its reader */ + size_t body_read; /* body bytes handed out so far */ + bool body_done; /* the whole body has been taken off the wire */ + bool body_whole; /* ioxd_body_all kept it */ + int body_err; /* 0, a status to answer (400, 413), or -1: peer gone */ + size_t chunk_left; /* data bytes of the current chunk still to deliver */ + bool head_only; /* a HEAD request: the reply's body stays unsent */ + bool no_body; /* decided with the head: HEAD, 1xx, 204, 304 */ + bool continue_sent; /* the 100 Continue an Expect asked for went out */ +}; +#define STATE(ctx) ((struct serve_state *)(ctx)->priv) +#define READER(ctx) (&STATE(ctx)->pipe->in) +#define WRITER(ctx) (&STATE(ctx)->pipe->out) + +/* ── request headers ───────────────────────────────────────────────────────────────────── */ + +/* Fold A-Z to a-z; every other byte unchanged. */ +static inline unsigned char lower_ascii(unsigned char a) +{ + return (unsigned)(a - 'A') < 26U ? (unsigned char)(a | 0x20U) : a; +} + +/* Case-insensitive equality of two slices: a length test, then a byte loop. No libc, no locale. */ +static bool eq_ci(const char *a, size_t an, const char *b, size_t bn) +{ + if (an != bn) + return false; + for (size_t i = 0; i < an; i++) + if (lower_ascii((unsigned char)a[i]) != lower_ascii((unsigned char)b[i])) + return false; + return true; +} + +/* A Content-Length value: decimal digits and nothing else, into *out; false for anything else, + * including a number too large for size_t. Anything looser lets one request smuggle another + * behind a proxy that reads the value differently. */ +static bool parse_length(const char *s, size_t n, size_t *out) +{ + if (n == 0) + return false; + size_t v = 0; + for (size_t i = 0; i < n; i++) { + unsigned d = (unsigned char)s[i] - (unsigned)'0'; /* wraps huge for a non-digit */ + if (d > 9 || v > (SIZE_MAX - d) / 10) + return false; + v = v * 10 + d; + } + *out = v; + return true; +} + +/* The comma-separated tokens of a list header value, one per call from *at, blanks trimmed; + * false once the list is done. */ +static bool next_token(const char *value, size_t len, size_t *at, ioxd_slice *tok) +{ + while (*at < len && (value[*at] == ' ' || value[*at] == ',' || value[*at] == '\t')) (*at)++; + if (*at >= len) + return false; + size_t start = *at; + while (*at < len && value[*at] != ',') (*at)++; + size_t end = *at; /* trim trailing blanks */ + while (end > start && (value[end - 1] == ' ' || value[end - 1] == '\t')) end--; + *tok = (ioxd_slice){ value + start, end - start }; + return true; +} + +/* Is `tok` one of the comma-separated tokens in the header value [s, s+n)? Case-insensitive. */ +static bool token_present_ci(const char *value, size_t len, const char *tok) +{ + size_t tok_len = strlen(tok), at = 0; + ioxd_slice t; + while (next_token(value, len, &at, &t)) + if (eq_ci(t.p, t.len, tok, tok_len)) + return true; + return false; +} + +/* What the engine picks from the request headers as it lower-cases their names: the framing + * fields, Host, Connection and Expect - and whether they make a request it must refuse. */ +struct picked_headers { + ioxd_slice content_length; + unsigned n_content_length, n_transfer_enc, n_host; + bool te_last_chunked; /* the last transfer coding named is chunked */ + bool te_other; /* a coding other than that final chunked was named */ + bool close, keep_alive; /* over every Connection line */ + bool expect_continue; + int refuse; /* 0, or the status to answer: 400, 417 */ +}; + +/* Lower-case ASCII in place, eight bytes per step. The bytes must all be below 0x80 - true for + * header names, which picohttpparser only accepts as HTTP tokens - so the adds cannot carry + * between bytes: +0x3f sets a byte's high bit from 'A' up, +0x25 from 'Z'+1 up, and the + * difference marks exactly 'A'..'Z'. */ +static inline void lower_inplace(char *s, size_t n) +{ + size_t i = 0; + for (; i + 8 <= n; i += 8) { + uint64_t w; + memcpy(&w, s + i, 8); + uint64_t upper = ((w + 0x3f3f3f3f3f3f3f3fULL) & ~(w + 0x2525252525252525ULL)) & 0x8080808080808080ULL; + w |= upper >> 2; /* 0x80 >> 2 == 0x20 */ + memcpy(s + i, &w, 8); + } + for (; i < n; i++) + if ((unsigned)(s[i] - 'A') < 26U) s[i] += 'a' - 'A'; +} + +/* One Transfer-Encoding line into the picked state: its codings join the list the earlier + * lines made, so a chunked that was last is last no more. */ +static void pick_transfer_encoding(struct picked_headers *picked, ioxd_slice value) +{ + picked->n_transfer_enc++; + if (picked->te_last_chunked) + picked->te_other = true; + picked->te_last_chunked = false; + size_t at = 0; + ioxd_slice tok; + bool any = false; + while (next_token(value.p, value.len, &at, &tok)) { + any = true; + size_t peek = at; + ioxd_slice more; + bool last = !next_token(value.p, value.len, &peek, &more); + if (last && eq_ci(tok.p, tok.len, "chunked", 7)) + picked->te_last_chunked = true; + else + picked->te_other = true; + } + if (!any) + picked->refuse = 400; /* an empty list */ +} + +/* One pass over the request headers: lower-case each name in place (the buffer is ours), so + * handlers and this switch compare with plain memcmp. The switch on the name length rejects + * nearly every header before a byte is compared. A folded continuation line (obs-fold) comes + * from the parser with no name: it must not be interpreted, so the request is refused. */ +static struct picked_headers pick_headers(ioxd_request *req) +{ + struct picked_headers picked = {}; + for (size_t i = 0; i < req->n_headers; i++) { + ioxd_kv *hdr = &req->headers[i]; + char *name = (char *)hdr->key.p; + if (!name) { + picked.refuse = 400; + continue; + } + lower_inplace(name, hdr->key.len); + switch (hdr->key.len) { + case 4: + if (memcmp(name, "host", 4) == 0) picked.n_host++; + break; + case 6: + if (memcmp(name, "expect", 6) == 0) { + if (eq_ci(hdr->value.p, hdr->value.len, "100-continue", 12)) picked.expect_continue = true; + else picked.refuse = 417; + } + break; + case 10: + if (memcmp(name, "connection", 10) == 0) { + if (token_present_ci(hdr->value.p, hdr->value.len, "close")) picked.close = true; + if (token_present_ci(hdr->value.p, hdr->value.len, "keep-alive")) picked.keep_alive = true; + } + break; + case 14: + if (memcmp(name, "content-length", 14) == 0) { + if (picked.n_content_length++ && !(hdr->value.len == picked.content_length.len + && memcmp(hdr->value.p, picked.content_length.p, hdr->value.len) == 0)) + picked.refuse = 400; /* two that disagree */ + picked.content_length = hdr->value; + } + break; + case 17: + if (memcmp(name, "transfer-encoding", 17) == 0) pick_transfer_encoding(&picked, hdr->value); + break; + default: + break; + } + } + return picked; +} + +/* HTTP/1.1 keeps alive unless a Connection line says "close"; HTTP/1.0 only with "keep-alive". */ +static bool keep_alive_from(int minor_version, const struct picked_headers *picked) +{ + if (picked->close) + return false; + return minor_version >= 1 || picked->keep_alive; +} + +/* ── the reply: head serialization and the write slab ──────────────────────────────────── */ + +/* Write v in decimal at dst; return the digit count. A digit loop, no printf. */ +static inline int put_uint(char *dst, size_t v) +{ + char tmp[20]; + int i = 0; + do { + tmp[i++] = (char)('0' + (v % 10)); + v /= 10; + } while (v); + for (int j = 0; j < i; j++) + dst[j] = tmp[i - 1 - j]; + return i; +} + +/* The same in hex, for chunk sizes. */ +static inline int put_hex(char *dst, size_t v) +{ + static const char digits[] = "0123456789abcdef"; + char tmp[16]; + int i = 0; + do { + tmp[i++] = digits[v & 15]; + v >>= 4; + } while (v); + for (int j = 0; j < i; j++) + dst[j] = tmp[i - 1 - j]; + return i; +} + +/* A constant slice: pointer + length, so a precomposed line is one memcpy. */ +struct cslice { const char *p; int len; }; +#define CSLICE(lit) (struct cslice){ (lit), (int)(sizeof(lit) - 1) } + +/* The precomposed status line for the common codes; nullptr for the rest (built on the spot). */ +static struct cslice status_line(int code) +{ + switch (code) { + case 200: return CSLICE("HTTP/1.1 200 OK\r\n"); + case 204: return CSLICE("HTTP/1.1 204 No Content\r\n"); + case 400: return CSLICE("HTTP/1.1 400 Bad Request\r\n"); + case 404: return CSLICE("HTTP/1.1 404 Not Found\r\n"); + case 405: return CSLICE("HTTP/1.1 405 Method Not Allowed\r\n"); + case 500: return CSLICE("HTTP/1.1 500 Internal Server Error\r\n"); + default: return (struct cslice){ nullptr, 0 }; + } +} + +/* A bodyless framework reply (parse errors, limits): an error path, so plain snprintf. Best + * effort; the caller then closes. */ +static void send_status(ioxd_pipewriter *pw, int code) +{ + char head[128]; + int len = snprintf(head, sizeof head, "HTTP/1.1 %d %s\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + code, ioxd_reason(code)); + if (len < 0) + return; + if ((size_t)len >= sizeof head) + len = (int)sizeof head - 1; + ioxd_pipewriter_reset(pw); /* whatever the handler had buffered is moot */ + ioxd_pipewriter_send(pw, head, (size_t)len); +} + +/* How the body is delimited on the wire. */ +enum framing { + FRAME_LENGTH, + FRAME_CHUNKED, + FRAME_UNTIL_CLOSE, + FRAME_NONE, /* a reply that cannot have one: 1xx, 204, a 304 with no length */ +}; + +/* The status as it goes on the wire: three digits, or a 500 for a handler's mistake. */ +static int wire_status(int status) +{ + return status >= 100 && status <= 999 ? status : 500; +} + +/* Serialize the head into dst by memcpy of precomposed pieces plus the integer writer - no + * snprintf. Every field name goes out lower-cased: the engine's own are lowercase literals, a + * handler's were folded as ioxd_header copied them, so they are one memcpy of the arena. + * Returns the length, or -1 if it does not fit. */ +static int build_head(const ioxd_ctx *ctx, char *dst, size_t cap, enum framing framing, size_t body_len) +{ + const ioxd_response *res = &ctx->res; + char *p = dst; + char *end = dst + cap; + int status = wire_status(res->status); + +#define NEED(n) do { if ((size_t)(end - p) < (size_t)(n)) return -1; } while (0) +#define PUT(src, n) do { NEED(n); memcpy(p, (src), (size_t)(n)); p += (n); } while (0) +#define PUTC(lit) PUT((lit), sizeof(lit) - 1) + + struct cslice line = status_line(status); + if (line.p) { + PUT(line.p, line.len); + } else { + PUTC("HTTP/1.1 "); + NEED(3); + p += put_uint(p, (size_t)status); + PUTC(" "); + const char *reason = ioxd_reason(status); + PUT(reason, strlen(reason)); + PUTC("\r\n"); + } + + PUTC("content-type: "); + PUT(res->content_type.p, res->content_type.len); + PUTC("\r\n"); + + if (framing == FRAME_LENGTH) { + PUTC("content-length: "); + NEED(20); + p += put_uint(p, body_len); + PUTC("\r\n"); + } else if (framing == FRAME_CHUNKED) { + PUTC("transfer-encoding: chunked\r\n"); + } + + /* Connection: only when it says something. HTTP/1.1 is persistent by default, so a kept-alive + * 1.1 reply carries none; a 1.0 client that asked for keep-alive is told it got it; a closing + * reply always says close. */ + bool keep = ctx->req.keep_alive && !res->close; + if (!keep) + PUTC("connection: close\r\n"); + else if (ctx->req.minor_version == 0) + PUTC("connection: keep-alive\r\n"); + + PUT(res->head, res->head_len); /* the handler's lines, serialized as added */ + + PUTC("\r\n"); +#undef PUTC +#undef PUT +#undef NEED + return (int)(p - dst); +} + +/* The chunked terminator: the empty last chunk and the end of the trailers. */ +static void put_terminator(char *at) +{ + at[0] = '0'; + at[1] = '\r'; + at[2] = '\n'; + at[3] = '\r'; + at[4] = '\n'; +} + +/* Mark the reply dead (the peer is gone, or a head that cannot be built) and fail the call. */ +static int fail(ioxd_response *res) +{ + res->failed = true; + return -1; +} + +/* Send the slab, with the head in front of it the first time. That first time decides the + * framing: a final flush with the head unsent means the whole body is here (Content-Length, one + * send); an early flush means the body outgrew the slab, so it streams - with the declared length + * if the handler gave one, else chunked on HTTP/1.1, else until close on HTTP/1.0. It also + * settles what the request and the status dictate: no body at all after HEAD, a 1xx, 204 or + * 304 (RFC 9112 6.3), no framing header on a 1xx or 204, and "connection: close" when the + * body still on the wire is more than will be drained. A declared length is held to: a whole + * buffered reply gets the real one, a stream never exceeds it, and one that falls short closes. */ +static int flush(ioxd_ctx *ctx, bool final) +{ + ioxd_response *res = &ctx->res; + ioxd_pipewriter *pw = WRITER(ctx); + struct serve_state *state = STATE(ctx); + if (res->failed) + return -1; + char head[IOXD_HEAD_CAP]; + int head_len = 0; + if (!res->head_sent) { /* the first send: decide the framing */ + int status = wire_status(res->status); + bool bodyless = status < 200 || status == 204 || status == 304; + enum framing framing = FRAME_LENGTH; + size_t body_len = res->has_length && !final ? res->content_length : pw->len; + state->no_body = state->head_only || bodyless; + if (res->has_length && final) + res->content_length = pw->len; /* buffered whole: the length is what was written */ + if (bodyless) { + framing = status == 304 && res->has_length ? FRAME_LENGTH : FRAME_NONE; + } else if (!res->has_length && !final) { + if (ctx->req.minor_version >= 1) { + framing = FRAME_CHUNKED; + res->chunked = true; + } else { + framing = FRAME_UNTIL_CLOSE; + res->close = true; + } + } + if (!state->body_done && !state->body_err) { /* a body left unread: more than the drain takes means close */ + size_t left = ctx->req.chunked ? SIZE_MAX : ctx->req.content_length - state->body_read; + if (left > IOXD_DRAIN_MAX || (ctx->req.expect_continue && !state->continue_sent)) + res->close = true; /* an expecting client may never send it at all */ + } + head_len = build_head(ctx, head, sizeof head, framing, body_len); + if (head_len < 0) { + send_status(pw, 500); + return fail(res); + } + res->head_sent = true; + } + if (state->no_body) { /* the head alone; what was written as a body stays here */ + ioxd_pipewriter_reset(pw); + if (head_len && ioxd_pipewriter_through(pw, head, (size_t)head_len) < 0) + return fail(res); + return 0; + } + bool over = false; + if (res->has_length) { /* never a byte past the declared length */ + size_t left = res->content_length - res->body_sent; + if (pw->len > left) { + pw->len = left; + over = true; + } + } + res->body_sent += pw->len; + if (res->chunked && pw->len) { /* the slab's bytes as one chunk: size in front, CRLF behind */ + char size_line[16]; + int digits = put_hex(size_line, pw->len); + char *front = ioxd_pipewriter_front(pw, (size_t)digits + 2); + char *back = ioxd_pipewriter_back(pw, 2); + if (!front || !back) + return fail(res); + memcpy(front, size_line, (size_t)digits); + front[digits] = '\r'; + front[digits + 1] = '\n'; + back[0] = '\r'; + back[1] = '\n'; + } + if (final && res->chunked) { /* the terminator rides the same send */ + char *back = ioxd_pipewriter_back(pw, 5); + if (!back) + return fail(res); + put_terminator(back); + } + if (head_len) { + char *front = ioxd_pipewriter_front(pw, (size_t)head_len); + if (front) + memcpy(front, head, (size_t)head_len); /* one contiguous send */ + else if (ioxd_pipewriter_through(pw, head, (size_t)head_len) < 0) /* bigger than the lead: on its own, first */ + return fail(res); + } + if (ioxd_pipewriter_flush(pw) < 0) + return fail(res); + if (over) + return fail(res); /* the handler wrote past its own length: nothing more goes */ + return 0; +} + +/* After the chain: send what is left - the whole reply if nothing went out yet - and close a + * chunked stream. A streamed reply that fell short of its declared length closes the + * connection, so the client sees it was cut. */ +static int finish(ioxd_ctx *ctx) +{ + ioxd_response *res = &ctx->res; + ioxd_pipewriter *pw = WRITER(ctx); + if (res->failed) + return -1; + if (!res->head_sent || pw->len) { /* nothing sent yet, or bytes still in the slab */ + if (flush(ctx, true) < 0) + return -1; + } else if (res->chunked) { /* streamed and drained: just the terminator */ + char *back = ioxd_pipewriter_back(pw, 5); + if (!back) + return fail(res); + put_terminator(back); + if (ioxd_pipewriter_flush(pw) < 0) + return fail(res); + } + if (res->has_length && !STATE(ctx)->no_body && res->body_sent != res->content_length) + res->close = true; + return 0; +} + +/* Append body bytes to the slab; send it, head first, whenever it fills. */ +int ioxd_write(ioxd_ctx *ctx, const void *data, size_t len) +{ + ioxd_response *res = &ctx->res; + ioxd_pipewriter *pw = WRITER(ctx); + if (res->failed) + return -1; + const char *src = data; + while (len) { + size_t room = ioxd_pipewriter_room(pw); + if (room == 0) { + if (flush(ctx, false) < 0) + return -1; + continue; + } + size_t n = len < room ? len : room; + memcpy(ioxd_pipewriter_at(pw), src, n); + ioxd_pipewriter_advance(pw, n); + src += n; + len -= n; + } + return 0; +} + +/* Something bigger than the whole slab: format it on the heap and write it in pieces. */ +static int write_formatted_heap(ioxd_ctx *ctx, const char *fmt, va_list ap, size_t len) +{ + char *tmp = malloc(len + 1); + if (!tmp) + return fail(&ctx->res); /* the reply cannot be completed as promised */ + vsnprintf(tmp, len + 1, fmt, ap); + int rc = ioxd_write(ctx, tmp, len); + free(tmp); + return rc; +} + +/* Format straight into the slab. If it does not fit the room left, flush and format again into + * the empty slab; if it would not fit even that, it goes through the heap. */ +int ioxd_printf(ioxd_ctx *ctx, const char *fmt, ...) +{ + ioxd_response *res = &ctx->res; + ioxd_pipewriter *pw = WRITER(ctx); + if (res->failed) + return -1; + va_list ap, again; + va_start(ap, fmt); + va_copy(again, ap); + size_t room = ioxd_pipewriter_room(pw); + int n = vsnprintf(ioxd_pipewriter_at(pw), room, fmt, ap); + va_end(ap); + + int rc = -1; + if (n < 0) { + /* a formatting error: nothing written */ + } else if ((size_t)n < room) { /* it fit */ + ioxd_pipewriter_advance(pw, (size_t)n); + rc = 0; + } else if ((size_t)n >= pw->cap) { /* bigger than the slab itself */ + rc = write_formatted_heap(ctx, fmt, again, (size_t)n); + } else if (flush(ctx, false) == 0) { /* make room, then it fits */ + ioxd_pipewriter_advance(pw, (size_t)vsnprintf(ioxd_pipewriter_at(pw), pw->cap, fmt, again)); + rc = 0; + } + va_end(again); + return rc; +} + +/* Send what is in the slab now. Starts streaming: the head goes out with it. */ +int ioxd_flush(ioxd_ctx *ctx) +{ + return flush(ctx, false); +} + +/* n bytes of the reply to write into directly, flushing first when they do not fit. */ +void *ioxd_reserve(ioxd_ctx *ctx, size_t n) +{ + ioxd_response *res = &ctx->res; + ioxd_pipewriter *pw = WRITER(ctx); + if (res->failed || n > pw->cap) + return nullptr; + if (n > ioxd_pipewriter_room(pw) && flush(ctx, false) < 0) + return nullptr; + return ioxd_pipewriter_at(pw); +} + +/* The caller wrote n of the reserved bytes. */ +void ioxd_advance(ioxd_ctx *ctx, size_t n) +{ + ioxd_pipewriter_advance(WRITER(ctx), n); +} + +/* ── the body: read on demand ──────────────────────────────────────────────────────────── */ + +/* A body failure with a status: the engine answers with it after the handler unless a reply is + * already streaming, and res.status shows it so a handler can stop before it writes anything. */ +static void body_fail(ioxd_ctx *ctx, int status) +{ + STATE(ctx)->body_err = status; + ctx->res.status = status; +} + +/* Before the first read of the body: a client that sent "Expect: 100-continue" is waiting for + * the interim reply before it sends a byte (RFC 9110 10.1.1), so it goes out now, ahead of + * anything the handler has buffered. False when the peer is gone. */ +static bool body_begin(ioxd_ctx *ctx) +{ + struct serve_state *state = STATE(ctx); + if (!ctx->req.expect_continue || state->continue_sent || ctx->res.head_sent) + return true; + state->continue_sent = true; + if (ioxd_pipewriter_through(WRITER(ctx), "HTTP/1.1 100 Continue\r\n\r\n", 25) < 0) { + state->body_err = -1; + return false; + } + return true; +} + +/* The live bytes with something unexamined, or more of them; a failure recorded: no room is a + * 413, the peer gone or the input ending inside the body is -1. */ +static bool body_bytes(ioxd_ctx *ctx, ioxd_slice *live) +{ + int rc = ioxd_pipereader_read(READER(ctx), live); + if (rc > 0) + return true; + if (rc == IOXD_PIPE_FULL) + body_fail(ctx, 413); + else + STATE(ctx)->body_err = -1; + return false; +} + +/* --- a chunked body --- */ + +/* The line at the front of the live bytes, whole: its length without the CRLF, or -1 recorded. */ +static long body_line(ioxd_ctx *ctx, ioxd_slice *live) +{ + for (;;) { + if (!body_bytes(ctx, live)) + return -1; + const char *eol = memmem(live->p, live->len, "\r\n", 2); + if (eol) + return eol - live->p; + ioxd_pipereader_examine(READER(ctx), live->len); + } +} + +/* After the last chunk: trailer lines up to an empty one, then the body is done. They are + * dropped unread, and bounded, so a peer cannot hold the connection with an endless trailer. */ +static bool chunk_trailers(ioxd_ctx *ctx) +{ + ioxd_slice live = { nullptr, 0 }; + size_t taken = 0; + for (;;) { + long len = body_line(ctx, &live); + if (len < 0) + return false; + taken += (size_t)len + 2; + if (taken > IOXD_TRAILER_MAX) { + body_fail(ctx, 400); + return false; + } + ioxd_pipereader_drop(READER(ctx), (size_t)len + 2); + if (len == 0) + break; + } + STATE(ctx)->body_done = true; + return true; +} + +/* The next chunk's size line - hex digits, an optional extension, CRLF - into chunk_left. The + * last chunk (size 0) also takes its trailers and ends the body. The line is held to the + * grammar (RFC 9112 7.1): digits, then either the CRLF or blanks and a ';' with something after + * it; a size line the reader cannot hold is malformed, not too large. */ +static bool chunk_header(ioxd_ctx *ctx) +{ + ioxd_slice live = { nullptr, 0 }; + long len = body_line(ctx, &live); + if (len < 0) { + if (STATE(ctx)->body_err == 413) + body_fail(ctx, 400); + return false; + } + size_t size = 0, i = 0; + for (; i < (size_t)len; i++) { + int digit = ioxd__hexval((unsigned char)live.p[i]); + if (digit < 0) + break; + if (size > (SIZE_MAX >> 4)) { + body_fail(ctx, 400); + return false; + } + size = (size << 4) | (size_t)digit; + } + size_t j = i; + while (j < (size_t)len && (live.p[j] == ' ' || live.p[j] == '\t')) j++; + bool ended = (j == (size_t)len && j == i) || (j + 1 < (size_t)len && live.p[j] == ';'); + if (i == 0 || !ended) { + body_fail(ctx, 400); + return false; + } + ioxd_pipereader_drop(READER(ctx), (size_t)len + 2); + if (size == 0) + return chunk_trailers(ctx); + STATE(ctx)->chunk_left = size; + return true; +} + +/* The CRLF that ends a chunk's data. */ +static bool chunk_end(ioxd_ctx *ctx) +{ + ioxd_slice live = { nullptr, 0 }; + for (;;) { + if (!body_bytes(ctx, &live)) + return false; + if (live.len >= 2) + break; + ioxd_pipereader_examine(READER(ctx), live.len); + } + if (live.p[0] != '\r' || live.p[1] != '\n') { + body_fail(ctx, 400); + return false; + } + ioxd_pipereader_drop(READER(ctx), 2); + return true; +} + +/* --- the three reads --- */ + +/* The whole body, kept in the reader: a Content-Length body once it is all here, a chunked one + * chunk by chunk with the data slid together. In place after the head when it all arrived in + * one kernel buffer, gathered otherwise. Once; then the slice, which is also req.body. */ +ioxd_slice ioxd_body_all(ioxd_ctx *ctx) +{ + struct serve_state *state = STATE(ctx); + ioxd_pipereader *pr = &state->pipe->in; + ioxd_request *req = &ctx->req; + const ioxd_slice none = { nullptr, 0 }; + + if (state->body_whole) + return req->body; + if (state->body_read || state->body_err) /* already streaming, or failed */ + return none; + if (!state->body_done && !body_begin(ctx)) + return none; + + if (req->chunked) { + while (!state->body_done) { + if (state->chunk_left == 0) { + if (!chunk_header(ctx)) + return none; + continue; + } + ioxd_slice live = { nullptr, 0 }; + if (!body_bytes(ctx, &live)) + return none; + size_t n = live.len < state->chunk_left ? live.len : state->chunk_left; + if (!ioxd_pipereader_keep(pr, n)) { + body_fail(ctx, 413); + return none; + } + state->chunk_left -= n; + state->body_read += n; + if (state->chunk_left == 0 && !chunk_end(ctx)) + return none; + } + req->body = ioxd_pipereader_run(pr); + } else { + if (req->content_length > pr->cap) { + body_fail(ctx, 413); + return none; + } + ioxd_slice live = none; + while (req->content_length && live.len < req->content_length) { + if (live.len) + ioxd_pipereader_examine(pr, live.len); + if (!body_bytes(ctx, &live)) + return none; + } + const char *kept = req->content_length ? ioxd_pipereader_keep(pr, req->content_length) : live.p; + if (req->content_length && !kept) { + body_fail(ctx, 413); + return none; + } + req->body = (ioxd_slice){ kept, req->content_length }; + state->body_read = req->content_length; + state->body_done = true; + } + state->body_whole = true; + return req->body; +} + +/* Up to n bytes of a Content-Length body into dst, straight from the reader. */ +static int fixed_data(ioxd_ctx *ctx, struct serve_state *state, char *dst, size_t n) +{ + size_t remaining = ctx->req.content_length - state->body_read; + if (n > remaining) + n = remaining; + int got = ioxd_pipereader_copy(&state->pipe->in, dst, n); + if (got <= 0) { + state->body_err = -1; /* gone, or the input ended inside the body */ + return -1; + } + state->body_read += (size_t)got; + if (state->body_read == ctx->req.content_length) + state->body_done = true; + return got; +} + +/* Up to n data bytes of the current chunk into dst; the CRLF after its last byte is taken too. */ +static int chunk_data(ioxd_ctx *ctx, struct serve_state *state, char *dst, size_t n) +{ + if (n > state->chunk_left) + n = state->chunk_left; + int got = ioxd_pipereader_copy(&state->pipe->in, dst, n); + if (got <= 0) { + state->body_err = -1; + return -1; + } + state->chunk_left -= (size_t)got; + state->body_read += (size_t)got; + if (state->chunk_left == 0 && !chunk_end(ctx)) + return got; /* what was copied still counts; the next call fails */ + return got; +} + +/* The next bytes of the body into dst, reading until n are there or the body ends. */ +int ioxd_body_read_until(ioxd_ctx *ctx, void *dst, size_t n) +{ + struct serve_state *state = STATE(ctx); + if (state->body_err) + return -1; + if (n == 0 || state->body_done) + return 0; + if (!body_begin(ctx)) + return -1; + if (n > INT_MAX) + n = INT_MAX; /* the count comes back as an int */ + char *out = dst; + size_t got = 0; + while (got < n && !state->body_done) { + int k; + if (!ctx->req.chunked) + k = fixed_data(ctx, state, out + got, n - got); + else if (state->chunk_left == 0) + k = chunk_header(ctx) ? 0 : -1; + else + k = chunk_data(ctx, state, out + got, n - got); + if (k < 0) + return -1; + got += (size_t)k; + } + return (int)got; +} + +/* The next chunk of a chunked body, whole, into dst: the rest of the current one when a read + * stopped inside it, else the next. */ +int ioxd_body_read_next_chunk(ioxd_ctx *ctx, void *dst, size_t cap) +{ + struct serve_state *state = STATE(ctx); + if (state->body_err || !ctx->req.chunked) + return -1; + if (state->body_done) + return 0; + if (!body_begin(ctx)) + return -1; + if (state->chunk_left == 0) { + if (!chunk_header(ctx)) + return -1; + if (state->body_done) + return 0; + } + if (state->chunk_left > cap || cap > INT_MAX) { /* the chunk does not fit dst */ + body_fail(ctx, 413); + return -1; + } + char *out = dst; + size_t want = state->chunk_left, got = 0; + while (got < want) { + int k = chunk_data(ctx, state, out + got, want - got); + if (k < 0) + return -1; + got += (size_t)k; + } + return (int)got; +} + +/* After the chain: take an unread body off the wire so the connection stays in sync, up to a + * limit - past it, the reply says close and the rest is never read. */ +static void drain_body(ioxd_ctx *ctx) +{ + struct serve_state *state = STATE(ctx); + char tmp[4096]; + size_t drained = 0; + while (!state->body_done && !state->body_err) { + if (drained >= IOXD_DRAIN_MAX) { + ctx->res.close = true; + return; + } + int n = ioxd_body_read_until(ctx, tmp, sizeof tmp); + if (n <= 0) + return; + drained += (size_t)n; + } +} + +/* ── the connection loop ───────────────────────────────────────────────────────────────── */ + +/* Get a complete request head into read_buf, parsed straight into req (method, target, + * version, headers). What is buffered is parsed first: after a reply, a pipelined next request + * may already be there. More is read only when the head is incomplete. Returns the head's + * length, or -1 once the connection is finished (431 or 400 answered, or the peer went away). */ +static long read_head(ioxd_ctx *ctx) +{ + ioxd_pipereader *pr = READER(ctx); + ioxd_request *req = &ctx->req; + size_t already = 0; /* what the previous attempt scanned */ + ioxd_slice live = { nullptr, 0 }; + for (;;) { + int rc = ioxd_pipereader_read(pr, &live); + if (rc == 0) + return -1; /* the peer is done: a clean end between requests */ + if (rc == IOXD_PIPE_FULL) { + send_status(WRITER(ctx), 431); + return -1; + } + if (rc < 0) + return -1; + req->n_headers = IOXD_MAX_HEADERS; /* in: room; out: count */ + int parsed = phr_parse_request(live.p, live.len, + &req->method.p, &req->method.len, + &req->target.p, &req->target.len, + &req->minor_version, + (struct phr_header *)req->headers, &req->n_headers, + already); + if (parsed >= 0) { + if (!ioxd_pipereader_keep(pr, (size_t)parsed)) { /* the head stays put, where it was parsed */ + send_status(WRITER(ctx), 431); + return -1; + } + ioxd_pipereader_run_begin(pr); /* the body's kept bytes are a run of their own */ + return parsed; + } + if (parsed == -1) { /* malformed */ + send_status(WRITER(ctx), 400); + return -1; + } + ioxd_pipereader_examine(pr, live.len); /* incomplete: the next read waits for more */ + already = live.len; + } +} + +/* Does the slice begin with this literal, ignoring ASCII case? */ +static bool starts_ci(ioxd_slice s, const char *lit) +{ + size_t n = strlen(lit); + return s.len >= n && eq_ci(s.p, n, lit, n); +} + +/* The rest of the request from its head: path and query (an absolute-form target loses its + * scheme and authority first), the query split into params, the headers lower-cased and the + * ones the engine needs picked out, and the framing settled. The body stays on the wire. + * Returns 0, or the status the request must be refused with. */ +static int fill_request(ioxd_request *req, char *params_arena, size_t arena_cap) +{ + ioxd_slice target = req->target; + if (target.len && target.p[0] != '/' && (starts_ci(target, "http://") || starts_ci(target, "https://"))) { /* absolute-form: RFC 9112 3.2.2 */ + size_t skip = target.p[4] == ':' ? 7 : 8; + const char *slash = memchr(target.p + skip, '/', target.len - skip); + target = slash ? (ioxd_slice){ slash, (size_t)(target.p + target.len - slash) } : (ioxd_slice){ "/", 1 }; + } + const char *qmark = memchr(target.p, '?', target.len); + if (qmark) { + req->path = (ioxd_slice){ target.p, (size_t)(qmark - target.p) }; + req->query = (ioxd_slice){ qmark + 1, target.len - req->path.len - 1 }; + } else { + req->path = target; + req->query = (ioxd_slice){ target.p + target.len, 0 }; + } + bool truncated = false; + req->n_params = req->query.len + ? ioxd_kv_parse(req->query.p, req->query.len, req->params, IOXD_MAX_PARAMS, params_arena, arena_cap, &truncated) + : 0; + req->n_route_params = 0; /* the router fills these */ + req->body = (ioxd_slice){ nullptr, 0 }; /* on demand: ioxd_body_all fills it */ + + struct picked_headers picked = pick_headers(req); + req->chunked = false; + req->content_length = 0; + req->keep_alive = keep_alive_from(req->minor_version, &picked); + req->expect_continue = picked.expect_continue; + if (picked.refuse) + return picked.refuse; + if (truncated) /* part of the query would be missing: never act on part */ + return req->n_params == IOXD_MAX_PARAMS ? 400 : 414; + if (req->minor_version >= 1 ? picked.n_host != 1 : picked.n_host > 1) + return 400; /* RFC 9112 3.2: exactly one Host on HTTP/1.1 */ + if (picked.n_transfer_enc) { + if (picked.n_content_length) + return 400; /* both framings: RFC 9112 6.1 */ + if (picked.te_other) + return 501; /* a transfer coding we do not implement */ + if (!picked.te_last_chunked) + return 400; /* chunked, but not as the last coding */ + req->chunked = true; + } else if (picked.n_content_length) { + if (!parse_length(picked.content_length.p, picked.content_length.len, &req->content_length)) + return 400; + } + return 0; +} + +/* The engine's bookkeeping for reading the body on demand: where it starts, what already + * arrived with the head, and - for a Content-Length body that is entirely here - where the next + * request starts. */ +static void init_body_state(struct serve_state *state, struct ioxd_pipe *pipe, const ioxd_request *req) +{ + *state = (struct serve_state){ .pipe = pipe }; + state->body_done = !req->chunked && req->content_length == 0; +} + +/* A response with its defaults and an empty slab. */ +static void init_response(ioxd_response *res, ioxd_pipewriter *pw) +{ + res->status = 200; + res->content_type = (ioxd_slice){ "text/plain", 10 }; + res->n_headers = 0; /* headers[] is only read up to here */ + res->close = false; + res->head_sent = false; + res->content_length = 0; + res->has_length = false; + res->chunked = false; + res->failed = false; + res->body_sent = 0; + res->head_len = 0; + ioxd_pipewriter_reset(pw); +} + + +/* The proactor handler for every connection: one request per iteration - get the head, run + * the chain against a context, drain what it left of the body, send what it wrote - while kept + * alive. Returning closes the connection. */ +void ioxd__serve(struct ioxd_pipe *pipe) +{ + char params[IOXD_PARAM_CAP]; /* decoded query parameters */ + + for (;;) { + ioxd_ctx ctx; /* this request's context */ + struct serve_state state = { .pipe = pipe }; + ctx.priv = &state; + + long head_len = read_head(&ctx); + if (head_len < 0) + return; + int refused = fill_request(&ctx.req, params, sizeof params); + init_body_state(&state, pipe, &ctx.req); + init_response(&ctx.res, &pipe->out); + ctx.user = nullptr; + if (refused) { /* the framing cannot be trusted: answer, close */ + send_status(&pipe->out, refused); + return; + } + state.head_only = ctx.req.method.len == 4 && memcmp(ctx.req.method.p, "HEAD", 4) == 0; + + ioxd__dispatch(&ctx); /* middleware chain + endpoint */ + + if (state.body_err) { /* too large, malformed, or gone */ + if (state.body_err > 0 && !ctx.res.head_sent) + send_status(&pipe->out, state.body_err); + return; + } + if (ctx.req.expect_continue && !state.continue_sent && !state.body_done) + ctx.res.close = true; /* never asked for: the client may not send it, so no drain */ + else + drain_body(&ctx); /* what the handler left unread */ + if (state.body_err) + return; + if (finish(&ctx) < 0) /* sends; suspends meanwhile */ + return; + if (!ctx.req.keep_alive || ctx.res.close) + return; + ioxd_pipereader_release(&pipe->in); /* this request's bytes go; a pipelined next one stays */ + } +} diff --git a/lib/http/engine.h b/lib/http/engine.h new file mode 100644 index 0000000..26cefdd --- /dev/null +++ b/lib/http/engine.h @@ -0,0 +1,8 @@ +/* + * http/engine.h - the HTTP/1.1 engine's one entry for the runner: the per-connection loop. + */ +#pragma once + +#include "io/pipe.h" + +void ioxd__serve(struct ioxd_pipe *pipe); /* requests on the connection until it ends */ diff --git a/lib/http/internal.h b/lib/http/internal.h new file mode 100644 index 0000000..7cbb7b2 --- /dev/null +++ b/lib/http/internal.h @@ -0,0 +1,16 @@ +/* + * http/internal.h - the helpers the HTTP plane's files share and no module owns. Private; not + * installed. Each module's own entries are in its header (engine.h, router.h). + */ +#pragma once + +/* The value of a hex digit, or -1. Percent-decoding (api.c) and chunk sizes (engine.c). */ +static inline int ioxd__hexval(unsigned char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + c |= 0x20U; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + return -1; +} diff --git a/lib/http/router.c b/lib/http/router.c new file mode 100644 index 0000000..d978939 --- /dev/null +++ b/lib/http/router.c @@ -0,0 +1,575 @@ +/* + * router.c - groups, endpoints, middleware, and the segment tree they resolve into. Everything is + * registered before the workers start and resolved once by ioxd_run; after that it is read-only + * and every worker shares it without a lock. A request costs one walk down the tree and one call + * through its endpoint's flat middleware chain. + */ +#include "http/router.h" + +#include "http/internal.h" + +#include +#include +#include + +#define SEEN_MAX 8 /* end-of-path nodes one walk remembers */ +#define ALLOW_MAX 16 /* methods a 405's allow header lists */ + +struct ioxd_group { + ioxd_group *parent; /* nullptr only for the root */ + const char *prefix; /* our copy of the caller's */ + ioxd_mw mws[IOXD_MAX_MW]; + int n_mws; +}; + +struct ioxd_endpoint { + ioxd_endpoint *next; /* the registration list */ + ioxd_group *group; + const char *method; /* our copy of the caller's */ + size_t method_len; + const char *path; /* below the group's prefix; our copy too */ + int seq; /* registration order, for the allow header */ + ioxd_handler fn; + ioxd_mw own[IOXD_MAX_MW]; + int n_own; + /* resolved by ioxd__router_build */ + char *full; /* the whole path, prefixes included */ + ioxd_slice names[IOXD_MAX_ROUTE_PARAMS]; /* the :name captures, in path order */ + size_t n_names; + ioxd_mw *chain; /* root, each group outer to inner, then own */ + int n_chain; +}; + +/* One segment of the tree; the root is the empty one. */ +struct node { + ioxd_slice seg; /* the static segment this node is */ + struct node **kids; /* static children */ + int n_kids; + struct node *param; /* the child that takes any segment */ + ioxd_endpoint **eps; /* the endpoints here, one per method */ + int n_eps; +}; + +/* Every end-of-path node a walk reached, for a 405's allow header: a path can end more than one + * route ("/users/new" is also the "/users/:id" of a capture route), and all of their methods are + * allowed. A path that ends more than SEEN_MAX of them loses the rest of the list. */ +struct seen { + const struct node *nodes[SEEN_MAX]; + int n; +}; + +/* The chain cursor handed to each middleware; ioxd_next_run advances it. */ +struct ioxd_next { + const ioxd_mw *mws; + int n; + int i; + ioxd_handler handler; +}; + +static void not_found(ioxd_ctx *ctx); + +static ioxd_group g_root = { .prefix = "" }; /* no prefix; ioxd_use's middleware */ +static ioxd_group *g_current = &g_root; /* the script form's open group */ +static ioxd_endpoint *g_first, *g_last; /* endpoints in registration order */ +static int g_n_eps; /* how many, so each gets its seq */ +static struct node g_tree; /* the root node */ +static ioxd_handler g_fallback = not_found; +static bool g_built; + +/* Exact slice compare. */ +static bool same(ioxd_slice a, ioxd_slice b) +{ + return a.len == b.len && memcmp(a.p, b.p, a.len) == 0; +} + +/* Is this the endpoint's method? name must be a literal, for the sizeof. */ +#define method_is(ep, name) ((ep)->method_len == sizeof(name) - 1 && \ + memcmp((ep)->method, (name), sizeof(name) - 1) == 0) + +/* Out of memory at startup: nothing sensible to continue with. */ +static void *must(void *p) +{ + if (!p) { + perror("ioxd: malloc"); + abort(); + } + return p; +} + +/* ── registration ──────────────────────────────────────────────────────────────────────── */ + +/* Registration is over once ioxd_run has resolved the table: it is read-only from then on and + * the workers are already reading it, so anything later is dropped with a word about it. */ +static bool too_late(const char *what, const char *which) +{ + if (!g_built) + return false; + fprintf(stderr, "ioxd: %s %s registered after ioxd_run started; ignored\n", what, which); + return true; +} + +/* A group below parent (nullptr: the root) at prefix. */ +ioxd_group *ioxd_group_new(ioxd_group *parent, const char *prefix) +{ + ioxd_group *group = must(calloc(1, sizeof *group)); + group->parent = parent ? parent : &g_root; + group->prefix = must(strdup(prefix)); + return group; +} + +/* Middleware around everything below the group. */ +void ioxd_group_use(ioxd_group *group, ioxd_mw mw) +{ + if (!group) + group = &g_root; + if (too_late("middleware for", group == &g_root ? "the root" : group->prefix)) + return; + if (group->n_mws == IOXD_MAX_MW) { + fprintf(stderr, "ioxd: group %s already has %d middleware, dropping one\n", group->prefix, IOXD_MAX_MW); + return; + } + group->mws[group->n_mws++] = mw; +} + +/* Root middleware: every request. */ +void ioxd_use(ioxd_mw mw) +{ + if (too_late("middleware for", "the root")) + return; + ioxd_group_use(&g_root, mw); +} + +/* An endpoint in a group (nullptr: the root). */ +ioxd_endpoint *ioxd_route(ioxd_group *group, const char *method, const char *path, ioxd_handler fn) +{ + if (too_late(method, path)) + return nullptr; + ioxd_endpoint *ep = must(calloc(1, sizeof *ep)); + ep->group = group ? group : &g_root; + ep->method = must(strdup(method)); + ep->method_len = strlen(method); + ep->path = must(strdup(path)); + ep->seq = g_n_eps++; + ep->fn = fn; + if (g_last) + g_last->next = ep; + else + g_first = ep; + g_last = ep; + return ep; +} + +/* Middleware around one endpoint. */ +void ioxd_endpoint_use(ioxd_endpoint *endpoint, ioxd_mw mw) +{ + if (!endpoint) + return; + if (too_late("middleware for", endpoint->path)) + return; + if (endpoint->n_own == IOXD_MAX_MW) { + fprintf(stderr, "ioxd: %s %s already has %d middleware, dropping one\n", endpoint->method, endpoint->path, IOXD_MAX_MW); + return; + } + endpoint->own[endpoint->n_own++] = mw; +} + +/* --- the script form (the IOXD_ macros) --- */ + +/* Open a group below the current one and make it current; its middleware list ends at a null. */ +ioxd_group *ioxd__group_begin(struct ioxd_group_args args) +{ + ioxd_group *group = ioxd_group_new(g_current, args.prefix); + for (int i = 0; i < IOXD_MAX_MW && args.mws[i]; i++) + ioxd_group_use(group, args.mws[i]); + g_current = group; + return group; +} + +/* Close the current group; null, so the block's loop ends. */ +ioxd_group *ioxd__group_end(void) +{ + if (g_current->parent) + g_current = g_current->parent; + return nullptr; +} + +/* The block's cleanup handler, where the compiler has one: a break, return or goto out of an + * IOXD_GROUP skips the loop's increment, so the group is popped here instead. A block that ended + * on its own already popped and nulled the variable, and this does nothing. */ +void ioxd__group_pop(ioxd_group **open) +{ + if (*open) + ioxd__group_end(); +} + +/* The group a script-form registration goes into. */ +ioxd_group *ioxd__group_current(void) +{ + return g_current; +} + +/* An endpoint in the current group, with its middleware list (ended by a null). */ +ioxd_endpoint *ioxd__endpoint(const char *method, struct ioxd_endpoint_args args) +{ + ioxd_endpoint *ep = ioxd_route(g_current, method, args.path, args.fn); + for (int i = 0; i < IOXD_MAX_MW && args.mws[i]; i++) + ioxd_endpoint_use(ep, args.mws[i]); + return ep; +} + +/* Replace the built-in 404 fallback. */ +void ioxd_default(ioxd_handler fn) +{ + if (too_late("a fallback", "handler")) + return; + g_fallback = fn; +} + +/* ── resolution, once, from ioxd_run ───────────────────────────────────────────────────── */ + +/* The next segment of a path from *at, slashes skipped; false at the end. */ +static bool next_segment(const char **at, const char *end, ioxd_slice *seg) +{ + const char *p = *at; + while (p < end && *p == '/') + p++; + if (p == end) { + *at = end; + return false; + } + const char *seg_end = memchr(p, '/', (size_t)(end - p)); + if (!seg_end) + seg_end = end; + *seg = (ioxd_slice){ p, (size_t)(seg_end - p) }; + *at = seg_end; + return true; +} + +/* Put one part of n bytes in front of what is already at buf + *at, with a '/' between them when + * neither side brought one - so a group "/api" and a path "users" join as "/api/users". */ +static void prepend(char *buf, size_t *at, const char *part, size_t n) +{ + if (n && buf[*at] && part[n - 1] != '/' && buf[*at] != '/') + buf[--*at] = '/'; + *at -= n; + memcpy(buf + *at, part, n); +} + +/* The endpoint's whole path: its groups' prefixes, outermost first, then its own path. Written + * right to left, from the innermost group up, so no list of the groups is needed, then moved to + * the front of the buffer over whatever the joining slashes did not need. */ +static char *full_path(const ioxd_endpoint *ep) +{ + size_t len = strlen(ep->path), parts = 1; + for (const ioxd_group *g = ep->group; g; g = g->parent) { + len += strlen(g->prefix); + parts++; + } + char *full = must(malloc(len + parts + 1)); /* at most one joining '/' per part */ + size_t at = len + parts; + full[at] = '\0'; + prepend(full, &at, ep->path, strlen(ep->path)); + for (const ioxd_group *g = ep->group; g; g = g->parent) + prepend(full, &at, g->prefix, strlen(g->prefix)); + memmove(full, full + at, len + parts + 1 - at); + return full; +} + +/* The endpoint's middleware, flat: the root's, each group's outer to inner, then its own. Filled + * right to left, like the path. */ +static void flatten_chain(ioxd_endpoint *ep) +{ + int n = ep->n_own; + for (const ioxd_group *g = ep->group; g; g = g->parent) + n += g->n_mws; + ep->n_chain = n; + if (n == 0) + return; + ep->chain = must(malloc((size_t)n * sizeof *ep->chain)); + int at = n - ep->n_own; + memcpy(ep->chain + at, ep->own, (size_t)ep->n_own * sizeof *ep->chain); + for (const ioxd_group *g = ep->group; g; g = g->parent) { + at -= g->n_mws; + memcpy(ep->chain + at, g->mws, (size_t)g->n_mws * sizeof *ep->chain); + } +} + +/* The static child for a segment, made if missing. */ +static struct node *child(struct node *node, ioxd_slice seg) +{ + for (int i = 0; i < node->n_kids; i++) + if (same(node->kids[i]->seg, seg)) + return node->kids[i]; + struct node *kid = must(calloc(1, sizeof *kid)); + struct node **kids = must(realloc(node->kids, ((size_t)node->n_kids + 1) * sizeof *kids)); + kid->seg = seg; + node->kids = kids; + node->kids[node->n_kids++] = kid; + return kid; +} + +/* Put an endpoint into the tree along its full path; a ':name' segment goes through the capture + * child and its name is kept with the endpoint. A duplicate keeps the first. */ +static void insert(ioxd_endpoint *ep) +{ + struct node *node = &g_tree; + const char *at = ep->full, *end = ep->full + strlen(ep->full); + ioxd_slice seg; + while (next_segment(&at, end, &seg)) { + if (seg.p[0] == ':') { + if (ep->n_names == IOXD_MAX_ROUTE_PARAMS) { + fprintf(stderr, "ioxd: %s %s has more than %d captures; ignored\n", ep->method, ep->full, IOXD_MAX_ROUTE_PARAMS); + return; + } + ep->names[ep->n_names++] = (ioxd_slice){ seg.p + 1, seg.len - 1 }; + if (!node->param) + node->param = must(calloc(1, sizeof *node->param)); + node = node->param; + } else { + node = child(node, seg); + } + } + for (int i = 0; i < node->n_eps; i++) { + if (node->eps[i]->method_len == ep->method_len && memcmp(node->eps[i]->method, ep->method, ep->method_len) == 0) { + fprintf(stderr, "ioxd: duplicate route %s %s; keeping the first\n", ep->method, ep->full); + return; + } + } + ioxd_endpoint **eps = must(realloc(node->eps, ((size_t)node->n_eps + 1) * sizeof *eps)); + node->eps = eps; + node->eps[node->n_eps++] = ep; +} + +/* How deep the open group is; anything but zero at ioxd_run means an IOXD_GROUP block was left + * without its end, and every registration after it silently nested inside. */ +static int group_depth(void) +{ + int depth = 0; + for (const ioxd_group *g = g_current; g->parent; g = g->parent) + depth++; + return depth; +} + +/* Resolve everything registered: full paths into the tree, middleware into flat chains. */ +void ioxd__router_build(void) +{ + if (g_built) + return; + int depth = group_depth(); + if (depth) + fprintf(stderr, "ioxd: %d group(s) still open at ioxd_run, the innermost \"%s\": everything " + "registered after it nested inside\n", depth, g_current->prefix); + g_built = true; + for (ioxd_endpoint *ep = g_first; ep; ep = ep->next) { + ep->full = full_path(ep); + insert(ep); + flatten_chain(ep); + } +} + +/* ── a request ─────────────────────────────────────────────────────────────────────────── */ + +/* The endpoint at a node for the method, or nullptr. A node with a GET and no HEAD answers HEAD + * with its GET endpoint (RFC 9110 9.3.2): the handler still sees "HEAD" as the method, and the + * engine drops the body it writes. */ +static const ioxd_endpoint *endpoint_for(const struct node *node, ioxd_slice method) +{ + const ioxd_endpoint *get = nullptr; + for (int i = 0; i < node->n_eps; i++) { + if (node->eps[i]->method_len == method.len && memcmp(node->eps[i]->method, method.p, method.len) == 0) + return node->eps[i]; + if (method_is(node->eps[i], "GET")) + get = node->eps[i]; + } + return method.len == 4 && memcmp(method.p, "HEAD", 4) == 0 ? get : nullptr; +} + +/* Walk the tree along the path from at, the segments that capture nodes take going into + * req->route_params (raw values; the names come with the endpoint, and dispatch decodes). The + * static child is tried before the capture, so a static segment wins, and the capture is tried + * when the static branch comes to nothing - including when it reaches the end without this + * method. Returns the endpoint for the method, or nullptr; every node the path itself reached + * with endpoints on it lands in *seen, for a 405. */ +static const ioxd_endpoint *walk(const struct node *node, const char *at, const char *end, + ioxd_request *req, struct seen *seen) +{ + ioxd_slice seg; + if (!next_segment(&at, end, &seg)) { + if (node->n_eps && seen->n < SEEN_MAX) + seen->nodes[seen->n++] = node; + return endpoint_for(node, req->method); + } + for (int i = 0; i < node->n_kids; i++) { + if (same(node->kids[i]->seg, seg)) { + const ioxd_endpoint *ep = walk(node->kids[i], at, end, req, seen); + if (ep) + return ep; + break; /* static children are unique: no other candidate */ + } + } + if (node->param && req->n_route_params < IOXD_MAX_ROUTE_PARAMS) { + size_t mark = req->n_route_params; + req->route_params[req->n_route_params++].value = seg; + const ioxd_endpoint *ep = walk(node->param, at, end, req, seen); + if (ep) + return ep; + req->n_route_params = mark; + } + return nullptr; +} + +/* Percent-decode a captured segment into the request's arena and point it there ('+' is a plain + * '+' in a path, and a malformed %XX is kept as is). Untouched when it has nothing to decode, or + * when the arena has no room for it. */ +static ioxd_slice decoded(ioxd_slice raw, char *arena, size_t cap, size_t *used) +{ + if (!memchr(raw.p, '%', raw.len) || *used + raw.len > cap) + return raw; + char *dst = arena + *used; + size_t out = 0; + for (size_t i = 0; i < raw.len; i++) { + if (raw.p[i] == '%' && i + 2 < raw.len) { + int hi = ioxd__hexval((unsigned char)raw.p[i + 1]); + int lo = ioxd__hexval((unsigned char)raw.p[i + 2]); + if (hi >= 0 && lo >= 0) { + dst[out++] = (char)(hi * 16 + lo); + i += 2; + continue; + } + } + dst[out++] = raw.p[i]; + } + *used += out; + return (ioxd_slice){ dst, out }; +} + +/* Is this method already in the list? Two nodes of one walk can allow the same one. */ +static bool listed(const ioxd_endpoint *out[], int n, const ioxd_endpoint *ep) +{ + for (int i = 0; i < n; i++) + if (out[i]->method_len == ep->method_len && + memcmp(out[i]->method, ep->method, ep->method_len) == 0) + return true; + return false; +} + +/* The endpoints of every node the walk reached, each method once, in registration order: an + * insertion sort by seq, which is all the ordering a handful of methods needs. */ +static int allowed_endpoints(const struct seen *seen, const ioxd_endpoint *out[ALLOW_MAX]) +{ + int n = 0; + for (int i = 0; i < seen->n; i++) { + for (int j = 0; j < seen->nodes[i]->n_eps && n < ALLOW_MAX; j++) { + const ioxd_endpoint *ep = seen->nodes[i]->eps[j]; + if (listed(out, n, ep)) + continue; + int at = 0; + while (at < n && out[at]->seq < ep->seq) + at++; + memmove(out + at + 1, out + at, (size_t)(n - at) * sizeof *out); + out[at] = ep; + n++; + } + } + return n; +} + +/* Those methods as "GET, HEAD, POST" in the request's arena, HEAD written after a GET that has + * no HEAD of its own since that is what answers it. nullptr when there is nothing to say, or + * when the list would not fit. */ +static const char *allow_value(ioxd_request *req, const struct seen *seen) +{ + const ioxd_endpoint *eps[ALLOW_MAX]; + int n = allowed_endpoints(seen, eps); + bool get = false, head = false; + size_t len = n ? (size_t)(n - 1) * 2 + 1 : 0; + for (int i = 0; i < n; i++) { + len += eps[i]->method_len; + get = get || method_is(eps[i], "GET"); + head = head || method_is(eps[i], "HEAD"); + } + bool add_head = get && !head; + if (n == 0 || len + (add_head ? 6 : 0) > sizeof req->route_arena) + return nullptr; + char *at = req->route_arena; + for (int i = 0; i < n; i++) { + if (i) { + memcpy(at, ", ", 2); + at += 2; + } + memcpy(at, eps[i]->method, eps[i]->method_len); + at += eps[i]->method_len; + if (add_head && method_is(eps[i], "GET")) { + memcpy(at, ", HEAD", 6); + at += 6; + } + } + *at = '\0'; + return req->route_arena; +} + +/* Run the next middleware, or the endpoint once the chain is exhausted. A middleware that does + * not call this short-circuits the request. The cursor moves in place, so a middleware that calls + * this a second time does not replay what is behind it: once the chain has run out the call does + * nothing at all. */ +void ioxd_next_run(ioxd_ctx *ctx, ioxd_next *next) +{ + if (next->i > next->n) /* the chain and the handler are both spent */ + return; + int at = next->i++; + if (at < next->n) + next->mws[at](ctx, next); + else + next->handler(ctx); +} + +/* A handler behind a chain; a direct call when the chain is empty. */ +static void run(ioxd_ctx *ctx, const ioxd_mw *mws, int n, ioxd_handler fn) +{ + if (n == 0) { + fn(ctx); + return; + } + ioxd_next next = { mws, n, 0, fn }; + ioxd_next_run(ctx, &next); +} + +/* The built-in fallbacks. */ +static void not_found(ioxd_ctx *ctx) +{ + ctx->res.status = 404; + ioxd_text(ctx, "404 Not Found\n"); +} +static void not_allowed(ioxd_ctx *ctx) /* status and allow are set before its chain */ +{ + ioxd_text(ctx, "405 Method Not Allowed\n"); +} + +/* Find the request's endpoint and run it behind its chain; the fallbacks run behind the root's. */ +void ioxd__dispatch(ioxd_ctx *ctx) +{ + ioxd_request *req = &ctx->req; + struct seen seen = { .n = 0 }; + req->n_route_params = 0; + const ioxd_endpoint *ep = walk(&g_tree, req->path.p, req->path.p + req->path.len, req, &seen); + if (ep) { + size_t used = 0; + for (size_t i = 0; i < req->n_route_params; i++) { + req->route_params[i].key = ep->names[i]; + req->route_params[i].value = decoded(req->route_params[i].value, req->route_arena, + sizeof req->route_arena, &used); + } + run(ctx, ep->chain, ep->n_chain, ep->fn); + return; + } + req->n_route_params = 0; + if (seen.n) { /* the path is known, the method is not */ + const char *allow = allow_value(req, &seen); + ctx->res.status = 405; + if (allow) + ioxd_header(ctx, "allow", allow); + run(ctx, g_root.mws, g_root.n_mws, not_allowed); + return; + } + run(ctx, g_root.mws, g_root.n_mws, g_fallback); +} diff --git a/lib/http/router.h b/lib/http/router.h new file mode 100644 index 0000000..d3245b5 --- /dev/null +++ b/lib/http/router.h @@ -0,0 +1,10 @@ +/* + * http/router.h - the router's entries for the runner and the engine: resolve everything + * registered once, before the workers start; then dispatch each request through the result. + */ +#pragma once + +#include "ioxd.h" + +void ioxd__router_build(void); /* the segment tree and the flat chains, once */ +void ioxd__dispatch(ioxd_ctx *ctx); /* the request's endpoint, behind its chain */ diff --git a/lib/http/run.c b/lib/http/run.c new file mode 100644 index 0000000..2ce396e --- /dev/null +++ b/lib/http/run.c @@ -0,0 +1,227 @@ +/* + * run.c - ioxd_run: one proactor thread per core serving HTTP, until SIGINT/SIGTERM. + */ +#include "http/engine.h" +#include "http/router.h" +#include "io/pipe.h" +#include "io/proactor.h" +#include "tls/handshake.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static volatile sig_atomic_t g_stop; + +/* SIGINT/SIGTERM: raise the flag every worker loop polls. */ +static void on_signal(int sig) +{ + (void)sig; + g_stop = 1; +} + +/* pthread entry: the worker's whole life. */ +static void *worker_thread(void *arg) +{ + proactor_run(arg); + return nullptr; +} + +/* CPUs this process may run on (its cpuset), so the default is one worker per available core - + * never a fixed count that would oversubscribe a small cpuset. */ +static int cpu_count(void) +{ + cpu_set_t set; + CPU_ZERO(&set); + if (sched_getaffinity(0, sizeof set, &set) == 0) { + int n = CPU_COUNT(&set); + if (n > 0) + return n; + } + long n = sysconf(_SC_NPROCESSORS_ONLN); + return n > 0 ? (int)n : 1; +} + +/* Lift the soft fd limit to the hard one: open connections and the registered file table are + * both checked against it, and the default soft limit is often 1024. */ +static void raise_nofile(void) +{ + struct rlimit rl; + if (getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur < rl.rlim_max) { + rl.rlim_cur = rl.rlim_max; + setrlimit(RLIMIT_NOFILE, &rl); + } +} + +/* The configuration for the runs that follow: what ioxd_configure was given, zeros where the + * default is wanted. Validated on the way in, so the run never sees a bad value. */ +static ioxd_config g_config; + +static bool power_of_two(unsigned v) +{ + return v && (v & (v - 1)) == 0; +} + +int ioxd_configure(const ioxd_config *config) +{ + const char *why = nullptr; + if (config->ring_entries && (!power_of_two(config->ring_entries) || config->ring_entries > 32768)) + why = "ring_entries must be a power of two, at most 32768"; + else if (config->recv_buffers && (!power_of_two(config->recv_buffers) || config->recv_buffers > 32768)) + why = "recv_buffers must be a power of two, at most 32768 (the kernel refuses 65536)"; + else if (config->recv_buffer_size && (config->recv_buffer_size < 64 || config->recv_buffer_size > (1U << 20))) + why = "recv_buffer_size must be between 64 bytes and 1 MB"; + else if (config->stack_size && (config->stack_size < 64UL * 1024 || config->stack_size > 64UL * 1024 * 1024)) + why = "stack_size must be between 64 KB and 64 MB"; + if (why) { + fprintf(stderr, "ioxd_configure: %s\n", why); + return -1; + } + g_config = *config; + return 0; +} + +/* The configuration a worker gets: what was set, the build's defaults for the rest. */ +static ioxd_config effective_config(void) +{ + ioxd_config c = g_config; + if (!c.ring_entries) c.ring_entries = RING_ENTRIES; + if (!c.recv_buffers) c.recv_buffers = BUF_COUNT; + if (!c.recv_buffer_size) c.recv_buffer_size = BUF_SIZE; + if (!c.stack_size) c.stack_size = STACK_SIZE; + if (!c.idle_stacks) c.idle_stacks = CORO_POOL_MAX; + if (!c.idle_connections) c.idle_connections = CONN_POOL_MAX; + return c; +} + +/* The ports bound before the run, each with its store or none; every worker opens all of them. */ +static struct listener g_listeners[IOXD_MAX_LISTENERS]; +static int g_n_listeners; + +int ioxd_bind(int port, ioxd_certs *certs) +{ + if (port < 1 || port > 65535 || g_n_listeners == IOXD_MAX_LISTENERS) { + fprintf(stderr, "ioxd_bind: port %d refused (1..65535, at most %d ports)\n", port, IOXD_MAX_LISTENERS); + return -1; + } + g_listeners[g_n_listeners++] = (struct listener){ .port = (uint16_t)port, .certs = certs }; + return 0; +} + +/* Worker threads (workers <= 0: one per available core), one proactor each, running `handler` + * on every connection of every bound port until SIGINT/SIGTERM. What ioxd_run and ioxd_run_pipes + * share. */ +static int run_workers(int workers, handler_fn handler) +{ + if (workers <= 0) + workers = cpu_count(); + if (g_n_listeners == 0) { + fprintf(stderr, "ioxd_run: nothing bound: ioxd_bind a port first\n"); + return 2; + } + + g_stop = 0; /* a previous run's signal must not stop this one at once */ + raise_nofile(); + signal(SIGPIPE, SIG_IGN); + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_handler = on_signal; + sigemptyset(&sa.sa_mask); + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); + + proactor_t *ws = calloc((size_t)workers, sizeof *ws); + pthread_t *th = calloc((size_t)workers, sizeof *th); + if (!ws || !th) { + perror("calloc"); + free(ws); + free(th); + return 1; + } + + int rc = 0, started = 0; + ioxd_config cfg = effective_config(); + for (int i = 0; i < workers; i++) { + ws[i].id = i; + ws[i].cpu = i; + ws[i].cfg = cfg; + ws[i].handler = handler; + ws[i].n_listeners = g_n_listeners; + memcpy(ws[i].listeners, g_listeners, sizeof g_listeners); + ws[i].stop = &g_stop; + if (pthread_create(&th[i], nullptr, worker_thread, &ws[i]) != 0) { + perror("pthread_create"); + g_stop = 1; /* the ones already running must retire, not serve on alone */ + rc = 1; + break; + } + started++; + } + if (started) { + fprintf(stderr, "ioxd: %d workers on", started); + for (int i = 0; i < g_n_listeners; i++) + fprintf(stderr, " :%u%s", g_listeners[i].port, g_listeners[i].certs ? "/tls" : ""); + fputc('\n', stderr); + } + + for (int i = 0; i < started; i++) + pthread_join(th[i], nullptr); + for (int i = 0; i < started; i++) + if (ws[i].failed) /* a worker whose ring died: the run did not succeed */ + rc = 1; + free(th); + free(ws); + return rc; +} + +/* A TLS listener's connection runs the handshake before its handler; a plain one goes straight in. */ +static int prologue(struct ioxd_pipe *pipe) +{ + struct listener *l = pipe->in.conn->listener; + return l->certs ? ioxd__tls_prologue(pipe, l->certs) : 0; +} + +static void serve_http(struct ioxd_pipe *pipe) +{ + if (prologue(pipe) != 0) + return; + ioxd__serve(pipe); + if (pipe->in.conn->listener->certs) + ioxd__tls_close_notify(pipe); +} + +/* ioxd_run, through the header's inline: the caller's sizeof(ioxd_ctx) must be ours, or the + * limits that size it (IOXD_MAX_HEADERS and friends) were redefined on one side and every + * handler would read the context at the wrong offsets. */ +int ioxd__run(int workers, size_t ctx_size) +{ + if (ctx_size != sizeof(ioxd_ctx)) { + fprintf(stderr, "ioxd_run: the application's ioxd_ctx is %zu bytes, the library's %zu: " + "IOXD_MAX_* limits redefined on one side\n", ctx_size, sizeof(ioxd_ctx)); + return 1; + } + ioxd__router_build(); /* the routes, resolved once, shared read-only */ + return run_workers(workers, serve_http); +} + +static ioxd_pipe_handler g_pipe_handler; + +static void serve_pipe(struct ioxd_pipe *pipe) +{ + if (prologue(pipe) != 0) + return; + g_pipe_handler(pipe); + if (pipe->in.conn->listener->certs) + ioxd__tls_close_notify(pipe); +} + +int ioxd_run_pipes(int workers, ioxd_pipe_handler fn) +{ + g_pipe_handler = fn; + return run_workers(workers, serve_pipe); +} diff --git a/lib/io/bufring.c b/lib/io/bufring.c new file mode 100644 index 0000000..336adb7 --- /dev/null +++ b/lib/io/bufring.c @@ -0,0 +1,103 @@ +/* + * bufring.c - the provided buffer ring of io/bufring.h. + */ +#include "io/bufring.h" +#include "io/internal.h" + +#include +#include +#include +#include + +/* Map anonymous read/write pages, or abort. */ +static void *map_pages(size_t bytes) +{ + void *m = mmap(nullptr, bytes, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (m == MAP_FAILED) { + perror("mmap"); + abort(); + } + return m; +} + +/* The kernel named a buffer we never offered: the slab pointer it implies is outside the mapping, + * so there is nothing safe to do with it. */ +[[noreturn]] void ioxd__bufring_bad_id(const struct bufring *b, uint16_t buf_id) +{ + fprintf(stderr, "ioxd: provided buffer id %u is outside the ring (%u buffers)\n", buf_id, b->count); + abort(); +} + +/* Map the slab and the ring, register the ring as buffer group BGID, and offer every buffer. + * count is a power of two no larger than 32768: ioxd_configure checked it. */ +void ioxd__bufring_init(struct bufring *b, struct uring *ring, int worker, unsigned count, unsigned size) +{ + b->count = count; + b->size = size; + b->mask = count - 1; + b->ring = map_pages((size_t)count * sizeof(struct io_uring_buf)); + b->slab = map_pages((size_t)count * size); + b->dirty = false; + b->returned = 0; + + struct io_uring_buf_reg reg; + memset(®, 0, sizeof reg); + reg.ring_addr = (uint64_t)(uintptr_t)b->ring; + reg.ring_entries = count; + reg.bgid = BGID; + int rc = uring_register(ring, IORING_REGISTER_PBUF_RING, ®, 1); + if (rc < 0) { + fprintf(stderr, "[w%d] register pbuf ring: %s\n", worker, ioxd__errstr(-rc)); + abort(); + } + + /* Fill every slot, then publish the tail once. bufs[0] overlaps the ring header and the tail + * sits in bufs[0].resv, so writing only addr/len/bid leaves it untouched. */ + for (unsigned i = 0; i < count; i++) { + struct io_uring_buf *slot = &b->ring->bufs[i]; + slot->addr = (uint64_t)(uintptr_t)ioxd__bufring_at(b, (uint16_t)i); + slot->len = size; + slot->bid = (uint16_t)i; + } + b->tail = count; + __atomic_store_n(&b->ring->tail, (uint16_t)b->tail, __ATOMIC_RELEASE); +} + +/* Stage a buffer's return to the ring. The loop publishes the tail once per batch: one atomic + * release for many returns, and the kernel is not re-reading a hot tail per request. */ +void ioxd__bufring_return(struct bufring *b, uint16_t buf_id) +{ + struct io_uring_buf *slot = &b->ring->bufs[b->tail & b->mask]; + slot->addr = (uint64_t)(uintptr_t)ioxd__bufring_at(b, buf_id); + slot->len = b->size; + slot->bid = buf_id; + b->tail++; + b->returned++; /* how many recvs the loop may re-arm */ + b->dirty = true; /* tail needs publishing before the enter */ +} + +/* Publish staged returns to the kernel. */ +void ioxd__bufring_publish(struct bufring *b) +{ + if (!b->dirty) + return; + __atomic_store_n(&b->ring->tail, (uint16_t)b->tail, __ATOMIC_RELEASE); + b->dirty = false; +} + +/* Unregister the group. Call before uring_exit. */ +void ioxd__bufring_unregister(struct bufring *b, struct uring *ring) +{ + (void)b; + struct io_uring_buf_reg reg; + memset(®, 0, sizeof reg); + reg.bgid = BGID; + uring_register(ring, IORING_UNREGISTER_PBUF_RING, ®, 1); +} + +/* Unmap the ring and the slab. Call after uring_exit, once no in-flight op can reference them. */ +void ioxd__bufring_unmap(struct bufring *b) +{ + munmap(b->ring, (size_t)b->count * sizeof(struct io_uring_buf)); + munmap(b->slab, (size_t)b->count * b->size); +} diff --git a/lib/io/bufring.h b/lib/io/bufring.h new file mode 100644 index 0000000..7b0593c --- /dev/null +++ b/lib/io/bufring.h @@ -0,0 +1,51 @@ +/* + * bufring.h - the provided buffer ring: a slab of count x size bytes that the kernel picks from + * when a multishot recv delivers data, registered as one buffer group, and how buffers go back to + * it. One per worker, touched only by that worker's thread; returns are staged and published to + * the kernel once per loop iteration. The count and the size are the worker's configuration + * (ioxd_config); BUF_COUNT and BUF_SIZE are their defaults. + */ +#pragma once + +#include + +#include "io/uring.h" + +/* defaults (override with -D, or at run time with ioxd_configure) */ +#ifndef BUF_COUNT +#define BUF_COUNT 4096 /* provided recv buffers per worker, power of two */ +#endif +static_assert(((unsigned)BUF_COUNT & ((unsigned)BUF_COUNT - 1U)) == 0 && BUF_COUNT >= 2 && BUF_COUNT <= 32768, + "BUF_COUNT: a power of two, at most 32768 (the kernel refuses a ring of 65536 entries)"); +#ifndef BUF_SIZE +#define BUF_SIZE 2048 /* bytes per recv buffer (a request rarely needs more) */ +#endif +#define BGID 1 /* the one buffer group a worker registers */ + +struct bufring { + struct io_uring_buf_ring *ring; /* kernel-shared ring of buffer descriptors */ + uint8_t *slab; /* count x size bytes */ + unsigned count; /* buffers: a power of two */ + unsigned size; /* bytes in each */ + unsigned mask; /* count - 1: the ring index of a tail */ + unsigned tail; /* local tail, published to ring->tail */ + bool dirty; /* staged returns awaiting one publish */ + unsigned returned; /* returns since the loop's last starved sweep */ +}; + +void ioxd__bufring_init (struct bufring *b, struct uring *ring, int worker, unsigned count, unsigned size); /* map, register, offer every buffer */ +void ioxd__bufring_return (struct bufring *b, uint16_t buf_id); /* stage a buffer's return */ +void ioxd__bufring_publish (struct bufring *b); /* the staged returns, one release */ +void ioxd__bufring_unregister(struct bufring *b, struct uring *ring); /* before uring_exit */ +void ioxd__bufring_unmap (struct bufring *b); /* after uring_exit */ + +[[noreturn]] void ioxd__bufring_bad_id(const struct bufring *b, uint16_t buf_id); /* a buffer id outside the slab: abort */ + +/* Where a buffer's bytes are. The id comes from the kernel, so it is checked: a wrong one would + * hand the reader a pointer past the slab. One predicted branch, twice per request. */ +static inline uint8_t *ioxd__bufring_at(const struct bufring *b, uint16_t buf_id) +{ + if (buf_id >= b->count) + ioxd__bufring_bad_id(b, buf_id); + return b->slab + (size_t)buf_id * b->size; +} diff --git a/lib/io/conn.c b/lib/io/conn.c new file mode 100644 index 0000000..d4e50db --- /dev/null +++ b/lib/io/conn.c @@ -0,0 +1,496 @@ +/* + * conn.c - one connection's life on its worker: the pooled conn_t, its two owners' refcount, the + * multishot recv and the queue of buffers it delivers, parking on -ENOBUFS, closing, and the + * awaits the pipe is built on. + */ +#include "io/internal.h" +#include "io/pipe.h" + +#include +#include +#include +#include +#include +#include +#include + +/* Stage a one-shot op and park until its CQE. The loop fills op->res and resumes us. */ +static int await_op(struct io_uring_sqe *sqe, op_t *op) +{ + op->waiter = coro_current(); + sqe->user_data = UD(op, TAG_OP); + coro_yield(); + return op->res; /* NOLINT(clang-analyzer-core.uninitialized.UndefReturn): set by the loop before it resumed us */ +} + +/* Ask the kernel to cancel the op carrying that user_data. The acknowledgement CQE is ignored. */ +static void submit_cancel(proactor_t *p, uint64_t target_user_data) +{ + struct io_uring_sqe *sqe = ioxd__sqe(p); + sqe->opcode = IORING_OP_ASYNC_CANCEL; + sqe->fd = -1; + sqe->addr = target_user_data; + sqe->user_data = TAG_IGNORE; +} + +/* Cancel the multishot recv, at most one cancel in flight: the queue-full policy would otherwise + * stage another on every arrival while the queue stays full. Cleared on the terminal CQE. */ +static void cancel_recv(proactor_t *p, conn_t *c) +{ + if (c->cancelling) + return; + c->cancelling = true; + submit_cancel(p, UD(c, TAG_RECV)); +} + +/* End the input once: the first reason wins, so a cancel we asked for afterwards does not + * overwrite the peer's FIN or the error that really ended it. */ +static void end_input(conn_t *c, int err) +{ + if (!c->eof) { + c->eof = true; + c->err = err; + } +} + +/* ── the object ────────────────────────────────────────────────────────────────────────── */ + +/* Take a conn_t from the pool (or calloc one) and reset it for a fresh fd. Two owners hold it: + * the handler coroutine and the multishot recv, so refs starts at 2. */ +conn_t *ioxd__conn_new(proactor_t *p, struct listener *l, int fd) +{ + conn_t *c = p->conn_free; + if (c) { + p->conn_free = c->pool_next; + p->conn_free_count--; + } else { + c = calloc(1, sizeof *c); + if (!c) { + perror("calloc"); + abort(); + } + } + c->fd = fd; + c->p = p; + c->listener = l; + c->waiter = nullptr; + c->rx_head = 0; + c->rx_tail = 0; + c->recv = RECV_ARMED; + c->pausing = false; + c->cancelling = false; + c->refs = 2; + c->closed = false; + c->eof = false; + c->err = 0; + c->pool_next = nullptr; + p->live++; + return c; +} + +/* Drop one owner's ref. At zero the conn holds nothing (fd closed, buffers returned) and goes + * back to the pool, or is freed past the cap. */ +static void conn_unref(conn_t *c) +{ + if (--c->refs != 0) + return; + proactor_t *p = c->p; + p->live--; + if (p->conn_free_count < p->cfg.idle_connections) { + c->pool_next = p->conn_free; + p->conn_free = c; + p->conn_free_count++; + } else { + free(c); + } +} + +/* Free the pool at worker teardown. */ +void ioxd__conn_pool_drain(proactor_t *p) +{ + while (p->conn_free) { + conn_t *c = p->conn_free; + p->conn_free = c->pool_next; + free(c); + } + p->conn_free_count = 0; +} + +/* ── the recv side ─────────────────────────────────────────────────────────────────────── */ + +/* Arm the multishot recv: one SQE, then a CQE per arrival, each in a buffer the kernel picks. */ +void ioxd__arm_recv(proactor_t *p, conn_t *c) +{ + struct io_uring_sqe *sqe = ioxd__sqe(p); + sqe->opcode = IORING_OP_RECV; + sqe->fd = c->fd; + sqe->flags = IOSQE_BUFFER_SELECT | (p->ring.fixed_files ? IOSQE_FIXED_FILE : 0); + sqe->ioprio = IORING_RECV_MULTISHOT; + sqe->buf_group = BGID; + sqe->user_data = UD(c, TAG_RECV); + c->recv = RECV_ARMED; + c->cancelling = false; /* a fresh arm: no cancel of ours is in flight */ +} + +/* Resume the coroutine parked waiting for bytes, if there is one. It pops the queue itself. */ +static void wake_reader(conn_t *c) +{ + coro_t *waiter = c->waiter; + if (waiter) { + c->waiter = nullptr; + coro_resume(waiter); + } +} + +/* Count a recv that found the buffer ring empty, and say so on stderr at most once a second per + * worker: starvation otherwise shows only as latency, and the cure is a larger -DBUF_COUNT. */ +static void note_starved(proactor_t *p) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC_COARSE, &now); + p->starved_total++; + p->starved_since_log++; + if (now.tv_sec < p->starved_log_at) + return; + fprintf(stderr, "ioxd: [w%d] recv found no provided buffer %llu times (%llu in total, %u connections parked): " + "raise recv_buffers (ioxd_configure; now %u)\n", + p->id, (unsigned long long)p->starved_since_log, (unsigned long long)p->starved_total, p->nstarved + 1, p->bufs.count); + p->starved_since_log = 0; + p->starved_log_at = now.tv_sec + 1; +} + +/* Park a connection whose recv ended on -ENOBUFS until a buffer comes back. */ +static void starved_push(proactor_t *p, conn_t *c) +{ + if (p->nstarved == p->cap_starved) { + unsigned cap = p->cap_starved ? p->cap_starved * 2 : 64; + conn_t **grown = realloc(p->starved, cap * sizeof *grown); + if (!grown) { + perror("realloc"); + abort(); + } + p->starved = grown; + p->cap_starved = cap; + } + p->starved[p->nstarved++] = c; +} + +/* Forget a parked connection (it closed before any buffer came back). The tail slides down rather + * than the last entry taking its place: the loop re-arms the list oldest first. */ +static void starved_remove(proactor_t *p, conn_t *c) +{ + for (unsigned i = 0; i < p->nstarved; i++) { + if (p->starved[i] == c) { + p->nstarved--; + memmove(&p->starved[i], &p->starved[i + 1], (p->nstarved - i) * sizeof *p->starved); + return; + } + } +} + +/* A recv CQE: queue the data and wake the reader, or record the end of input and drop the recv's + * ref. -ENOBUFS is not an error: the buffer group ran dry, so park and re-arm later. */ +void ioxd__on_recv(proactor_t *p, conn_t *c, int result, unsigned flags) +{ + bool more = flags & IORING_CQE_F_MORE; + bool has_buf = flags & IORING_CQE_F_BUFFER; + uint16_t buf_id = (uint16_t)(flags >> (unsigned)IORING_CQE_BUFFER_SHIFT); + + trace("[w%d] recv fd=%d result=%d more=%d buf=%d buf_id=%u queued=%u state=%d closed=%d eof=%d\n", + p->id, c->fd, result, more, has_buf, buf_id, c->rx_tail - c->rx_head, c->recv, c->closed, c->eof); + + if (!more) + c->cancelling = false; /* the multishot ends here: no cancel is left in flight */ + + if (result == -ENOBUFS) { + if (c->closed) { + c->recv = RECV_DONE; + conn_unref(c); + return; + } + if (c->pausing) { /* the pause we asked for; starvation stopped it first */ + c->pausing = false; + c->recv = RECV_PAUSED; + wake_reader(c); + return; + } + if (c->eof) { /* the input already ended: there is nothing to re-arm */ + c->recv = RECV_DONE; + wake_reader(c); + conn_unref(c); + return; + } + c->recv = RECV_STARVED; + note_starved(p); + starved_push(p, c); + return; + } + + if (result <= 0) { /* peer FIN (0), an error, or our own cancel */ + if (has_buf) + ioxd__bufring_return(&p->bufs, buf_id); + if (c->pausing && !c->closed && result == -ECANCELED) { /* our pause, not the end of input */ + c->pausing = false; + c->recv = RECV_PAUSED; + wake_reader(c); + return; + } + /* Kernel TLS reports a control record - the peer's alert, a KeyUpdate it cannot honour - + * as -EIO. TLS.md calls that the end of input, so a close_notify reads like a FIN. */ + end_input(c, result == -EIO && c->listener->certs ? 0 : result); + c->recv = RECV_DONE; + wake_reader(c); /* a parked reader sees 0 / -errno */ + conn_unref(c); /* the recv's ref; may recycle c */ + return; + } + + if (!has_buf) { + /* A positive result must name the buffer it landed in. Nothing can be done with bytes we + * cannot find, so end the input the way the queue-full policy does. */ + end_input(c, -EPROTO); + if (more) + cancel_recv(p, c); + wake_reader(c); + } else if (c->closed) { + ioxd__bufring_return(&p->bufs, buf_id); /* the handler is gone; nobody will read it */ + } else if (c->rx_tail - c->rx_head == RX_QUEUE) { + /* The handler is not draining. Rather than let one peer hoard the buffer group, end its + * input: the next read sees -ENOBUFS. */ + ioxd__bufring_return(&p->bufs, buf_id); + end_input(c, -ENOBUFS); + if (more) + cancel_recv(p, c); + wake_reader(c); + } else { + struct rx_item *item = &c->rx[c->rx_tail++ & RX_MASK]; + item->ptr = ioxd__bufring_at(&p->bufs, buf_id); + item->len = (uint32_t)result; + item->buf_id = buf_id; + wake_reader(c); + } + + if (!more) { /* the kernel ended the multishot: re-arm */ + if (c->pausing && !c->closed) { /* ended on its own while a pause was asked */ + c->pausing = false; + c->recv = RECV_PAUSED; + wake_reader(c); + } else if (!c->closed && !c->eof) { + ioxd__arm_recv(p, c); + } else { + c->recv = RECV_DONE; + conn_unref(c); + } + } else if (p->cancel_each && !c->closed) { /* shutting down without a blanket cancel */ + cancel_recv(p, c); + } +} + +/* Shutdown: a recv parked on -ENOBUFS holds no operation the kernel can cancel, so end its input + * by hand. Its handler wakes with an error, unwinds and closes the connection like any other. */ +void ioxd__recv_drain(conn_t *c) +{ + if (c->recv != RECV_STARVED) + return; + end_input(c, -ECANCELED); + c->recv = RECV_DONE; + wake_reader(c); + conn_unref(c); +} + +/* ── close ─────────────────────────────────────────────────────────────────────────────── */ + +/* Close the socket with a CLOSE SQE, which rides the next enter with the rest of the batch: no + * syscall here, and the close stays in order behind the SQEs already staged for that same socket - + * a plain close(2) would race them. A file slot is named by index, a real fd by itself. */ +void ioxd__close_socket(proactor_t *p, int fd) +{ + struct io_uring_sqe *sqe = ioxd__sqe(p); + sqe->opcode = IORING_OP_CLOSE; + sqe->user_data = TAG_CLOSE; /* only a negative result is news */ + if (p->ring.fixed_files) + sqe->file_index = (uint32_t)fd + 1; /* slot + 1; 0 would mean "a real fd" */ + else + sqe->fd = fd; +} + +/* Runs once when the handler returns: cancel the recv, hand unread buffers back, close the fd, + * drop the handler's ref. The recv's own ref drops on its terminal CQE. */ +static void conn_close(conn_t *c) +{ + proactor_t *p = c->p; + c->closed = true; + trace("[w%d] close fd=%d state=%d eof=%d err=%d queued=%u\n", + p->id, c->fd, c->recv, c->eof, c->err, c->rx_tail - c->rx_head); + + if (c->recv == RECV_ARMED) { + cancel_recv(p, c); + } else if (c->recv == RECV_STARVED || c->recv == RECV_PAUSED) { + if (c->recv == RECV_STARVED) + starved_remove(p, c); + c->recv = RECV_DONE; + c->refs--; /* the recv side's reference; ours, dropped last, keeps c alive */ + } + while (c->rx_head != c->rx_tail) + ioxd__bufring_return(&p->bufs, c->rx[c->rx_head++ & RX_MASK].buf_id); + + ioxd__close_socket(p, c->fd); + conn_unref(c); +} + +/* The connection's coroutine: run the worker's handler to completion, then close. */ +void ioxd__conn_main(void *arg) +{ + conn_t *c = arg; + char gather[IOXD_PIPE_GATHER]; + char slab[IOXD_PIPE_LEAD + IOXD_PIPE_CAP + IOXD_PIPE_SLACK]; + struct ioxd_pipe pipe; + ioxd__pipe_init(&pipe, c, gather, sizeof gather, slab, IOXD_PIPE_LEAD, IOXD_PIPE_CAP, IOXD_PIPE_SLACK); + c->p->handler(&pipe); + ioxd__pipe_close(&pipe); + conn_close(c); +} + +/* ── awaits ────────────────────────────────────────────────────────────────────────────── */ + +/* The next received buffer, whole: the caller owns it until ioxd__bufring_return. Suspends until one + * arrives; 1 with the item, 0 at the end of input, <0 an error. */ +int ioxd__await_item(conn_t *c, struct rx_item *out) +{ + for (;;) { + if (c->rx_head != c->rx_tail) { + *out = c->rx[c->rx_head & RX_MASK]; + c->rx_head++; + return 1; + } + if (c->eof) + return c->err; + c->waiter = coro_current(); + coro_yield(); /* ioxd__on_recv wakes us */ + } +} + + +/* Stop the multishot recv so nothing more leaves the socket, and park until it has stopped. + * 0 once it is paused, -1 when the input ended instead - the caller has nothing left to program. */ +int ioxd__recv_pause(conn_t *c) +{ + proactor_t *p = c->p; + if (c->recv == RECV_ARMED) { + c->pausing = true; + cancel_recv(p, c); + while (c->recv == RECV_ARMED) { /* data may still land meanwhile: fine, it is queued */ + c->waiter = coro_current(); + coro_yield(); + } + c->pausing = false; + } + if (c->recv == RECV_STARVED) { + starved_remove(p, c); + c->recv = RECV_PAUSED; + } + if (c->eof) /* it ended while we were stopping it */ + return -1; + return c->recv == RECV_PAUSED ? 0 : -1; +} + +/* Arm the recv again after a pause. False when there is nothing to arm - the input ended, or the + * handler is already gone - so a prologue learns that its connection went away under it. */ +bool ioxd__recv_resume(conn_t *c) +{ + if (c->recv != RECV_PAUSED || c->eof || c->closed) + return false; + if (c->p->draining) { + /* The worker is stopping and its blanket cancel has already been and gone: a recv armed + * now would never be cancelled and the handler would park behind it until the grace period + * ran out. End the input instead, and let the handler unwind like everyone else's. */ + end_input(c, -ECANCELED); + c->recv = RECV_DONE; + conn_unref(c); /* the recv's ref; the handler still holds its own */ + return false; + } + ioxd__arm_recv(c->p, c); + return true; +} + +int ioxd__recv_exact(conn_t *c, void *dst, size_t n) +{ + uint8_t *at = dst; + size_t left = n; + while (left) { + op_t op; + struct io_uring_sqe *sqe = ioxd__sqe(c->p); + sqe->opcode = IORING_OP_RECV; + sqe->fd = c->fd; + sqe->flags = c->p->ring.fixed_files ? IOSQE_FIXED_FILE : 0; + sqe->addr = (uint64_t)(uintptr_t)at; + sqe->len = (uint32_t)left; + sqe->msg_flags = MSG_WAITALL; + int got = await_op(sqe, &op); + if (got < 0) + return got; + if (got == 0) + return -ECONNRESET; + at += got; + left -= (size_t)got; + } + return (int)n; +} + +int ioxd__setsockopt(conn_t *c, int level, int name, const void *val, size_t len) +{ + op_t op; + struct io_uring_sqe *sqe = ioxd__sqe(c->p); + sqe->opcode = IORING_OP_URING_CMD; + sqe->fd = c->fd; + sqe->flags = c->p->ring.fixed_files ? IOSQE_FIXED_FILE : 0; + sqe->cmd_op = SOCKET_URING_OP_SETSOCKOPT; + sqe->level = (uint32_t)level; + sqe->optname = (uint32_t)name; + sqe->optval = (uint64_t)(uintptr_t)val; + sqe->optlen = (uint32_t)len; + int rc = await_op(sqe, &op); + /* The plain fallback needs a real descriptor, and under registered files (the default) c->fd is + * a slot index, so it is dead code there: kernel TLS then wants a kernel with + * SOCKET_URING_OP_SETSOCKOPT (6.7+), or a build with -DFIXED_FILES=0. */ + if ((rc == -EOPNOTSUPP || rc == -EINVAL) && !c->p->ring.fixed_files) /* a kernel without the command */ + rc = setsockopt(c->fd, level, name, val, (socklen_t)len) < 0 ? -errno : 0; + return rc; +} + +int ioxd__sendmsg(conn_t *c, const struct msghdr *msg) +{ + op_t op; + struct io_uring_sqe *sqe = ioxd__sqe(c->p); + sqe->opcode = IORING_OP_SENDMSG; + sqe->fd = c->fd; + sqe->flags = c->p->ring.fixed_files ? IOSQE_FIXED_FILE : 0; + sqe->addr = (uint64_t)(uintptr_t)msg; + sqe->len = 1; + sqe->msg_flags = MSG_NOSIGNAL; + return await_op(sqe, &op); +} + +/* Send all of buf: a SEND SQE per round, parked until its CQE. Returns len, or -errno. */ +int await_send(conn_t *c, const void *buf, size_t len) +{ + const uint8_t *src = buf; + size_t left = len; + while (left > 0) { + op_t op; + struct io_uring_sqe *sqe = ioxd__sqe(c->p); + sqe->opcode = IORING_OP_SEND; + sqe->fd = c->fd; + sqe->flags = c->p->ring.fixed_files ? IOSQE_FIXED_FILE : 0; + sqe->addr = (uint64_t)(uintptr_t)src; + sqe->len = left > UINT32_MAX ? UINT32_MAX : (uint32_t)left; + sqe->msg_flags = MSG_NOSIGNAL; /* no SIGPIPE; the loop finishes short sends (kernel TLS refuses MSG_WAITALL) */ + int n = await_op(sqe, &op); + if (n < 0) + return n; + if (n == 0) + return -EPIPE; + src += n; + left -= (size_t)n; + } + return (int)len; +} diff --git a/lib/io/conn.h b/lib/io/conn.h new file mode 100644 index 0000000..538bd61 --- /dev/null +++ b/lib/io/conn.h @@ -0,0 +1,82 @@ +/* + * conn.h - one connection on its worker: the socket, the queue of buffers the kernel filled for + * it, the state of its multishot recv, and the awaits a coroutine calls on it. Thread-per-core: + * a connection is only ever touched by the worker that accepted it. + */ +#pragma once + +#include +#include + +#include "io/coro.h" + +/* tunables (override with -D) */ +#ifndef RX_QUEUE +#define RX_QUEUE 64 /* undelivered slices one connection may hold, pow 2 */ +#endif +static_assert(((unsigned)RX_QUEUE & ((unsigned)RX_QUEUE - 1U)) == 0 && RX_QUEUE >= 2, + "RX_QUEUE: a power of two (RX_MASK is a bit mask over it)"); +#define RX_MASK (RX_QUEUE - 1U) +#ifndef CONN_POOL_MAX +#define CONN_POOL_MAX 1024 /* idle conn_t kept warm per worker by default (ioxd_config.idle_connections) */ +#endif + +typedef struct proactor proactor_t; +typedef struct conn conn_t; +struct listener; /* io/proactor.h: the port it was accepted on */ +struct msghdr; /* , for ioxd__sendmsg */ + +/* A slice the kernel delivered into a provided buffer, waiting for the handler to read it. */ +struct rx_item { + uint8_t *ptr; + uint32_t len; + uint16_t buf_id; +}; + +enum recv_state { + RECV_ARMED, /* multishot recv in flight; the kernel may post CQEs */ + RECV_STARVED, /* it ended on -ENOBUFS; re-armed once buffers return */ + RECV_PAUSED, /* stopped on purpose, its ref kept; resume re-arms */ + RECV_DONE, /* it posted its terminal CQE */ +}; + +struct conn { + int fd; /* the socket, or its file slot under fixed files */ + proactor_t *p; + struct listener *listener; /* the port it came in on: plain or TLS */ + coro_t *waiter; /* coroutine parked waiting for bytes, or nullptr */ + struct rx_item rx[RX_QUEUE]; /* delivered while nobody was reading */ + unsigned rx_head, rx_tail; + enum recv_state recv; + bool pausing; /* a cancel is in flight to pause the recv */ + bool cancelling; /* a cancel is in flight: do not stage a second one */ + int refs; /* the handler coroutine + the armed/starved recv */ + bool closed; /* the handler returned; fd closed */ + bool eof; /* recv ended: peer FIN, error, or queue overflow */ + int err; /* 0 on FIN, else the negative errno */ + struct conn *pool_next; /* free-list link while recycled (not in use): a LIFO - a */ + /* returned conn becomes the head and points at the old one */ +}; + +/* Awaits: call from a coroutine on the owning worker. The coroutine parks; the loop resumes it + * when the completion arrives. */ +int await_send(conn_t *c, const void *buf, size_t len); /* len when all sent, else -errno */ +int ioxd__await_item(conn_t *c, struct rx_item *out); /* the next received buffer, whole: 1, 0 at the end, <0 -errno (the reader's primitive) */ + +/* For a protocol prologue (TLS): stop the multishot recv so nothing more leaves the socket, take + * what it already delivered, read exact byte counts straight from the socket, program the + * socket, then resume. All suspend like any await. */ +int ioxd__recv_pause (conn_t *c); /* 0 once stopped; -1 if the input already ended */ +bool ioxd__recv_resume(conn_t *c); /* true once re-armed; false if it ended meanwhile */ +int ioxd__recv_exact (conn_t *c, void *dst, size_t n); /* n bytes into dst, or <0 */ +int ioxd__setsockopt (conn_t *c, int level, int name, const void *val, size_t len); /* 0 or -errno; over the ring */ +int ioxd__sendmsg (conn_t *c, const struct msghdr *msg); /* one sendmsg, for a message with control data */ + +/* For the loop (proactor.c): a connection's life from accept to the pool. */ +conn_t *ioxd__conn_new(proactor_t *p, struct listener *l, int fd); /* from the pool, or fresh */ +void ioxd__conn_main(void *arg); /* the connection's coroutine body */ +void ioxd__arm_recv(proactor_t *p, conn_t *c); /* one multishot recv */ +void ioxd__on_recv(proactor_t *p, conn_t *c, int res, unsigned flags); /* a recv CQE */ +void ioxd__recv_drain(conn_t *c); /* shutdown: end a recv parked on -ENOBUFS */ +void ioxd__close_socket(proactor_t *p, int fd); /* close a socket through the ring */ +void ioxd__conn_pool_drain(proactor_t *p); /* free the pool at teardown */ diff --git a/src/io/coro.c b/lib/io/coro.c similarity index 60% rename from src/io/coro.c rename to lib/io/coro.c index 9660465..e66722c 100644 --- a/src/io/coro.c +++ b/lib/io/coro.c @@ -12,11 +12,22 @@ extern void swap_ctx(void **save_sp, void *load_sp); /* switch_x86_64.S */ static thread_local coro_t *cur; /* the running coroutine; nullptr on the loop stack */ static thread_local void *loop_sp; /* the loop's stack pointer while a coroutine runs */ -#ifndef CORO_POOL_MAX -#define CORO_POOL_MAX 512 /* warm stacks kept per worker, reused instead of munmap/mmap */ +#ifndef CORO_GUARD +#define CORO_GUARD (64UL * 1024) /* PROT_NONE below every stack. Big enough that a frame cannot + * step over it into the neighbour below: ioxd__conn_main's is + * 25 KB and ioxd__serve's 10 KB, so one page would not do. + * Address space only - PROT_NONE pages have no RSS. */ #endif -static thread_local coro_t *pool_head; /* free list of whole stack blocks, linked via ->next */ -static thread_local int pool_count; +/* Pooled stacks keep their pages: no madvise(MADV_DONTNEED) on the way in. The trade is deliberate, + * a warm stack for the next connection against the RSS of an idle one, and the pool is capped. */ +static thread_local coro_t *pool_head; /* free list of whole stack blocks, linked via ->next */ +static thread_local unsigned pool_count; +static thread_local unsigned pool_max = CORO_POOL_MAX; + +void coro_pool_limit(unsigned max_idle) +{ + pool_max = max_idle; +} /* The running coroutine, or nullptr on the loop stack. */ coro_t *coro_current(void) @@ -34,17 +45,25 @@ static void coro_entry(void) abort(); /* a finished coroutine must never be resumed */ } -/* Get a stack - pooled, or freshly mapped with a guard page - and forge its first frame so the +/* Get a stack - pooled, or freshly mapped with its guard - and forge its first frame so the * first switch into it 'returns' into coro_entry. */ coro_t *coro_create(void (*fn)(void *), void *arg, size_t stack_bytes) { size_t page = (size_t)sysconf(_SC_PAGESIZE); stack_bytes = (stack_bytes + page - 1) & ~(page - 1); - size_t total = stack_bytes + page; /* plus the guard page */ + size_t guard = (CORO_GUARD + page - 1) & ~(page - 1); + size_t total = stack_bytes + guard; coro_t *c; - if (pool_head && pool_head->size == total) { - /* a warm stack: guard page still armed, no mmap, no mprotect */ + if (pool_head) { + /* Every caller passes the worker's configured size, so the pool holds one size. A mismatch + * would mean a second size is in play and this reuse would hand back the wrong stack. */ + if (pool_head->size != total) { + fprintf(stderr, "ioxd: coroutine stack size %zu does not match the pooled %zu\n", + total, pool_head->size); + abort(); + } + /* a warm stack: guard still armed, no mmap, no mprotect */ c = pool_head; pool_head = c->next; pool_count--; @@ -55,7 +74,7 @@ coro_t *coro_create(void (*fn)(void *), void *arg, size_t stack_bytes) perror("mmap(stack)"); abort(); } - if (mprotect(mem, page, PROT_NONE) < 0) { /* overflow faults here, not a neighbour */ + if (mprotect(mem, guard, PROT_NONE) < 0) { /* overflow faults here, not a neighbour */ perror("mprotect(guard)"); abort(); } @@ -89,7 +108,8 @@ coro_t *coro_create(void (*fn)(void *), void *arg, size_t stack_bytes) * idle stacks kept, not how many coroutines may run. */ static void coro_destroy(coro_t *c) { - if (pool_count < CORO_POOL_MAX) { + c->sp = nullptr; /* the frame it named is gone; a stale sp must not be switched to */ + if (pool_count < pool_max) { c->next = pool_head; pool_head = c; pool_count++; @@ -109,10 +129,20 @@ void coro_pool_drain(void) pool_count = 0; } -/* Loop only: switch into c until it yields; if it finished, recycle its stack. */ +/* Loop only: switch into c until it yields; if it finished, recycle its stack. Both rules are + * checked in every build: an assert would go away under -DNDEBUG, and breaking either corrupts the + * loop's saved stack pointer or switches to a stack that has been recycled - neither of which + * shows up as anything but a crash somewhere else. */ void coro_resume(coro_t *c) { - assert(cur == nullptr && "coro_resume is loop-only; a coroutine spawns, it never resumes"); + if (cur) { + fprintf(stderr, "ioxd: coro_resume from inside a coroutine; only the loop resumes\n"); + abort(); + } + if (c->done) { + fprintf(stderr, "ioxd: coro_resume of a coroutine that already finished\n"); + abort(); + } cur = c; swap_ctx(&loop_sp, c->sp); cur = nullptr; diff --git a/lib/io/coro.h b/lib/io/coro.h new file mode 100644 index 0000000..a849836 --- /dev/null +++ b/lib/io/coro.h @@ -0,0 +1,62 @@ +/* + * coro.h - stackful coroutines for one proactor thread. + * + * A coroutine runs on its own mmap'd stack with an unmapped guard region (CORO_GUARD) below it. + * Suspending saves the callee-saved registers and switches to the loop's stack; resuming is the + * reverse. Everything on the coroutine's stack stays exactly where it was while it is parked, + * which is what lets an io_uring completion be routed to a struct that lives in an await's frame. + * + * A finished coroutine's stack is not unmapped but pooled per thread, guard still armed, and + * handed to the next coro_create; past CORO_POOL_MAX idle stacks it is unmapped instead. + * + * Discipline: only the loop calls coro_resume, only a coroutine calls coro_yield. A coroutine + * that wants to start another one hands it to the scheduler (proactor_spawn); resuming from + * inside a coroutine would overwrite the loop's saved stack pointer. + * + * Two things the switch does not carry, both deliberate. It saves the six callee-saved registers + * and nothing else, so MXCSR and the x87 control word - the rounding mode, the denormal and + * exception masks - are whatever the last coroutine left behind: a handler that changes an FP mode + * must put it back before it yields. And switch_x86_64.S carries no .note.gnu.property, which + * leaves the whole linked program without the IBT/SHSTK markings, so no shadow stack is ever armed + * around a stack this file forged by hand. + */ +#pragma once + +#include + +#ifndef CORO_POOL_MAX +#define CORO_POOL_MAX 512 /* warm stacks kept per worker by default (ioxd_config.idle_stacks) */ +#endif + +/* Private to libioxd: hidden symbols cannot be interposed, so nothing outside the library can + * substitute a scheduler primitive (the .S hides swap_ctx the same way). */ +#define CORO_API __attribute__((visibility("hidden"))) + +typedef struct coro { + void *sp; /* saved stack pointer while suspended */ + void *stack; /* mmap base; the low CORO_GUARD bytes are the guard */ + size_t size; /* mapping size, guard included */ + void (*fn)(void *); + void *arg; + bool done; /* fn returned; the next resume-return recycles the stack */ + struct coro *next; /* scheduler's ready-list link */ +} coro_t; + +/* Allocate a stack and forge its first frame. The descriptor lives at the top of that stack. */ +CORO_API coro_t *coro_create(void (*fn)(void *), void *arg, size_t stack_bytes); + +/* Loop only. Run c until it yields; if it finished, its stack goes to the pool (or is unmapped + * past CORO_POOL_MAX) before returning. */ +CORO_API void coro_resume(coro_t *c); + +/* Coroutine only. Back to the loop; returns when the loop resumes this coroutine again. */ +CORO_API void coro_yield(void); + +/* The running coroutine, nullptr on the loop stack. */ +CORO_API coro_t *coro_current(void); + +/* Unmap the per-thread free list of pooled stacks. Call at worker teardown, on the worker thread. */ +CORO_API void coro_pool_drain(void); + +/* How many idle stacks this thread keeps warm (CORO_POOL_MAX until told). Before the first create. */ +CORO_API void coro_pool_limit(unsigned max_idle); diff --git a/lib/io/internal.h b/lib/io/internal.h new file mode 100644 index 0000000..14eb02e --- /dev/null +++ b/lib/io/internal.h @@ -0,0 +1,53 @@ +/* + * io/internal.h - the contract between the loop (proactor.c) and the operations (conn.c): how + * a completion finds what it belongs to, and the trace switch. Private; not installed. Each + * module's own declarations are in its header (uring.h, coro.h, bufring.h, conn.h, proactor.h). + */ +#pragma once + +#include "io/proactor.h" + +#include +#include + +/* -DTRACE: one line per completion and lifetime event, for chasing a misbehaving path. */ +#ifdef TRACE +#define trace(...) fprintf(stderr, __VA_ARGS__) +#else +#define trace(...) ((void)0) +#endif + +/* ── completion routing ────────────────────────────────────────────────────────────────── */ + +/* user_data is a pointer with a tag in its low three bits; everything pointed at is 8-aligned. + * TAG_IGNORE is the zero tag on purpose: an SQE whose user_data was never set then dispatches as + * "nobody waits for this" instead of as an op with a null pointer. */ +enum { + TAG_IGNORE = 0, /* a completion nobody waits for */ + TAG_OP = 1, /* an op_t: a one-shot await */ + TAG_RECV = 2, /* a conn_t: its multishot recv */ + TAG_ACCEPT = 3, /* the listener's multishot accept */ + TAG_CLOSE = 4, /* a socket's close: only a failure is news */ + TAG_DRAIN = 5, /* the shutdown's blanket cancel */ +}; + +#define UD(ptr, tag) ((uint64_t)(uintptr_t)(ptr) | (uint64_t)(tag)) +#define UD_PTR(ud) ((void *)(uintptr_t)((ud) & ~(uint64_t)7)) +#define UD_TAG(ud) ((unsigned)((ud) & 7U)) + +/* A one-shot operation. It lives in the awaiting coroutine's stack frame, which is frozen while + * the coroutine is parked, so its address is valid for exactly as long as the op is in flight. */ +typedef struct op { + coro_t *waiter; + int res; + unsigned flags; +} op_t; + +#include + +/* The text of an errno, for a log line. glibc's strerror has had a per-thread buffer since 2.32, + * which is what every supported box runs; clang-tidy's concurrency check does not know that. */ +static inline const char *ioxd__errstr(int err) +{ + return strerror(err); /* NOLINT(concurrency-mt-unsafe) */ +} diff --git a/lib/io/pipe.c b/lib/io/pipe.c new file mode 100644 index 0000000..a59e44f --- /dev/null +++ b/lib/io/pipe.c @@ -0,0 +1,450 @@ +/* + * io/pipe.c - the reader and the writer of io/pipe.h, and the public pipe on top of them. + */ +#include "io/pipe.h" +#include "io/proactor.h" + +#include + +/* ── the reader ────────────────────────────────────────────────────────────────────────── */ + +void ioxd_pipereader_init(ioxd_pipereader *pr, conn_t *conn, char *buf, size_t cap) +{ + *pr = (ioxd_pipereader){}; + pr->conn = conn; + pr->buf = buf; + pr->cap = cap; +} + +/* The live bytes: in buf, or in the current kernel buffer. */ +static ioxd_slice live_span(const ioxd_pipereader *pr) +{ + if (pr->live_in_buf) + return (ioxd_slice){ pr->buf + pr->buf_pos, pr->buf_end - pr->buf_pos }; + if (pr->has_cur) + return (ioxd_slice){ (const char *)pr->cur.ptr + pr->cur_pos, pr->cur.len - pr->cur_pos }; + return (ioxd_slice){ nullptr, 0 }; +} + +/* Let the current kernel buffer go once nothing is left in it, neither live bytes nor the run. + * One that also holds frozen kept bytes lives on as `pinned`. */ +static void cur_done(ioxd_pipereader *pr) +{ + if (!pr->has_cur || pr->cur_pos < pr->cur.len || (pr->run_in_cur && pr->run_len)) + return; + if (!pr->cur_is_pinned) + ioxd__bufring_return(&pr->conn->p->bufs, pr->cur.buf_id); + pr->has_cur = false; + pr->cur_is_pinned = false; + pr->cur_pos = 0; +} + +/* Reclaim the bytes dropped from the front of buf's live region. */ +static void compact(ioxd_pipereader *pr) +{ + if (pr->buf_pos > pr->floor) { + memmove(pr->buf + pr->floor, pr->buf + pr->buf_pos, pr->buf_end - pr->buf_pos); + pr->buf_end -= pr->buf_pos - pr->floor; + pr->buf_pos = pr->floor; + } +} + +/* Move the current buffer's run and live bytes into buf, so what follows can join them. A run + * that was kept in place has pointers out to it, so its buffer stays pinned rather than going + * back to the ring - unless another buffer is pinned already (the HTTP engine's head), in which + * case the run just moves and ioxd_pipereader_run is where to find it. */ +static bool gather(ioxd_pipereader *pr) +{ + size_t live = pr->has_cur ? pr->cur.len - pr->cur_pos : 0; + size_t run = pr->run_in_cur ? pr->run_len : 0; + if (pr->floor + run + live > pr->cap) + return false; + if (run) { + memcpy(pr->buf + pr->floor, (const char *)pr->cur.ptr + pr->run_start, run); + pr->run_start = pr->floor; + pr->floor += run; + if (!pr->has_pinned) { + pr->pinned = pr->cur; + pr->has_pinned = true; + pr->cur_is_pinned = true; + } + } + pr->run_in_cur = false; + if (live) + memcpy(pr->buf + pr->floor, (const char *)pr->cur.ptr + pr->cur_pos, live); + pr->buf_pos = pr->floor; + pr->buf_end = pr->floor + live; + pr->live_in_buf = true; + if (pr->has_cur) { + pr->cur_pos = pr->cur.len; + cur_done(pr); + } + return true; +} + +/* A buffer that cannot be used: back to the ring, and the reader is done. */ +static int refuse(ioxd_pipereader *pr, const struct rx_item *item) +{ + ioxd__bufring_return(&pr->conn->p->bufs, item->buf_id); + pr->error = IOXD_PIPE_FULL; + return IOXD_PIPE_FULL; +} + +/* More bytes: the next kernel buffer, in place when nothing is live, else appended in buf. */ +static int more(ioxd_pipereader *pr) +{ + if (pr->eof) + return 0; + struct rx_item item; + int rc = ioxd__await_item(pr->conn, &item); + if (rc <= 0) { + pr->eof = true; + if (rc < 0) + pr->error = IOXD_PIPE_GONE; + return rc < 0 ? IOXD_PIPE_GONE : 0; + } + if (!pr->live_in_buf && !pr->has_cur) { /* nothing live: this buffer is the live span */ + pr->cur = item; + pr->has_cur = true; + pr->cur_pos = 0; + pr->cur_is_pinned = false; + return 1; + } + if (!pr->live_in_buf && !gather(pr)) /* the live bytes leave the current buffer first */ + return refuse(pr, &item); + if (pr->buf_end + item.len > pr->cap) { + compact(pr); + if (pr->buf_end + item.len > pr->cap) + return refuse(pr, &item); + } + memcpy(pr->buf + pr->buf_end, item.ptr, item.len); + pr->buf_end += item.len; + ioxd__bufring_return(&pr->conn->p->bufs, item.buf_id); + return 1; +} + +int ioxd_pipereader_read(ioxd_pipereader *pr, ioxd_slice *live) +{ + if (pr->error) + return pr->error; + for (;;) { + ioxd_slice l = live_span(pr); + if (l.len > pr->examined) { + *live = l; + return 1; + } + int rc = more(pr); + if (rc <= 0) + return rc; + } +} + +void ioxd_pipereader_examine(ioxd_pipereader *pr, size_t n) +{ + pr->examined = n; +} + +/* Forget n live bytes of the current place; never more than there are. */ +static void consume(ioxd_pipereader *pr, size_t n) +{ + size_t have = live_span(pr).len; + if (n > have) + n = have; + if (pr->live_in_buf) { + pr->buf_pos += n; + if (pr->buf_pos >= pr->buf_end) { + pr->buf_pos = pr->buf_end = pr->floor; + pr->live_in_buf = false; + } + } else if (pr->has_cur) { + pr->cur_pos += n; + cur_done(pr); + } + pr->examined = pr->examined > n ? pr->examined - n : 0; +} + +void ioxd_pipereader_drop(ioxd_pipereader *pr, size_t n) +{ + consume(pr, n); +} + +const char *ioxd_pipereader_keep(ioxd_pipereader *pr, size_t n) +{ + const char *kept; + ioxd_slice live = live_span(pr); + if (n > live.len) + return nullptr; /* more than is live: the caller's mistake */ + if (n == 0) + return live.p; + if (pr->live_in_buf) { + if (pr->run_len == 0) { /* a run starts where the live bytes are */ + pr->run_in_cur = false; + pr->run_start = pr->buf_pos; + pr->floor = pr->buf_pos; + } else if (pr->buf_pos != pr->floor) { /* dropped bytes in between: slide these down */ + memmove(pr->buf + pr->floor, pr->buf + pr->buf_pos, n); + } + kept = pr->buf + pr->floor; + pr->floor += n; + } else if (pr->has_cur && (pr->run_len == 0 || pr->run_in_cur)) { /* in place */ + if (pr->run_len == 0) { + pr->run_in_cur = true; + pr->run_start = pr->cur_pos; + } + size_t run_end = pr->run_start + pr->run_len; + if (run_end != pr->cur_pos) + memmove((char *)pr->cur.ptr + run_end, (const char *)pr->cur.ptr + pr->cur_pos, n); + kept = (const char *)pr->cur.ptr + run_end; + } else if (pr->has_cur) { /* the run is in buf, the live bytes are not: copy across */ + if (pr->floor + n > pr->cap) { + pr->error = IOXD_PIPE_FULL; + return nullptr; + } + memcpy(pr->buf + pr->floor, (const char *)pr->cur.ptr + pr->cur_pos, n); + kept = pr->buf + pr->floor; + pr->floor += n; + pr->buf_pos = pr->buf_end = pr->floor; /* buf's (empty) live region moves up with it */ + } else { + return nullptr; /* nothing live: the caller's mistake */ + } + pr->run_len += n; + consume(pr, n); + return kept; +} + +void ioxd_pipereader_run_begin(ioxd_pipereader *pr) +{ + if (pr->run_in_cur && pr->run_len) { + if (pr->has_pinned && !pr->cur_is_pinned) { /* another buffer is pinned already: this run moves to buf */ + if (pr->floor + pr->run_len > pr->cap) { + pr->error = IOXD_PIPE_FULL; + } else { + memcpy(pr->buf + pr->floor, (const char *)pr->cur.ptr + pr->run_start, pr->run_len); + pr->floor += pr->run_len; + } + } else { + pr->pinned = pr->cur; + pr->has_pinned = true; + pr->cur_is_pinned = true; + } + } + pr->run_len = 0; + pr->run_in_cur = false; + cur_done(pr); +} + +ioxd_slice ioxd_pipereader_run(const ioxd_pipereader *pr) +{ + const char *base = pr->run_in_cur ? (const char *)pr->cur.ptr : pr->buf; + return (ioxd_slice){ base + pr->run_start, pr->run_len }; +} + +void ioxd_pipereader_release(ioxd_pipereader *pr) +{ + pr->run_len = 0; + pr->run_in_cur = false; + if (pr->live_in_buf) { + memmove(pr->buf, pr->buf + pr->buf_pos, pr->buf_end - pr->buf_pos); + pr->buf_end -= pr->buf_pos; + pr->buf_pos = 0; + } else { + pr->buf_pos = pr->buf_end = 0; + } + pr->floor = 0; + if (pr->has_pinned) { + if (pr->cur_is_pinned) + pr->cur_is_pinned = false; /* it lives on as the current buffer */ + else + ioxd__bufring_return(&pr->conn->p->bufs, pr->pinned.buf_id); + pr->has_pinned = false; + } + cur_done(pr); +} + +void ioxd_pipereader_close(ioxd_pipereader *pr) +{ + if (pr->has_cur && !pr->cur_is_pinned) + ioxd__bufring_return(&pr->conn->p->bufs, pr->cur.buf_id); + if (pr->has_pinned) + ioxd__bufring_return(&pr->conn->p->bufs, pr->pinned.buf_id); + pr->has_cur = pr->has_pinned = pr->cur_is_pinned = false; +} + +int ioxd_pipereader_copy(ioxd_pipereader *pr, void *dst, size_t n) +{ + if (pr->error) + return pr->error; + for (;;) { + ioxd_slice l = live_span(pr); + if (l.len) { + size_t k = l.len < n ? l.len : n; + memcpy(dst, l.p, k); + consume(pr, k); + return (int)k; + } + int rc = more(pr); + if (rc <= 0) + return rc; + } +} + +int ioxd_pipereader_avail(ioxd_pipereader *pr, ioxd_slice *live) +{ + if (pr->error) + return pr->error; + ioxd_slice l = live_span(pr); + while (l.len <= pr->examined) { /* all seen: take a delivered buffer, if one is queued */ + if (pr->conn->rx_head == pr->conn->rx_tail) + return 0; + int rc = more(pr); + if (rc <= 0) + return rc; + l = live_span(pr); + } + *live = l; + return 1; +} + +bool ioxd_pipereader_inject(ioxd_pipereader *pr, const void *data, size_t n) +{ + if (pr->error) + return false; + if (!pr->live_in_buf) { + if (pr->has_cur) { /* live bytes, or a run, in place: they move first */ + if (!gather(pr)) + return false; + } else { + pr->buf_pos = pr->buf_end = pr->floor; + pr->live_in_buf = true; + } + } + if (pr->buf_end + n > pr->cap) { + compact(pr); + if (pr->buf_end + n > pr->cap) + return false; + } + memcpy(pr->buf + pr->buf_end, data, n); + pr->buf_end += n; + return true; +} + +/* ── the writer ────────────────────────────────────────────────────────────────────────── */ + +void ioxd_pipewriter_init(ioxd_pipewriter *pw, conn_t *conn, char *buf, size_t lead, size_t cap, size_t slack) +{ + *pw = (ioxd_pipewriter){}; + pw->conn = conn; + pw->buf = buf; + pw->lead = lead; + pw->cap = cap; + pw->slack = slack; +} + +void ioxd_pipewriter_reset(ioxd_pipewriter *pw) +{ + pw->head = pw->len = pw->tail = 0; +} + +int ioxd_pipewriter_flush(ioxd_pipewriter *pw) +{ + if (pw->failed) + return -1; + size_t total = pw->head + pw->len + pw->tail; + if (total == 0) + return 0; + int rc = await_send(pw->conn, pw->buf + pw->lead - pw->head, total); + pw->head = pw->len = pw->tail = 0; + if (rc < 0) { + pw->failed = true; + return -1; + } + return 0; +} + +void *ioxd_pipewriter_reserve(ioxd_pipewriter *pw, size_t n) +{ + if (pw->failed || n > pw->cap) + return nullptr; + if (pw->len + n > pw->cap && ioxd_pipewriter_flush(pw) < 0) + return nullptr; + return ioxd_pipewriter_at(pw); +} + +void ioxd_pipewriter_advance(ioxd_pipewriter *pw, size_t n) +{ + size_t room = ioxd_pipewriter_room(pw); + pw->len += n < room ? n : room; /* never past the slab, whatever was claimed */ +} + +char *ioxd_pipewriter_front(ioxd_pipewriter *pw, size_t n) +{ + if (n > pw->lead - pw->head) + return nullptr; + pw->head += n; + return pw->buf + pw->lead - pw->head; +} + +char *ioxd_pipewriter_back(ioxd_pipewriter *pw, size_t n) +{ + if (n > pw->slack - pw->tail) + return nullptr; + char *at = pw->buf + pw->lead + pw->len + pw->tail; + pw->tail += n; + return at; +} + +int ioxd_pipewriter_through(ioxd_pipewriter *pw, const void *data, size_t n) +{ + if (pw->failed) + return -1; + if (await_send(pw->conn, data, n) < 0) { + pw->failed = true; + return -1; + } + return 0; +} + +int ioxd_pipewriter_write(ioxd_pipewriter *pw, const void *data, size_t n) +{ + if (pw->failed) + return -1; + if (n > pw->cap) /* larger than the slab: straight from the caller's memory */ + return ioxd_pipewriter_flush(pw) < 0 ? -1 : ioxd_pipewriter_through(pw, data, n); + void *at = ioxd_pipewriter_reserve(pw, n); + if (!at) + return -1; + memcpy(at, data, n); + pw->len += n; + return 0; +} + +int ioxd_pipewriter_send(ioxd_pipewriter *pw, const void *data, size_t n) +{ + return ioxd_pipewriter_write(pw, data, n) < 0 ? -1 : ioxd_pipewriter_flush(pw); +} + +/* ── the pipe ──────────────────────────────────────────────────────────────────────────── */ + +void ioxd__pipe_init(struct ioxd_pipe *p, conn_t *conn, char *gather, size_t gather_cap, char *slab, size_t lead, size_t cap, size_t slack) +{ + ioxd_pipereader_init(&p->in, conn, gather, gather_cap); + ioxd_pipewriter_init(&p->out, conn, slab, lead, cap, slack); +} + +void ioxd__pipe_close(struct ioxd_pipe *p) +{ + ioxd_pipewriter_flush(&p->out); /* what a handler left in the slab still goes */ + ioxd_pipereader_close(&p->in); +} + +int ioxd_pipe_read (ioxd_pipe *p, ioxd_slice *live) { return ioxd_pipereader_read(&p->in, live); } +void ioxd_pipe_examine(ioxd_pipe *p, size_t n) { ioxd_pipereader_examine(&p->in, n); } +void ioxd_pipe_drop (ioxd_pipe *p, size_t n) { ioxd_pipereader_drop(&p->in, n); } +const char *ioxd_pipe_keep (ioxd_pipe *p, size_t n) { return ioxd_pipereader_keep(&p->in, n); } +void ioxd_pipe_release(ioxd_pipe *p) { ioxd_pipereader_release(&p->in); } +int ioxd_pipe_copy (ioxd_pipe *p, void *dst, size_t n) { return ioxd_pipereader_copy(&p->in, dst, n); } +void *ioxd_pipe_reserve(ioxd_pipe *p, size_t n) { return ioxd_pipewriter_reserve(&p->out, n); } +void ioxd_pipe_advance(ioxd_pipe *p, size_t n) { ioxd_pipewriter_advance(&p->out, n); } +int ioxd_pipe_write (ioxd_pipe *p, const void *data, size_t n) { return ioxd_pipewriter_write(&p->out, data, n); } +int ioxd_pipe_flush (ioxd_pipe *p) { return ioxd_pipewriter_flush(&p->out); } +int ioxd_pipe_send (ioxd_pipe *p, const void *data, size_t n) { return ioxd_pipewriter_send(&p->out, data, n); } diff --git a/lib/io/pipe.h b/lib/io/pipe.h new file mode 100644 index 0000000..85e08c3 --- /dev/null +++ b/lib/io/pipe.h @@ -0,0 +1,107 @@ +/* + * io/pipe.h - a connection as a pipe: a reader over the buffers the kernel filled and a writer + * over a slab. Every call that must wait suspends the calling coroutine and the worker's loop + * resumes it on the completion, so the same code drives any connection the I/O plane runs. The + * HTTP engine reads through the reader; ioxd_run_pipes hands a pipe to a handler of your own. + */ +#pragma once + +#include "ioxd.h" +#include "io/conn.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 + * the consumer keeps bytes it wants contiguous. Live bytes are the ones not yet consumed; kept + * bytes stay where they are - the consumer's, to point into and even overwrite - until release. + * At most two kernel buffers are held: one with kept bytes of earlier runs, one with the live + * bytes and the run in progress. */ + +// BUF_SIZE can be increased to keep entire requests in a single rx_item, avoiding buffering +typedef struct ioxd_pipereader { + conn_t *conn; + char *buf; /* the gathering buffer, the consumer's */ + size_t cap; + size_t floor; /* buf[0, floor): kept bytes */ + bool live_in_buf; /* the live bytes are buf[buf_pos, buf_end), else in cur */ + size_t buf_pos, buf_end; + struct rx_item cur; /* the kernel buffer the live bytes sit in */ + bool has_cur; + size_t cur_pos; /* its bytes consumed so far */ + struct rx_item pinned; /* a kernel buffer that holds frozen kept bytes */ + bool has_pinned; + bool cur_is_pinned; /* cur and pinned are the same buffer */ + bool run_in_cur; /* the run in progress sits in cur (in place), else in buf */ + size_t run_start, run_len; + size_t examined; /* live bytes the consumer looked at without consuming */ + bool eof; + int error; /* 0, or IOXD_PIPE_GONE / IOXD_PIPE_FULL, sticky */ +} ioxd_pipereader; + +void ioxd_pipereader_init (ioxd_pipereader *pr, conn_t *conn, char *buf, size_t cap); +void ioxd_pipereader_close (ioxd_pipereader *pr); /* returns the buffers it holds */ +int ioxd_pipereader_read (ioxd_pipereader *pr, ioxd_slice *live); /* 1: live bytes with something unexamined; waits for more otherwise; 0 at the end of input; <0 error */ +void ioxd_pipereader_examine (ioxd_pipereader *pr, size_t n); /* looked at n live bytes: the next read waits for more */ +void ioxd_pipereader_drop (ioxd_pipereader *pr, size_t n); /* consume n live bytes */ +const char *ioxd_pipereader_keep (ioxd_pipereader *pr, size_t n); /* consume n live bytes but keep them, contiguous with the run; where they are, or nullptr: no room (FULL), or n past the live bytes */ +void ioxd_pipereader_run_begin(ioxd_pipereader *pr); /* freeze the run; the next keep starts another */ +ioxd_slice ioxd_pipereader_run (const ioxd_pipereader *pr); /* the run in progress */ +void ioxd_pipereader_release (ioxd_pipereader *pr); /* forget every kept byte; live bytes stay */ +int ioxd_pipereader_copy (ioxd_pipereader *pr, void *dst, size_t n); /* up to n live bytes into dst, consumed; >0, 0 at the end, <0 error */ +int ioxd_pipereader_avail (ioxd_pipereader *pr, ioxd_slice *live); /* like read, but never waits: 0 when nothing unexamined was delivered; <0 error */ +bool ioxd_pipereader_inject (ioxd_pipereader *pr, const void *data, size_t n); /* bytes that arrived by another route, appended to the live bytes; false: no room */ + +/* The writer: a slab with a head and a tail. Data goes in at the tail (reserve/advance, or write); + * a frame's front goes into the lead just before the pending data and its back into the slack just + * after it, and one flush sends the whole span. The HTTP reply puts its head and a chunk's size + * line in front and a chunk's CRLF behind; a raw pipe may never touch them. */ +typedef struct ioxd_pipewriter { + conn_t *conn; + char *buf; /* [lead][cap][slack] */ + size_t lead, cap, slack; + size_t head; /* bytes of the lead in use: a frame's front */ + size_t len; /* data bytes */ + size_t tail; /* bytes of the slack in use: a frame's back */ + bool failed; /* the peer is gone: every call fails from here on */ +} ioxd_pipewriter; + +void ioxd_pipewriter_init (ioxd_pipewriter *pw, conn_t *conn, char *buf, size_t lead, size_t cap, size_t slack); +void ioxd_pipewriter_reset (ioxd_pipewriter *pw); /* drop everything pending */ +void *ioxd_pipewriter_reserve(ioxd_pipewriter *pw, size_t n); /* n bytes at the tail, flushing first when they do not fit; nullptr on failure or n > cap */ +void ioxd_pipewriter_advance(ioxd_pipewriter *pw, size_t n); +char *ioxd_pipewriter_front (ioxd_pipewriter *pw, size_t n); /* n bytes just before the pending span; nullptr when the lead has no room */ +char *ioxd_pipewriter_back (ioxd_pipewriter *pw, size_t n); /* n bytes just after it; nullptr when the slack has no room */ +int ioxd_pipewriter_write (ioxd_pipewriter *pw, const void *data, size_t n); /* copy in; larger than the slab goes straight out */ +int ioxd_pipewriter_through(ioxd_pipewriter *pw, const void *data, size_t n); /* send now, ahead of the pending span, bypassing the slab */ +int ioxd_pipewriter_flush (ioxd_pipewriter *pw); /* send front, data and back; suspends */ +int ioxd_pipewriter_send (ioxd_pipewriter *pw, const void *data, size_t n); /* write, then flush */ + +/* Where the next byte goes, and how many fit before the slab is full. */ +static inline char *ioxd_pipewriter_at(const ioxd_pipewriter *pw) +{ + return pw->buf + pw->lead + pw->len; +} +static inline size_t ioxd_pipewriter_room(const ioxd_pipewriter *pw) +{ + return pw->cap - pw->len; +} + +/* The pair every handler receives, HTTP or raw (the public ioxd_pipe). Its buffers live on the + * connection's coroutine stack. */ +#ifndef IOXD_PIPE_GATHER +#define IOXD_PIPE_GATHER 16384 /* the reader's gathering buffer: kept plus live bytes */ +#endif +#ifndef IOXD_PIPE_LEAD +#define IOXD_PIPE_LEAD 512 /* in front of the slab: a frame's front */ +#endif +#ifndef IOXD_PIPE_CAP +#define IOXD_PIPE_CAP 8192 /* the slab: bytes buffered before a flush */ +#endif +#ifndef IOXD_PIPE_SLACK +#define IOXD_PIPE_SLACK 8 /* behind the slab: a frame's back */ +#endif +struct ioxd_pipe { + ioxd_pipereader in; + ioxd_pipewriter out; +}; +void ioxd__pipe_init (struct ioxd_pipe *p, conn_t *conn, char *gather, size_t gather_cap, char *slab, size_t lead, size_t cap, size_t slack); +void ioxd__pipe_close(struct ioxd_pipe *p); diff --git a/lib/io/proactor.c b/lib/io/proactor.c new file mode 100644 index 0000000..8c8ab51 --- /dev/null +++ b/lib/io/proactor.c @@ -0,0 +1,453 @@ +/* + * proactor.c - the worker: pin to a CPU, own a ring, a buffer ring and a listener, then loop: + * start spawned coroutines, enter once per batch, dispatch every completion. Connections live in + * conn.c and buffers in bufring.c; this file is the loop and what feeds it. + */ +#include "io/internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define DRAIN_GRACE_MS 2000 /* how long a stopping worker waits for its connections */ + +/* Coarse monotonic milliseconds: for the drain deadline and the once-a-second retries, never for + * anything that needs better than a tick. */ +static uint64_t now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC_COARSE, &ts); + return (uint64_t)ts.tv_sec * 1000U + (uint64_t)(ts.tv_nsec / 1000000L); +} + +/* ── submission ────────────────────────────────────────────────────────────────────────── */ + +/* Claim an SQE. If the SQ is full mid-batch, submit what is staged and retry. -EBUSY means the + * kernel is holding completions it could not fit in the CQ and will not take more submissions, so + * the retry enters with GETEVENTS to flush them: safe mid-batch, since the loop dispatches whatever + * lands next time round. */ +/* Publish the CQ head for the entries taken so far: once per batch in the loop, and before any + * enter in the middle of one, so the kernel has room for what the enter completes. */ +static void publish_cq(proactor_t *p) +{ + if (p->cq_taken) { + uring_cq_advance(&p->ring, p->cq_taken); + p->cq_taken = 0; + } +} + +struct io_uring_sqe *ioxd__sqe(proactor_t *p) +{ + struct io_uring_sqe *sqe = uring_get_sqe(&p->ring); + int rc = 0; + for (int i = 0; !sqe && i < 16; i++) { + ioxd__bufring_publish(&p->bufs); /* the kernel must see staged returns before this enter */ + publish_cq(p); /* and have room in the CQ for what it completes */ + rc = uring_submit(&p->ring); + if (rc == -EBUSY || rc == -EAGAIN || rc == -EINTR) + rc = uring_submit_wait(&p->ring, 0, nullptr); /* reap without waiting; the CQ has room again */ + sqe = uring_get_sqe(&p->ring); + } + if (!sqe) { + fprintf(stderr, "[w%d] SQ still full after flushing: %s\n", + p->id, rc < 0 ? ioxd__errstr(-rc) : "the kernel is not consuming it"); + abort(); + } + return sqe; +} + +/* ── accept ────────────────────────────────────────────────────────────────────────────── */ + +/* Whether this worker may take another connection. Under registered files a socket lives in a + * slot, so the table is a hard ceiling: better to stop arming than to fail one accept per arrival. */ +static bool accept_room(const proactor_t *p) +{ + return !p->draining && (p->file_slots == 0 || p->live < p->file_slots); +} + +/* Leave the accept unarmed until there is room again. */ +static void stall_accept(struct listener *l) +{ + l->stalled = true; + l->stalled_live = l->p->live; + l->retry_at = (time_t)(now_ms() / 1000U) + 1; +} + +/* Arm the multishot accept: one SQE, then a CQE per new connection. */ +static void arm_accept(struct listener *l) +{ + if (!accept_room(l->p)) { + stall_accept(l); + return; + } + l->stalled = false; + struct io_uring_sqe *sqe = ioxd__sqe(l->p); + sqe->opcode = IORING_OP_ACCEPT; + sqe->fd = l->fd; + sqe->ioprio = IORING_ACCEPT_MULTISHOT; + sqe->user_data = UD(l, TAG_ACCEPT); + if (l->p->ring.fixed_files) + sqe->file_index = IORING_FILE_INDEX_ALLOC; /* land each socket in a free slot, not an fd */ +} + +/* Re-arm a listener that stalled: once a connection has closed (p->live below what it was), and + * once a second regardless, since the shortage may be another thread's and no close of ours would + * ever announce it. Only ever a handful of listeners, and only after an accept ran out of room. */ +static void rearm_stalled(proactor_t *p) +{ + bool any = false; + for (int i = 0; i < p->n_listeners; i++) + any |= p->listeners[i].stalled; + if (!any) + return; + + time_t now = (time_t)(now_ms() / 1000U); + for (int i = 0; i < p->n_listeners; i++) { + struct listener *l = &p->listeners[i]; + if (l->stalled && (p->live < l->stalled_live || now >= l->retry_at)) + arm_accept(l); + } +} + +/* Out of descriptors, slots or memory: the next accept would fail the same way. */ +static bool accept_exhausted(int err) +{ + return err == -ENFILE || err == -EMFILE || err == -ENOMEM || err == -ENOBUFS; +} + +/* Log an accept error at most once a second per listener, with how many it stands for: a full file + * table would otherwise write one line per arrival, forever. */ +static void note_accept_error(struct listener *l, int result) +{ + time_t now = (time_t)(now_ms() / 1000U); + l->err_since_log++; + if (now < l->err_log_at) + return; + fprintf(stderr, "[w%d] accept :%u: %s (%llu since the last line)\n", + l->p->id, l->port, ioxd__errstr(-result), (unsigned long long)l->err_since_log); + l->err_since_log = 0; + l->err_log_at = now + 1; +} + +/* An accept CQE: wrap the new fd in a conn, arm its recv, spawn its handler coroutine. */ +static void on_accept(struct listener *l, int result, unsigned flags) +{ + proactor_t *p = l->p; + trace("[w%d] accept :%u result=%d more=%d\n", p->id, l->port, result, !!(flags & IORING_CQE_F_MORE)); + if (result >= 0 && p->draining) { + ioxd__close_socket(p, result); /* accepted just before the cancel: no new work */ + } else if (result >= 0) { + conn_t *c = ioxd__conn_new(p, l, result); /* TCP_NODELAY came with the listener */ + ioxd__arm_recv(p, c); + proactor_spawn(p, ioxd__conn_main, c); + p->accepted++; + } else if (!(p->draining && result == -ECANCELED)) { /* our own shutdown cancel is not an error */ + note_accept_error(l, result); + } + if (flags & IORING_CQE_F_MORE) /* still armed: nothing to do */ + return; + if (result < 0 && accept_exhausted(result)) { + stall_accept(l); /* rearm_stalled picks it up when there is room */ + return; + } + arm_accept(l); +} + +/* ── completions ───────────────────────────────────────────────────────────────────────── */ + +/* Route one CQE by the tag in its user_data. Handler coroutines resume inline from here. */ +static void dispatch(proactor_t *p, struct io_uring_cqe *cqe) +{ + void *ptr = UD_PTR(cqe->user_data); + switch (UD_TAG(cqe->user_data)) { + case TAG_OP: { + op_t *op = ptr; + op->res = cqe->res; + op->flags = cqe->flags; + trace("[w%d] op res=%d flags=%#x\n", p->id, cqe->res, cqe->flags); + coro_resume(op->waiter); /* to its next await; op may be gone after */ + break; + } + case TAG_RECV: + ioxd__on_recv(p, ptr, cqe->res, cqe->flags); + break; + case TAG_ACCEPT: + on_accept(ptr, cqe->res, cqe->flags); + break; + case TAG_CLOSE: /* a failed close leaks a slot: say so */ + if (cqe->res < 0) + fprintf(stderr, "[w%d] close: %s\n", p->id, ioxd__errstr(-cqe->res)); + break; + case TAG_DRAIN: /* the shutdown's one blanket cancel */ + if (cqe->res < 0 && cqe->res != -ENOENT) { + fprintf(stderr, "[w%d] cancel all: %s; cancelling connections one at a time\n", + p->id, ioxd__errstr(-cqe->res)); + p->cancel_each = true; + } + break; + default: /* TAG_IGNORE: cancel acknowledgements */ + break; + } +} + +/* ── scheduling ────────────────────────────────────────────────────────────────────────── */ + +/* Queue a new coroutine; the loop starts it on its next iteration. */ +void proactor_spawn(proactor_t *p, void (*fn)(void *), void *arg) +{ + coro_t *c = coro_create(fn, arg, p->cfg.stack_size); + if (p->ready_tail) + p->ready_tail->next = c; + else + p->ready_head = c; + p->ready_tail = c; +} + +/* Start every coroutine spawned since the last iteration. */ +static void run_ready(proactor_t *p) +{ + while (p->ready_head) { + coro_t *c = p->ready_head; + p->ready_head = c->next; + if (!p->ready_head) + p->ready_tail = nullptr; + c->next = nullptr; + coro_resume(c); + } +} + +/* Re-arm recvs parked on -ENOBUFS, at most one per buffer that actually came back since the last + * sweep: re-arming the whole list on a single return would send them all back to the empty ring. + * Oldest first, so a connection parked early is not starved by later ones. */ +static void rearm_starved(proactor_t *p) +{ + unsigned room = p->bufs.returned; + p->bufs.returned = 0; /* only this round's returns count as room */ + if (p->nstarved == 0 || room == 0) + return; + + unsigned n = room < p->nstarved ? room : p->nstarved; + for (unsigned i = 0; i < n; i++) + ioxd__arm_recv(p, p->starved[i]); /* keeps the ref it already holds */ + p->nstarved -= n; + memmove(p->starved, p->starved + n, p->nstarved * sizeof *p->starved); +} + +/* ── listener ──────────────────────────────────────────────────────────────────────────── */ + +/* A SO_REUSEPORT socket on port; every worker opens its own, so the kernel spreads connections. TCP_NODELAY + * is set here because Linux accepted sockets inherit it: no setsockopt per accept. */ +static int listener_open(uint16_t port) +{ + int fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) { + perror("socket"); + abort(); + } + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof addr); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = htonl(INADDR_ANY); + if (bind(fd, (struct sockaddr *)&addr, sizeof addr) < 0) { /* NOLINT(readability-trailing-comma): glibc's transparent-union sockaddr argument trips the check */ + perror("bind"); + abort(); + } + if (listen(fd, 1024) < 0) { + perror("listen"); + abort(); + } + return fd; +} + +/* ── shutdown ──────────────────────────────────────────────────────────────────────────── */ + +/* Stop is set: take nothing new and ask the kernel to end everything in flight, so the loop can + * keep running until the connections have closed themselves. The accepts are cancelled by name + * (they must stop even on a kernel without CANCEL_ANY), then one ASYNC_CANCEL with + * IORING_ASYNC_CANCEL_ANY (5.19+) takes every recv and every send a coroutine is parked on. Their + * awaits fail with -ECANCELED, the handlers unwind, and conn_close returns the stacks and the fds. + * A recv parked on -ENOBUFS holds no operation to cancel, so it is ended here by hand. */ +static void begin_drain(proactor_t *p) +{ + p->draining = true; + for (int i = 0; i < p->n_listeners; i++) { + struct listener *l = &p->listeners[i]; + if (!l->stalled) { + struct io_uring_sqe *sqe = ioxd__sqe(p); + sqe->opcode = IORING_OP_ASYNC_CANCEL; + sqe->fd = -1; + sqe->addr = UD(l, TAG_ACCEPT); + sqe->user_data = TAG_IGNORE; + } + l->stalled = true; /* nothing re-arms it from here on */ + } + + struct io_uring_sqe *sqe = ioxd__sqe(p); + sqe->opcode = IORING_OP_ASYNC_CANCEL; + sqe->fd = -1; + sqe->cancel_flags = IORING_ASYNC_CANCEL_ANY; + sqe->user_data = UD(p, TAG_DRAIN); /* its result says whether the kernel knew it */ + + while (p->nstarved) + ioxd__recv_drain(p->starved[--p->nstarved]); +} + +/* ── the loop ──────────────────────────────────────────────────────────────────────────── */ + +/* Pin this thread to the idx-th CPU the process may run on. Reading the inherited affinity mask + * keeps the mapping right under a non-contiguous cpuset, e.g. a container on 0-31,64-95. */ +static void pin_to(int idx) +{ + cpu_set_t allowed; + CPU_ZERO(&allowed); + if (sched_getaffinity(0, sizeof allowed, &allowed) != 0) + return; + + int count = CPU_COUNT(&allowed); + if (count <= 0) + return; + + int target = idx % count; + int seen = 0; + for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { + if (!CPU_ISSET(cpu, &allowed)) + continue; + if (seen == target) { + cpu_set_t one; + CPU_ZERO(&one); + CPU_SET(cpu, &one); + pthread_setaffinity_np(pthread_self(), sizeof one, &one); + return; + } + seen++; + } +} + +/* How many registered file slots to ask for: FIXED_FILES, capped by the fd limit the kernel + * checks the table against. 0 disables the feature. */ +static unsigned fixed_slots(void) +{ + struct rlimit rl; + unsigned n = FIXED_FILES; + if (n && getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur < n) + n = (unsigned)rl.rlim_cur; + return n; +} + +/* The worker's whole life: setup, the loop until *stop, teardown in dependency order. */ +void proactor_run(proactor_t *p) +{ + if (p->cpu >= 0) + pin_to(p->cpu); + + coro_pool_limit(p->cfg.idle_stacks); + int rc = uring_init(&p->ring, p->cfg.ring_entries); /* on this thread: DEFER_TASKRUN ties it here */ + if (rc < 0) { + fprintf(stderr, "[w%d] io_uring_setup: %s%s\n", p->id, ioxd__errstr(-rc), + rc == -EPERM ? " (io_uring is disabled: see /proc/sys/kernel/io_uring_disabled)" : ""); + abort(); + } +#ifndef NO_REG_RING + uring_register_ring_fd(&p->ring); /* optional: enter skips an fd lookup */ +#endif + + unsigned slots = fixed_slots(); /* optional: sockets live in a file table */ + if (slots && uring_register_files_sparse(&p->ring, slots) == 0) + p->file_slots = slots; /* the ceiling accept_room holds us to */ + ioxd__bufring_init(&p->bufs, &p->ring, p->id, p->cfg.recv_buffers, p->cfg.recv_buffer_size); + char ports[IOXD_MAX_LISTENERS * 12] = ""; + size_t at = 0; + for (int i = 0; i < p->n_listeners; i++) { + struct listener *l = &p->listeners[i]; + l->p = p; + l->fd = listener_open(l->port); + arm_accept(l); + int n = snprintf(ports + at, sizeof ports - at, "%s:%u%s", i ? " " : "", l->port, l->certs ? "/tls" : ""); + if (n < 0) + break; + at += (size_t)n < sizeof ports - at ? (size_t)n : sizeof ports - at - 1; /* truncated: stop growing */ + } + fprintf(stderr, "[w%d] listening on %s (cpu %d, %u x %u B recv buffers, ring %u%s%s%s)\n", + p->id, ports, p->cpu, p->bufs.count, p->bufs.size, p->ring.sq_entries, + p->ring.has_sq_array ? "" : ", no sqarray", + p->ring.enter_flags ? ", registered ring" : "", + p->ring.fixed_files ? ", fixed files" : ""); + + struct __kernel_timespec wait_at_most = { .tv_sec = 0, .tv_nsec = 100000000L }; /* 100 ms: so an idle worker notices *stop */ + uint64_t deadline = 0; + for (;;) { + if (*p->stop && !p->draining) { + begin_drain(p); /* from here the stop flag is not read again */ + deadline = now_ms() + DRAIN_GRACE_MS; + } + if (p->draining && (p->live == 0 || now_ms() >= deadline)) + break; + + run_ready(p); + rearm_starved(p); + ioxd__bufring_publish(&p->bufs); + + rc = uring_submit_wait(&p->ring, 1, &wait_at_most); /* one syscall per batch */ + if (rc < 0 && rc != -ETIME && rc != -EINTR && rc != -EAGAIN && rc != -EBUSY) { + fprintf(stderr, "[w%d] io_uring_enter: %s\n", p->id, ioxd__errstr(-rc)); + p->failed = rc; /* ioxd_run returns non-zero for it */ + *p->stop = 1; /* one ring is gone: retire the others too */ + break; + } + + /* The batch, one CQE at a time, copied out before the handler runs: the head is published + * once at the end - or in ioxd__sqe, ahead of an enter in the middle of the batch, so the + * kernel has somewhere to put what that enter completes. */ + unsigned ready = uring_cq_ready(&p->ring); /* read the tail once */ + for (unsigned i = 0; i < ready; i++) { + struct io_uring_cqe cqe = *uring_cqe_at(&p->ring, p->cq_taken); + p->cq_taken++; + dispatch(p, &cqe); /* handlers run in here */ + } + publish_cq(p); + rearm_stalled(p); /* a close may have made room to accept again */ + } + + /* The closes the last handlers staged have to reach the kernel before the ring goes, or their + * sockets stay open until the process exits. */ + ioxd__bufring_publish(&p->bufs); + uring_submit(&p->ring); + + fprintf(stderr, "[w%d] stopping: %llu accepted, %u still open, %llu cq overflows\n", + p->id, (unsigned long long)p->accepted, p->live, + (unsigned long long)p->ring.cq_overflows); + + /* Sockets, then the ring (which cancels every in-flight op and drops its buffer references), + * then the memory the kernel could still have referenced. */ + for (int i = 0; i < p->n_listeners; i++) + close(p->listeners[i].fd); + ioxd__bufring_unregister(&p->bufs, &p->ring); + uring_exit(&p->ring); + if (p->live == 0) { + ioxd__bufring_unmap(&p->bufs); + } else { + /* Connections that outlived the grace period may still have a recv the kernel is holding + * a buffer for. Unmapping the slab under it would be worse than leaking it at exit. */ + fprintf(stderr, "[w%d] %u connections did not close in %d ms: the recv buffers stay mapped\n", + p->id, p->live, DRAIN_GRACE_MS); + } + free(p->starved); + p->starved = nullptr; + p->nstarved = p->cap_starved = 0; + ioxd__conn_pool_drain(p); + coro_pool_drain(); +} diff --git a/lib/io/proactor.h b/lib/io/proactor.h new file mode 100644 index 0000000..0e2dd1a --- /dev/null +++ b/lib/io/proactor.h @@ -0,0 +1,104 @@ +/* + * proactor.h - one worker: one thread, one io_uring, one SO_REUSEPORT socket per listening port, + * one provided buffer ring, and the coroutines that run on it. The connections it serves are + * io/conn.h. Thread-per-core, shared-nothing: nothing in here is touched by any other thread + * except the stop flag. + * + * The loop: run freshly spawned coroutines, re-arm recvs parked on -ENOBUFS, publish and enter + * once (submit everything staged, wait for >= 1 completion), then dispatch the CQ batch, each CQE + * copied out of the ring before its handler runs. The head is published once at the end of the + * batch - and in ioxd__sqe ahead of an enter made in the middle of one, so the kernel has room for + * what that enter completes. Dispatching resumes handler coroutines inline, so the sends they + * stage ride the next enter together with the batch. + * + * On stop the loop does not simply leave: it cancels the accepts and everything in flight, then + * keeps running until the last connection has closed itself or a two-second grace period is up, + * so parked coroutines wake with errors and their handlers unwind before anything is torn down. + */ +#pragma once + +#include +#include +#include + +#include "ioxd/config.h" +#include "io/bufring.h" +#include "io/conn.h" +#include "io/coro.h" +#include "io/uring.h" + +/* defaults (override with -D, or at run time with ioxd_configure) */ +#ifndef RING_ENTRIES +#define RING_ENTRIES 4096 /* SQ depth; the CQ is twice that */ +#endif +#ifndef STACK_SIZE +#define STACK_SIZE (128UL * 1024) /* per coroutine, plus a 64 KB guard; only touched pages cost RSS */ +#endif +#ifndef FIXED_FILES +#define FIXED_FILES 16384 /* registered file slots per worker; 0 disables */ +#endif + +typedef struct proactor proactor_t; +struct ioxd_pipe; +typedef void (*handler_fn)(struct ioxd_pipe *pipe); /* a connection, as a pipe */ + +#ifndef IOXD_MAX_LISTENERS +#define IOXD_MAX_LISTENERS 8 /* ports one server may serve */ +#endif + +/* A listening port. Every worker opens its own socket on it (SO_REUSEPORT), so the kernel spreads + * the port's connections across workers; an accept CQE carries the listener it came from. */ +struct listener { + proactor_t *p; + int fd; /* the socket, or its file slot under fixed files */ + uint16_t port; + void *certs; /* the port's certificate store, or nullptr: plain */ + + /* accept back-pressure: out of descriptors, file slots or memory, re-arming at once would + * spin, so the accept is left unarmed until there is room again (see rearm_stalled). */ + bool stalled; + unsigned stalled_live; /* p->live when it stalled: re-arm once that drops */ + time_t retry_at; /* ... or at this second, whichever comes first */ + time_t err_log_at; /* the next second an accept error may be logged */ + uint64_t err_since_log; /* accept errors swallowed since the last line */ +}; + +struct proactor { + /* set by the creator */ + int id; + int cpu; /* pin the thread here; -1 = don't */ + struct listener listeners[IOXD_MAX_LISTENERS]; /* port and certs set by the creator */ + int n_listeners; + handler_fn handler; + volatile sig_atomic_t *stop; + ioxd_config cfg; /* every field filled in: the run resolved the defaults */ + + /* owned by the worker thread */ + struct uring ring; + unsigned cq_taken; /* CQEs taken from the ring this batch, head not yet published */ + struct bufring bufs; /* the provided buffers recvs deliver into */ + conn_t **starved; /* connections parked on -ENOBUFS */ + unsigned nstarved, cap_starved; + uint64_t starved_total; /* recvs that found the ring empty, ever */ + uint64_t starved_since_log; /* ... since the last log line */ + time_t starved_log_at; /* the next second a log line may go out */ + coro_t *ready_head, *ready_tail; /* spawned, not yet started */ + unsigned live; /* open connections */ + unsigned file_slots; /* registered file table size, 0 when there is none */ + uint64_t accepted; + conn_t *conn_free; /* recycled conn_t objects, reused on accept */ + unsigned conn_free_count; + bool draining; /* stop was seen: take nothing new, let the rest end */ + bool cancel_each; /* ... and CANCEL_ANY was refused: one at a time */ + int failed; /* 0, or the -errno that retired this worker */ +}; + +/* The worker thread's whole life: ring, buffers, listeners, loop until *stop, drain, teardown. */ +void proactor_run(proactor_t *p); + +/* Start a coroutine on this worker. Safe from the loop or from any coroutine on it. */ +void proactor_spawn(proactor_t *p, void (*fn)(void *), void *arg); + +/* Claim an SQE to stage an operation; flushes without waiting when the SQ is full. For the + * plane's own files: every op goes through here so it rides the loop's next enter. */ +struct io_uring_sqe *ioxd__sqe(proactor_t *p); diff --git a/src/io/switch_x86_64.S b/lib/io/switch_x86_64.S similarity index 51% rename from src/io/switch_x86_64.S rename to lib/io/switch_x86_64.S index 72b6171..c9f0f4f 100644 --- a/src/io/switch_x86_64.S +++ b/lib/io/switch_x86_64.S @@ -7,28 +7,61 @@ * with them at the call site. (MXCSR and the x87 control word are technically callee-saved too; * nothing here changes them, so they are not preserved.) * + * Hidden: the scheduler's switch is not a symbol anything outside libioxd may interpose. + * + * The CFI describes one frame of six pushes. It is still right after the stack swap, because the + * stack being loaded was left in exactly that shape by its own trip through here (or forged that + * way by coro_create), so gdb and perf unwind out of either coroutine. + * * No .note.gnu.property on purpose: an unmarked object leaves the linked binary without the * IBT/SHSTK flags, so a shadow stack is never enabled around this hand-built frame. */ .text .globl swap_ctx + .hidden swap_ctx .type swap_ctx, @function swap_ctx: + .cfi_startproc pushq %rbp + .cfi_adjust_cfa_offset 8 + .cfi_offset %rbp, -16 pushq %rbx + .cfi_adjust_cfa_offset 8 + .cfi_offset %rbx, -24 pushq %r12 + .cfi_adjust_cfa_offset 8 + .cfi_offset %r12, -32 pushq %r13 + .cfi_adjust_cfa_offset 8 + .cfi_offset %r13, -40 pushq %r14 + .cfi_adjust_cfa_offset 8 + .cfi_offset %r14, -48 pushq %r15 + .cfi_adjust_cfa_offset 8 + .cfi_offset %r15, -56 movq %rsp, (%rdi) movq %rsi, %rsp popq %r15 + .cfi_restore %r15 + .cfi_adjust_cfa_offset -8 popq %r14 + .cfi_restore %r14 + .cfi_adjust_cfa_offset -8 popq %r13 + .cfi_restore %r13 + .cfi_adjust_cfa_offset -8 popq %r12 + .cfi_restore %r12 + .cfi_adjust_cfa_offset -8 popq %rbx + .cfi_restore %rbx + .cfi_adjust_cfa_offset -8 popq %rbp + .cfi_restore %rbp + .cfi_adjust_cfa_offset -8 ret + .cfi_endproc .size swap_ctx, .-swap_ctx .section .note.GNU-stack,"",@progbits diff --git a/src/io/uring.c b/lib/io/uring.c similarity index 67% rename from src/io/uring.c rename to lib/io/uring.c index 44bd76b..8257723 100644 --- a/src/io/uring.c +++ b/lib/io/uring.c @@ -31,10 +31,26 @@ int uring_register(struct uring *ring, unsigned opcode, void *arg, unsigned nr_a return rc < 0 ? -errno : (int)rc; } -/* Create the ring and mmap both rings plus the SQE array. 0, or -errno. */ -int uring_init(struct uring *ring, unsigned entries) +/* The struct owning nothing: no descriptor, no mapping. uring_exit over this unmaps nothing and + * closes nothing, which is what a failed init has to leave behind. */ +static void ring_clear(struct uring *ring) { memset(ring, 0, sizeof *ring); + ring->fd = ring->enter_fd = -1; +} + +/* Give up on a half-built ring: empty the struct and hand back the error. */ +static int ring_failed(struct uring *ring, int err) +{ + ring_clear(ring); + return err; +} + +/* Create the ring and mmap both rings plus the SQE array. 0, or -errno. On any failure the struct + * is left empty, so a caller that calls uring_exit anyway unmaps nothing and closes nothing. */ +int uring_init(struct uring *ring, unsigned entries) +{ + ring_clear(ring); /* SINGLE_ISSUER: only this thread submits, the kernel skips SQ locking. * DEFER_TASKRUN: completion work runs batched inside enter(GETEVENTS), never as an interrupt. @@ -42,51 +58,64 @@ int uring_init(struct uring *ring, unsigned entries) struct io_uring_params params; memset(¶ms, 0, sizeof params); params.flags = IORING_SETUP_SINGLE_ISSUER | IORING_SETUP_DEFER_TASKRUN | IORING_SETUP_NO_SQARRAY; - int fd = sys_setup(entries, ¶ms); - if (fd == -EINVAL) { + int fd = sys_setup(entries, ¶ms); + bool sq_array = false; + if (fd == -EINVAL) { /* maybe NO_SQARRAY, maybe not: try without it */ memset(¶ms, 0, sizeof params); params.flags = IORING_SETUP_SINGLE_ISSUER | IORING_SETUP_DEFER_TASKRUN; fd = sys_setup(entries, ¶ms); - ring->has_sq_array = true; + if (fd < 0) + return ring_failed(ring, -EINVAL); /* the first error was the real one */ + sq_array = true; } if (fd < 0) - return fd; + return ring_failed(ring, fd); if (!(params.features & IORING_FEAT_SINGLE_MMAP)) { /* every kernel since 5.4 */ close(fd); - return -ENOSYS; + return ring_failed(ring, -ENOSYS); } - ring->fd = fd; - ring->enter_fd = fd; - ring->sq_entries = params.sq_entries; - /* one mapping holds both rings; size it for whichever ends later */ - size_t sq_bytes = params.sq_off.array + (size_t)params.sq_entries * sizeof(unsigned); + /* One mapping holds both rings; size it for whichever ends later. Without the SQ index array + * sq_off.array is 0, so the SQ side ends after the last of its header words. */ + size_t sq_bytes = sq_array ? params.sq_off.array + (size_t)params.sq_entries * sizeof(unsigned) + : params.sq_off.dropped + sizeof(unsigned); size_t cq_bytes = params.cq_off.cqes + (size_t)params.cq_entries * sizeof(struct io_uring_cqe); - ring->ring_bytes = sq_bytes > cq_bytes ? sq_bytes : cq_bytes; - ring->ring_mem = mmap(nullptr, ring->ring_bytes, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE, - fd, IORING_OFF_SQ_RING); - if (ring->ring_mem == MAP_FAILED) { + size_t ring_bytes = sq_bytes > cq_bytes ? sq_bytes : cq_bytes; + void *ring_mem = mmap(nullptr, ring_bytes, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE, + fd, IORING_OFF_SQ_RING); + if (ring_mem == MAP_FAILED) { int e = -errno; close(fd); - return e; + return ring_failed(ring, e); } - ring->sqe_bytes = (size_t)params.sq_entries * sizeof(struct io_uring_sqe); - ring->sqe_mem = mmap(nullptr, ring->sqe_bytes, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE, - fd, IORING_OFF_SQES); - if (ring->sqe_mem == MAP_FAILED) { + size_t sqe_bytes = (size_t)params.sq_entries * sizeof(struct io_uring_sqe); + void *sqe_mem = mmap(nullptr, sqe_bytes, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE, + fd, IORING_OFF_SQES); + if (sqe_mem == MAP_FAILED) { int e = -errno; - munmap(ring->ring_mem, ring->ring_bytes); + munmap(ring_mem, ring_bytes); close(fd); - return e; + return ring_failed(ring, e); } - char *base = ring->ring_mem; + /* Everything is up: only now does the struct own an fd and two mappings. */ + ring->fd = fd; + ring->enter_fd = fd; + ring->sq_entries = params.sq_entries; + ring->has_sq_array = sq_array; + ring->ring_mem = ring_mem; + ring->ring_bytes = ring_bytes; + ring->sqe_mem = sqe_mem; + ring->sqe_bytes = sqe_bytes; + + char *base = ring_mem; ring->sq_head = (unsigned *)(base + params.sq_off.head); ring->sq_tail = (unsigned *)(base + params.sq_off.tail); - ring->sq_array = (unsigned *)(base + params.sq_off.array); + ring->sq_array = sq_array ? (unsigned *)(base + params.sq_off.array) : nullptr; + ring->sq_flags = (unsigned *)(base + params.sq_off.flags); ring->sq_mask = *(unsigned *)(base + params.sq_off.ring_mask); - ring->sqes = ring->sqe_mem; + ring->sqes = sqe_mem; ring->cq_head = (unsigned *)(base + params.cq_off.head); ring->cq_tail = (unsigned *)(base + params.cq_off.tail); @@ -105,6 +134,8 @@ int uring_register_ring_fd(struct uring *ring) int rc = uring_register(ring, IORING_REGISTER_RING_FDS, &up, 1); if (rc < 0) return rc; + if (rc != 1) /* the count registered: up.offset is only ours then */ + return -ENOSPC; ring->enter_fd = (int)up.offset; ring->enter_flags = IORING_ENTER_REGISTERED_RING; return 0; @@ -135,8 +166,8 @@ void uring_exit(struct uring *ring) } if (ring->sqe_mem) munmap(ring->sqe_mem, ring->sqe_bytes); if (ring->ring_mem) munmap(ring->ring_mem, ring->ring_bytes); - if (ring->fd > 0) close(ring->fd); - memset(ring, 0, sizeof *ring); + if (ring->fd >= 0) close(ring->fd); /* fd 0 is a legal descriptor */ + ring_clear(ring); } /* Claim the next SQE against the local tail, zeroed. nullptr when the SQ is full. */ @@ -168,6 +199,14 @@ static int flush_and_enter(struct uring *ring, unsigned wait_nr, unsigned flags, if (*ring->sq_tail != ring->sqe_tail) store_release(ring->sq_tail, ring->sqe_tail); + /* The CQ filled and the kernel is holding the rest in its overflow list, where nothing we do + * to the ring will find them. Only an enter flushes that backlog, so make one even when there + * is nothing to submit and nothing to wait for. */ + if (load_acquire(ring->sq_flags) & IORING_SQ_CQ_OVERFLOW) { + ring->cq_overflows++; + flags |= IORING_ENTER_GETEVENTS; + } + if (to_submit == 0 && wait_nr == 0 && !(flags & IORING_ENTER_GETEVENTS)) return 0; return sys_enter(ring->enter_fd, to_submit, wait_nr, flags | ring->enter_flags, arg, argsz); @@ -201,5 +240,7 @@ unsigned uring_cq_ready(struct uring *ring) /* Release n consumed CQEs; publishes the head once. */ void uring_cq_advance(struct uring *ring, unsigned n) { + if (n == 0) /* nothing consumed: no store, no barrier */ + return; store_release(ring->cq_head, *ring->cq_head + n); } diff --git a/src/io/uring.h b/lib/io/uring.h similarity index 70% rename from src/io/uring.h rename to lib/io/uring.h index e38a315..dcd5bdf 100644 --- a/src/io/uring.h +++ b/lib/io/uring.h @@ -13,6 +13,31 @@ /* Names for the newer bits, in case the build box's headers predate them (the kernel decides at * runtime; unsupported features fall back). */ +#ifndef IORING_SETUP_SINGLE_ISSUER +#define IORING_SETUP_SINGLE_ISSUER (1U << 12) +#endif +#ifndef IORING_SETUP_DEFER_TASKRUN +#define IORING_SETUP_DEFER_TASKRUN (1U << 13) +#endif +#ifndef IORING_SETUP_NO_SQARRAY +#define IORING_SETUP_NO_SQARRAY (1U << 16) +#endif +#ifndef IORING_SQ_CQ_OVERFLOW +#define IORING_SQ_CQ_OVERFLOW (1U << 1) +#endif +#ifndef IORING_ASYNC_CANCEL_ANY +#define IORING_ASYNC_CANCEL_ANY (1U << 2) +#endif +/* EXT_ARG and the struct it points at arrived together, so one guard covers both. */ +#ifndef IORING_ENTER_EXT_ARG +#define IORING_ENTER_EXT_ARG (1U << 3) +struct io_uring_getevents_arg { + uint64_t sigmask; + uint32_t sigmask_sz; + uint32_t pad; + uint64_t ts; +}; +#endif #ifndef IORING_REGISTER_RING_FDS #define IORING_REGISTER_RING_FDS 20 #define IORING_UNREGISTER_RING_FDS 21 @@ -28,7 +53,7 @@ #endif struct uring { - int fd; + int fd; /* -1 until both mappings are up (see uring_init) */ int enter_fd; /* fd, or the registered-ring index (see enter_flags) */ unsigned enter_flags; /* 0, or IORING_ENTER_REGISTERED_RING */ bool fixed_files; /* a sparse registered file table exists */ @@ -36,7 +61,8 @@ struct uring { /* submission side */ unsigned *sq_head; /* kernel-written consumer index */ unsigned *sq_tail; /* our published producer index */ - unsigned *sq_array; /* SQ index array; unused under NO_SQARRAY */ + unsigned *sq_array; /* SQ index array; nullptr under NO_SQARRAY */ + unsigned *sq_flags; /* kernel-written: IORING_SQ_CQ_OVERFLOW */ unsigned sq_mask, sq_entries; unsigned sqe_tail; /* local tail: claimed, not yet published */ struct io_uring_sqe *sqes; @@ -47,6 +73,7 @@ struct uring { unsigned *cq_tail; /* kernel-written producer index */ unsigned cq_mask; struct io_uring_cqe *cqes; + uint64_t cq_overflows; /* enters that found the kernel holding overflowed CQEs */ /* mappings */ void *ring_mem; size_t ring_bytes; @@ -61,8 +88,11 @@ void uring_exit(struct uring *ring); struct io_uring_sqe *uring_get_sqe(struct uring *ring); /* Publish claimed SQEs and enter. uring_submit never waits; uring_submit_wait blocks until - * wait_nr completions are available or ts (may be nullptr) expires. Return: submitted count or - * -errno (-ETIME on timeout). Under DEFER_TASKRUN only the waiting form reaps completions. */ + * wait_nr completions are available or ts (may be nullptr) expires. Return: the number of SQEs the + * kernel consumed, or -errno. A timeout reads as -ETIME only when there was nothing to submit; + * with SQEs in hand the kernel returns the count it took and says nothing about the wait, so a + * non-negative return is not "completions are ready" - always drain the CQ. + * Under DEFER_TASKRUN only the waiting form reaps completions. */ int uring_submit(struct uring *ring); int uring_submit_wait(struct uring *ring, unsigned wait_nr, struct __kernel_timespec *ts); diff --git a/lib/json/json.c b/lib/json/json.c new file mode 100644 index 0000000..11e612e --- /dev/null +++ b/lib/json/json.c @@ -0,0 +1,372 @@ +/* + * json/json.c - the forward-only JSON writer of ioxd.h. Every value goes straight to the sink: + * a run of safe bytes at a time, an escape at a time, a number formatted into a small stack + * buffer. The sink is the reply, a raw pipe, or a buffer; the writer never allocates. + */ +#include "ioxd/json.h" +#include "ioxd/pipe.h" + +#include +#include +#include +#include +#include +#include + +#define RUN_MAX 1024 /* bytes asked of the sink at a time */ +#define RUN_MIN 64 /* and the least worth asking for */ + +/* One bit per level in has_value and is_object, the root's included: the deepest must still fit. */ +static_assert(IOXD_JSON_DEPTH < 64, "a level per bit of has_value and is_object, plus the root"); + +/* n bytes of the sink to write into, or nullptr; the caller then advances by what it wrote. */ +static char *reserve(ioxd_json *j, size_t n) +{ + switch (j->kind) { + case IOXD_JSON_TO_REPLY: return ioxd_reserve(j->to.ctx, n); + case IOXD_JSON_TO_PIPE: return ioxd_pipe_reserve(j->to.pipe, n); + case IOXD_JSON_TO_MEM: return j->to.mem.p && *j->to.mem.len + n <= j->to.mem.cap + ? j->to.mem.p + *j->to.mem.len : nullptr; + } + return nullptr; +} + +static void advance(ioxd_json *j, size_t n) +{ + switch (j->kind) { + case IOXD_JSON_TO_REPLY: ioxd_advance(j->to.ctx, n); break; + case IOXD_JSON_TO_PIPE: ioxd_pipe_advance(j->to.pipe, n); break; + case IOXD_JSON_TO_MEM: *j->to.mem.len += n; break; + } +} + +/* Raw bytes to the sink, in runs the slab can take; false marks the writer failed. A sink refuses + * a reserve larger than its slab outright, so a refused run is halved and asked for again, down + * to RUN_MIN: a slab smaller than RUN_MAX still takes the document. */ +static bool put(ioxd_json *j, const char *p, size_t n) +{ + while (n) { + size_t run = n < RUN_MAX ? n : RUN_MAX; + char *at = reserve(j, run); + while (!at && run > RUN_MIN) { + run = run / 2 > RUN_MIN ? run / 2 : RUN_MIN; + at = reserve(j, run); + } + if (!at) { + j->failed = true; + return false; + } + memcpy(at, p, run); + advance(j, run); + p += run; + n -= run; + } + return true; +} + +static bool put_cstr(ioxd_json *j, const char *s) +{ + return put(j, s, strlen(s)); +} + +/* The bytes that must be escaped: the quote, the backslash, and control characters. */ +static bool needs_escape(unsigned char c) +{ + return c < 0x20 || c == '"' || c == '\\'; +} + +/* A string, quoted and escaped: safe runs are copied whole, escapes one at a time. */ +static bool put_string(ioxd_json *j, const char *p, size_t n) +{ + static const char hex[] = "0123456789abcdef"; + if (!put(j, "\"", 1)) + return false; + while (n) { + size_t run = 0; + while (run < n && !needs_escape((unsigned char)p[run])) + run++; + if (run && !put(j, p, run)) + return false; + p += run; + n -= run; + if (n == 0) + break; + unsigned char c = (unsigned char)*p++; + n--; + char esc[6] = { '\\', 0, 0, 0, 0, 0 }; + size_t len = 2; + switch (c) { + case '"': esc[1] = '"'; break; + case '\\': esc[1] = '\\'; break; + case '\n': esc[1] = 'n'; break; + case '\r': esc[1] = 'r'; break; + case '\t': esc[1] = 't'; break; + case '\b': esc[1] = 'b'; break; + case '\f': esc[1] = 'f'; break; + default: /* \u00XX */ + esc[1] = 'u'; + esc[2] = '0'; + esc[3] = '0'; + esc[4] = hex[c >> 4]; + esc[5] = hex[c & 15]; + len = 6; + break; + } + if (!put(j, esc, len)) + return false; + } + return put(j, "\"", 1); +} + +/* Before a value or a key: the comma its level owes, unless it follows a key. */ +static bool separator(ioxd_json *j) +{ + if (j->failed) + return false; + if (j->after_key) { + j->after_key = false; + return true; + } + uint64_t bit = (uint64_t)1 << j->depth; + if (j->has_value & bit) + return put(j, ",", 1); + j->has_value |= bit; + return true; +} + +/* A value may start here: inside an object it has to follow a key, or the document is broken and + * the writer fails. Outside one - in an array, or at the top level - anything goes. */ +static bool value_ok(ioxd_json *j) +{ + if (j->failed) + return false; + if ((j->is_object & ((uint64_t)1 << j->depth)) && !j->after_key) { + j->failed = true; + return false; + } + return separator(j); +} + +/* ── the sinks ─────────────────────────────────────────────────────────────────────────── */ + +ioxd_json ioxd_json_reply(ioxd_ctx *ctx) +{ + ioxd_content_type(ctx, "application/json"); + ioxd_json j = { .kind = IOXD_JSON_TO_REPLY }; + j.to.ctx = ctx; + return j; +} + +ioxd_json ioxd_json_pipe(struct ioxd_pipe *pipe) +{ + ioxd_json j = { .kind = IOXD_JSON_TO_PIPE }; + j.to.pipe = pipe; + return j; +} + +ioxd_json ioxd_json_mem(char *buf, size_t cap, size_t *len) +{ + ioxd_json j = { .kind = IOXD_JSON_TO_MEM }; + j.to.mem.p = buf; + j.to.mem.cap = cap; + j.to.mem.len = len; + *len = 0; + return j; +} + +/* ── the values ────────────────────────────────────────────────────────────────────────── */ + +/* Open a container: its own level starts empty. The depth is checked before anything is written, + * so a document that goes one level too deep leaves no comma behind. */ +static bool open_level(ioxd_json *j, char bracket) +{ + if (j->failed) + return false; + if (j->depth == IOXD_JSON_DEPTH) { + j->failed = true; + return false; + } + if (!value_ok(j)) + return false; + j->depth++; + uint64_t bit = (uint64_t)1 << j->depth; + j->has_value &= ~bit; + if (bracket == '{') + j->is_object |= bit; + else + j->is_object &= ~bit; + return put(j, &bracket, 1); +} + +bool ioxd_json_object(ioxd_json *j) +{ + return open_level(j, '{'); +} + +bool ioxd_json_array(ioxd_json *j) +{ + return open_level(j, '['); +} + +/* Close the innermost container. Which bracket is remembered by what was written: an object's + * level is one that took keys - tracked as a bit too. Nothing open, or a key still waiting for + * its value, is a document that cannot be finished: the writer fails, and says so. */ +bool ioxd_json_end(ioxd_json *j) +{ + if (j->failed) + return false; + if (j->depth == 0 || j->after_key) { + j->failed = true; + return false; + } + bool object = j->is_object & ((uint64_t)1 << j->depth); + j->depth--; + return put(j, object ? "}" : "]", 1); +} + +/* Nothing failed, and nothing is left open: the document is whole. */ +bool ioxd_json_done(ioxd_json *j) +{ + return !j->failed && j->depth == 0; +} + +/* A key belongs in an object, and one key per value: anything else is a broken document. */ +bool ioxd_json_key(ioxd_json *j, const char *name) +{ + if (j->failed) + return false; + if (!(j->is_object & ((uint64_t)1 << j->depth)) || j->after_key) { + j->failed = true; + return false; + } + if (!separator(j)) + return false; + if (!put_string(j, name, strlen(name)) || !put(j, ":", 1)) + return false; + j->after_key = true; + return true; +} + +bool ioxd_json_string(ioxd_json *j, ioxd_slice s) +{ + return value_ok(j) && put_string(j, s.p, s.len); +} + +bool ioxd_json_cstr(ioxd_json *j, const char *s) +{ + if (!s) + return ioxd_json_null(j); /* a null pointer is JSON null */ + return value_ok(j) && put_string(j, s, strlen(s)); +} + +/* Decimal digits of a magnitude, right-aligned in tmp; the start of them. */ +static char *digits(char *tmp_end, uint64_t v) +{ + char *p = tmp_end; + do { + *--p = (char)('0' + (v % 10)); + v /= 10; + } while (v); + return p; +} + +bool ioxd_json_uint(ioxd_json *j, uint64_t v) +{ + char tmp[24]; + char *p = digits(tmp + sizeof tmp, v); + return value_ok(j) && put(j, p, (size_t)(tmp + sizeof tmp - p)); +} + +bool ioxd_json_int(ioxd_json *j, int64_t v) +{ + char tmp[24]; + uint64_t magnitude = v < 0 ? (uint64_t)(-(v + 1)) + 1 : (uint64_t)v; /* INT64_MIN has no positive twin */ + char *p = digits(tmp + sizeof tmp, magnitude); + if (v < 0) + *--p = '-'; + return value_ok(j) && put(j, p, (size_t)(tmp + sizeof tmp - p)); +} + +/* snprintf and strtod spell the decimal point the way LC_NUMERIC says, and JSON knows only '.'; + * a locale like fa_IR spells it with several bytes, so mending one byte afterwards is not enough. + * The numbers are formatted in a private "C" locale instead - made once for the process, worn by + * the thread for the two calls and handed straight back, so no worker disturbs another and + * nothing reads the shared static localeconv returns. */ +static pthread_once_t c_locale_once = PTHREAD_ONCE_INIT; +static locale_t c_locale; + +static void make_c_locale(void) +{ + c_locale = newlocale(LC_NUMERIC_MASK, "C", (locale_t)0); +} + +/* The shortest decimal that reads back as the same value: the precisions from first to last, the + * first that round-trips. The length written, or -1 if it did not fit or there is no "C" locale. */ +static int shortest(char *tmp, size_t cap, double v, int first, int last, bool as_float) +{ + pthread_once(&c_locale_once, make_c_locale); + if (!c_locale) + return -1; + locale_t previous = uselocale(c_locale); + if (!previous) + return -1; + int n = -1; + for (int precision = first; precision <= last; precision++) { + n = snprintf(tmp, cap, "%.*g", precision, v); + if (n < 0 || (size_t)n >= cap) + break; + if (as_float ? strtof(tmp, nullptr) == (float)v : strtod(tmp, nullptr) == v) + break; + } + uselocale(previous); + return n < 0 || (size_t)n >= cap ? -1 : n; +} + +/* A double: 15, 16 or 17 significant digits. */ +bool ioxd_json_double(ioxd_json *j, double v) +{ + if (!isfinite(v)) + return ioxd_json_null(j); + char tmp[32]; + int n = shortest(tmp, sizeof tmp, v, 15, 17, false); + if (n < 0) { + j->failed = true; + return false; + } + return value_ok(j) && put(j, tmp, (size_t)n); +} + +/* A float: 6 to 9, round-tripped against the float, so 0.1f is 0.1 and not the wider double it + * would otherwise be promoted to. */ +bool ioxd_json_float(ioxd_json *j, float v) +{ + if (!isfinite(v)) + return ioxd_json_null(j); + char tmp[32]; + int n = shortest(tmp, sizeof tmp, (double)v, 6, 9, true); + if (n < 0) { + j->failed = true; + return false; + } + return value_ok(j) && put(j, tmp, (size_t)n); +} + +bool ioxd_json_bool(ioxd_json *j, bool v) +{ + return value_ok(j) && put_cstr(j, v ? "true" : "false"); +} + +bool ioxd_json_null(ioxd_json *j) +{ + return value_ok(j) && put_cstr(j, "null"); +} + +/* Already JSON, copied as is. Nothing is not a value: an empty slice would leave "[,]" behind. */ +bool ioxd_json_raw(ioxd_json *j, ioxd_slice json) +{ + if (json.len == 0) { + j->failed = true; + return false; + } + return value_ok(j) && put(j, json.p, json.len); +} diff --git a/lib/tls/handshake.c b/lib/tls/handshake.c new file mode 100644 index 0000000..b480b85 --- /dev/null +++ b/lib/tls/handshake.c @@ -0,0 +1,377 @@ +/* + * tls/handshake.c - the prologue of a TLS connection: an OpenSSL handshake over the pipe, the + * traffic secrets caught by the keylog callback, HKDF-Expand-Label into key and IV, and the keys + * into the socket, so the kernel does every record from then on. See TLS.md. + */ +#include "tls/handshake.h" +#include "tls/store.h" +#include "io/internal.h" + +#include +#include +#include +#include + +#if IOXD_TLS + +#include +#include +#include +#include +#include +#include +#include +#include + +#define SECRET_LEN 32U /* SHA-256 suite: the traffic secrets */ +#define RECORD_MAX (5 + 16384 + 256) /* header, plaintext, tag and padding */ +#define PLAIN_MAX IOXD_PIPE_GATHER /* early plaintext: what the reader can take */ +#define KDF_FAILED (-4096) /* install's own failure, apart from errnos */ + +/* The secrets of one handshake, found through the SSL's ex_data. */ +struct secrets { + unsigned char tx[SECRET_LEN], rx[SECRET_LEN]; /* server and client application traffic secrets */ + bool have_tx, have_rx; +}; + +static int ex_index; +static pthread_once_t ex_once = PTHREAD_ONCE_INIT; +static void make_ex_index(void) +{ + ex_index = SSL_get_ex_new_index(0, nullptr, nullptr, nullptr, nullptr); +} + +/* The outermost OpenSSL error, as text; the queue is cleared, so nothing stale is read later. */ +static const char *ssl_error_text(void) +{ + static thread_local char text[256]; + ERR_error_string_n(ERR_peek_last_error(), text, sizeof text); + ERR_clear_error(); + return text; +} + +/* The value of a hex digit, or -1. */ +static int hexdigit(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +/* 64 hex characters into 32 bytes. */ +static bool unhex(const char *hex, unsigned char *out) +{ + for (size_t i = 0; i < SECRET_LEN; i++) { + int hi = hexdigit(hex[2 * i]), lo = hexdigit(hex[2 * i + 1]); + if (hi < 0 || lo < 0) + return false; + out[i] = (unsigned char)((unsigned)hi << 4 | (unsigned)lo); + } + return true; +} + +/* "SERVER_TRAFFIC_SECRET_0 " and its CLIENT twin: the two we need. */ +void ioxd__tls_keylog(const SSL *ssl, const char *line) +{ + struct secrets *s = SSL_get_ex_data(ssl, ex_index); + if (!s) + return; + const char *tag = nullptr; + unsigned char *into = nullptr; + bool *have = nullptr; + if (strncmp(line, "SERVER_TRAFFIC_SECRET_0 ", 24) == 0) { tag = line + 24; into = s->tx; have = &s->have_tx; } + if (strncmp(line, "CLIENT_TRAFFIC_SECRET_0 ", 24) == 0) { tag = line + 24; into = s->rx; have = &s->have_rx; } + if (!tag) + return; + const char *secret = strchr(tag, ' '); /* past the client random */ + if (secret && strlen(secret + 1) >= (size_t)2 * SECRET_LEN && unhex(secret + 1, into)) + *have = true; +} + +/* RFC 8446 HKDF-Expand-Label(secret, label, "", n). */ +static bool expand_label(const unsigned char *secret, const char *label, unsigned char *out, size_t n) +{ + unsigned char info[64]; + size_t label_len = 6 + strlen(label), at = 0; + info[at++] = (unsigned char)(n >> 8); + info[at++] = (unsigned char)n; + info[at++] = (unsigned char)label_len; + memcpy(info + at, "tls13 ", 6); + at += 6; + memcpy(info + at, label, strlen(label)); + at += strlen(label); + info[at++] = 0; /* empty context */ + EVP_PKEY_CTX *k = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, nullptr); + if (!k) + return false; + size_t len = n; + bool ok = EVP_PKEY_derive_init(k) == 1 + && EVP_PKEY_CTX_hkdf_mode(k, EVP_PKEY_HKDEF_MODE_EXPAND_ONLY) == 1 + && EVP_PKEY_CTX_set_hkdf_md(k, EVP_sha256()) == 1 + && EVP_PKEY_CTX_set1_hkdf_key(k, secret, SECRET_LEN) == 1 + && EVP_PKEY_CTX_add1_hkdf_info(k, info, (int)at) == 1 + && EVP_PKEY_derive(k, out, &len) == 1 && len == n; + EVP_PKEY_CTX_free(k); + return ok; +} + +/* One direction's keys into the socket, from its traffic secret and the record sequence. */ +static int install(conn_t *c, int direction, const unsigned char *secret, uint64_t seq) +{ + unsigned char key[16], iv[12]; + if (!expand_label(secret, "key", key, sizeof key) || !expand_label(secret, "iv", iv, sizeof iv)) + return KDF_FAILED; + struct tls12_crypto_info_aes_gcm_128 ci = {}; + ci.info.version = TLS_1_3_VERSION; + ci.info.cipher_type = TLS_CIPHER_AES_GCM_128; + memcpy(ci.key, key, sizeof key); + memcpy(ci.salt, iv, 4); /* the 12-byte nonce, as the kernel splits it */ + memcpy(ci.iv, iv + 4, 8); + for (unsigned i = 0; i < 8; i++) + ci.rec_seq[i] = (unsigned char)(seq >> (56U - 8U * i)); + int rc = ioxd__setsockopt(c, SOL_TLS, direction, &ci, sizeof ci); + explicit_bzero(&ci, sizeof ci); + explicit_bzero(key, sizeof key); + explicit_bzero(iv, sizeof iv); + return rc; +} + +/* Everything the write BIO holds, out through the pipe. */ +static int flush_outbound(struct ioxd_pipe *pipe, BIO *wbio) +{ + char buf[4096]; + int n; + while ((n = BIO_read(wbio, buf, sizeof buf)) > 0) + if (ioxd_pipewriter_write(&pipe->out, buf, (size_t)n) < 0) + return -1; + return ioxd_pipewriter_flush(&pipe->out); +} + +/* The length of the TLS record whose 5-byte header is at rec, header included. */ +static size_t record_len(const unsigned char *rec) +{ + return 5 + ((size_t)rec[3] << 8 | rec[4]); +} + +/* One whole TLS record out of the reader into rec: 1 when taken, -1 on error. During the + * handshake it waits for delivery. While draining it never waits: 0 when nothing delivered is + * left, which means the socket is at a record boundary; the tail of a record cut by the pause is + * fetched straight from the socket instead. OpenSSL gets exactly one record per feed, so what a + * client sent after its Finished stays in the reader for the drain rather than vanishing into + * the read BIO. */ +static int take_record(struct ioxd_pipe *pipe, bool draining, unsigned char *rec, size_t *len) +{ + ioxd_pipereader *pr = &pipe->in; + size_t have = 0, need = 5; + while (have < need) { + ioxd_slice live = { nullptr, 0 }; + int rc = draining ? ioxd_pipereader_avail(pr, &live) : ioxd_pipereader_read(pr, &live); + if (rc < 0 || (rc == 0 && !draining)) + return -1; + if (rc == 0) { /* nothing more was delivered */ + if (have == 0) + return 0; + if (ioxd__recv_exact(pr->conn, rec + have, need - have) < 0) + return -1; /* the rest of a split record, from the socket */ + have = need; + } else { + size_t k = live.len < need - have ? live.len : need - have; + memcpy(rec + have, live.p, k); + ioxd_pipereader_drop(pr, k); + have += k; + } + if (have == 5 && need == 5) { + need = record_len(rec); + if (need > RECORD_MAX) + return -1; + } + } + *len = have; + return 1; +} + +/* The next record the client sent, into the read BIO. */ +static int feed_inbound(struct ioxd_pipe *pipe, BIO *rbio, unsigned char *rec) +{ + size_t len; + if (take_record(pipe, false, rec, &len) <= 0) + return -1; + return BIO_write(rbio, rec, (int)len) == (int)len ? 0 : -1; +} + +/* After the handshake, before the kernel takes over receiving: the client may already have sent + * application data, part of it delivered to us, part still in the socket. Kernel RX starts at a + * record boundary in the socket, so with the multishot recv stopped this takes whole records + * from what was delivered - fetching the tail of a split one straight from the socket - through + * OpenSSL, keeping the plaintext aside. The count consumed is the RX record sequence. */ +static long drain_records(struct ioxd_pipe *pipe, SSL *ssl, BIO *rbio, BIO *wbio, unsigned char *plain, + size_t *plain_len, const char **why) +{ + unsigned char *rec = plain + PLAIN_MAX; /* scratch for one record, past the plaintext */ + long records = 0; + for (;;) { + size_t len; + int rc = take_record(pipe, true, rec, &len); + if (rc < 0) { + *why = "early application data could not be taken"; + return -1; + } + if (rc == 0) + return records; /* the socket is at a boundary */ + if (rec[0] != 23) { /* an alert, or a handshake message: nothing we can hand over */ + *why = "a record other than application data after the handshake"; + return -1; + } + if (*plain_len == PLAIN_MAX) { + *why = "more early application data than the reader can hold"; + return -1; + } + if (BIO_write(rbio, rec, (int)len) != (int)len) { + *why = "out of memory"; + return -1; + } + records++; + int n = -1; + while (*plain_len < PLAIN_MAX && (n = SSL_read(ssl, plain + *plain_len, (int)(PLAIN_MAX - *plain_len))) > 0) + *plain_len += (size_t)n; + if (*plain_len == PLAIN_MAX && SSL_pending(ssl) > 0) { + *why = "more early application data than the reader can hold"; + return -1; + } + if (*plain_len == PLAIN_MAX) + continue; + int err = SSL_get_error(ssl, n); + if (err == SSL_ERROR_ZERO_RETURN) /* close_notify: what came before it is still served */ + return records; + if (err != SSL_ERROR_WANT_READ) { + *why = "a record OpenSSL could not take after the handshake"; + return -1; + } + if (BIO_pending(wbio) > 0) { /* OpenSSL answered something: a KeyUpdate we cannot follow */ + *why = "a post-handshake message from the client (key update?)"; + return -1; + } + } +} + +int ioxd__tls_prologue(struct ioxd_pipe *pipe, ioxd_certs *certs) +{ + pthread_once(&ex_once, make_ex_index); + conn_t *c = pipe->in.conn; + struct table *t = ioxd__tls_acquire(certs); + struct secrets s = {}; + unsigned char *plain = nullptr; + const char *why = nullptr; /* set on every failure: logged once */ + int r; + + SSL *ssl = SSL_new(ioxd__tls_fallback(t)); + BIO *rbio = BIO_new(BIO_s_mem()); + BIO *wbio = BIO_new(BIO_s_mem()); + plain = malloc(PLAIN_MAX + RECORD_MAX); /* early plaintext, then one record: off the coroutine's stack */ + if (!ssl || !rbio || !wbio || !plain) { + BIO_free(rbio); /* not the SSL's yet */ + BIO_free(wbio); + why = "out of memory"; + goto out; + } + SSL_set_bio(ssl, rbio, wbio); /* the SSL owns both from here */ + SSL_set_accept_state(ssl); + SSL_set_ex_data(ssl, ex_index, &s); + ioxd__tls_bind(ssl, t); /* the table its ClientHello picks a host from */ + + for (;;) { /* the handshake, as an ordinary await loop */ + int ret = SSL_do_handshake(ssl); + int err = ret == 1 ? SSL_ERROR_NONE : SSL_get_error(ssl, ret); + if (flush_outbound(pipe, wbio) < 0) { + why = "peer gone while sending the handshake"; + goto out; + } + if (ret == 1) + break; + if (err != SSL_ERROR_WANT_READ) { + why = ssl_error_text(); + goto out; + } + if (feed_inbound(pipe, rbio, plain + PLAIN_MAX) < 0) { + why = "peer gone during the handshake"; + goto out; + } + } + if (!s.have_tx || !s.have_rx) { + why = "no traffic secrets from the keylog callback"; + goto out; + } + + if (ioxd__recv_pause(c) < 0) { /* nothing more leaves the socket meanwhile */ + why = "input ended after the handshake"; + goto out; + } + size_t plain_len = 0; + long records = drain_records(pipe, ssl, rbio, wbio, plain, &plain_len, &why); + if (records < 0) + goto out; + + r = ioxd__setsockopt(c, SOL_TCP, TCP_ULP, "tls", sizeof "tls"); + if (r < 0) { + why = r == -ENOENT ? "kernel TLS unavailable: is the tls module loaded?" : ioxd__errstr(-r); + goto out; + } + r = install(c, TLS_TX, s.tx, 0); + if (r == 0) + r = install(c, TLS_RX, s.rx, (uint64_t)records); + if (r < 0) { + why = r == KDF_FAILED ? "key derivation failed" : ioxd__errstr(-r); + goto out; + } + if (plain_len && !ioxd_pipereader_inject(&pipe->in, plain, plain_len)) { + why = "early application data does not fit"; + goto out; + } + if (!ioxd__recv_resume(c)) + why = "input ended after the handshake"; /* or the worker is draining: nothing to serve */ +out: + if (why) + fprintf(stderr, "ioxd_certs: connection dropped: %s\n", why); + explicit_bzero(&s, sizeof s); + if (plain) { + explicit_bzero(plain, PLAIN_MAX + RECORD_MAX); + free(plain); + } + SSL_free(ssl); /* and its BIOs */ + ioxd__tls_release(certs, t); + return why ? -1 : 0; +} + +void ioxd__tls_close_notify(struct ioxd_pipe *pipe) +{ + unsigned char alert[2] = { 1, 0 }; /* warning, close_notify */ + struct iovec iov = { alert, sizeof alert }; + union { + char buf[CMSG_SPACE(sizeof(unsigned char))]; + struct cmsghdr align; + } ctl = {}; + struct msghdr msg = { .msg_iov = &iov, .msg_iovlen = 1, .msg_control = ctl.buf, .msg_controllen = sizeof ctl.buf }; + struct cmsghdr *cm = CMSG_FIRSTHDR(&msg); + cm->cmsg_level = SOL_TLS; + cm->cmsg_type = TLS_SET_RECORD_TYPE; + cm->cmsg_len = CMSG_LEN(sizeof(unsigned char)); + *CMSG_DATA(cm) = 21; /* the alert record type */ + ioxd__sendmsg(pipe->in.conn, &msg); +} + +#else /* built without TLS */ + +int ioxd__tls_prologue(struct ioxd_pipe *pipe, ioxd_certs *certs) +{ + (void)pipe; + (void)certs; + return -1; +} + +void ioxd__tls_close_notify(struct ioxd_pipe *pipe) +{ + (void)pipe; +} + +#endif diff --git a/lib/tls/handshake.h b/lib/tls/handshake.h new file mode 100644 index 0000000..b5ab44a --- /dev/null +++ b/lib/tls/handshake.h @@ -0,0 +1,23 @@ +/* + * tls/handshake.h - the prologue a TLS connection runs before its handler, and the alert it sends + * when it ends; for the runner. The keylog callback is here too: the store installs it on every + * context it makes, and this is where the secrets it catches are wanted. + */ +#pragma once + +#include "ioxd/tls.h" +#include "io/pipe.h" + +/* The handshake over the pipe, then the keys into the socket. 0, or -1: the connection is not + * usable (the handshake failed, the peer left, kernel TLS is unavailable). */ +int ioxd__tls_prologue(struct ioxd_pipe *pipe, ioxd_certs *certs); + +/* Tell the peer the connection is ending: a close_notify alert, sent as a TLS control record + * through the kernel. Best effort, for a connection whose prologue succeeded. */ +void ioxd__tls_close_notify(struct ioxd_pipe *pipe); + +#if IOXD_TLS +#include + +void ioxd__tls_keylog(const SSL *ssl, const char *line); /* catches the traffic secrets */ +#endif diff --git a/lib/tls/store.c b/lib/tls/store.c new file mode 100644 index 0000000..aa0db2a --- /dev/null +++ b/lib/tls/store.c @@ -0,0 +1,379 @@ +/* + * tls/store.c - the certificate store: one SSL_CTX per host directory, pinned to what kernel TLS + * can carry (TLS 1.3, TLS_AES_128_GCM_SHA256, no tickets), chosen by SNI at the ClientHello. + * The table of hosts is reference-counted so a reload swaps it under handshakes in flight. + * + * A published context is never written to again. A reload carries a host that failed to load + * forward by sharing its old context, so a context can belong to more than one table and cannot + * name one: the ClientHello callback is installed once, when the context is built, and finds the + * table through the SSL its handshake holds a reference for. + */ +#include "tls/store.h" +#include "tls/handshake.h" +#include "io/internal.h" + +#include +#include +#include + +#if IOXD_TLS + +#include +#include +#include +#include +#include +#include +#include +#include + +struct host { + char *name; + SSL_CTX *ctx; +}; + +struct table { + int refs; /* the store's own, plus one per handshake in flight */ + struct host *hosts; + int n; + SSL_CTX *fallback; /* `default`: what answers when SNI matches nothing */ +}; + +struct ioxd_certs { + char *dir; + struct table *table; + pthread_mutex_t lock; /* around the table pointer and its refs */ + pthread_mutex_t reload; /* one reload at a time, over the whole of it */ +}; + +/* The table a handshake started on, hung on its SSL: where the ClientHello callback finds it. */ +static int table_ex; +static pthread_once_t table_ex_once = PTHREAD_ONCE_INIT; +static void make_table_ex(void) +{ + table_ex = SSL_get_ex_new_index(0, nullptr, nullptr, nullptr, nullptr); +} + +/* The prologue's SSL, told which table it started on. It holds a reference to that table for as + * long as the SSL lives, so the ClientHello callback can read it back whenever the peer gets + * round to sending one. */ +void ioxd__tls_bind(SSL *ssl, struct table *t) +{ + pthread_once(&table_ex_once, make_table_ex); + SSL_set_ex_data(ssl, table_ex, t); +} + +/* The outermost OpenSSL error, as text; the rest of the queue goes with it, since one left behind + * is reported against the next call that fails. */ +static const char *ssl_error(void) +{ + static thread_local char text[256]; + ERR_error_string_n(ERR_peek_last_error(), text, sizeof text); + ERR_clear_error(); + return text; +} + +/* ASCII lowercase, and nothing else: folding every byte would match CR to `-` and DEL to `_`. */ +static unsigned char fold(unsigned char c) +{ + return c >= 'A' && c <= 'Z' ? (unsigned char)(c | 0x20U) : c; +} + +/* Exact hostname compare, ASCII case-insensitive. */ +static bool host_eq(const char *a, size_t alen, const char *b) +{ + if (strlen(b) != alen) + return false; + for (size_t i = 0; i < alen; i++) + if (fold((unsigned char)a[i]) != fold((unsigned char)b[i])) + return false; + return true; +} + +/* The context for a server name: exact, then the wildcard of its parent domain. */ +static SSL_CTX *lookup(const struct table *t, const char *name, size_t len) +{ + if (len > 1 && name[len - 1] == '.') /* the root dot: `sni.test.` is `sni.test` */ + len--; + for (int i = 0; i < t->n; i++) + if (host_eq(name, len, t->hosts[i].name)) + return t->hosts[i].ctx; + const char *dot = memchr(name, '.', len); + if (dot) { /* a.example.com -> _.example.com */ + char wild[256]; + size_t rest = len - (size_t)(dot - name); + if (rest + 1 < sizeof wild) { + wild[0] = '_'; + memcpy(wild + 1, dot, rest); + for (int i = 0; i < t->n; i++) + if (host_eq(wild, rest + 1, t->hosts[i].name)) + return t->hosts[i].ctx; + } + } + return nullptr; +} + +/* The ClientHello: pick the certificate by the server name, when there is one we know. The table + * comes from the SSL rather than from `arg`, which is always NULL - see the note at the top. */ +static int on_client_hello(SSL *ssl, int *alert, void *arg) /* NOLINT(readability-non-const-parameter): OpenSSL's signature */ +{ + (void)alert; + (void)arg; + const struct table *t = SSL_get_ex_data(ssl, table_ex); + const unsigned char *ext; + size_t ext_len; + if (!t) + return SSL_CLIENT_HELLO_SUCCESS; + if (!SSL_client_hello_get0_ext(ssl, TLSEXT_TYPE_server_name, &ext, &ext_len) || ext_len < 5) + return SSL_CLIENT_HELLO_SUCCESS; /* no SNI: the default */ + size_t list_len = (size_t)ext[0] << 8 | ext[1]; /* ServerNameList: one host_name entry */ + size_t name_len = (size_t)ext[3] << 8 | ext[4]; + if (ext[2] != 0 || name_len + 3 != list_len || list_len + 2 != ext_len) + return SSL_CLIENT_HELLO_SUCCESS; /* not the single entry we read: the default */ + SSL_CTX *ctx = lookup(t, (const char *)ext + 5, name_len); + if (ctx) + SSL_set_SSL_CTX(ssl, ctx); + return SSL_CLIENT_HELLO_SUCCESS; +} + +/* One host's context: pinned to what the kernel can carry, with the files it was given. The + * callbacks go on here, once: nothing writes to a context after it is published. */ +static SSL_CTX *context_for(const char *cert, const char *key) +{ + SSL_CTX *ctx = SSL_CTX_new(TLS_server_method()); + if (!ctx) + return nullptr; + bool ok = SSL_CTX_set_min_proto_version(ctx, TLS1_3_VERSION) + && SSL_CTX_set_ciphersuites(ctx, "TLS_AES_128_GCM_SHA256") + && SSL_CTX_set_num_tickets(ctx, 0) == 1 + && SSL_CTX_use_certificate_chain_file(ctx, cert) == 1 + && SSL_CTX_use_PrivateKey_file(ctx, key, SSL_FILETYPE_PEM) == 1 + && SSL_CTX_check_private_key(ctx) == 1; + if (!ok) { + SSL_CTX_free(ctx); + return nullptr; + } + SSL_CTX_set_options(ctx, SSL_OP_NO_RENEGOTIATION | SSL_OP_NO_TICKET); + SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_OFF); + SSL_CTX_set_keylog_callback(ctx, ioxd__tls_keylog); + SSL_CTX_set_client_hello_cb(ctx, on_client_hello, nullptr); + return ctx; +} + +/* Why the context's certificate cannot serve now, or NULL when it can: a certificate that is not + * valid yet or has run out is a load failure like any other, so the old one stays. */ +static const char *not_current(const SSL_CTX *ctx) +{ + const X509 *x = SSL_CTX_get0_certificate(ctx); + if (!x) + return "no certificate in the chain"; + int before = X509_cmp_time(X509_get0_notBefore(x), nullptr); + int after = X509_cmp_time(X509_get0_notAfter(x), nullptr); + if (before == 0 || after == 0) + return "a validity period that does not parse"; + if (before > 0) + return "not valid yet: notBefore is in the future"; + if (after < 0) + return "expired: notAfter has passed"; + return nullptr; +} + +static void table_free(struct table *t) +{ + for (int i = 0; i < t->n; i++) { + SSL_CTX_free(t->hosts[i].ctx); + free(t->hosts[i].name); + } + free(t->hosts); + free(t); +} + +/* The host whose context answers when SNI matches nothing. */ +static const char *fallback_name(const struct table *t) +{ + for (int i = 0; i < t->n; i++) + if (t->hosts[i].ctx == t->fallback) + return t->hosts[i].name; + return nullptr; +} + +/* Every host directory under dir into a new table; a host that fails keeps its context from + * `old` when it had one there. NULL when no host loads, or when nothing can answer for SNI that + * matches nothing: `default` is required, and only a reload may carry the previous one over. */ +static struct table *load(const char *dir, const struct table *old) +{ + DIR *d = opendir(dir); + if (!d) { + fprintf(stderr, "ioxd_certs: %s: %s\n", dir, ioxd__errstr(errno)); + return nullptr; + } + struct table *t = calloc(1, sizeof *t); + if (!t) { + perror("ioxd_certs"); + closedir(d); + return nullptr; + } + t->refs = 1; + struct dirent *e; + while ((e = readdir(d))) { /* NOLINT(concurrency-mt-unsafe): one DIR per reload, reloads serialised */ + if (e->d_name[0] == '.') + continue; + char cert[PATH_MAX], key[PATH_MAX]; + int cn = snprintf(cert, sizeof cert, "%s/%s/cert.pem", dir, e->d_name); + int kn = snprintf(key, sizeof key, "%s/%s/key.pem", dir, e->d_name); + if (cn < 0 || (size_t)cn >= sizeof cert || kn < 0 || (size_t)kn >= sizeof key) { + fprintf(stderr, "ioxd_certs: %s: the path to its certificate does not fit\n", e->d_name); + continue; + } + struct stat cs, ks; + if (stat(cert, &cs) != 0 || !S_ISREG(cs.st_mode) || stat(key, &ks) != 0 || !S_ISREG(ks.st_mode)) + continue; /* not a host directory */ + if (ks.st_mode & ((unsigned)S_IRGRP | (unsigned)S_IROTH)) /* once per host, on every load */ + fprintf(stderr, "ioxd_certs: %s: mode %03o, readable past its owner\n", key, + (unsigned)(ks.st_mode & 0777)); + SSL_CTX *ctx = context_for(cert, key); + const char *why = ctx ? not_current(ctx) : ssl_error(); + if (ctx && why) { /* it parsed, but it cannot serve now */ + SSL_CTX_free(ctx); + ctx = nullptr; + } + if (!ctx) { + fprintf(stderr, "ioxd_certs: %s: %s (%s)\n", e->d_name, why, cert); + if (old) { /* keep what was serving */ + for (int i = 0; i < old->n; i++) + if (strcmp(old->hosts[i].name, e->d_name) == 0) { + ctx = old->hosts[i].ctx; + SSL_CTX_up_ref(ctx); /* shared with the old table, written to by neither */ + } + } + if (!ctx) + continue; + } + struct host *grown = realloc(t->hosts, ((size_t)t->n + 1) * sizeof *grown); + char *name = strdup(e->d_name); + if (!grown || !name) { + perror("ioxd_certs"); + abort(); + } + t->hosts = grown; + t->hosts[t->n++] = (struct host){ name, ctx }; + if (strcmp(e->d_name, "default") == 0) + t->fallback = ctx; + } + closedir(d); + if (t->n == 0) { + fprintf(stderr, "ioxd_certs: %s: no /cert.pem + key.pem loaded\n", dir); + table_free(t); + return nullptr; + } + if (!t->fallback) { /* no `default`: on a reload the host that was + * answering keeps doing so, if it is still here */ + const char *prev = old ? fallback_name(old) : nullptr; + for (int i = 0; prev && !t->fallback && i < t->n; i++) + if (strcmp(t->hosts[i].name, prev) == 0) + t->fallback = t->hosts[i].ctx; + if (!t->fallback) { + fprintf(stderr, "ioxd_certs: %s: no `default` host (default/cert.pem + key.pem) to answer" + " when SNI matches nothing\n", dir); + table_free(t); + return nullptr; + } + fprintf(stderr, "ioxd_certs: no `default` host; %s still answers when SNI matches nothing\n", prev); + } + return t; +} + +ioxd_certs *ioxd_certs_load(const char *dir) +{ + struct table *t = load(dir, nullptr); + if (!t) + return nullptr; + ioxd_certs *certs = calloc(1, sizeof *certs); + char *own = strdup(dir); + if (!certs || !own) { + perror("ioxd_certs"); + table_free(t); + free(own); + free(certs); + return nullptr; + } + certs->dir = own; + certs->table = t; + pthread_mutex_init(&certs->lock, nullptr); + pthread_mutex_init(&certs->reload, nullptr); + fprintf(stderr, "ioxd_certs: %d host%s from %s\n", t->n, t->n == 1 ? "" : "s", dir); + return certs; +} + +void ioxd_certs_free(ioxd_certs *certs) +{ + if (!certs) + return; + ioxd__tls_release(certs, certs->table); /* the store's own reference; the last one frees */ + pthread_mutex_destroy(&certs->reload); + pthread_mutex_destroy(&certs->lock); + free(certs->dir); + free(certs); +} + +/* A reference to the table serving now; released after the handshake. */ +struct table *ioxd__tls_acquire(ioxd_certs *certs) +{ + pthread_mutex_lock(&certs->lock); + struct table *t = certs->table; + t->refs++; + pthread_mutex_unlock(&certs->lock); + return t; +} + +void ioxd__tls_release(ioxd_certs *certs, struct table *t) +{ + pthread_mutex_lock(&certs->lock); + int left = --t->refs; + pthread_mutex_unlock(&certs->lock); + if (left == 0) + table_free(t); +} + +SSL_CTX *ioxd__tls_fallback(const struct table *t) +{ + return t->fallback; +} + +int ioxd_certs_reload(ioxd_certs *certs) +{ + pthread_mutex_lock(&certs->reload); /* one at a time, so the table it reads stays put */ + struct table *old = ioxd__tls_acquire(certs); /* and cannot be freed while load() reads it */ + struct table *fresh = load(certs->dir, old); + if (fresh) { + pthread_mutex_lock(&certs->lock); + certs->table = fresh; + pthread_mutex_unlock(&certs->lock); + fprintf(stderr, "ioxd_certs: reloaded %d host%s from %s\n", fresh->n, fresh->n == 1 ? "" : "s", certs->dir); + ioxd__tls_release(certs, old); /* the store's own reference to the old table */ + } + ioxd__tls_release(certs, old); /* the one this reload took */ + pthread_mutex_unlock(&certs->reload); + return fresh ? 0 : -1; +} + +#else /* built without TLS */ + +ioxd_certs *ioxd_certs_load(const char *dir) +{ + fprintf(stderr, "ioxd_certs: %s: this build has no TLS (make TLS=1 with libssl-dev)\n", dir); + return nullptr; +} + +void ioxd_certs_free(ioxd_certs *certs) +{ + (void)certs; +} + +int ioxd_certs_reload(ioxd_certs *certs) +{ + (void)certs; + return -1; +} + +#endif diff --git a/lib/tls/store.h b/lib/tls/store.h new file mode 100644 index 0000000..5c299af --- /dev/null +++ b/lib/tls/store.h @@ -0,0 +1,19 @@ +/* + * tls/store.h - the certificate store's entries for the handshake: the table serving now, held + * by reference for as long as a handshake runs, the context a handshake starts on, and the + * binding that lets its ClientHello pick a host from that same table. The public side of the + * store - ioxd_certs_load, ioxd_certs_reload, ioxd_certs_free - is ioxd/tls.h. + */ +#pragma once + +#include "ioxd/tls.h" + +#if IOXD_TLS +#include + +struct table; +struct table *ioxd__tls_acquire (ioxd_certs *certs); /* the table serving now, referenced */ +void ioxd__tls_release (ioxd_certs *certs, struct table *t); +SSL_CTX *ioxd__tls_fallback(const struct table *t); /* the context a handshake starts on */ +void ioxd__tls_bind (SSL *ssl, struct table *t); /* the table its ClientHello picks a host from */ +#endif diff --git a/libioma.so b/libioma.so new file mode 100755 index 0000000..3785578 Binary files /dev/null and b/libioma.so differ diff --git a/manual/build.py b/manual/build.py new file mode 100644 index 0000000..6340842 --- /dev/null +++ b/manual/build.py @@ -0,0 +1,757 @@ +#!/usr/bin/env python3 +"""The manual: man-page style HTML for every public header, generated from the headers themselves. + + python3 manual/build.py # writes manual/*.html next to this script + +Each header under include/ioxd/ becomes one page in section 3 (ioxd_http(3), ioxd_json(3), ...): +NAME from the header's top comment, SYNOPSIS from its declarations, DESCRIPTION from the comment +above each declaration - in the header's own order and sections - EXAMPLES from the snippets in +this file, SEE ALSO from the rest. ioxd(7) is the overview, index.html the front page, and +functions.html every public name in one alphabetical list. Private names (ioxd__, IOXD__) are +left out, as are the macro internals that only serve a public one. +""" +import html +import os +import re +import sys +from datetime import date + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +INCLUDE = os.path.join(ROOT, "include") + +PAGES = [ # (header, page name, one-line subject used on the index) + ("ioxd/config.h", "ioxd_config", "the runtime's knobs"), + ("ioxd/http.h", "ioxd_http", "request, response, context, body, reply, the run"), + ("ioxd/router.h", "ioxd_router", "groups, endpoints, middleware; the script macros"), + ("ioxd/slice.h", "ioxd_slice", "slices, conversions, key/value parsing"), + ("ioxd/json.h", "ioxd_json", "JSON written as you go; structs described once"), + ("ioxd/pipe.h", "ioxd_pipe", "a connection as a pipe, for other protocols"), + ("ioxd/tls.h", "ioxd_tls", "a certificate store, for a TLS port"), +] + + +def version(): + with open(os.path.join(ROOT, "Makefile")) as f: + m = re.search(r"^VERSION\s*:=\s*(\S+)", f.read(), re.M) + return m.group(1) if m else "0" + + +# ── parsing a header ─────────────────────────────────────────────────────────────────────── + +class Entry: + """One thing in a header: a section title, or a comment with the declarations under it.""" + def __init__(self, kind): + self.kind = kind # 'section' | 'entry' + self.title = "" # section + self.paras = [] # entry: the comment above, as paragraphs (text or ('pre', code)) + self.decls = [] # entry: [(code, trailing comment, [names], public)] + + +def comment_text(lines): + """The lines of a block comment, without their decoration, as paragraphs: plain text, or + indented code kept as it is.""" + body = [] + for ln in lines: + ln = re.sub(r"^\s*/\*\s?", "", ln) + ln = re.sub(r"\s*\*/\s*$", "", ln) + ln = re.sub(r"^\s*\*\s?", "", ln) if not ln.lstrip().startswith("*/") else "" + body.append(ln.rstrip()) + paras, text, code = [], [], [] + + def flush(): + nonlocal text, code + if code: + paras.append(("pre", "\n".join(code))) + code = [] + if text: + paras.append(" ".join(t.strip() for t in text)) + text = [] + + for ln in body: + if ln.startswith(" "): + if text: + paras.append(" ".join(t.strip() for t in text)); text = [] + code.append(ln[4:]) + elif ln.strip() == "": + flush() + else: + if code: + paras.append(("pre", "\n".join(code))); code = [] + text.append(ln) + flush() + return paras + + +def names_of(code): + """The public identifiers a declaration defines: a function, a macro, a type, a constant.""" + first = code.split("\n")[0].strip() + names = [] + m = re.match(r"#define\s+([A-Za-z_]\w*)", first) + if m: + return [m.group(1)] + m = re.match(r"typedef\s+.*\(\*\s*([A-Za-z_]\w*)\s*\)", first) + if m: + return [m.group(1)] + if first.startswith("typedef struct") or first.startswith("typedef union") or first.startswith("typedef enum"): + last = code.strip().split("\n")[-1] + m = re.search(r"}\s*([A-Za-z_]\w*)\s*;\s*$", last) or re.search(r"typedef\s+struct\s+\w+\s+([A-Za-z_]\w*)\s*;", first) + if m: + return [m.group(1)] + m = re.match(r"struct\s+([A-Za-z_]\w*)\s*\{", first) + if m: + return ["struct " + m.group(1)] + m = re.match(r"struct\s+([A-Za-z_]\w*)\s*;", first) + if m: + return ["struct " + m.group(1)] + m = re.search(r"([A-Za-z_]\w*)\s*\(", first) + if m and not first.startswith("#"): + return [m.group(1)] + return names + + +def is_public(names, code): + for n in names: + if n.startswith("ioxd__") or n.startswith("IOXD__") or n.endswith("_args"): + return False + if code.lstrip().startswith("#define IOXD__"): + return False + if re.match(r"struct\s+\w+\s*;", code.strip()): + return False # a forward declaration: the type is elsewhere + return True + + +MACRO_USAGE = { # the script macros, as they are written; the expansion is machinery + "IOXD_GROUP": "IOXD_GROUP(prefix, middleware...) { ... }", + "IOXD_USE": "IOXD_USE(middleware)", + "IOXD_ROUTE": "IOXD_ROUTE(method, path, handler, middleware...)", + "IOXD_GET": "IOXD_GET(path, handler, middleware...)", + "IOXD_POST": "IOXD_POST(path, handler, middleware...)", + "IOXD_PUT": "IOXD_PUT(path, handler, middleware...)", + "IOXD_PATCH": "IOXD_PATCH(path, handler, middleware...)", + "IOXD_DELETE": "IOXD_DELETE(path, handler, middleware...)", + "IOXD_DEFAULT": "IOXD_DEFAULT(handler)", + "IOXD_JSON_VALUE": "IOXD_JSON_VALUE(ioxd_json *j, x)", + "IOXD_JSON_FIELD": "IOXD_JSON_FIELD(ioxd_json *j, const char *name, x)", + "IOXD_JSON_STRUCT": "IOXD_JSON_STRUCT(name, FIELDS)", + "IOXD_JSON_WRITER": "IOXD_JSON_WRITER(name, FIELDS)", +} + + +def shown(code): + """A declaration as the manual shows it: an inline function by its signature alone, a + function-like macro by the way it is written rather than what it expands to.""" + if code.startswith("static inline") and "{" in code: + return code[:code.find("{")].rstrip() + ";" + m = re.match(r"#define\s+([A-Za-z_]\w*)\(", code) + if m: + name = m.group(1) + if name in MACRO_USAGE: + return MACRO_USAGE[name] + head = re.match(r"#define\s+[A-Za-z_]\w*\([^)]*\)", code) + return head.group(0) if head else code.split("\n")[0] + return code + + +def decl_ends(line): + core = strip_trailing_comment(line)[0].rstrip() + return core.endswith(";") or core.endswith("}") + + +def braces(line): + """The brace depth a line adds, comments and character literals not counted.""" + core = strip_trailing_comment(line)[0] + core = re.sub(r"'.'|\"[^\"]*\"", "", core) + return core.count("{") - core.count("}") + + +def strip_trailing_comment(line): + m = re.match(r"^(.*?)\s*/\*\s*(.*?)\s*\*/\s*$", line) + if m: + return m.group(1), m.group(2) + return line, "" + + +def parse_header(path): + with open(path) as f: + src = f.read().split("\n") + top = [] + i = 0 + if src and src[0].startswith("/*"): + while i < len(src): + top.append(src[i]) + if "*/" in src[i]: + i += 1 + break + i += 1 + top_paras = comment_text(top) + name_line = top_paras[0] if top_paras else "" + m = re.match(r"(\S+)\s+-\s+(.*)", name_line) + subject = m.group(2) if m else name_line + + entries = [] + pending = [] # comment paragraphs waiting for a declaration + skipping_else = False + while i < len(src): + line = src[i] + s = line.strip() + if skipping_else: + if s.startswith("#endif"): + skipping_else = False + i += 1 + continue + if s == "" or s.startswith("#pragma") or s.startswith("#include"): + if s == "" and pending and entries and entries[-1].kind == "entry" and not entries[-1].decls: + pass + i += 1 + continue + if s.startswith("#else"): + skipping_else = True + i += 1 + continue + if s.startswith("#endif") or s.startswith("#if") or s.startswith("#ifdef") or s.startswith("#ifndef"): + i += 1 + continue + if s.startswith("/*"): + block = [line] + while "*/" not in src[i]: + i += 1 + block.append(src[i]) + i += 1 + text = " ".join(b.strip(" /*") for b in block) + sec = re.match(r"\s*/\*\s*[─-]+\s*(.*?)\s*[─-]+\s*\*/\s*$", "".join(block)) + if sec: + e = Entry("section"); e.title = sec.group(1); entries.append(e) + pending = [] + continue + pending = comment_text(block) + e = Entry("entry"); e.paras = pending + entries.append(e) + continue + # a declaration: collect until it is whole - a ';' or a closing brace at depth zero, with + # any trailing comment out of the way; a macro continues while its lines end in '\\' + decl = [line] + depth = braces(line) + if s.startswith("#define"): + while src[i].rstrip().endswith("\\"): + i += 1 + decl.append(src[i]) + else: + while not (depth == 0 and decl_ends(decl[-1])): + i += 1 + decl.append(src[i]) + depth += braces(src[i]) + i += 1 + last, trailing = strip_trailing_comment(decl[-1]) + if trailing and not last.strip().startswith("}"): + decl[-1] = last # a prototype's trailing comment is its description + else: + trailing = "" # a struct's closing line: its members keep theirs + code = "\n".join(decl) + names = names_of(code) + public = is_public(names, code) + if entries and entries[-1].kind == "entry": + entries[-1].decls.append((code.rstrip(), trailing, names, public)) + else: + e = Entry("entry"); e.decls.append((code.rstrip(), trailing, names, public)); entries.append(e) + # a section title with nothing public under it is dropped later + return subject, top_paras, entries + + +# ── rendering ────────────────────────────────────────────────────────────────────────────── + +def esc(t): + return html.escape(t, quote=False) + + +def anchor_id(name): + return re.sub(r"[^A-Za-z0-9_]", "_", name) + + +def render_paras(paras, link): + out = [] + for p in paras: + if isinstance(p, tuple): + out.append('
' + link(esc(p[1])) + "
") + else: + out.append("

" + link(esc(p)) + "

") + return "\n".join(out) + + +def make_linker(index, self_page): + """Turn every public name mentioned in text into a link to its page and anchor.""" + names = sorted((n for n in index if not n.startswith("struct ")), key=len, reverse=True) + pat = re.compile(r"(?{n}' + return pat.sub(repl, text) + return link + + +def page_html(title, section, body, version_str, nav_extra=""): + today = date.today().isoformat() + upper = title.upper() + f"({section})" + return f""" + + + + +{esc(title)}({section}) - libioxd manual + + + + +
+
{upper}libioxd Programmer's Manual{upper}
+{body} +
libioxd {esc(version_str)}{today}{upper}
+
+ + +""" + + +def synopsis_of(entries): + lines = [] + for e in entries: + if e.kind != "entry": + continue + for code, _t, names, public in e.decls: + if not public: + continue + first = code.split("\n")[0] + if code.startswith("typedef struct") and "\n" in code or code.startswith("struct") and "{" in code: + nm = names[0] if names else "..." + lines.append(f"typedef struct {{ ... }} {nm};" if code.startswith("typedef") else f"{nm} {{ ... }};") + elif code.startswith("#define"): + lines.append(shown(code) if "(" in first.split()[1] else strip_trailing_comment(first)[0].strip()) + elif code.startswith("static inline"): + body_at = code.find("{") + lines.append(code[:body_at].rstrip() + ";" if body_at > 0 else code) + else: + lines.append(re.sub(r"\s+", " ", code.replace("\n", " ")).strip() if "\n" in code else code.strip()) + return "\n".join(lines) + + +def render_page(header, page, subject, top_paras, entries, index, version_str, examples, see_also): + link = make_linker(index, page) + body = [] + body.append("

NAME

") + body.append(f"

{esc(header)} - {link(esc(subject))}

") + body.append("

SYNOPSIS

") + body.append("
#include <ioxd.h>\n\n" + link(esc(synopsis_of(entries))) + "
") + body.append("

DESCRIPTION

") + if len(top_paras) > 1: + body.append(render_paras(top_paras[1:], link)) + for e in entries: + if e.kind == "section": + body.append(f"

{esc(e.title)}

") + continue + pub = [d for d in e.decls if d[3]] + if not pub and not e.paras: + continue + if not pub and e.paras and e.decls: + continue # a comment on private machinery + body.append('
') + if pub: + ids = " ".join(anchor_id(n) for d in pub for n in d[2]) + first_id = anchor_id(pub[0][2][0]) if pub[0][2] else "" + body.append(f'
')
+            for code, trailing, names, _p in pub:
+                for n in names[1:]:
+                    body.append(f'')
+                body.append(esc(shown(code)))
+            body.append("
") + if e.paras: + body.append('
' + render_paras(e.paras, link) + "
") + trail = [(code, t) for code, t, _n, _p in pub if t] + if trail: + body.append('
') + for code, t in trail: + short = code.split("\n")[0].strip() + m = re.search(r"([A-Za-z_]\w*)\s*\(", short) + label = m.group(1) if m and not short.startswith("#") else (re.match(r"#define\s+(\S+)", short).group(1) if short.startswith("#define") else short) + body.append(f"
{esc(label)}
{link(esc(t))}
") + body.append("
") + body.append("
") + if examples: + body.append("

EXAMPLES

") + for title, code in examples: + body.append(f"

{link(esc(title))}

") + body.append('
' + link(esc(code)) + "
") + body.append("

SEE ALSO

") + body.append("

" + ", ".join(f'{p}({s})' for p, s in see_also) + "

") + return page_html(page, "3", "\n".join(body), version_str) + + +# ── the hand-written pages: the overview, the examples ───────────────────────────────────── + +OVERVIEW = """ +

NAME

+

ioxd - an HTTP/1.1 server library on io_uring, one worker per core, a stackful coroutine per connection

+ +

SYNOPSIS

+
#include <ioxd.h>
+
+cc main.c $(pkg-config --cflags --libs ioxd) -o server
+ +

DESCRIPTION

+

libioxd serves HTTP/1.1, plain or over TLS 1.3, from a thread per core. Each worker owns an io_uring +ring, a ring of receive buffers the kernel delivers into, and its own sockets on every bound port +(SO_REUSEPORT); nothing is shared between workers while serving. Every connection runs on its own +coroutine, so a handler reads the body and writes the reply in straight-line code: a call that has to +wait for the wire suspends the coroutine, and the worker's loop resumes it on the completion.

+ +

A program registers its routes, sets the runtime's knobs if it wants to, binds its ports, then runs:

+
static void hello(ioxd_ctx *ctx)
+{
+    ioxd_slice name = ctx->req.route_params[0].value;
+    ioxd_printf(ctx, "hello %.*s\\n", (int)name.len, name.p);
+}
+
+int main(void)
+{
+    IOXD_GET("/hello/:name", hello);
+    ioxd_bind(8080, NULL);                                  /* plain */
+    ioxd_bind(8443, ioxd_certs_load("certs"));              /* TLS 1.3, from a directory of certificates */
+    return ioxd_run(0);                                     /* one worker per core, until SIGINT or SIGTERM */
+}
+ +

The request and the reply

+

A handler receives an ioxd_ctx: the request as plain data - method, +path, query, headers, parameters, all slices into the connection's buffers - and the response being +shaped. The body stays on the wire until asked for: ioxd_body_all +reads it whole, ioxd_body_read_until streams it. The reply +is written into a slab with ioxd_write, ioxd_printf +or the JSON writer; when everything fits it goes out in one send with its head in +front, and when it does not it streams, chunked. What goes on the wire follows the protocol whatever the handler +did: a reply to HEAD carries no body, a declared length is held to, a request whose framing cannot be trusted is +refused before a handler sees it.

+ +

Routes

+

Endpoints live in groups: a prefix plus middleware, nesting. Everything is +resolved once, when the run starts, into a segment tree and one flat middleware chain per endpoint, so a request +costs one walk and no scan. The script macros (IOXD_GROUP, IOXD_GET, IOXD_USE) are the same registrations +written as a block.

+ +

Threads and lifetimes

+

Register routes, configure and bind from the main thread, before the run. Handlers run on worker threads, +one at a time per worker; a request's slices are valid until the handler returns, and anything a handler hands +the reply (a header, a content type) is copied. Nothing in the library is shared between workers except the +read-only route tree and a TLS store's certificate table, which is reference counted.

+ +

Limits

+

A request head, and a body read whole, must fit the reader's 16 KB; streamed bodies have no limit. At most +64 request headers, 32 query parameters, 8 route captures, 16 added reply headers within 3 KB. These size the +context, so ioxd_run refuses an application built with different values. +The runtime's own sizes - the ring, the receive buffers, the coroutine stacks, the pools - are the +configuration, per worker.

+ +

Building

+

Linux 6.x on x86-64, gcc 14 or newer (the library is C23; the headers are usable from C11), OpenSSL 3 for +the TLS handshake (built by default; make TLS=0 or -DIOXD_TLS=OFF leaves it out). +make produces libioxd.a and libioxd.so; make install the headers and a pkg-config file; +CMake exports ioxd::ioxd. Kernel TLS needs a kernel with SOCKET_URING_OP_SETSOCKOPT (6.7 or newer) +when the registered file table is on, which is the default.

+ +

FILES

+
+
<ioxd.h>
the whole API: an umbrella over the headers below
+
<ioxd/config.h>
the runtime's knobs: ring, buffers, stacks, pools
+
<ioxd/http.h>
request, response, context, body, reply, bind and run
+
<ioxd/router.h>
groups, endpoints, middleware, the script macros
+
<ioxd/slice.h>
slices, conversions, key/value parsing
+
<ioxd/json.h>
the JSON writer and IOXD_JSON_STRUCT
+
<ioxd/pipe.h>
a connection as a pipe, for protocols other than HTTP
+
<ioxd/tls.h>
a certificate store, for a TLS port
+
+ +

SEE ALSO

+

every public name, and in the repository: README.md, ARCHITECTURE.md (how the +runtime works), TLS.md, PERF.md, REVIEW.md.

+""" + +EXAMPLES = { + "ioxd_config": [ + ("Twice the receive buffers, before the run; every other field keeps its default:", + """ioxd_config config = { .recv_buffers = 8192 }; +if (ioxd_configure(&config) < 0) + return 1; /* the reason is on stderr */ +ioxd_bind(8080, NULL); +return ioxd_run(0);"""), + ], + "ioxd_http": [ + ("A route parameter, a query parameter converted, and a formatted reply:", + """static void user(ioxd_ctx *ctx) +{ + int64_t id; + if (!ioxd_to_i64(ctx->req.route_params[0].value, &id)) { + ctx->res.status = 400; + ioxd_text(ctx, "the id must be an integer\\n"); + return; + } + for (size_t i = 0; i < ctx->req.n_params; i++) + if (ioxd_slice_eq(ctx->req.params[i].key, "fields")) + ioxd_printf(ctx, "fields=%.*s\\n", (int)ctx->req.params[i].value.len, ctx->req.params[i].value.p); + ioxd_header(ctx, "x-user", "42"); /* copied: a temporary is fine */ + ioxd_printf(ctx, "user %lld\\n", (long long)id); +}"""), + ("A body read whole, then a reply streamed with a flush every ten lines:", + """static void repeat(ioxd_ctx *ctx) +{ + ioxd_slice body = ioxd_body_all(ctx); /* over 16 KB: empty, and res.status is 413 */ + if (ctx->res.status != 200) + return; + for (int i = 1; i <= 25; i++) { + if (ioxd_printf(ctx, "%d: %.*s\\n", i, (int)body.len, body.p) < 0) + return; /* the peer is gone */ + if (i % 10 == 0 && ioxd_flush(ctx) < 0) + return; + } +}"""), + ("A large upload streamed through a fixed buffer:", + """static void upload(ioxd_ctx *ctx) +{ + char buf[4096]; + size_t total = 0; + for (;;) { + int n = ioxd_body_read_until(ctx, buf, sizeof buf); + if (n < 0) return; /* malformed or gone: the engine answers */ + if (n == 0) break; + total += (size_t)n; + } + ioxd_printf(ctx, "%zu bytes\\n", total); +}"""), + ("Middleware around the handler, the onion way:", + """static void timing(ioxd_ctx *ctx, ioxd_next *next) +{ + struct timespec t0, t1; + clock_gettime(CLOCK_MONOTONIC, &t0); + ioxd_next_run(ctx, next); /* the rest of the chain, then the endpoint */ + clock_gettime(CLOCK_MONOTONIC, &t1); + /* the head may already be out (a streamed reply): ioxd_header then returns false */ +}"""), + ], + "ioxd_router": [ + ("The same registrations as calls and as a script:", + """ioxd_group *api = ioxd_group_new(NULL, "/api"); +ioxd_group_use(api, auth); +ioxd_get(api, "/users/:id", user); /* GET /api/users/:id, behind auth */ +ioxd_endpoint_use(ioxd_post(api, "/users", create), audit); + +IOXD_USE(log); /* root middleware: every request */ +IOXD_GROUP("/api", auth) { + IOXD_GET ("/users/:id", user); + IOXD_POST("/users", create, audit); + IOXD_GROUP("/admin", require_token) { + IOXD_GET("/stats", stats); + } +} +IOXD_DEFAULT(not_found);"""), + ], + "ioxd_slice": [ + ("Strict conversions: the whole slice is the value, or the call fails and *out is untouched:", + """int64_t id; +double price; +bool on; +if (!ioxd_to_i64(ctx->req.route_params[0].value, &id)) /* "42", "-7"; not "42x", not " 42" */ + ... +if (ioxd_to_double(v, &price) && ioxd_to_bool(w, &on)) /* "2.5", "1e-3"; "yes", "off" */ + ..."""), + ("A form body parsed like a query string:", + """ioxd_slice body = ioxd_body_all(ctx); +ioxd_kv form[8]; +char arena[512]; +bool truncated; +size_t n = ioxd_kv_parse(body.p, body.len, form, 8, arena, sizeof arena, &truncated); +if (truncated) { ctx->res.status = 400; return; } /* never act on part of it */ +for (size_t i = 0; i < n; i++) + if (ioxd_slice_eq(form[i].key, "name")) + ioxd_printf(ctx, "hello %.*s\\n", (int)form[i].value.len, form[i].value.p);"""), + ], + "ioxd_json": [ + ("Written as you go, straight into the reply:", + """ioxd_json j = ioxd_json_reply(ctx); +ioxd_json_object(&j); + IOXD_JSON_FIELD(&j, "id", id); + IOXD_JSON_FIELD(&j, "name", name); + ioxd_json_key(&j, "tags"); ioxd_json_array(&j); + ioxd_json_cstr(&j, "new"); + ioxd_json_end(&j); +ioxd_json_end(&j);"""), + ("A struct described once, nested objects and arrays included:", + """#define ORDER_FIELDS(X) \\ + X(VALUE, int, number) \\ + X(VALUE, double, total) +IOXD_JSON_STRUCT(order, ORDER_FIELDS) + +#define USER_FIELDS(X) \\ + X(VALUE, int64_t, id) \\ + X(VALUE, const char *, name) /* NULL comes out as null */ \\ + X(ARRAY, const char *, tags, n_tags) \\ + X(OBJECTS, order, orders, n_orders) +IOXD_JSON_STRUCT(user, USER_FIELDS) + +struct user u = { .id = 42, .name = "Zoe", .tags = tags, .n_tags = 2, .orders = orders, .n_orders = 1 }; +ioxd_json j = ioxd_json_reply(ctx); +user_to_json(&j, &u); /* {"id":42,"name":"Zoe","tags":[...],"orders":[{...}]} */"""), + ], + "ioxd_pipe": [ + ("A line echo server on raw TCP: read until a newline, answer, repeat:", + """static void echo(ioxd_pipe *pipe) +{ + for (;;) { + ioxd_slice live; + int rc = ioxd_pipe_read(pipe, &live); /* one contiguous span, or waits */ + if (rc <= 0) + return; /* 0: the peer is done; <0: gone or FULL */ + const char *nl = memchr(live.p, '\\n', live.len); + if (!nl) { + ioxd_pipe_examine(pipe, live.len); /* seen it all: the next read waits for more */ + continue; + } + size_t n = (size_t)(nl - live.p) + 1; + if (ioxd_pipe_send(pipe, live.p, n) < 0) + return; + ioxd_pipe_drop(pipe, n); + } +} + +int main(void) +{ + ioxd_bind(8100, NULL); + return ioxd_run_pipes(0, echo); +}"""), + ], + "ioxd_tls": [ + ("A TLS port beside a plain one; the files rotated, then reloaded:", + """ioxd_certs *certs = ioxd_certs_load("/etc/ioxd/certs"); /* //cert.pem and key.pem; `default` required */ +if (!certs) + return 1; +ioxd_bind(8080, NULL); +ioxd_bind(8443, certs); +/* ... later, after new files were written: */ +ioxd_certs_reload(certs); /* a host that fails keeps its old certificate */"""), + ], +} + +STYLE = """/* The manual's look: a man page, as man7.org renders one - monospace, sections in capitals at the + * margin, the text indented under them - with links, anchors and a light nav bar on top. */ +:root { + --paper: #ffffff; --ink: #111111; --dim: #555555; --rule: #d8d8d8; --link: #1a3fbf; --code: #f4f4f4; +} +@media (prefers-color-scheme: dark) { + :root { --paper: #111213; --ink: #e6e6e6; --dim: #a0a0a0; --rule: #333; --link: #7da2ff; --code: #1c1e21; } +} +html { background: var(--paper); } +body { + margin: 0; color: var(--ink); background: var(--paper); + font: 14px/1.5 "DejaVu Sans Mono", "Liberation Mono", Menlo, Consolas, ui-monospace, monospace; +} +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +.crumbs { padding: .5em 1.5em; border-bottom: 1px solid var(--rule); color: var(--dim); } +main { max-width: 100ch; margin: 0 auto; padding: 1em 1.5em 4em; } +.hdr, .ftr { display: flex; justify-content: space-between; font-weight: bold; } +.ftr { margin-top: 3em; font-weight: normal; color: var(--dim); } +h1 { font-size: 1em; font-weight: bold; margin: 1.4em 0 .5em; } +h2 { font-size: 1em; font-weight: bold; margin: 1.8em 0 .5em; letter-spacing: .02em; } +h3 { font-size: 1em; font-weight: bold; margin: 1.4em 0 .4em 3ch; } +h3::before { content: ""; } +main > p, .entry, dl, pre, .text { margin-left: 7ch; } +main > pre.ex, main > pre.syn { margin-left: 7ch; } +p { margin: .5em 0; } +pre { margin: .5em 0; white-space: pre-wrap; word-break: break-word; } +pre.syn { padding: .6em 1em; background: var(--code); border-left: 3px solid var(--rule); } +pre.decl { font-weight: bold; margin: 1.2em 0 .3em; } +pre.ex { padding: .6em 1em; background: var(--code); border-left: 3px solid var(--rule); } +.entry { margin-top: .4em; } +.entry .text { margin-left: 4ch; } +.entry .text pre.ex, .text pre.ex { margin-left: 0; } +dl.trail { margin: .3em 0 0 4ch; } +dl.trail dt { font-weight: bold; margin-top: .4em; } +dl.trail dd { margin: 0 0 0 4ch; color: var(--ink); } +dl.files dt { font-weight: bold; margin-top: .6em; } +dl.files dd { margin: 0 0 0 4ch; } +table { border-collapse: collapse; margin-left: 7ch; } +td, th { text-align: left; padding: .25em 1.5em .25em 0; vertical-align: top; } +th { font-weight: bold; } +.dim { color: var(--dim); } +@media (max-width: 700px) { + main > p, .entry, dl, pre, .text, table, h3 { margin-left: 1ch; } + .hdr span:nth-child(2), .ftr span:nth-child(2) { display: none; } +} +""" + + +def build(): + version_str = version() + parsed = [] + index = {} # name -> (page, anchor) + for header, page, subject_hint in PAGES: + subject, top_paras, entries = parse_header(os.path.join(INCLUDE, header)) + parsed.append((header, page, subject or subject_hint, top_paras, entries)) + for e in entries: + if e.kind != "entry": + continue + for _code, _t, names, public in e.decls: + if public: + for n in names: + index.setdefault(n, (page, anchor_id(n))) + index.setdefault("ioxd_run", ("ioxd_http", "ioxd_run")) + for header, page, subject, top_paras, entries in parsed: + see = [(p, "3") for _h, p, _s in PAGES if p != page] + [("ioxd", "7")] + out = render_page(header, page, subject, top_paras, entries, index, version_str, EXAMPLES.get(page, []), see) + with open(os.path.join(HERE, page + ".html"), "w") as f: + f.write(out) + + # ioxd(7) + with open(os.path.join(HERE, "ioxd.7.html"), "w") as f: + f.write(page_html("ioxd", "7", OVERVIEW, version_str)) + + # every name + rows = [] + for n in sorted(index, key=lambda s: s.lower()): + page, aid = index[n] + rows.append(f'{esc(n)}{page}(3)') + names_body = ("

NAME

functions - every public name of libioxd, alphabetically, with the page that describes it

" + "

DESCRIPTION

" + "\n".join(rows) + "
") + with open(os.path.join(HERE, "functions.html"), "w") as f: + f.write(page_html("functions", "3", names_body, version_str)) + + # the front page + rows = "\n".join(f'{p}(3)<{esc(h)}>{esc(s)}' + for h, p, s in PAGES) + front = f"""

libioxd manual

+

The manual of libioxd, an HTTP/1.1 server library on io_uring for Linux, in the shape of man pages: +one page per public header, generated from the headers themselves, so what a page says is what the +header declares. Start with the overview.

+

SECTION 7: OVERVIEW

+
ioxd(7)the library, its model and its limits
+

SECTION 3: HEADERS

+{rows} +
functions(3)every public name, alphabetically
+

SEE ALSO

+

The repository's README.md, ARCHITECTURE.md (how the runtime works, for those who change it), TLS.md, +PERF.md and REVIEW.md. This manual is built by manual/build.py; make manual refreshes it.

+""" + with open(os.path.join(HERE, "index.html"), "w") as f: + f.write(page_html("index", "", front, version_str).replace("index() - libioxd manual", "libioxd manual") + .replace('
INDEX()libioxd Programmer\'s ManualINDEX()
', + '
LIBIOXDlibioxd Programmer\'s ManualLIBIOXD
') + .replace("INDEX()\n", "LIBIOXD\n")) + with open(os.path.join(HERE, "style.css"), "w") as f: + f.write(STYLE) + print(f"manual: {len(PAGES)} pages, {len(index)} names, version {version_str}") + + +if __name__ == "__main__": + build() diff --git a/manual/functions.html b/manual/functions.html new file mode 100644 index 0000000..bc0d975 --- /dev/null +++ b/manual/functions.html @@ -0,0 +1,126 @@ + + + + + +functions(3) - libioxd manual + + + + +
+
FUNCTIONS(3)libioxd Programmer's ManualFUNCTIONS(3)
+

NAME

functions - every public name of libioxd, alphabetically, with the page that describes it

DESCRIPTION

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ioxd_advanceioxd_http(3)
ioxd_bindioxd_http(3)
ioxd_body_allioxd_http(3)
ioxd_body_read_next_chunkioxd_http(3)
ioxd_body_read_untilioxd_http(3)
ioxd_certsioxd_http(3)
ioxd_certs_freeioxd_tls(3)
ioxd_certs_loadioxd_tls(3)
ioxd_certs_reloadioxd_tls(3)
ioxd_configioxd_config(3)
ioxd_configureioxd_config(3)
ioxd_content_lengthioxd_http(3)
ioxd_content_typeioxd_http(3)
ioxd_cstrioxd_slice(3)
ioxd_ctxioxd_http(3)
ioxd_defaultioxd_router(3)
IOXD_DEFAULTioxd_router(3)
ioxd_deleteioxd_router(3)
IOXD_DELETEioxd_router(3)
ioxd_endpointioxd_router(3)
ioxd_endpoint_useioxd_router(3)
ioxd_flushioxd_http(3)
ioxd_getioxd_router(3)
IOXD_GETioxd_router(3)
ioxd_groupioxd_router(3)
IOXD_GROUPioxd_router(3)
ioxd_group_newioxd_router(3)
ioxd_group_useioxd_router(3)
ioxd_handlerioxd_http(3)
ioxd_headerioxd_http(3)
ioxd_jsonioxd_json(3)
ioxd_json_arrayioxd_json(3)
ioxd_json_boolioxd_json(3)
ioxd_json_cstrioxd_json(3)
IOXD_JSON_DEPTHioxd_json(3)
ioxd_json_doneioxd_json(3)
ioxd_json_doubleioxd_json(3)
ioxd_json_endioxd_json(3)
IOXD_JSON_FIELDioxd_json(3)
ioxd_json_floatioxd_json(3)
ioxd_json_intioxd_json(3)
ioxd_json_keyioxd_json(3)
ioxd_json_memioxd_json(3)
ioxd_json_nullioxd_json(3)
ioxd_json_objectioxd_json(3)
ioxd_json_pipeioxd_json(3)
ioxd_json_rawioxd_json(3)
ioxd_json_replyioxd_json(3)
ioxd_json_stringioxd_json(3)
IOXD_JSON_STRUCTioxd_json(3)
ioxd_json_uintioxd_json(3)
IOXD_JSON_VALUEioxd_json(3)
IOXD_JSON_WRITERioxd_json(3)
ioxd_kvioxd_slice(3)
ioxd_kv_parseioxd_slice(3)
IOXD_MAX_HEADERSioxd_http(3)
IOXD_MAX_MWioxd_router(3)
IOXD_MAX_PARAMSioxd_http(3)
IOXD_MAX_RESP_HEADERSioxd_http(3)
IOXD_MAX_ROUTE_PARAMSioxd_http(3)
ioxd_mwioxd_http(3)
ioxd_nextioxd_http(3)
ioxd_next_runioxd_http(3)
ioxd_patchioxd_router(3)
IOXD_PATCHioxd_router(3)
ioxd_pipeioxd_pipe(3)
ioxd_pipe_advanceioxd_pipe(3)
ioxd_pipe_copyioxd_pipe(3)
ioxd_pipe_dropioxd_pipe(3)
ioxd_pipe_examineioxd_pipe(3)
ioxd_pipe_flushioxd_pipe(3)
IOXD_PIPE_FULLioxd_pipe(3)
IOXD_PIPE_GONEioxd_pipe(3)
ioxd_pipe_handlerioxd_pipe(3)
ioxd_pipe_keepioxd_pipe(3)
ioxd_pipe_readioxd_pipe(3)
ioxd_pipe_releaseioxd_pipe(3)
ioxd_pipe_reserveioxd_pipe(3)
ioxd_pipe_sendioxd_pipe(3)
ioxd_pipe_writeioxd_pipe(3)
ioxd_postioxd_router(3)
IOXD_POSTioxd_router(3)
ioxd_printfioxd_http(3)
ioxd_putioxd_router(3)
IOXD_PUTioxd_router(3)
ioxd_reasonioxd_http(3)
ioxd_requestioxd_http(3)
ioxd_reserveioxd_http(3)
IOXD_RESP_HEAD_CAPioxd_http(3)
ioxd_responseioxd_http(3)
ioxd_routeioxd_router(3)
IOXD_ROUTEioxd_router(3)
IOXD_ROUTE_ARENAioxd_http(3)
ioxd_runioxd_http(3)
ioxd_run_pipesioxd_pipe(3)
ioxd_sliceioxd_slice(3)
ioxd_slice_ends_withioxd_slice(3)
ioxd_slice_eqioxd_slice(3)
ioxd_slice_eq_ciioxd_slice(3)
ioxd_slice_starts_withioxd_slice(3)
ioxd_slice_trimioxd_slice(3)
ioxd_textioxd_http(3)
ioxd_to_boolioxd_slice(3)
ioxd_to_doubleioxd_slice(3)
ioxd_to_i64ioxd_slice(3)
ioxd_to_intioxd_slice(3)
ioxd_to_u64ioxd_slice(3)
ioxd_useioxd_router(3)
IOXD_USEioxd_router(3)
ioxd_writeioxd_http(3)
+
libioxd 0.1.02026-09-09FUNCTIONS(3)
+
+ + diff --git a/manual/index.html b/manual/index.html new file mode 100644 index 0000000..77273db --- /dev/null +++ b/manual/index.html @@ -0,0 +1,35 @@ + + + + + +libioxd manual + + + + +
+
LIBIOXDlibioxd Programmer's ManualLIBIOXD
+

libioxd manual

+

The manual of libioxd, an HTTP/1.1 server library on io_uring for Linux, in the shape of man pages: +one page per public header, generated from the headers themselves, so what a page says is what the +header declares. Start with the overview.

+

SECTION 7: OVERVIEW

+
ioxd(7)the library, its model and its limits
+

SECTION 3: HEADERS

+ + + + + + + +
ioxd_config(3)<ioxd/config.h>the runtime's knobs
ioxd_http(3)<ioxd/http.h>request, response, context, body, reply, the run
ioxd_router(3)<ioxd/router.h>groups, endpoints, middleware; the script macros
ioxd_slice(3)<ioxd/slice.h>slices, conversions, key/value parsing
ioxd_json(3)<ioxd/json.h>JSON written as you go; structs described once
ioxd_pipe(3)<ioxd/pipe.h>a connection as a pipe, for other protocols
ioxd_tls(3)<ioxd/tls.h>a certificate store, for a TLS port
functions(3)every public name, alphabetically
+

SEE ALSO

+

The repository's README.md, ARCHITECTURE.md (how the runtime works, for those who change it), TLS.md, +PERF.md and REVIEW.md. This manual is built by manual/build.py; make manual refreshes it.

+ +
libioxd 0.1.02026-09-09LIBIOXD
+
+ + diff --git a/manual/ioxd.7.html b/manual/ioxd.7.html new file mode 100644 index 0000000..c60fefd --- /dev/null +++ b/manual/ioxd.7.html @@ -0,0 +1,100 @@ + + + + + +ioxd(7) - libioxd manual + + + + +
+
IOXD(7)libioxd Programmer's ManualIOXD(7)
+ +

NAME

+

ioxd - an HTTP/1.1 server library on io_uring, one worker per core, a stackful coroutine per connection

+ +

SYNOPSIS

+
#include <ioxd.h>
+
+cc main.c $(pkg-config --cflags --libs ioxd) -o server
+ +

DESCRIPTION

+

libioxd serves HTTP/1.1, plain or over TLS 1.3, from a thread per core. Each worker owns an io_uring +ring, a ring of receive buffers the kernel delivers into, and its own sockets on every bound port +(SO_REUSEPORT); nothing is shared between workers while serving. Every connection runs on its own +coroutine, so a handler reads the body and writes the reply in straight-line code: a call that has to +wait for the wire suspends the coroutine, and the worker's loop resumes it on the completion.

+ +

A program registers its routes, sets the runtime's knobs if it wants to, binds its ports, then runs:

+
static void hello(ioxd_ctx *ctx)
+{
+    ioxd_slice name = ctx->req.route_params[0].value;
+    ioxd_printf(ctx, "hello %.*s\n", (int)name.len, name.p);
+}
+
+int main(void)
+{
+    IOXD_GET("/hello/:name", hello);
+    ioxd_bind(8080, NULL);                                  /* plain */
+    ioxd_bind(8443, ioxd_certs_load("certs"));              /* TLS 1.3, from a directory of certificates */
+    return ioxd_run(0);                                     /* one worker per core, until SIGINT or SIGTERM */
+}
+ +

The request and the reply

+

A handler receives an ioxd_ctx: the request as plain data - method, +path, query, headers, parameters, all slices into the connection's buffers - and the response being +shaped. The body stays on the wire until asked for: ioxd_body_all +reads it whole, ioxd_body_read_until streams it. The reply +is written into a slab with ioxd_write, ioxd_printf +or the JSON writer; when everything fits it goes out in one send with its head in +front, and when it does not it streams, chunked. What goes on the wire follows the protocol whatever the handler +did: a reply to HEAD carries no body, a declared length is held to, a request whose framing cannot be trusted is +refused before a handler sees it.

+ +

Routes

+

Endpoints live in groups: a prefix plus middleware, nesting. Everything is +resolved once, when the run starts, into a segment tree and one flat middleware chain per endpoint, so a request +costs one walk and no scan. The script macros (IOXD_GROUP, IOXD_GET, IOXD_USE) are the same registrations +written as a block.

+ +

Threads and lifetimes

+

Register routes, configure and bind from the main thread, before the run. Handlers run on worker threads, +one at a time per worker; a request's slices are valid until the handler returns, and anything a handler hands +the reply (a header, a content type) is copied. Nothing in the library is shared between workers except the +read-only route tree and a TLS store's certificate table, which is reference counted.

+ +

Limits

+

A request head, and a body read whole, must fit the reader's 16 KB; streamed bodies have no limit. At most +64 request headers, 32 query parameters, 8 route captures, 16 added reply headers within 3 KB. These size the +context, so ioxd_run refuses an application built with different values. +The runtime's own sizes - the ring, the receive buffers, the coroutine stacks, the pools - are the +configuration, per worker.

+ +

Building

+

Linux 6.x on x86-64, gcc 14 or newer (the library is C23; the headers are usable from C11), OpenSSL 3 for +the TLS handshake (built by default; make TLS=0 or -DIOXD_TLS=OFF leaves it out). +make produces libioxd.a and libioxd.so; make install the headers and a pkg-config file; +CMake exports ioxd::ioxd. Kernel TLS needs a kernel with SOCKET_URING_OP_SETSOCKOPT (6.7 or newer) +when the registered file table is on, which is the default.

+ +

FILES

+
+
<ioxd.h>
the whole API: an umbrella over the headers below
+
<ioxd/config.h>
the runtime's knobs: ring, buffers, stacks, pools
+
<ioxd/http.h>
request, response, context, body, reply, bind and run
+
<ioxd/router.h>
groups, endpoints, middleware, the script macros
+
<ioxd/slice.h>
slices, conversions, key/value parsing
+
<ioxd/json.h>
the JSON writer and IOXD_JSON_STRUCT
+
<ioxd/pipe.h>
a connection as a pipe, for protocols other than HTTP
+
<ioxd/tls.h>
a certificate store, for a TLS port
+
+ +

SEE ALSO

+

every public name, and in the repository: README.md, ARCHITECTURE.md (how the +runtime works), TLS.md, PERF.md, REVIEW.md.

+ +
libioxd 0.1.02026-09-09IOXD(7)
+
+ + diff --git a/manual/ioxd_config.html b/manual/ioxd_config.html new file mode 100644 index 0000000..8038342 --- /dev/null +++ b/manual/ioxd_config.html @@ -0,0 +1,52 @@ + + + + + +ioxd_config(3) - libioxd manual + + + + +
+
IOXD_CONFIG(3)libioxd Programmer's ManualIOXD_CONFIG(3)
+

NAME

+

ioxd/config.h - the runtime's knobs: the ring, the receive buffers, the coroutine stacks and the pools, per worker. Set once before ioxd_run; the build's values are the defaults.

+

SYNOPSIS

+
#include <ioxd.h>
+
+typedef struct { ... } ioxd_config;
+int ioxd_configure(const ioxd_config *config);
+

DESCRIPTION

+
+
+typedef struct ioxd_config {
+    unsigned ring_entries;      /* submission queue entries, the completion queue twice that; a power of two, at most 32768; 4096 */
+    unsigned recv_buffers;      /* provided receive buffers; a power of two, at most 32768; 4096 */
+    unsigned recv_buffer_size;  /* bytes in each, 64 to 1 MB; 2048 */
+    size_t   stack_size;        /* a connection's coroutine stack, above a 64 KB guard; at least 64 KB; 128 KB */
+    unsigned idle_stacks;       /* stacks kept warm for the next connections; 512 */
+    unsigned idle_connections;  /* connection records kept warm; 1024 */
+} ioxd_config;
+
+

Every field is per worker, and a zero field keeps its default. The receive buffers are what the kernel delivers into, so their count bounds how much may be in flight before recvs park on -ENOBUFS (the log says "raise recv_buffers" when that happens) and their size bounds what one delivery holds; a worker's slab is count x size bytes.

+
+
+
+int ioxd_configure(const ioxd_config *config);
+
+

Apply a configuration to the runs that follow. -1, with the reason on stderr, when a value is refused - nothing changes then. ioxd_configure(&(ioxd_config){ 0 }) is the defaults.

+
+

EXAMPLES

+

Twice the receive buffers, before the run; every other field keeps its default:

+
ioxd_config config = { .recv_buffers = 8192 };
+if (ioxd_configure(&config) < 0)
+    return 1;                                   /* the reason is on stderr */
+ioxd_bind(8080, NULL);
+return ioxd_run(0);
+

SEE ALSO

+

ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_tls(3), ioxd(7)

+
libioxd 0.1.02026-09-09IOXD_CONFIG(3)
+
+ + diff --git a/manual/ioxd_http.html b/manual/ioxd_http.html new file mode 100644 index 0000000..3bf14f3 --- /dev/null +++ b/manual/ioxd_http.html @@ -0,0 +1,264 @@ + + + + + +ioxd_http(3) - libioxd manual + + + + +
+
IOXD_HTTP(3)libioxd Programmer's ManualIOXD_HTTP(3)
+

NAME

+

ioxd/http.h - the request, the response, the context a handler receives, the body read on demand, the reply written as you go, and the run.

+

SYNOPSIS

+
#include <ioxd.h>
+
+#define IOXD_MAX_HEADERS      64
+#define IOXD_MAX_PARAMS       32
+#define IOXD_MAX_ROUTE_PARAMS 8
+#define IOXD_MAX_RESP_HEADERS 16
+#define IOXD_RESP_HEAD_CAP    3072
+#define IOXD_ROUTE_ARENA      256
+typedef struct { ... } ioxd_request;
+typedef struct { ... } ioxd_response;
+typedef struct { ... } ioxd_ctx;
+typedef void (*ioxd_handler)(ioxd_ctx *ctx);
+typedef struct ioxd_next ioxd_next;
+typedef void (*ioxd_mw)(ioxd_ctx *ctx, ioxd_next *next);
+void ioxd_next_run(ioxd_ctx *ctx, ioxd_next *next);
+ioxd_slice ioxd_body_all(ioxd_ctx *ctx);
+int ioxd_body_read_until(ioxd_ctx *ctx, void *dst, size_t n);
+int ioxd_body_read_next_chunk(ioxd_ctx *ctx, void *dst, size_t cap);
+int  ioxd_write (ioxd_ctx *ctx, const void *data, size_t len);
+void *ioxd_reserve(ioxd_ctx *ctx, size_t n);
+void  ioxd_advance(ioxd_ctx *ctx, size_t n);
+int  ioxd_text  (ioxd_ctx *ctx, const char *s);
+int  ioxd_printf(ioxd_ctx *ctx, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
+bool ioxd_header        (ioxd_ctx *ctx, const char *name, const char *value);
+bool ioxd_content_type  (ioxd_ctx *ctx, const char *type);
+bool ioxd_content_length(ioxd_ctx *ctx, size_t n);
+int  ioxd_flush         (ioxd_ctx *ctx);
+typedef struct ioxd_certs ioxd_certs;
+int ioxd_bind(int port, ioxd_certs *certs);
+static inline int ioxd_run(int workers);
+const char *ioxd_reason(int status);
+

DESCRIPTION

+
+
+#define IOXD_MAX_HEADERS      64
+#define IOXD_MAX_PARAMS       32
+#define IOXD_MAX_ROUTE_PARAMS 8
+#define IOXD_MAX_RESP_HEADERS 16
+#define IOXD_RESP_HEAD_CAP    3072
+#define IOXD_ROUTE_ARENA      256
+
+

The limits that size a request and a reply. They are build-time constants of the LIBRARY: an application may not redefine them, since they lay out the context the library allocates - ioxd_run checks that the two sides agree and refuses to start otherwise. A request past a limit is answered 400 (more headers than fit, more query parameters than fit) or 414 (more decoded query than fits its arena); a reply past one is refused by the call that adds to it.

+
+
IOXD_MAX_HEADERS
request headers; more is a 400
+
IOXD_MAX_PARAMS
query parameters; more is a 400
+
IOXD_MAX_ROUTE_PARAMS
:name captures a route pattern may have
+
IOXD_MAX_RESP_HEADERS
headers a reply may add
+
IOXD_RESP_HEAD_CAP
bytes the added headers may serialize to
+
IOXD_ROUTE_ARENA
per-request bytes the router decodes into
+
+
+

the request

+
+
+typedef struct ioxd_request {
+    ioxd_slice  method;                         /* "GET", "POST", ...                        */
+    ioxd_slice  target;                         /* raw request target: path plus any query   */
+    ioxd_slice  path;                           /* the path, query stripped                  */
+    ioxd_slice  query;                          /* raw text after '?', undecoded             */
+    int         minor_version;                  /* 0 or 1 for HTTP/1.0 or 1.1                */
+
+    ioxd_kv     headers[IOXD_MAX_HEADERS];      /* names lower-cased, values as received     */
+    size_t      n_headers;
+    ioxd_kv     params[IOXD_MAX_PARAMS];        /* query parameters, percent-decoded         */
+    size_t      n_params;
+    ioxd_kv     route_params[IOXD_MAX_ROUTE_PARAMS];   /* the :name captures, percent-decoded       */
+    size_t      n_route_params;
+
+    size_t      content_length;                 /* what the head declared; 0 if nothing      */
+    bool        chunked;                        /* the body is chunked: length unknown       */
+    ioxd_slice  body;                           /* the whole body, once ioxd_body_all read it */
+    bool        keep_alive;                     /* computed from version + Connection         */
+    bool        expect_continue;                /* "Expect: 100-continue": the body waits for the interim reply the first body read sends */
+    char        route_arena[IOXD_ROUTE_ARENA];  /* private: the router's per-request scratch */
+} ioxd_request;
+
+

All the data of a request, as slices into the connection's read buffer (decoded parameters into a per-request arena), valid only until the handler returns. Read the arrays directly; header names are lower-cased, so compare them with lowercase literals. The body is not here until you ask for it: ioxd_body_all reads it whole, ioxd_body_read_until streams it. The engine has already checked the framing (RFC 9112): a Content-Length that is not a plain number, conflicting duplicates, a Transfer-Encoding with anything but a final "chunked", both fields together, a folded header line, or an HTTP/1.1 request without exactly one Host never reach a handler - they are answered 400 (501 for a transfer coding we do not implement) and the connection closes.

+
+

the response

+
+
+typedef struct ioxd_response {
+    int         status;                         /* 200 by default                            */
+    ioxd_slice  content_type;                   /* "text/plain" by default; assign a slice that outlives the handler (a literal), or ioxd_content_type copies one */
+    ioxd_kv     headers[IOXD_MAX_RESP_HEADERS]; /* added with ioxd_header: copies, names lower-cased */
+    size_t      n_headers;
+    bool        close;                          /* close the connection after this reply     */
+    bool        head_sent;
+    size_t      content_length;                 /* declared with ioxd_content_length         */
+    bool        has_length;
+
+    bool        chunked, failed;                /* private: how a stream is framed; peer gone */
+    size_t      body_sent;                      /* private: body bytes sent so far           */
+    size_t      head_len;                       /* private: the added headers, serialized    */
+    char        head[IOXD_RESP_HEAD_CAP];
+} ioxd_response;
+
+

The reply being shaped. Body bytes wait in the connection's write slab (ioxd_write, ioxd_printf, ioxd_reserve); a full slab is sent and emptied, and the head (status, content type, headers) goes out in front of the first send - after the chain when everything fit, earlier when the body streams - and is frozen from then on (head_sent). What goes on the wire follows the request and the status, not only the handler: a reply to HEAD, a 1xx, 204 or 304 carries no body whatever was written (HEAD keeps the Content-Length the GET would have had; 1xx and 204 carry no framing header at all), a status outside 100-999 goes out as 500, and a declared Content-Length that the writes do not match is corrected when the reply was buffered whole and closes the connection when it streamed.

+
+

the context

+
+
+typedef struct ioxd_ctx {
+    ioxd_request  req;
+    ioxd_response res;
+    void         *user;                         /* free slot: middleware hands data to the handler */
+    void         *priv;                         /* the engine's own state                    */
+} ioxd_ctx;
+typedef void (*ioxd_handler)(ioxd_ctx *ctx);
+
+
+
+
+typedef struct ioxd_next ioxd_next;
+typedef void (*ioxd_mw)(ioxd_ctx *ctx, ioxd_next *next);
+void ioxd_next_run(ioxd_ctx *ctx, ioxd_next *next);
+
+

Middleware runs around the handler (the onion model): shape the context, call ioxd_next_run to run the rest of the chain and then the endpoint, then act on the result - or write a reply and return WITHOUT calling ioxd_next_run to short-circuit (auth failure, cache hit).

+
+

the body

+
+
+ioxd_slice ioxd_body_all(ioxd_ctx *ctx);
+
+

The whole body, read into the request buffer once and returned as a slice (also req.body). It must fit the buffer (16 KB by default): otherwise the slice is empty and res.status is 413, which becomes the reply - a handler that streams its reply should check and stop. Not after one of the reads below. A malformed chunked body is a 400 the same way. With "Expect: 100-continue" the first of these reads answers "100 Continue" before waiting; a handler that never reads such a body gets its reply sent and the connection closed.

+
+
+
+int ioxd_body_read_until(ioxd_ctx *ctx, void *dst, size_t n);
+
+

The next bytes of the body into dst, reading until n are there or the body ends. Returns the count (less than n only at the end), 0 once it is all consumed (or for n == 0), -1 on error (the connection then closes after the reply). Any size of body, nothing kept in the engine.

+
+
+
+int ioxd_body_read_next_chunk(ioxd_ctx *ctx, void *dst, size_t cap);
+
+

The next chunk of a chunked body, exactly as the sender framed it, into dst: the rest of the current chunk when a read stopped inside one, else the next whole one. Returns its length, 0 at the last chunk, -1 on error - a chunk larger than cap is a 413 - or when the body is not chunked.

+
+

the reply

+
+
+int  ioxd_write (ioxd_ctx *ctx, const void *data, size_t len);
+
+

Body writes into the slab. When it fills, it is sent - head first - and the body streams from then on: chunked on HTTP/1.1, until close on HTTP/1.0, or with the length declared below. Return 0, or -1 once the reply failed: the peer is gone, or a format could not be written (further writes are ignored either way).

+
+
+
+void *ioxd_reserve(ioxd_ctx *ctx, size_t n);
+void  ioxd_advance(ioxd_ctx *ctx, size_t n);
+int  ioxd_text  (ioxd_ctx *ctx, const char *s);
+int  ioxd_printf(ioxd_ctx *ctx, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
+
+

Or write into the slab directly: reserve n bytes (flushing first when they do not fit; nullptr once the peer is gone or n exceeds the slab) and advance by what was written - never more than reserved; advance clamps to the room that was there.

+
+
ioxd_text
a C string
+
ioxd_printf
formatted, into the slab
+
+
+
+
+bool ioxd_header        (ioxd_ctx *ctx, const char *name, const char *value);
+bool ioxd_content_type  (ioxd_ctx *ctx, const char *type);
+bool ioxd_content_length(ioxd_ctx *ctx, size_t n);
+int  ioxd_flush         (ioxd_ctx *ctx);
+
+

Shape the head, only before it is sent: each returns false afterwards. ioxd_header copies the name (sent lower-cased) and the value, so temporaries are fine; it also returns false for a name that is not an HTTP token, a value with a control byte (CR, LF, NUL: no response splitting), a header the engine owns (content-length, transfer-encoding, connection), when the table or its IOXD_RESP_HEAD_CAP bytes are full. "content-type" through it sets the content type.

+
+
ioxd_content_type
copied
+
ioxd_content_length
stream a large body with a known length
+
ioxd_flush
send what is in the slab now (starts streaming)
+
+
+

run

+
+
+typedef struct ioxd_certs ioxd_certs;
+int ioxd_bind(int port, ioxd_certs *certs);
+
+

Bind a port: plain HTTP when tls is NULL, TLS 1.3 terminated in the kernel otherwise, with the certificate store from ioxd_certs_load (TLS.md). Every bound port serves the same routes; bind as many as you need (at most 8), then run. -1 if refused: a bad port, or the table is full.

+
+
+
+static inline int ioxd_run(int workers);
+
+

Start `workers` proactor threads (<= 0: one per core) serving HTTP on every bound port, and block until SIGINT/SIGTERM. Returns 0 on clean shutdown, non-zero when nothing was bound, when a port could not be opened, when a worker failed, or when the limits above differ between this header and the library (the context would not match). May be called again after it returns; the ports stay bound.

+
+
+
+const char *ioxd_reason(int status);
+
+

The reason phrase for a status code ("OK", "Not Found", ...); "Unknown" if unlisted.

+
+

EXAMPLES

+

A route parameter, a query parameter converted, and a formatted reply:

+
static void user(ioxd_ctx *ctx)
+{
+    int64_t id;
+    if (!ioxd_to_i64(ctx->req.route_params[0].value, &id)) {
+        ctx->res.status = 400;
+        ioxd_text(ctx, "the id must be an integer\n");
+        return;
+    }
+    for (size_t i = 0; i < ctx->req.n_params; i++)
+        if (ioxd_slice_eq(ctx->req.params[i].key, "fields"))
+            ioxd_printf(ctx, "fields=%.*s\n", (int)ctx->req.params[i].value.len, ctx->req.params[i].value.p);
+    ioxd_header(ctx, "x-user", "42");                 /* copied: a temporary is fine */
+    ioxd_printf(ctx, "user %lld\n", (long long)id);
+}
+

A body read whole, then a reply streamed with a flush every ten lines:

+
static void repeat(ioxd_ctx *ctx)
+{
+    ioxd_slice body = ioxd_body_all(ctx);             /* over 16 KB: empty, and res.status is 413 */
+    if (ctx->res.status != 200)
+        return;
+    for (int i = 1; i <= 25; i++) {
+        if (ioxd_printf(ctx, "%d: %.*s\n", i, (int)body.len, body.p) < 0)
+            return;                                   /* the peer is gone */
+        if (i % 10 == 0 && ioxd_flush(ctx) < 0)
+            return;
+    }
+}
+

A large upload streamed through a fixed buffer:

+
static void upload(ioxd_ctx *ctx)
+{
+    char   buf[4096];
+    size_t total = 0;
+    for (;;) {
+        int n = ioxd_body_read_until(ctx, buf, sizeof buf);
+        if (n < 0) return;                            /* malformed or gone: the engine answers */
+        if (n == 0) break;
+        total += (size_t)n;
+    }
+    ioxd_printf(ctx, "%zu bytes\n", total);
+}
+

Middleware around the handler, the onion way:

+
static void timing(ioxd_ctx *ctx, ioxd_next *next)
+{
+    struct timespec t0, t1;
+    clock_gettime(CLOCK_MONOTONIC, &t0);
+    ioxd_next_run(ctx, next);                         /* the rest of the chain, then the endpoint */
+    clock_gettime(CLOCK_MONOTONIC, &t1);
+    /* the head may already be out (a streamed reply): ioxd_header then returns false */
+}
+

SEE ALSO

+

ioxd_config(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_tls(3), ioxd(7)

+
libioxd 0.1.02026-09-09IOXD_HTTP(3)
+
+ + diff --git a/manual/ioxd_json.html b/manual/ioxd_json.html new file mode 100644 index 0000000..5d60bb6 --- /dev/null +++ b/manual/ioxd_json.html @@ -0,0 +1,170 @@ + + + + + +ioxd_json(3) - libioxd manual + + + + +
+
IOXD_JSON(3)libioxd Programmer's ManualIOXD_JSON(3)
+

NAME

+

ioxd/json.h - JSON written as you go, and a struct described once, serialized with one call.

+

SYNOPSIS

+
#include <ioxd.h>
+
+#define IOXD_JSON_DEPTH 63
+typedef struct { ... } ioxd_json;
+ioxd_json ioxd_json_reply(ioxd_ctx *ctx);
+ioxd_json ioxd_json_pipe (struct ioxd_pipe *pipe);
+ioxd_json ioxd_json_mem  (char *buf, size_t cap, size_t *len);
+bool ioxd_json_object(ioxd_json *j);
+bool ioxd_json_array (ioxd_json *j);
+bool ioxd_json_end   (ioxd_json *j);
+bool ioxd_json_done  (ioxd_json *j);
+bool ioxd_json_key   (ioxd_json *j, const char *name);
+bool ioxd_json_string(ioxd_json *j, ioxd_slice s);
+bool ioxd_json_cstr  (ioxd_json *j, const char *s);
+bool ioxd_json_int   (ioxd_json *j, int64_t v);
+bool ioxd_json_uint  (ioxd_json *j, uint64_t v);
+bool ioxd_json_double(ioxd_json *j, double v);
+bool ioxd_json_float (ioxd_json *j, float v);
+bool ioxd_json_bool  (ioxd_json *j, bool v);
+bool ioxd_json_null  (ioxd_json *j);
+bool ioxd_json_raw   (ioxd_json *j, ioxd_slice json);
+IOXD_JSON_VALUE(ioxd_json *j, x)
+IOXD_JSON_FIELD(ioxd_json *j, const char *name, x)
+IOXD_JSON_STRUCT(name, FIELDS)
+IOXD_JSON_WRITER(name, FIELDS)
+

DESCRIPTION

+

JSON, written as you go

+
+
+#define IOXD_JSON_DEPTH 63
+typedef struct ioxd_json {
+    enum {
+        IOXD_JSON_TO_REPLY,
+        IOXD_JSON_TO_PIPE,
+        IOXD_JSON_TO_MEM,
+    } kind;
+
+    union {
+        ioxd_ctx         *ctx;
+        struct ioxd_pipe *pipe;
+        struct { char *p; size_t cap, *len; } mem;
+    } to;
+
+    uint64_t has_value;                         /* per level: a value is there, so a comma is due */
+    uint64_t is_object;                         /* per level: it closes with '}' rather than ']'  */
+    unsigned depth;
+    bool     after_key;                         /* the next value follows a key: no comma        */
+    bool     failed;
+} ioxd_json;
+ioxd_json ioxd_json_reply(ioxd_ctx *ctx);
+ioxd_json ioxd_json_pipe (struct ioxd_pipe *pipe);
+ioxd_json ioxd_json_mem  (char *buf, size_t cap, size_t *len);
+bool ioxd_json_object(ioxd_json *j);
+bool ioxd_json_array (ioxd_json *j);
+bool ioxd_json_end   (ioxd_json *j);
+bool ioxd_json_done  (ioxd_json *j);
+bool ioxd_json_key   (ioxd_json *j, const char *name);
+bool ioxd_json_string(ioxd_json *j, ioxd_slice s);
+bool ioxd_json_cstr  (ioxd_json *j, const char *s);
+bool ioxd_json_int   (ioxd_json *j, int64_t v);
+bool ioxd_json_uint  (ioxd_json *j, uint64_t v);
+bool ioxd_json_double(ioxd_json *j, double v);
+bool ioxd_json_float (ioxd_json *j, float v);
+bool ioxd_json_bool  (ioxd_json *j, bool v);
+bool ioxd_json_null  (ioxd_json *j);
+bool ioxd_json_raw   (ioxd_json *j, ioxd_slice json);
+
+

A forward-only JSON writer, the shape of .NET's Utf8JsonWriter: no tree, no allocation. The bytes go straight into the reply - or a raw pipe, or a buffer - escaped as they are written, and stream out as the slab fills. Nesting and commas are tracked, so a handler just says what it means:

+
ioxd_json j = ioxd_json_reply(ctx);                  // content-type: application/json
+ioxd_json_object(&j);
+    ioxd_json_key(&j, "id");    ioxd_json_int(&j, id);
+    ioxd_json_key(&j, "name");  ioxd_json_string(&j, name);
+    ioxd_json_key(&j, "tags");  ioxd_json_array(&j);
+        ioxd_json_cstr(&j, "new");
+    ioxd_json_end(&j);
+ioxd_json_end(&j);
+if (!ioxd_json_done(&j)) { ... }                     // whole: nothing failed, nothing open
+

Strings are emitted byte for byte, with only '"', '\' and the control characters escaped: invalid UTF-8 goes out exactly as it came in, so untrusted input has to be validated first. Every call returns false once the sink is gone (the peer left; the buffer is full), the nesting passed IOXD_JSON_DEPTH, or the call had no place in the document - a key outside an object, a value where a key was due, an end with nothing open. The rest is then dropped, so checking the last call is enough; check done() after it to catch an end that was never written.

+
+
IOXD_JSON_DEPTH
levels: one bit of each mask below apiece
+
ioxd_json_reply
into the reply; sets its content type
+
ioxd_json_pipe
into a raw pipe's slab
+
ioxd_json_mem
into memory; *len is what was written
+
ioxd_json_object
{
+
ioxd_json_array
[
+
ioxd_json_end
} or ], whichever is open
+
ioxd_json_done
nothing failed, all closed
+
ioxd_json_key
"name":
+
ioxd_json_string
"...", escaped
+
ioxd_json_cstr
NULL is null
+
ioxd_json_double
the shortest that reads back the same; nan and inf become null
+
ioxd_json_float
the same, read back as a float: 0.1f is 0.1
+
ioxd_json_raw
already JSON: copied as is
+
+
+
+
+IOXD_JSON_VALUE(ioxd_json *j, x)
+
+

A value by its C type, and a key with one: the _Generic picks ioxd_json_int for the integer types, _uint for the unsigned ones, _float and _double for those two, _bool, _cstr for a char pointer, _string for a slice.

+
+
+
+IOXD_JSON_FIELD(ioxd_json *j, const char *name, x)
+
+

A key and its value in one line. The answer goes through a function so that a field written for its effect - `IOXD_JSON_FIELD(j, "n", n);` - is a plain statement and not a value the compiler sees discarded, while `if (IOXD_JSON_FIELD(j, "n", n))` still reads it.

+
+
+
+IOXD_JSON_STRUCT(name, FIELDS)
+IOXD_JSON_WRITER(name, FIELDS)
+
+

A struct described once, serialized with one call. The description is a list of fields, each line its kind, its C type (or, for a nested struct, that struct's name) and its name:

+
#define USER_FIELDS(X)                       \
+    X(VALUE,    int64_t,      id)            \
+    X(VALUE,    const char *, name)          \
+    X(OBJECT,   address,      address)       \
+    X(OPTIONAL, address,      billing)       \
+    X(ARRAY,    const char *, tags,   n_tags)    \
+    X(OBJECTS,  order,        orders, n_orders)
+IOXD_JSON_STRUCT(user, USER_FIELDS)
+

VALUE is a scalar, written by its C type; OBJECT a nested struct held by value; OPTIONAL a pointer to one, null where the pointer is NULL; ARRAY scalars and the field holding their count; OBJECTS the same for nested structs. IOXD_JSON_STRUCT defines the struct and the function - struct user, and user_to_json(ioxd_json *, const struct user *); IOXD_JSON_WRITER only the function, for a struct declared elsewhere with the same fields. A nested struct's own IOXD_JSON_STRUCT comes first. Counts are size_t; arrays are pointers to their first element. A note beside a field is written as a block comment, the way playground/hello/main.c writes them: a // one would run on through the backslash and swallow the lines after it.

+
+

EXAMPLES

+

Written as you go, straight into the reply:

+
ioxd_json j = ioxd_json_reply(ctx);
+ioxd_json_object(&j);
+    IOXD_JSON_FIELD(&j, "id", id);
+    IOXD_JSON_FIELD(&j, "name", name);
+    ioxd_json_key(&j, "tags"); ioxd_json_array(&j);
+        ioxd_json_cstr(&j, "new");
+    ioxd_json_end(&j);
+ioxd_json_end(&j);
+

A struct described once, nested objects and arrays included:

+
#define ORDER_FIELDS(X)        \
+    X(VALUE,   int,    number)  \
+    X(VALUE,   double, total)
+IOXD_JSON_STRUCT(order, ORDER_FIELDS)
+
+#define USER_FIELDS(X)                         \
+    X(VALUE,   int64_t,      id)               \
+    X(VALUE,   const char *, name)             /* NULL comes out as null */ \
+    X(ARRAY,   const char *, tags,   n_tags)   \
+    X(OBJECTS, order,        orders, n_orders)
+IOXD_JSON_STRUCT(user, USER_FIELDS)
+
+struct user u = { .id = 42, .name = "Zoe", .tags = tags, .n_tags = 2, .orders = orders, .n_orders = 1 };
+ioxd_json j = ioxd_json_reply(ctx);
+user_to_json(&j, &u);                                 /* {"id":42,"name":"Zoe","tags":[...],"orders":[{...}]} */
+

SEE ALSO

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_pipe(3), ioxd_tls(3), ioxd(7)

+
libioxd 0.1.02026-09-09IOXD_JSON(3)
+
+ + diff --git a/manual/ioxd_pipe.html b/manual/ioxd_pipe.html new file mode 100644 index 0000000..40795fd --- /dev/null +++ b/manual/ioxd_pipe.html @@ -0,0 +1,101 @@ + + + + + +ioxd_pipe(3) - libioxd manual + + + + +
+
IOXD_PIPE(3)libioxd Programmer's ManualIOXD_PIPE(3)
+

NAME

+

ioxd/pipe.h - a connection as a pipe: a reader over the bytes the kernel received and a writer over a slab, for handlers of protocols other than HTTP.

+

SYNOPSIS

+
#include <ioxd.h>
+
+typedef struct ioxd_pipe ioxd_pipe;
+typedef void (*ioxd_pipe_handler)(ioxd_pipe *pipe);
+int ioxd_run_pipes(int workers, ioxd_pipe_handler fn);
+#define IOXD_PIPE_GONE (-1)
+#define IOXD_PIPE_FULL (-2)
+int         ioxd_pipe_read   (ioxd_pipe *pipe, ioxd_slice *live);
+void        ioxd_pipe_examine(ioxd_pipe *pipe, size_t n);
+void        ioxd_pipe_drop   (ioxd_pipe *pipe, size_t n);
+const char *ioxd_pipe_keep   (ioxd_pipe *pipe, size_t n);
+void        ioxd_pipe_release(ioxd_pipe *pipe);
+int         ioxd_pipe_copy   (ioxd_pipe *pipe, void *dst, size_t n);
+void  *ioxd_pipe_reserve(ioxd_pipe *pipe, size_t n);
+void   ioxd_pipe_advance(ioxd_pipe *pipe, size_t n);
+int    ioxd_pipe_write  (ioxd_pipe *pipe, const void *data, size_t n);
+int    ioxd_pipe_flush  (ioxd_pipe *pipe);
+int    ioxd_pipe_send   (ioxd_pipe *pipe, const void *data, size_t n);
+

DESCRIPTION

+

pipes

+
+
+typedef struct ioxd_pipe ioxd_pipe;
+typedef void (*ioxd_pipe_handler)(ioxd_pipe *pipe);
+int ioxd_run_pipes(int workers, ioxd_pipe_handler fn);
+
+

A connection as a pipe: a reader over the bytes the kernel received and a writer over a slab. Every call that must wait suspends the connection's coroutine, and the worker's loop resumes it on the completion, so a handler reads and writes in straight-line code. The HTTP engine is one such handler; ioxd_run_pipes runs one of yours on raw TCP connections instead.

+
+
ioxd_run_pipes
like ioxd_run, over the ports ioxd_bind bound, without HTTP
+
+
+
+
+#define IOXD_PIPE_GONE (-1)
+#define IOXD_PIPE_FULL (-2)
+int         ioxd_pipe_read   (ioxd_pipe *pipe, ioxd_slice *live);
+void        ioxd_pipe_examine(ioxd_pipe *pipe, size_t n);
+void        ioxd_pipe_drop   (ioxd_pipe *pipe, size_t n);
+const char *ioxd_pipe_keep   (ioxd_pipe *pipe, size_t n);
+void        ioxd_pipe_release(ioxd_pipe *pipe);
+int         ioxd_pipe_copy   (ioxd_pipe *pipe, void *dst, size_t n);
+
+

Reading. The live bytes are the ones received and not yet consumed, always handed out as one contiguous span - in place in the kernel's buffer when they lie within one. read returns 1 with them once some are unexamined, otherwise it waits for more; examine says how many were looked at without being consumed, so the next read waits for more rather than returning the same bytes; drop consumes (never more than is live); keep consumes but leaves the bytes where they are, contiguous with earlier kept bytes and valid until release - it returns where they are, or NULL when they would not fit the pipe's buffer (the pipe is then FULL) or n is more than is live; copy is the plain read into your own buffer. read and copy return 0 at the end of input, IOXD_PIPE_GONE on a dead peer, IOXD_PIPE_FULL when kept plus live bytes would exceed the pipe's buffer (16 KB). A handler that returns with bytes still in the writer's slab has them sent before the connection closes.

+
+
+
+void  *ioxd_pipe_reserve(ioxd_pipe *pipe, size_t n);
+void   ioxd_pipe_advance(ioxd_pipe *pipe, size_t n);
+int    ioxd_pipe_write  (ioxd_pipe *pipe, const void *data, size_t n);
+int    ioxd_pipe_flush  (ioxd_pipe *pipe);
+int    ioxd_pipe_send   (ioxd_pipe *pipe, const void *data, size_t n);
+
+

Writing: a slab, sent on flush. reserve n bytes to write into directly and advance by what was written, or write to copy in; send is write then flush. -1 once the peer is gone.

+
+

EXAMPLES

+

A line echo server on raw TCP: read until a newline, answer, repeat:

+
static void echo(ioxd_pipe *pipe)
+{
+    for (;;) {
+        ioxd_slice live;
+        int rc = ioxd_pipe_read(pipe, &live);         /* one contiguous span, or waits */
+        if (rc <= 0)
+            return;                                   /* 0: the peer is done; <0: gone or FULL */
+        const char *nl = memchr(live.p, '\n', live.len);
+        if (!nl) {
+            ioxd_pipe_examine(pipe, live.len);        /* seen it all: the next read waits for more */
+            continue;
+        }
+        size_t n = (size_t)(nl - live.p) + 1;
+        if (ioxd_pipe_send(pipe, live.p, n) < 0)
+            return;
+        ioxd_pipe_drop(pipe, n);
+    }
+}
+
+int main(void)
+{
+    ioxd_bind(8100, NULL);
+    return ioxd_run_pipes(0, echo);
+}
+

SEE ALSO

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_tls(3), ioxd(7)

+
libioxd 0.1.02026-09-09IOXD_PIPE(3)
+
+ + diff --git a/manual/ioxd_router.html b/manual/ioxd_router.html new file mode 100644 index 0000000..48a8d45 --- /dev/null +++ b/manual/ioxd_router.html @@ -0,0 +1,142 @@ + + + + + +ioxd_router(3) - libioxd manual + + + + +
+
IOXD_ROUTER(3)libioxd Programmer's ManualIOXD_ROUTER(3)
+

NAME

+

ioxd/router.h - groups, endpoints and middleware, and the same as a script.

+

SYNOPSIS

+
#include <ioxd.h>
+
+typedef struct ioxd_group    ioxd_group;
+typedef struct ioxd_endpoint ioxd_endpoint;
+ioxd_group *ioxd_group_new(ioxd_group *parent, const char *prefix);
+void        ioxd_group_use(ioxd_group *group, ioxd_mw mw);
+ioxd_endpoint *ioxd_route(ioxd_group *group, const char *method, const char *path, ioxd_handler fn);
+void           ioxd_endpoint_use(ioxd_endpoint *endpoint, ioxd_mw mw);
+static inline ioxd_endpoint *ioxd_get   (ioxd_group *g, const char *path, ioxd_handler fn);
+static inline ioxd_endpoint *ioxd_post  (ioxd_group *g, const char *path, ioxd_handler fn);
+static inline ioxd_endpoint *ioxd_put   (ioxd_group *g, const char *path, ioxd_handler fn);
+static inline ioxd_endpoint *ioxd_patch (ioxd_group *g, const char *path, ioxd_handler fn);
+static inline ioxd_endpoint *ioxd_delete(ioxd_group *g, const char *path, ioxd_handler fn);
+void ioxd_use(ioxd_mw mw);
+void ioxd_default(ioxd_handler fn);
+#define IOXD_MAX_MW 16
+IOXD_GROUP(prefix, middleware...) { ... }
+IOXD_USE(middleware)
+IOXD_ROUTE(method, path, handler, middleware...)
+IOXD_GET(path, handler, middleware...)
+IOXD_POST(path, handler, middleware...)
+IOXD_PUT(path, handler, middleware...)
+IOXD_PATCH(path, handler, middleware...)
+IOXD_DELETE(path, handler, middleware...)
+IOXD_DEFAULT(handler)
+

DESCRIPTION

+

routing

+
+
+typedef struct ioxd_group    ioxd_group;
+typedef struct ioxd_endpoint ioxd_endpoint;
+ioxd_group *ioxd_group_new(ioxd_group *parent, const char *prefix);
+void        ioxd_group_use(ioxd_group *group, ioxd_mw mw);
+
+

Endpoints live in groups, and groups nest. A group is a path prefix plus middleware: an endpoint "/users" in a group "/api" under a group "/v1" answers at "/v1/api/users", wrapped by the middleware of every group above it, outermost first, then its own. NULL as the group is the root: no prefix, and the middleware given to ioxd_use. A prefix and what follows it are joined by a '/' when neither side brings one ("/api" and "users" is "/api/users"), and a repeated slash counts once.

+

Register everything before ioxd_run, from the main thread; methods, paths and prefixes are copied, so temporaries are fine, and anything registered once ioxd_run has started is ignored with a line on stderr. ioxd_run resolves it once: every endpoint's full path into a segment tree and its middleware into one flat chain, which the workers then share read-only. A request costs one walk down the tree - no scan, no regex - and one call through its chain.

+
+
ioxd_group_new
"/api"; "" for middleware only
+
ioxd_group_use
wraps everything below it
+
+
+
+
+ioxd_endpoint *ioxd_route(ioxd_group *group, const char *method, const char *path, ioxd_handler fn);
+void           ioxd_endpoint_use(ioxd_endpoint *endpoint, ioxd_mw mw);
+
+

An endpoint: method matched exactly, except that HEAD is answered by the GET of a path that has no HEAD of its own; path matched by segment below the group's prefix, with :name captures ("/users/:id") landing in req.route_params. A static segment beats a capture at any depth, and a static path that lacks the method falls through to a capture route that has it. A trailing slash is tolerated. Segments are matched as they arrive on the wire, so an escape in a static segment does not match it, but a captured value is handed over percent-decoded ("/users/a%2Fb" captures "a/b") - raw in the rare case that it does not fit IOXD_ROUTE_ARENA.

+
+
ioxd_endpoint_use
wraps this one only
+
+
+
+
+static inline ioxd_endpoint *ioxd_get   (ioxd_group *g, const char *path, ioxd_handler fn);
+static inline ioxd_endpoint *ioxd_post  (ioxd_group *g, const char *path, ioxd_handler fn);
+static inline ioxd_endpoint *ioxd_put   (ioxd_group *g, const char *path, ioxd_handler fn);
+static inline ioxd_endpoint *ioxd_patch (ioxd_group *g, const char *path, ioxd_handler fn);
+static inline ioxd_endpoint *ioxd_delete(ioxd_group *g, const char *path, ioxd_handler fn);
+
+

The verbs, for short: ioxd_get(api, "/users/:id", user).

+
+
+
+void ioxd_use(ioxd_mw mw);
+
+

Root middleware: every request, the fallbacks included.

+
+
+
+void ioxd_default(ioxd_handler fn);
+
+

The fallback when no path matches (a built-in 404 by default). A path that matches without the method gets a built-in 405 whose allow header lists every method that path has, on a capture route too. Both run behind the root's middleware only, so that allow header names methods a group's own middleware would otherwise have gated.

+
+

the same, as a script

+
+
+#define IOXD_MAX_MW 16
+
+

Registration as a block-structured script: a current group, which the block after IOXD_GROUP sets (the root outside any block), endpoints registered into it, with their own middleware listed after the handler, and IOXD_USE adding middleware to it - so a group's middleware is either listed after its prefix or added with IOXD_USE inside its block. Plain functions underneath, so everything is type-checked; a group's block runs exactly once, and leaving it early - break, return, goto - still closes the group.

+
IOXD_USE(log);
+IOXD_GET("/", home);
+IOXD_GROUP("/api", api_header) {
+    IOXD_GET("/ping", ping);
+    IOXD_GROUP("/admin", require_token) {
+        IOXD_GET("/stats", stats, timing);
+    }
+}
+
+
IOXD_MAX_MW
middleware per group and per endpoint
+
+
+
+
+IOXD_GROUP(prefix, middleware...) { ... }
+IOXD_USE(middleware)
+IOXD_ROUTE(method, path, handler, middleware...)
+IOXD_GET(path, handler, middleware...)
+IOXD_POST(path, handler, middleware...)
+IOXD_PUT(path, handler, middleware...)
+IOXD_PATCH(path, handler, middleware...)
+IOXD_DELETE(path, handler, middleware...)
+IOXD_DEFAULT(handler)
+
+

A break, a return or a goto out of the block skips the loop's increment, so where the compiler has __attribute__((cleanup)) the pop is hung on the block's variable and runs on every way out; a block that ended on its own has already popped and nulled it. Elsewhere the plain form stands, and a group still open at ioxd_run is reported.

+
+

EXAMPLES

+

The same registrations as calls and as a script:

+
ioxd_group *api = ioxd_group_new(NULL, "/api");
+ioxd_group_use(api, auth);
+ioxd_get(api, "/users/:id", user);                    /* GET /api/users/:id, behind auth */
+ioxd_endpoint_use(ioxd_post(api, "/users", create), audit);
+
+IOXD_USE(log);                                        /* root middleware: every request */
+IOXD_GROUP("/api", auth) {
+    IOXD_GET ("/users/:id", user);
+    IOXD_POST("/users",     create, audit);
+    IOXD_GROUP("/admin", require_token) {
+        IOXD_GET("/stats", stats);
+    }
+}
+IOXD_DEFAULT(not_found);
+

SEE ALSO

+

ioxd_config(3), ioxd_http(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd_tls(3), ioxd(7)

+
libioxd 0.1.02026-09-09IOXD_ROUTER(3)
+
+ + diff --git a/manual/ioxd_slice.html b/manual/ioxd_slice.html new file mode 100644 index 0000000..31aecfc --- /dev/null +++ b/manual/ioxd_slice.html @@ -0,0 +1,108 @@ + + + + + +ioxd_slice(3) - libioxd manual + + + + +
+
IOXD_SLICE(3)libioxd Programmer's ManualIOXD_SLICE(3)
+

NAME

+

ioxd/slice.h - a slice, bytes with a length, and a key/value pair of them: what every request carries. Compare and convert them without copying; parse a query string or a form body.

+

SYNOPSIS

+
#include <ioxd.h>
+
+typedef struct { const char *p; size_t len; } ioxd_slice;
+typedef struct { ioxd_slice key, value; } ioxd_kv;
+bool       ioxd_slice_eq         (ioxd_slice s, const char *cstr);
+bool       ioxd_slice_eq_ci      (ioxd_slice s, const char *cstr);
+bool       ioxd_slice_starts_with(ioxd_slice s, const char *prefix);
+bool       ioxd_slice_ends_with  (ioxd_slice s, const char *suffix);
+ioxd_slice ioxd_slice_trim       (ioxd_slice s);
+bool ioxd_cstr(ioxd_slice s, char *buf, size_t cap);
+bool ioxd_to_int   (ioxd_slice s, int      *out);
+bool ioxd_to_i64   (ioxd_slice s, int64_t  *out);
+bool ioxd_to_u64   (ioxd_slice s, uint64_t *out);
+bool ioxd_to_double(ioxd_slice s, double   *out);
+bool ioxd_to_bool  (ioxd_slice s, bool     *out);
+size_t ioxd_kv_parse(const char *text, size_t len, ioxd_kv *out, size_t cap, char *arena, size_t arena_cap, bool *truncated);
+

DESCRIPTION

+
+
+typedef struct { const char *p; size_t len; } ioxd_slice;
+
+

A slice: pointer + length, the C span. Not NUL-terminated.

+
+
+
+typedef struct { ioxd_slice key, value; } ioxd_kv;
+
+

One key/value pair of slices: a header, a query parameter, a route parameter.

+
+

slices

+
+
+bool       ioxd_slice_eq         (ioxd_slice s, const char *cstr);
+bool       ioxd_slice_eq_ci      (ioxd_slice s, const char *cstr);
+bool       ioxd_slice_starts_with(ioxd_slice s, const char *prefix);
+bool       ioxd_slice_ends_with  (ioxd_slice s, const char *suffix);
+ioxd_slice ioxd_slice_trim       (ioxd_slice s);
+
+

Everything a request carries is a slice: bytes with a length, not NUL-terminated, valid until the handler returns. These compare and convert one without copying it.

+
+
ioxd_slice_eq
exact
+
ioxd_slice_eq_ci
ASCII case-insensitive
+
ioxd_slice_trim
no leading/trailing space, tab, CR, LF
+
+
+
+
+bool ioxd_cstr(ioxd_slice s, char *buf, size_t cap);
+
+

A NUL-terminated copy in buf, for whatever wants a C string. False when it did not fit: buf then holds what fit, still terminated (cap 0 writes nothing) - and false when the slice holds a NUL of its own, which would end the C string early ("secret.txt%00.png" is not a PNG).

+
+
+
+bool ioxd_to_int   (ioxd_slice s, int      *out);
+bool ioxd_to_i64   (ioxd_slice s, int64_t  *out);
+bool ioxd_to_u64   (ioxd_slice s, uint64_t *out);
+bool ioxd_to_double(ioxd_slice s, double   *out);
+bool ioxd_to_bool  (ioxd_slice s, bool     *out);
+
+

Conversions. The whole slice must be the value - nothing around it, nothing after it - and a number that does not fit the type fails. On failure *out is left alone and false comes back, so "0" and "not a number" cannot be confused. Integers: an optional '-' and decimal digits. Doubles: also a fraction and an exponent ("2.5", ".5", "1e-3"); never inf, nan or hex; too large fails, too small rounds towards zero. Booleans: true/false, 1/0, yes/no, on/off, any case.

+
+
+
+size_t ioxd_kv_parse(const char *text, size_t len, ioxd_kv *out, size_t cap, char *arena, size_t arena_cap,
+                     bool *truncated);
+
+

Parse "k=v&k2=v2" - a query string, a form body - into out, up to cap pairs. Keys and values that need it ('+', %XX) are decoded into arena and point there; the rest are views of s. A malformed %XX and %00 stay as written. Returns the pair count; *truncated (may be NULL) is set when a pair was left out - past cap, or not fitting the arena - so the caller can refuse the request rather than act on part of it. Every pair is returned, duplicates included, in order: pick a policy (first or last) and keep to it.

+
+

EXAMPLES

+

Strict conversions: the whole slice is the value, or the call fails and *out is untouched:

+
int64_t id;
+double  price;
+bool    on;
+if (!ioxd_to_i64(ctx->req.route_params[0].value, &id))     /* "42", "-7"; not "42x", not " 42" */
+    ...
+if (ioxd_to_double(v, &price) && ioxd_to_bool(w, &on))      /* "2.5", "1e-3"; "yes", "off" */
+    ...
+

A form body parsed like a query string:

+
ioxd_slice body = ioxd_body_all(ctx);
+ioxd_kv    form[8];
+char       arena[512];
+bool       truncated;
+size_t n = ioxd_kv_parse(body.p, body.len, form, 8, arena, sizeof arena, &truncated);
+if (truncated) { ctx->res.status = 400; return; }        /* never act on part of it */
+for (size_t i = 0; i < n; i++)
+    if (ioxd_slice_eq(form[i].key, "name"))
+        ioxd_printf(ctx, "hello %.*s\n", (int)form[i].value.len, form[i].value.p);
+

SEE ALSO

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_json(3), ioxd_pipe(3), ioxd_tls(3), ioxd(7)

+
libioxd 0.1.02026-09-09IOXD_SLICE(3)
+
+ + diff --git a/manual/ioxd_tls.html b/manual/ioxd_tls.html new file mode 100644 index 0000000..e2e4d00 --- /dev/null +++ b/manual/ioxd_tls.html @@ -0,0 +1,54 @@ + + + + + +ioxd_tls(3) - libioxd manual + + + + +
+
IOXD_TLS(3)libioxd Programmer's ManualIOXD_TLS(3)
+

NAME

+

ioxd/tls.h - TLS 1.3, terminated in the kernel after an OpenSSL handshake (TLS.md): a store of certificates to listen with.

+

SYNOPSIS

+
#include <ioxd.h>
+
+ioxd_certs *ioxd_certs_load(const char *dir);
+int ioxd_certs_reload(ioxd_certs *certs);
+void ioxd_certs_free(ioxd_certs *certs);
+

DESCRIPTION

+
+
+ioxd_certs *ioxd_certs_load(const char *dir);
+
+

A store from a directory: <dir>/<host>/cert.pem (the chain) and key.pem for each hostname, `default` for no SNI or no match, `_.example.com` for *.example.com. `default` is required - without it nothing can answer a name we do not have. NULL, with the reason on stderr, when nothing loads or the build has no TLS. Then: ioxd_bind(port, store).

+
+
+
+int ioxd_certs_reload(ioxd_certs *certs);
+
+

Read the directory again and switch to what it holds. A host that fails to load - unreadable, mismatched, not valid yet, expired - keeps its old certificate, and the host answering for unmatched SNI keeps answering. Safe while serving: handshakes in flight finish on the table they started with, and reloads serialise against each other. 0, or -1 when nothing could be loaded at all, in which case what was serving still is.

+
+
+
+void ioxd_certs_free(ioxd_certs *certs);
+
+

Give the store back: its certificates and the store itself, once the last handshake holding a table of it has finished. Not while a listener still uses it - every TLS connection takes a reference through the store - so this is for a store that was never listened on, or for after ioxd_run has returned. NULL is a no-op.

+
+

EXAMPLES

+

A TLS port beside a plain one; the files rotated, then reloaded:

+
ioxd_certs *certs = ioxd_certs_load("/etc/ioxd/certs");   /* <dir>/<host>/cert.pem and key.pem; `default` required */
+if (!certs)
+    return 1;
+ioxd_bind(8080, NULL);
+ioxd_bind(8443, certs);
+/* ... later, after new files were written: */
+ioxd_certs_reload(certs);                               /* a host that fails keeps its old certificate */
+

SEE ALSO

+

ioxd_config(3), ioxd_http(3), ioxd_router(3), ioxd_slice(3), ioxd_json(3), ioxd_pipe(3), ioxd(7)

+
libioxd 0.1.02026-09-09IOXD_TLS(3)
+
+ + diff --git a/manual/style.css b/manual/style.css new file mode 100644 index 0000000..7691c67 --- /dev/null +++ b/manual/style.css @@ -0,0 +1,46 @@ +/* The manual's look: a man page, as man7.org renders one - monospace, sections in capitals at the + * margin, the text indented under them - with links, anchors and a light nav bar on top. */ +:root { + --paper: #ffffff; --ink: #111111; --dim: #555555; --rule: #d8d8d8; --link: #1a3fbf; --code: #f4f4f4; +} +@media (prefers-color-scheme: dark) { + :root { --paper: #111213; --ink: #e6e6e6; --dim: #a0a0a0; --rule: #333; --link: #7da2ff; --code: #1c1e21; } +} +html { background: var(--paper); } +body { + margin: 0; color: var(--ink); background: var(--paper); + font: 14px/1.5 "DejaVu Sans Mono", "Liberation Mono", Menlo, Consolas, ui-monospace, monospace; +} +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +.crumbs { padding: .5em 1.5em; border-bottom: 1px solid var(--rule); color: var(--dim); } +main { max-width: 100ch; margin: 0 auto; padding: 1em 1.5em 4em; } +.hdr, .ftr { display: flex; justify-content: space-between; font-weight: bold; } +.ftr { margin-top: 3em; font-weight: normal; color: var(--dim); } +h1 { font-size: 1em; font-weight: bold; margin: 1.4em 0 .5em; } +h2 { font-size: 1em; font-weight: bold; margin: 1.8em 0 .5em; letter-spacing: .02em; } +h3 { font-size: 1em; font-weight: bold; margin: 1.4em 0 .4em 3ch; } +h3::before { content: ""; } +main > p, .entry, dl, pre, .text { margin-left: 7ch; } +main > pre.ex, main > pre.syn { margin-left: 7ch; } +p { margin: .5em 0; } +pre { margin: .5em 0; white-space: pre-wrap; word-break: break-word; } +pre.syn { padding: .6em 1em; background: var(--code); border-left: 3px solid var(--rule); } +pre.decl { font-weight: bold; margin: 1.2em 0 .3em; } +pre.ex { padding: .6em 1em; background: var(--code); border-left: 3px solid var(--rule); } +.entry { margin-top: .4em; } +.entry .text { margin-left: 4ch; } +.entry .text pre.ex, .text pre.ex { margin-left: 0; } +dl.trail { margin: .3em 0 0 4ch; } +dl.trail dt { font-weight: bold; margin-top: .4em; } +dl.trail dd { margin: 0 0 0 4ch; color: var(--ink); } +dl.files dt { font-weight: bold; margin-top: .6em; } +dl.files dd { margin: 0 0 0 4ch; } +table { border-collapse: collapse; margin-left: 7ch; } +td, th { text-align: left; padding: .25em 1.5em .25em 0; vertical-align: top; } +th { font-weight: bold; } +.dim { color: var(--dim); } +@media (max-width: 700px) { + main > p, .entry, dl, pre, .text, table, h3 { margin-left: 1ch; } + .hdr span:nth-child(2), .ftr span:nth-child(2) { display: none; } +} diff --git a/playground/hello/main.c b/playground/hello/main.c index 2825a50..95da15d 100644 --- a/playground/hello/main.c +++ b/playground/hello/main.c @@ -1,49 +1,132 @@ /* - * playground/hello - a small libioma server: two routes, one worker per core. + * playground/hello - a small libioxd server: three routes, one worker per core, and the same + * routes over TLS when it is given a directory of certificates. * - * make && ./ioma-hello + * make && ./ioxd-hello * curl http://127.0.0.1:8080/hello/diogo + * curl http://127.0.0.1:8080/users/42 * curl -d 'knock' http://127.0.0.1:8080/repeat/25 * - * Against an installed libioma: cc main.c $(pkg-config --cflags --libs ioma) -o hello + * sh tests/mkcerts.sh certs && ./ioxd-hello certs # self-signed, so curl needs -k + * curl -k https://127.0.0.1:8443/hello/diogo + * curl -k --resolve sni.test:8443:127.0.0.1 https://sni.test:8443/users/42 + * + * Against an installed libioxd: cc main.c $(pkg-config --cflags --libs ioxd) -o hello */ -#include +#include /* GET /hello/:name - the capture is the first route parameter; the reply goes into the slab. */ -static void hello(ioma_ctx *ctx) +static void hello(ioxd_ctx *ctx) { - ioma_slice name = ctx->req.route_params[0].value; - ioma_printf(ctx, "hello %.*s\n", (int)name.len, name.p); + ioxd_slice name = ctx->req.route_params[0].value; + ioxd_printf(ctx, "hello %.*s\n", (int)name.len, name.p); } /* POST /repeat/:times - reads the body, then streams it back that many times as numbered lines. - * Each write lands in the reply slab; ioma_flush every ten of them sends what is there, so the + * Each write lands in the reply slab; ioxd_flush every ten of them sends what is there, so the * client sees lines as they are produced instead of one reply at the end. The first flush sends * the head and the body streams chunked from then on. The slab also goes out by itself whenever * it fills, and whatever is left goes out when the handler returns. A write or a flush returns -1 * once the peer is gone. */ -static void repeat(ioma_ctx *ctx) +static void repeat(ioxd_ctx *ctx) { int64_t times; - if (!ioma_to_i64(ctx->req.route_params[0].value, ×) || times < 1) { + if (!ioxd_to_i64(ctx->req.route_params[0].value, ×) || times < 1) { ctx->res.status = 400; - ioma_text(ctx, "usage: POST a body to /repeat/\n"); + ioxd_text(ctx, "usage: POST a body to /repeat/\n"); return; } - ioma_slice body = ioma_body_all(ctx); /* the whole body; over 16 KB it is refused */ + ioxd_slice body = ioxd_body_all(ctx); /* the whole body; over 16 KB it is refused */ if (ctx->res.status != 200) return; /* 413: the engine sends it, nothing to add */ for (int64_t i = 1; i <= times; i++) { - if (ioma_printf(ctx, "%lld: %.*s\n", (long long)i, (int)body.len, body.p) < 0) + if (ioxd_printf(ctx, "%lld: %.*s\n", (long long)i, (int)body.len, body.p) < 0) return; - if (i % 10 == 0 && ioma_flush(ctx) < 0) + if (i % 10 == 0 && ioxd_flush(ctx) < 0) return; } } -int main(void) +/* The shapes GET /users/:id replies with, described once: each list defines the struct and its + * *_to_json function. A line is the field's kind, its type (or the nested struct's name) and its + * name; ARRAY and OBJECTS lines add the field that holds the count. */ +#define ADDRESS_FIELDS(X) \ + X(VALUE, const char *, city) \ + X(VALUE, const char *, zip) /* NULL comes out as null */ +IOXD_JSON_STRUCT(address, ADDRESS_FIELDS) + +#define ORDER_FIELDS(X) \ + X(VALUE, int, number) \ + X(VALUE, double, total) +IOXD_JSON_STRUCT(order, ORDER_FIELDS) + +#define USER_FIELDS(X) \ + X(VALUE, int64_t, id) \ + X(VALUE, const char *, name) \ + X(VALUE, bool, active) \ + X(OBJECT, address, address) /* nested, by value */ \ + X(OPTIONAL, address, billing) /* a pointer: null when there is none */ \ + X(ARRAY, const char *, tags, n_tags) /* scalars, and the count field */ \ + X(OBJECTS, order, orders, n_orders) /* nested objects, and the count */ +IOXD_JSON_STRUCT(user, USER_FIELDS) + +/* GET /users/:id - fill the struct, serialize it with one call. The document goes straight into + * the reply, escaped, streamed chunked if it outgrows the slab; ioxd_json_reply sets the content + * type. */ +static void user_endpoint(ioxd_ctx *ctx) { - IOMA_GET ("/hello/:name", hello); - IOMA_POST("/repeat/:times", repeat); - return ioma_run(0, 8080); + int64_t id; + if (!ioxd_to_i64(ctx->req.route_params[0].value, &id)) { + ctx->res.status = 400; + ioxd_text(ctx, "the id must be an integer\n"); + return; + } + const char *tags[] = { "new", "c23" }; + struct order orders[] = { { 1, 9.5 }, { 2, 0.25 } }; + struct user u = { + .id = id, .name = "Zo\xc3\xab \"Z\" O'Neil", .active = id % 2 == 0, + .address = { .city = "Porto", .zip = NULL }, + .billing = NULL, + .tags = tags, .n_tags = 2, + .orders = orders, .n_orders = id % 2 == 0 ? 2 : 0, + }; + ioxd_json j = ioxd_json_reply(ctx); + user_to_json(&j, &u); +} + +/* The routes, the runtime's knobs, the ports, then the run. Plain HTTP on 8080; with a + * certificate directory on the command line the same routes on 8443 over TLS 1.3 as well. The + * store holds one host per subdirectory - //cert.pem and key.pem - and the client's + * SNI picks the host, `default` answering for no name or an unknown one (TLS.md). The handshake + * is OpenSSL's; from then on the kernel encrypts and decrypts, and a handler cannot tell the two + * ports apart. */ +int main(int argc, char **argv) +{ + IOXD_GET ("/hello/:name", hello); + IOXD_GET ("/users/:id", user_endpoint); + IOXD_POST("/repeat/:times", repeat); + + /* Every field is per worker, and a zero keeps the build's default; a value the kernel or the + * engine would refuse is refused here, with the reason on stderr. The receive buffers are + * what the kernel delivers into: their count bounds how many deliveries may be in flight + * before recvs park (the log then says to raise it), their size what one delivery holds. */ + ioxd_config config = { + .ring_entries = 4096, /* submission queue depth; the completion queue is twice that */ + .recv_buffers = 8192, /* a power of two, at most 32768; 4096 by default */ + .recv_buffer_size = 2048, /* bytes in each: a request head rarely needs more */ + .stack_size = 128UL * 1024, /* a connection's coroutine stack, above a 64 KB guard */ + .idle_stacks = 512, /* kept warm between connections, so churn pays no mmap */ + .idle_connections = 1024, /* connection records kept warm, the same way */ + }; + if (ioxd_configure(&config) < 0) + return 1; + + ioxd_bind(8080, NULL); + if (argc > 1) { + ioxd_certs *certs = ioxd_certs_load(argv[1]); + if (!certs) + return 1; /* the reason is on stderr: no `default`, a bad key, a TLS=0 build */ + ioxd_bind(8443, certs); + } + return ioxd_run(0); /* one worker per core, over both ports */ } diff --git a/src/http/engine.c b/src/http/engine.c deleted file mode 100644 index d9daa82..0000000 --- a/src/http/engine.c +++ /dev/null @@ -1,900 +0,0 @@ -/* - * engine.c - the HTTP/1.1 engine: parse a request head with picohttpparser, run the middleware - * chain and the endpoint against a context, read the body on demand (whole or streamed) and - * drain what was left, then send what was written. All of it runs on the connection's coroutine, - * so await_recv and await_send simply suspend it and the loop resumes it. - */ -#include "http/internal.h" -#include "picohttpparser.h" - -#include -#include -#include -#include - -#ifndef IOMA_REQ_CAP -#define IOMA_REQ_CAP 16384 /* the head, and a body read whole, must fit here */ -#endif -#ifndef IOMA_PARAM_CAP -#define IOMA_PARAM_CAP 2048 /* per-request arena for percent-decoded query parameters */ -#endif -#ifndef IOMA_HEAD_CAP -#define IOMA_HEAD_CAP 4096 /* a serialized reply head must fit here */ -#endif -#ifndef IOMA_OUT_CAP -#define IOMA_OUT_CAP 8192 /* the write slab: body bytes buffered before a reply streams */ -#endif -#ifndef IOMA_DRAIN_MAX -#define IOMA_DRAIN_MAX (1024UL * 1024) /* unread body discarded after a handler before we close instead */ -#endif -#define IOMA_LEAD 512 /* room in front of the slab for the reply head or a chunk size */ - -/* serve() hands req.headers to picohttpparser as its header array: a kv (two slices) must lay - * out exactly like a phr_header (name, name_len, value, value_len). */ -static_assert(sizeof(ioma_kv) == sizeof(struct phr_header), "ioma_kv must mirror phr_header"); -static_assert(offsetof(ioma_kv, key) == offsetof(struct phr_header, name) && - offsetof(ioma_slice, len) == offsetof(struct phr_header, name_len) && - offsetof(ioma_kv, value) == offsetof(struct phr_header, value), - "ioma_kv must mirror phr_header"); - -/* The engine's per-request state, behind ctx->priv. */ -struct serve_state { - conn_t *conn; - char *read_buf; /* the read buffer (IOMA_REQ_CAP): head, then body */ - size_t filled; /* bytes received into it: the head and, for a */ - /* Content-Length body, its bytes as they arrive */ - size_t head_len; /* where the body starts */ - size_t body_read; /* body bytes handed out so far */ - bool body_done; /* the whole body has been taken off the wire */ - bool body_whole; /* ioma_body_all read it into read_buf */ - int body_err; /* 0, a status to answer (400, 413), or -1: peer gone */ - size_t next_req_off, next_req_len; /* the next pipelined request's bytes, in read_buf */ - /* a chunked body: its raw bytes are staged in read_buf and decoded from there */ - size_t raw_pos, raw_end; /* raw bytes not yet consumed: read_buf[raw_pos, raw_end) */ - size_t stage_floor; /* the stage stays above this: the head, plus a whole */ - /* read's decoded body so far */ - size_t chunk_left; /* data bytes of the current chunk still to deliver */ -}; -#define STATE(ctx) ((struct serve_state *)(ctx)->priv) - -/* ── request headers ───────────────────────────────────────────────────────────────────── */ - -/* Fold A-Z to a-z; every other byte unchanged. */ -static inline unsigned char lower_ascii(unsigned char a) -{ - return (unsigned)(a - 'A') < 26U ? (unsigned char)(a | 0x20U) : a; -} - -/* Case-insensitive equality of two slices: a length test, then a byte loop. No libc, no locale. */ -static bool eq_ci(const char *a, size_t an, const char *b, size_t bn) -{ - if (an != bn) - return false; - for (size_t i = 0; i < an; i++) - if (lower_ascii((unsigned char)a[i]) != lower_ascii((unsigned char)b[i])) - return false; - return true; -} - -/* Parse a decimal size; stops at the first non-digit. */ -static size_t parse_size(const char *s, size_t n) -{ - size_t v = 0; - for (size_t i = 0; i < n; i++) { - if (s[i] < '0' || s[i] > '9') break; - v = v * 10 + (size_t)(s[i] - '0'); - } - return v; -} - -/* Is `tok` one of the comma-separated tokens in the header value [s, s+n)? Case-insensitive. */ -static bool token_present_ci(const char *value, size_t len, const char *tok) -{ - size_t tok_len = strlen(tok); - size_t at = 0; - while (at < len) { - while (at < len && (value[at] == ' ' || value[at] == ',' || value[at] == '\t')) at++; - size_t start = at; - while (at < len && value[at] != ',') at++; - size_t end = at; /* trim trailing blanks */ - while (end > start && (value[end - 1] == ' ' || value[end - 1] == '\t')) end--; - if (eq_ci(value + start, end - start, tok, tok_len)) return true; - at++; - } - return false; -} - -/* The three headers the engine itself needs; p == nullptr when absent. */ -struct picked_headers { - ioma_slice content_length, transfer_enc, connection; -}; - -/* Lower-case ASCII in place, eight bytes per step. The bytes must all be below 0x80 - true for - * header names, which picohttpparser only accepts as HTTP tokens - so the adds cannot carry - * between bytes: +0x3f sets a byte's high bit from 'A' up, +0x25 from 'Z'+1 up, and the - * difference marks exactly 'A'..'Z'. */ -static inline void lower_inplace(char *s, size_t n) -{ - size_t i = 0; - for (; i + 8 <= n; i += 8) { - uint64_t w; - memcpy(&w, s + i, 8); - uint64_t upper = ((w + 0x3f3f3f3f3f3f3f3fULL) & ~(w + 0x2525252525252525ULL)) & 0x8080808080808080ULL; - w |= upper >> 2; /* 0x80 >> 2 == 0x20 */ - memcpy(s + i, &w, 8); - } - for (; i < n; i++) - if ((unsigned)(s[i] - 'A') < 26U) s[i] += 'a' - 'A'; -} - -/* One pass over the request headers: lower-case each name in place (the buffer is ours), so - * handlers and this switch compare with plain memcmp. The switch on the name length rejects - * nearly every header before a byte is compared. */ -static struct picked_headers pick_headers(ioma_request *req) -{ - struct picked_headers picked = { { nullptr, 0 }, { nullptr, 0 }, { nullptr, 0 } }; - for (size_t i = 0; i < req->n_headers; i++) { - ioma_kv *hdr = &req->headers[i]; - char *name = (char *)hdr->key.p; - lower_inplace(name, hdr->key.len); - switch (hdr->key.len) { - case 14: - if (memcmp(name, "content-length", 14) == 0) picked.content_length = hdr->value; - break; - case 17: - if (memcmp(name, "transfer-encoding", 17) == 0) picked.transfer_enc = hdr->value; - break; - case 10: - if (memcmp(name, "connection", 10) == 0) picked.connection = hdr->value; - break; - default: - break; - } - } - return picked; -} - -/* HTTP/1.1 keeps alive unless "close"; HTTP/1.0 only with "keep-alive". */ -static bool keep_alive_from(int minor_version, ioma_slice connection) -{ - bool keep = minor_version >= 1; - if (connection.p) { - if (token_present_ci(connection.p, connection.len, "close")) keep = false; - else if (token_present_ci(connection.p, connection.len, "keep-alive")) keep = true; - } - return keep; -} - -/* ── the reply: head serialization and the write slab ──────────────────────────────────── */ - -/* Write v in decimal at dst; return the digit count. A digit loop, no printf. */ -static inline int put_uint(char *dst, size_t v) -{ - char tmp[20]; - int i = 0; - do { - tmp[i++] = (char)('0' + (v % 10)); - v /= 10; - } while (v); - for (int j = 0; j < i; j++) - dst[j] = tmp[i - 1 - j]; - return i; -} - -/* The same in hex, for chunk sizes. */ -static inline int put_hex(char *dst, size_t v) -{ - static const char digits[] = "0123456789abcdef"; - char tmp[16]; - int i = 0; - do { - tmp[i++] = digits[v & 15]; - v >>= 4; - } while (v); - for (int j = 0; j < i; j++) - dst[j] = tmp[i - 1 - j]; - return i; -} - -/* A constant slice: pointer + length, so a precomposed line is one memcpy. */ -struct cslice { const char *p; int len; }; -#define CSLICE(lit) (struct cslice){ (lit), (int)(sizeof(lit) - 1) } - -/* The precomposed status line for the common codes; nullptr for the rest (built on the spot). */ -static struct cslice status_line(int code) -{ - switch (code) { - case 200: return CSLICE("HTTP/1.1 200 OK\r\n"); - case 204: return CSLICE("HTTP/1.1 204 No Content\r\n"); - case 400: return CSLICE("HTTP/1.1 400 Bad Request\r\n"); - case 404: return CSLICE("HTTP/1.1 404 Not Found\r\n"); - case 405: return CSLICE("HTTP/1.1 405 Method Not Allowed\r\n"); - case 500: return CSLICE("HTTP/1.1 500 Internal Server Error\r\n"); - default: return (struct cslice){ nullptr, 0 }; - } -} - -/* A bodyless framework reply (parse errors, limits): an error path, so plain snprintf. Best - * effort; the caller then closes. */ -static void send_status(conn_t *conn, int code) -{ - char head[128]; - int len = snprintf(head, sizeof head, "HTTP/1.1 %d %s\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", - code, ioma_reason(code)); - await_send(conn, head, (size_t)len); -} - -/* How the body is delimited on the wire. */ -enum framing { - FRAME_LENGTH, - FRAME_CHUNKED, - FRAME_UNTIL_CLOSE, -}; - -/* Serialize the head into dst by memcpy of precomposed pieces plus the integer writer - no - * snprintf. Every field name goes out lower-cased: the engine's own are lowercase literals, a - * handler's are folded as they are copied. Returns the length, or -1 if it does not fit. */ -static int build_head(const ioma_ctx *ctx, char *dst, size_t cap, enum framing framing, size_t body_len) -{ - const ioma_response *res = &ctx->res; - char *p = dst; - char *end = dst + cap; - -#define NEED(n) do { if ((size_t)(end - p) < (size_t)(n)) return -1; } while (0) -#define PUT(src, n) do { NEED(n); memcpy(p, (src), (size_t)(n)); p += (n); } while (0) -#define PUTC(lit) PUT((lit), sizeof(lit) - 1) - - struct cslice line = status_line(res->status); - if (line.p) { - PUT(line.p, line.len); - } else { - PUTC("HTTP/1.1 "); - NEED(3); - p += put_uint(p, (size_t)res->status); - PUTC(" "); - const char *reason = ioma_reason(res->status); - PUT(reason, strlen(reason)); - PUTC("\r\n"); - } - - PUTC("content-type: "); - PUT(res->content_type.p, res->content_type.len); - PUTC("\r\n"); - - if (framing == FRAME_LENGTH) { - PUTC("content-length: "); - NEED(20); - p += put_uint(p, body_len); - PUTC("\r\n"); - } else if (framing == FRAME_CHUNKED) { - PUTC("transfer-encoding: chunked\r\n"); - } - - /* Connection: only when it says something. HTTP/1.1 is persistent by default, so a kept-alive - * 1.1 reply carries none; a 1.0 client that asked for keep-alive is told it got it; a closing - * reply always says close. */ - bool keep = ctx->req.keep_alive && !res->close; - if (!keep) - PUTC("connection: close\r\n"); - else if (ctx->req.minor_version == 0) - PUTC("connection: keep-alive\r\n"); - - for (size_t i = 0; i < res->n_headers; i++) { /* names go out lower-cased */ - NEED(res->headers[i].key.len); - for (size_t j = 0; j < res->headers[i].key.len; j++) - *p++ = (char)lower_ascii((unsigned char)res->headers[i].key.p[j]); - PUTC(": "); - PUT(res->headers[i].value.p, res->headers[i].value.len); - PUTC("\r\n"); - } - - PUTC("\r\n"); -#undef PUTC -#undef PUT -#undef NEED - return (int)(p - dst); -} - -/* Wrap the slab's bytes as one chunk: the size line goes just before them (into the lead), the - * CRLF just after (into the slack). start/total describe the framed span. */ -static void frame_chunk(char *body, size_t len, char **start, size_t *total) -{ - char size_line[16]; - int digits = put_hex(size_line, len); - char *at = body - (digits + 2); - memcpy(at, size_line, (size_t)digits); - at[digits] = '\r'; - at[digits + 1] = '\n'; - body[len] = '\r'; - body[len + 1] = '\n'; - *start = at; - *total = len + (size_t)digits + 4; -} - -/* Mark the reply dead (the peer is gone, or a head that cannot be built) and fail the call. */ -static int fail(ioma_response *res) -{ - res->failed = true; - return -1; -} - -/* Send the slab, with the head in front of it the first time. That first time decides the - * framing: a final flush with the head unsent means the whole body is here (Content-Length, one - * send); an early flush means the body outgrew the slab, so it streams - with the declared length - * if the handler gave one, else chunked on HTTP/1.1, else until close on HTTP/1.0. */ -static int flush(ioma_ctx *ctx, bool final) -{ - ioma_response *res = &ctx->res; - conn_t *conn = STATE(ctx)->conn; - if (res->failed) - return -1; - - char head[IOMA_HEAD_CAP]; - int head_len = 0; - if (!res->head_sent) { /* the first send: decide the framing */ - enum framing framing = FRAME_LENGTH; - size_t body_len = res->has_length ? res->content_length : res->len; - if (!res->has_length && !final) { - if (ctx->req.minor_version >= 1) { - framing = FRAME_CHUNKED; - res->chunked = true; - } else { - framing = FRAME_UNTIL_CLOSE; - res->close = true; - } - } - head_len = build_head(ctx, head, sizeof head, framing, body_len); - if (head_len < 0) - return fail(res); - res->head_sent = true; - } - - char *start = res->buf; /* the span to send */ - size_t total = res->len; - if (res->chunked && res->len) - frame_chunk(res->buf, res->len, &start, &total); - - if (head_len) { - size_t lead_room = (size_t)(start - (res->buf - IOMA_LEAD)); /* free bytes in front of the span */ - if ((size_t)head_len <= lead_room) { /* prepend: one contiguous send */ - start -= head_len; - memcpy(start, head, (size_t)head_len); - total += (size_t)head_len; - } else if (await_send(conn, head, (size_t)head_len) < 0) { - return fail(res); - } - } - - res->len = 0; - if (total && await_send(conn, start, total) < 0) - return fail(res); - return 0; -} - -/* After the chain: send what is left - the whole reply if nothing went out yet - and close a - * chunked stream. */ -static int finish(ioma_ctx *ctx) -{ - ioma_response *res = &ctx->res; - if (res->failed) - return -1; - bool pending = !res->head_sent || res->len; /* nothing sent yet, or bytes still in the slab */ - if (pending && flush(ctx, true) < 0) - return -1; - if (res->chunked && await_send(STATE(ctx)->conn, "0\r\n\r\n", 5) < 0) - return -1; - return 0; -} - -/* Append body bytes to the slab; send it, head first, whenever it fills. */ -int ioma_write(ioma_ctx *ctx, const void *data, size_t len) -{ - ioma_response *res = &ctx->res; - if (res->failed) - return -1; - const char *src = data; - while (len) { - size_t room = res->cap - res->len; - if (room == 0) { - if (flush(ctx, false) < 0) - return -1; - continue; - } - size_t n = len < room ? len : room; - memcpy(res->buf + res->len, src, n); - res->len += n; - src += n; - len -= n; - } - return 0; -} - -/* Something bigger than the whole slab: format it on the heap and write it in pieces. */ -static int write_formatted_heap(ioma_ctx *ctx, const char *fmt, va_list ap, size_t len) -{ - char *tmp = malloc(len + 1); - if (!tmp) - return -1; - vsnprintf(tmp, len + 1, fmt, ap); - int rc = ioma_write(ctx, tmp, len); - free(tmp); - return rc; -} - -/* Format straight into the slab. If it does not fit the room left, flush and format again into - * the empty slab; if it would not fit even that, it goes through the heap. */ -int ioma_printf(ioma_ctx *ctx, const char *fmt, ...) -{ - ioma_response *res = &ctx->res; - if (res->failed) - return -1; - va_list ap, again; - va_start(ap, fmt); - va_copy(again, ap); - size_t room = res->cap - res->len; - int n = vsnprintf(res->buf + res->len, room, fmt, ap); - va_end(ap); - - int rc = -1; - if (n < 0) { - /* a formatting error: nothing written */ - } else if ((size_t)n < room) { /* it fit */ - res->len += (size_t)n; - rc = 0; - } else if ((size_t)n >= res->cap) { /* bigger than the slab itself */ - rc = write_formatted_heap(ctx, fmt, again, (size_t)n); - } else if (flush(ctx, false) == 0) { /* make room, then it fits */ - res->len += (size_t)vsnprintf(res->buf, res->cap, fmt, again); - rc = 0; - } - va_end(again); - return rc; -} - -/* Send what is in the slab now. Starts streaming: the head goes out with it. */ -int ioma_flush(ioma_ctx *ctx) -{ - return flush(ctx, false); -} - -/* ── the body: read on demand ──────────────────────────────────────────────────────────── */ - -/* A body failure with a status: the engine answers with it after the handler unless a reply is - * already streaming, and res.status shows it so a handler can stop before it writes anything. */ -static void body_fail(ioma_ctx *ctx, int status) -{ - STATE(ctx)->body_err = status; - ctx->res.status = status; -} - -/* --- a Content-Length body --- */ - -/* Up to n body bytes into dst: first the ones that arrived with the head, then straight from the - * wire, never past the declared length (a pipelined request is never consumed). One receive's - * worth; the count, or -1 when the peer is gone. */ -static int fixed_data(ioma_ctx *ctx, struct serve_state *state, char *dst, size_t n) -{ - size_t remaining = ctx->req.content_length - state->body_read; - if (n > remaining) - n = remaining; - size_t buffered = state->filled - state->head_len; /* body bytes that came with the head */ - if (state->body_read < buffered) { - if (n > buffered - state->body_read) - n = buffered - state->body_read; - memcpy(dst, state->read_buf + state->head_len + state->body_read, n); - } else { - int got = await_recv(state->conn, dst, n); - if (got <= 0) { - state->body_err = -1; - return -1; - } - n = (size_t)got; - } - state->body_read += n; - if (state->body_read == ctx->req.content_length) - state->body_done = true; - return (int)n; -} - -/* --- a chunked body: the raw bytes are staged in read_buf, after the head --- */ - -/* More raw bytes into the stage, read_buf[raw_pos, raw_end). Below stage_floor the buffer is - * spoken for, so an empty stage restarts there and a full one is compacted down to it. false with - * the failure recorded: no room at all (413), or the peer gone. */ -static bool raw_fill(ioma_ctx *ctx, struct serve_state *state) -{ - char *buf = state->read_buf; - if (state->raw_pos == state->raw_end) { - state->raw_pos = state->raw_end = state->stage_floor; - } else if (state->raw_end == IOMA_REQ_CAP && state->raw_pos > state->stage_floor) { - size_t held = state->raw_end - state->raw_pos; - memmove(buf + state->stage_floor, buf + state->raw_pos, held); - state->raw_pos = state->stage_floor; - state->raw_end = state->stage_floor + held; - } - if (state->raw_end == IOMA_REQ_CAP) { /* a size line, or a whole body, too big */ - body_fail(ctx, 413); - return false; - } - int n = await_recv(state->conn, buf + state->raw_end, IOMA_REQ_CAP - state->raw_end); - if (n <= 0) { - state->body_err = -1; - return false; - } - state->raw_end += (size_t)n; - return true; -} - -/* The line at the front of the stage, filling until its CRLF is there. Its length without the - * CRLF, or -1 with the failure recorded. */ -static long raw_line(ioma_ctx *ctx, struct serve_state *state) -{ - for (;;) { - char *line = state->read_buf + state->raw_pos; - char *eol = memmem(line, state->raw_end - state->raw_pos, "\r\n", 2); - if (eol) - return eol - line; - if (!raw_fill(ctx, state)) - return -1; - } -} - -/* After the last chunk: trailer lines up to an empty one, then the body is done and whatever - * follows is the next request. */ -static bool chunk_trailers(ioma_ctx *ctx, struct serve_state *state) -{ - for (;;) { - long len = raw_line(ctx, state); - if (len < 0) - return false; - state->raw_pos += (size_t)len + 2; - if (len == 0) - break; - } - state->body_done = true; - state->next_req_off = state->raw_pos; - state->next_req_len = state->raw_end - state->raw_pos; - return true; -} - -/* The next chunk's size line - hex digits, an optional extension, CRLF - into chunk_left. The - * last chunk (size 0) also takes its trailers and ends the body. */ -static bool chunk_header(ioma_ctx *ctx, struct serve_state *state) -{ - long len = raw_line(ctx, state); - if (len < 0) - return false; - const char *line = state->read_buf + state->raw_pos; - size_t size = 0, i = 0; - for (; i < (size_t)len; i++) { - int digit = ioma__hexval((unsigned char)line[i]); - if (digit < 0) - break; - if (size > (SIZE_MAX >> 4)) { - body_fail(ctx, 400); - return false; - } - size = (size << 4) | (size_t)digit; - } - bool ended = i == (size_t)len || line[i] == ';' || line[i] == ' ' || line[i] == '\t'; - if (i == 0 || !ended) { - body_fail(ctx, 400); - return false; - } - state->raw_pos += (size_t)len + 2; - if (size == 0) - return chunk_trailers(ctx, state); - state->chunk_left = size; - return true; -} - -/* Up to n data bytes of the current chunk: into dst, or when dst is null in place at the stage - * floor (a whole read), which then moves past them. What the stage holds, after one fill when it - * is empty; the CRLF that ends the chunk is consumed with its last byte. The count, or -1. */ -static int chunk_data(ioma_ctx *ctx, struct serve_state *state, char *dst, size_t n) -{ - char *buf = state->read_buf; - if (state->raw_pos == state->raw_end && !raw_fill(ctx, state)) - return -1; - size_t avail = state->raw_end - state->raw_pos; - if (n > avail) - n = avail; - if (n > state->chunk_left) - n = state->chunk_left; - if (dst) { - memcpy(dst, buf + state->raw_pos, n); - } else { - memmove(buf + state->stage_floor, buf + state->raw_pos, n); /* leftwards: the floor never passes raw_pos */ - state->stage_floor += n; - } - state->raw_pos += n; - state->chunk_left -= n; - state->body_read += n; - if (state->chunk_left == 0) { - while (state->raw_end - state->raw_pos < 2) - if (!raw_fill(ctx, state)) - return -1; - if (buf[state->raw_pos] != '\r' || buf[state->raw_pos + 1] != '\n') { - body_fail(ctx, 400); - return -1; - } - state->raw_pos += 2; - } - return (int)n; -} - -/* --- the three reads --- */ - -/* The whole body, in place right after the head: a Content-Length body is received until it is - * complete, a chunked one is decoded down over its own raw bytes. Once; then the slice, which is - * also req.body. */ -ioma_slice ioma_body_all(ioma_ctx *ctx) -{ - struct serve_state *state = STATE(ctx); - ioma_request *req = &ctx->req; - const ioma_slice none = { req->body.p, 0 }; - - if (state->body_whole) - return req->body; - if (state->body_read || state->body_err) /* already streaming, or failed */ - return none; - - char *body = state->read_buf + state->head_len; - if (req->chunked) { - while (!state->body_done) { - if (state->chunk_left == 0) { - if (!chunk_header(ctx, state)) - return none; - } else if (chunk_data(ctx, state, nullptr, state->chunk_left) < 0) { - return none; - } - } - req->body = (ioma_slice){ body, state->stage_floor - state->head_len }; - } else { - size_t total = state->head_len + req->content_length; - if (total > IOMA_REQ_CAP) { - body_fail(ctx, 413); - return none; - } - while (state->filled < total) { - int n = await_recv(state->conn, state->read_buf + state->filled, IOMA_REQ_CAP - state->filled); - if (n <= 0) { - state->body_err = -1; - return none; - } - state->filled += (size_t)n; - } - req->body = (ioma_slice){ body, req->content_length }; - state->next_req_off = total; - state->next_req_len = state->filled - total; - state->body_read = req->content_length; - state->body_done = true; - } - state->body_whole = true; - return req->body; -} - -/* The next bytes of the body into dst, reading until n are there or the body ends. */ -int ioma_body_read_until(ioma_ctx *ctx, void *dst, size_t n) -{ - struct serve_state *state = STATE(ctx); - if (state->body_err || n == 0) - return -1; - char *out = dst; - size_t got = 0; - while (got < n && !state->body_done) { - int k; - if (!ctx->req.chunked) - k = fixed_data(ctx, state, out + got, n - got); - else if (state->chunk_left == 0) - k = chunk_header(ctx, state) ? 0 : -1; - else - k = chunk_data(ctx, state, out + got, n - got); - if (k < 0) - return -1; - got += (size_t)k; - } - return (int)got; -} - -/* The next chunk of a chunked body, whole, into dst: the rest of the current one when a read - * stopped inside it, else the next. */ -int ioma_body_read_next_chunk(ioma_ctx *ctx, void *dst, size_t cap) -{ - struct serve_state *state = STATE(ctx); - if (state->body_err || !ctx->req.chunked) - return -1; - if (state->body_done) - return 0; - if (state->chunk_left == 0) { - if (!chunk_header(ctx, state)) - return -1; - if (state->body_done) - return 0; - } - if (state->chunk_left > cap) { /* the chunk does not fit dst */ - body_fail(ctx, 413); - return -1; - } - char *out = dst; - size_t want = state->chunk_left, got = 0; - while (got < want) { - int k = chunk_data(ctx, state, out + got, want - got); - if (k < 0) - return -1; - got += (size_t)k; - } - return (int)got; -} - -/* After the chain: take an unread body off the wire so the connection stays in sync, up to a - * limit - past it, the reply says close and the rest is never read. */ -static void drain_body(ioma_ctx *ctx) -{ - struct serve_state *state = STATE(ctx); - char tmp[4096]; - size_t drained = 0; - while (!state->body_done && !state->body_err) { - if (drained >= IOMA_DRAIN_MAX) { - ctx->res.close = true; - return; - } - int n = ioma_body_read_until(ctx, tmp, sizeof tmp); - if (n <= 0) - return; - drained += (size_t)n; - } -} - -/* ── the connection loop ───────────────────────────────────────────────────────────────── */ - -/* Get a complete request head into read_buf, parsed straight into req (method, target, - * version, headers). What is buffered is parsed first: after a reply, a pipelined next request - * may already be there. More is read only when the head is incomplete. Returns the head's - * length, or -1 once the connection is finished (431 or 400 answered, or the peer went away). */ -static long read_head(conn_t *conn, char *read_buf, size_t *filled, ioma_request *req) -{ - size_t already_parsed = 0; /* what the previous attempt scanned */ - for (;;) { - if (*filled) { - req->n_headers = IOMA_MAX_HEADERS; /* in: room; out: count */ - int parsed = phr_parse_request(read_buf, *filled, - &req->method.p, &req->method.len, - &req->target.p, &req->target.len, - &req->minor_version, - (struct phr_header *)req->headers, &req->n_headers, - already_parsed); - if (parsed >= 0) - return parsed; - if (parsed == -1) { /* malformed */ - send_status(conn, 400); - return -1; - } - already_parsed = *filled; /* incomplete: read more */ - } - if (*filled == IOMA_REQ_CAP) { - send_status(conn, 431); - return -1; - } - int n = await_recv(conn, read_buf + *filled, IOMA_REQ_CAP - *filled); - if (n <= 0) - return -1; /* peer closed or error */ - *filled += (size_t)n; - } -} - -/* The rest of the request from its head: path and query, the query split into params, the - * headers lower-cased and the three the engine needs picked out. The body stays on the wire; - * body_start is where it begins. */ -static void fill_request(ioma_request *req, const char *body_start, char *params_arena, size_t arena_cap) -{ - const char *qmark = memchr(req->target.p, '?', req->target.len); - if (qmark) { - req->path = (ioma_slice){ req->target.p, (size_t)(qmark - req->target.p) }; - req->query = (ioma_slice){ qmark + 1, req->target.len - req->path.len - 1 }; - } else { - req->path = req->target; - req->query = (ioma_slice){ req->target.p + req->target.len, 0 }; - } - req->n_params = req->query.len - ? ioma_kv_parse(req->query.p, req->query.len, req->params, IOMA_MAX_PARAMS, params_arena, arena_cap) - : 0; - req->n_route_params = 0; /* the router fills these */ - - struct picked_headers picked = pick_headers(req); - req->chunked = picked.transfer_enc.p && token_present_ci(picked.transfer_enc.p, picked.transfer_enc.len, "chunked"); - req->content_length = picked.content_length.p ? parse_size(picked.content_length.p, picked.content_length.len) : 0; - req->keep_alive = keep_alive_from(req->minor_version, picked.connection); - req->body = (ioma_slice){ body_start, 0 }; -} - -/* The engine's bookkeeping for reading the body on demand: where it starts, what already - * arrived with the head, and - for a Content-Length body that is entirely here - where the next - * request starts. */ -static void init_body_state(struct serve_state *state, conn_t *conn, char *read_buf, size_t filled, - size_t head_len, const ioma_request *req) -{ - memset(state, 0, sizeof *state); - state->conn = conn; - state->read_buf = read_buf; - state->filled = filled; - state->head_len = head_len; - state->raw_pos = head_len; /* chunked: what came with the head is raw body */ - state->raw_end = filled; - state->stage_floor = head_len; - if (!req->chunked) { - size_t total = head_len + req->content_length; - state->body_done = req->content_length == 0; - if (filled >= total) { - state->next_req_off = total; - state->next_req_len = filled - total; - } - } -} - -/* A response with its defaults and an empty slab. */ -static void init_response(ioma_response *res, char *slab) -{ - res->status = 200; - res->content_type = (ioma_slice){ "text/plain", 10 }; - res->n_headers = 0; /* headers[] is only read up to here */ - res->close = false; - res->head_sent = false; - res->content_length = 0; - res->has_length = false; - res->buf = slab + IOMA_LEAD; - res->cap = IOMA_OUT_CAP; - res->len = 0; - res->chunked = false; - res->failed = false; -} - -/* After a kept-alive reply: move the bytes that belong to the next request to the front of the - * buffer and return how many there are. */ -static size_t carry_next_request(struct serve_state *state, char *read_buf) -{ - if (state->next_req_len) - memmove(read_buf, read_buf + state->next_req_off, state->next_req_len); - return state->next_req_len; -} - -/* The proactor handler for every connection: one request per iteration - get the head, run - * the chain against a context, drain what it left of the body, send what it wrote - while kept - * alive. Returning closes the connection. */ -void ioma__serve(conn_t *conn) -{ - char read_buf[IOMA_REQ_CAP]; /* the request: head, then body bytes */ - char params[IOMA_PARAM_CAP]; /* decoded query parameters */ - char slab[IOMA_LEAD + IOMA_OUT_CAP + 2]; /* lead, the write slab, CRLF slack */ - size_t filled = 0; /* bytes in read_buf */ - - for (;;) { - ioma_ctx ctx; /* this request's context */ - struct serve_state state; /* and the engine's side of it */ - - long head_len = read_head(conn, read_buf, &filled, &ctx.req); - if (head_len < 0) - return; - fill_request(&ctx.req, read_buf + (size_t)head_len, params, sizeof params); - init_body_state(&state, conn, read_buf, filled, (size_t)head_len, &ctx.req); - init_response(&ctx.res, slab); - ctx.user = nullptr; - ctx.priv = &state; - - ioma__dispatch(&ctx); /* middleware chain + endpoint */ - - if (state.body_err) { /* too large, malformed, or gone */ - if (state.body_err > 0 && !ctx.res.head_sent) - send_status(conn, state.body_err); - return; - } - drain_body(&ctx); /* what the handler left unread */ - if (state.body_err) - return; - if (finish(&ctx) < 0) /* sends; suspends meanwhile */ - return; - if (!ctx.req.keep_alive || ctx.res.close) - return; - filled = carry_next_request(&state, read_buf); - } -} diff --git a/src/http/internal.h b/src/http/internal.h deleted file mode 100644 index 725153b..0000000 --- a/src/http/internal.h +++ /dev/null @@ -1,27 +0,0 @@ -/* - * http/internal.h - what the HTTP plane's files share with each other. Private; not installed. - * The plane sits on the I/O plane's interface (io/proactor.h): connections and the awaits. - */ -#pragma once - -#include "ioma.h" -#include "io/proactor.h" - -#include -#include -#include - -void ioma__serve(conn_t *conn); /* engine.c: the per-connection HTTP loop */ -void ioma__dispatch(ioma_ctx *ctx); /* router.c: middleware chain + endpoint */ -void ioma__router_build(void); /* router.c: resolve the routes, once */ - -/* The value of a hex digit, or -1. */ -static inline int ioma__hexval(unsigned char c) -{ - if (c >= '0' && c <= '9') - return c - '0'; - c |= 0x20U; - if (c >= 'a' && c <= 'f') - return c - 'a' + 10; - return -1; -} diff --git a/src/http/router.c b/src/http/router.c deleted file mode 100644 index f6739b1..0000000 --- a/src/http/router.c +++ /dev/null @@ -1,424 +0,0 @@ -/* - * router.c - groups, endpoints, middleware, and the segment tree they resolve into. Everything is - * registered before the workers start and resolved once by ioma_run; after that it is read-only - * and every worker shares it without a lock. A request costs one walk down the tree and one call - * through its endpoint's flat middleware chain. - */ -#include "http/internal.h" - -#include - - -struct ioma_group { - ioma_group *parent; /* nullptr only for the root */ - const char *prefix; - ioma_mw mws[IOMA_MAX_MW]; - int n_mws; -}; - -struct ioma_endpoint { - ioma_endpoint *next; /* the registration list */ - ioma_group *group; - const char *method; - size_t method_len; - const char *path; /* below the group's prefix */ - ioma_handler fn; - ioma_mw own[IOMA_MAX_MW]; - int n_own; - /* resolved by ioma__router_build */ - char *full; /* the whole path, prefixes included */ - ioma_slice names[IOMA_MAX_ROUTE_PARAMS]; /* the :name captures, in path order */ - size_t n_names; - ioma_mw *chain; /* root, each group outer to inner, then own */ - int n_chain; -}; - -/* One segment of the tree; the root is the empty one. */ -struct node { - ioma_slice seg; /* the static segment this node is */ - struct node **kids; /* static children */ - int n_kids; - struct node *param; /* the child that takes any segment */ - ioma_endpoint **eps; /* the endpoints here, one per method */ - int n_eps; - char *allow; /* "GET, POST": the methods here, for a 405 */ -}; - -/* The chain cursor handed to each middleware; ioma_next_run advances it. */ -struct ioma_next { - const ioma_mw *mws; - int n; - int i; - ioma_handler handler; -}; - -static void not_found(ioma_ctx *ctx); - -static ioma_group g_root = { .prefix = "" }; /* no prefix; ioma_use's middleware */ -static ioma_group *g_current = &g_root; /* the script form's open group */ -static ioma_endpoint *g_first, *g_last; /* endpoints in registration order */ -static struct node g_tree; /* the root node */ -static ioma_handler g_fallback = not_found; -static bool g_built; - -/* Exact slice compare. */ -static bool same(ioma_slice a, ioma_slice b) -{ - return a.len == b.len && memcmp(a.p, b.p, a.len) == 0; -} - -/* Out of memory at startup: nothing sensible to continue with. */ -static void *must(void *p) -{ - if (!p) { - perror("ioma: malloc"); - abort(); - } - return p; -} - -/* ── registration ──────────────────────────────────────────────────────────────────────── */ - -/* A group below parent (nullptr: the root) at prefix. */ -ioma_group *ioma_group_new(ioma_group *parent, const char *prefix) -{ - ioma_group *group = must(calloc(1, sizeof *group)); - group->parent = parent ? parent : &g_root; - group->prefix = prefix; - return group; -} - -/* Middleware around everything below the group. */ -void ioma_group_use(ioma_group *group, ioma_mw mw) -{ - if (!group) - group = &g_root; - if (group->n_mws == IOMA_MAX_MW) { - fprintf(stderr, "ioma: group %s already has %d middleware, dropping one\n", group->prefix, IOMA_MAX_MW); - return; - } - group->mws[group->n_mws++] = mw; -} - -/* Root middleware: every request. */ -void ioma_use(ioma_mw mw) -{ - ioma_group_use(&g_root, mw); -} - -/* An endpoint in a group (nullptr: the root). */ -ioma_endpoint *ioma_route(ioma_group *group, const char *method, const char *path, ioma_handler fn) -{ - if (g_built) { - fprintf(stderr, "ioma: %s %s registered after ioma_run started; ignored\n", method, path); - return nullptr; - } - ioma_endpoint *ep = must(calloc(1, sizeof *ep)); - ep->group = group ? group : &g_root; - ep->method = method; - ep->method_len = strlen(method); - ep->path = path; - ep->fn = fn; - if (g_last) - g_last->next = ep; - else - g_first = ep; - g_last = ep; - return ep; -} - -/* Middleware around one endpoint. */ -void ioma_endpoint_use(ioma_endpoint *endpoint, ioma_mw mw) -{ - if (!endpoint) - return; - if (endpoint->n_own == IOMA_MAX_MW) { - fprintf(stderr, "ioma: %s %s already has %d middleware, dropping one\n", endpoint->method, endpoint->path, IOMA_MAX_MW); - return; - } - endpoint->own[endpoint->n_own++] = mw; -} - -/* --- the script form (the IOMA_ macros) --- */ - -/* Open a group below the current one and make it current; its middleware list ends at a null. */ -ioma_group *ioma__group_begin(struct ioma_group_args args) -{ - ioma_group *group = ioma_group_new(g_current, args.prefix); - for (int i = 0; i < IOMA_MAX_MW && args.mws[i]; i++) - ioma_group_use(group, args.mws[i]); - g_current = group; - return group; -} - -/* Close the current group; null, so the block's loop ends. */ -ioma_group *ioma__group_end(void) -{ - if (g_current->parent) - g_current = g_current->parent; - return nullptr; -} - -/* The group a script-form registration goes into. */ -ioma_group *ioma__group_current(void) -{ - return g_current; -} - -/* An endpoint in the current group, with its middleware list (ended by a null). */ -ioma_endpoint *ioma__endpoint(const char *method, struct ioma_endpoint_args args) -{ - ioma_endpoint *ep = ioma_route(g_current, method, args.path, args.fn); - for (int i = 0; i < IOMA_MAX_MW && args.mws[i]; i++) - ioma_endpoint_use(ep, args.mws[i]); - return ep; -} - -/* Replace the built-in 404 fallback. */ -void ioma_default(ioma_handler fn) -{ - g_fallback = fn; -} - -/* ── resolution, once, from ioma_run ───────────────────────────────────────────────────── */ - -/* The next segment of a path from *at, slashes skipped; false at the end. */ -static bool next_segment(const char **at, const char *end, ioma_slice *seg) -{ - const char *p = *at; - while (p < end && *p == '/') - p++; - if (p == end) { - *at = end; - return false; - } - const char *seg_end = memchr(p, '/', (size_t)(end - p)); - if (!seg_end) - seg_end = end; - *seg = (ioma_slice){ p, (size_t)(seg_end - p) }; - *at = seg_end; - return true; -} - -/* The endpoint's whole path: its groups' prefixes, outermost first, then its own path. Written - * right to left, from the innermost group up, so no list of the groups is needed. */ -static char *full_path(const ioma_endpoint *ep) -{ - size_t path_len = strlen(ep->path), len = path_len; - for (const ioma_group *g = ep->group; g; g = g->parent) - len += strlen(g->prefix); - char *full = must(malloc(len + 1)); - size_t at = len - path_len; - memcpy(full + at, ep->path, path_len + 1); /* the path last, with its NUL */ - for (const ioma_group *g = ep->group; g; g = g->parent) { - size_t n = strlen(g->prefix); - at -= n; - memcpy(full + at, g->prefix, n); - } - return full; -} - -/* The endpoint's middleware, flat: the root's, each group's outer to inner, then its own. Filled - * right to left, like the path. */ -static void flatten_chain(ioma_endpoint *ep) -{ - int n = ep->n_own; - for (const ioma_group *g = ep->group; g; g = g->parent) - n += g->n_mws; - ep->n_chain = n; - if (n == 0) - return; - ep->chain = must(malloc((size_t)n * sizeof *ep->chain)); - int at = n - ep->n_own; - memcpy(ep->chain + at, ep->own, (size_t)ep->n_own * sizeof *ep->chain); - for (const ioma_group *g = ep->group; g; g = g->parent) { - at -= g->n_mws; - memcpy(ep->chain + at, g->mws, (size_t)g->n_mws * sizeof *ep->chain); - } -} - -/* The static child for a segment, made if missing. */ -static struct node *child(struct node *node, ioma_slice seg) -{ - for (int i = 0; i < node->n_kids; i++) - if (same(node->kids[i]->seg, seg)) - return node->kids[i]; - struct node *kid = must(calloc(1, sizeof *kid)); - struct node **kids = must(realloc(node->kids, ((size_t)node->n_kids + 1) * sizeof *kids)); - kid->seg = seg; - node->kids = kids; - node->kids[node->n_kids++] = kid; - return kid; -} - -/* Put an endpoint into the tree along its full path; a ':name' segment goes through the capture - * child and its name is kept with the endpoint. A duplicate keeps the first. */ -static void insert(ioma_endpoint *ep) -{ - struct node *node = &g_tree; - const char *at = ep->full, *end = ep->full + strlen(ep->full); - ioma_slice seg; - while (next_segment(&at, end, &seg)) { - if (seg.p[0] == ':') { - if (ep->n_names == IOMA_MAX_ROUTE_PARAMS) { - fprintf(stderr, "ioma: %s %s has more than %d captures; ignored\n", ep->method, ep->full, IOMA_MAX_ROUTE_PARAMS); - return; - } - ep->names[ep->n_names++] = (ioma_slice){ seg.p + 1, seg.len - 1 }; - if (!node->param) - node->param = must(calloc(1, sizeof *node->param)); - node = node->param; - } else { - node = child(node, seg); - } - } - for (int i = 0; i < node->n_eps; i++) { - if (node->eps[i]->method_len == ep->method_len && memcmp(node->eps[i]->method, ep->method, ep->method_len) == 0) { - fprintf(stderr, "ioma: duplicate route %s %s; keeping the first\n", ep->method, ep->full); - return; - } - } - ioma_endpoint **eps = must(realloc(node->eps, ((size_t)node->n_eps + 1) * sizeof *eps)); - node->eps = eps; - node->eps[node->n_eps++] = ep; -} - -/* The allow header of every node with endpoints: its methods, comma-separated. */ -static void set_allow(struct node *node) -{ - if (node->n_eps) { - size_t len = 1; - for (int i = 0; i < node->n_eps; i++) - len += node->eps[i]->method_len + 2; - char *at = node->allow = must(malloc(len)); - for (int i = 0; i < node->n_eps; i++) { - if (i) { - memcpy(at, ", ", 2); - at += 2; - } - memcpy(at, node->eps[i]->method, node->eps[i]->method_len); - at += node->eps[i]->method_len; - } - *at = '\0'; - } - for (int i = 0; i < node->n_kids; i++) - set_allow(node->kids[i]); - if (node->param) - set_allow(node->param); -} - -/* Resolve everything registered: full paths into the tree, middleware into flat chains. */ -void ioma__router_build(void) -{ - if (g_built) - return; - g_built = true; - for (ioma_endpoint *ep = g_first; ep; ep = ep->next) { - ep->full = full_path(ep); - insert(ep); - flatten_chain(ep); - } - set_allow(&g_tree); -} - -/* ── a request ─────────────────────────────────────────────────────────────────────────── */ - -/* The endpoint at a node for the method, or nullptr. */ -static const ioma_endpoint *endpoint_for(const struct node *node, ioma_slice method) -{ - for (int i = 0; i < node->n_eps; i++) - if (node->eps[i]->method_len == method.len && memcmp(node->eps[i]->method, method.p, method.len) == 0) - return node->eps[i]; - return nullptr; -} - -/* Walk the tree along the path from at, the segments that capture nodes take going into - * req->route_params (values; the names come with the endpoint). The static child is tried before - * the capture, so a static segment wins, and the capture is tried when the static branch comes - * to nothing - including when it reaches the end without this method. Returns the endpoint for - * the method, or nullptr; *seen is the first node the path itself reached, for a 405. */ -static const ioma_endpoint *walk(const struct node *node, const char *at, const char *end, - ioma_request *req, const struct node **seen) -{ - ioma_slice seg; - if (!next_segment(&at, end, &seg)) { - if (node->n_eps && !*seen) - *seen = node; - return endpoint_for(node, req->method); - } - for (int i = 0; i < node->n_kids; i++) { - if (same(node->kids[i]->seg, seg)) { - const ioma_endpoint *ep = walk(node->kids[i], at, end, req, seen); - if (ep) - return ep; - break; /* static children are unique: no other candidate */ - } - } - if (node->param && req->n_route_params < IOMA_MAX_ROUTE_PARAMS) { - size_t mark = req->n_route_params; - req->route_params[req->n_route_params++].value = seg; - const ioma_endpoint *ep = walk(node->param, at, end, req, seen); - if (ep) - return ep; - req->n_route_params = mark; - } - return nullptr; -} - -/* Run the next middleware, or the endpoint once the chain is exhausted. A middleware that does - * not call this short-circuits the request. */ -void ioma_next_run(ioma_ctx *ctx, ioma_next *next) -{ - if (next->i < next->n) { - ioma_mw mw = next->mws[next->i]; - ioma_next inner = { next->mws, next->n, next->i + 1, next->handler }; - mw(ctx, &inner); - return; - } - next->handler(ctx); -} - -/* A handler behind a chain; a direct call when the chain is empty. */ -static void run(ioma_ctx *ctx, const ioma_mw *mws, int n, ioma_handler fn) -{ - if (n == 0) { - fn(ctx); - return; - } - ioma_next next = { mws, n, 0, fn }; - ioma_next_run(ctx, &next); -} - -/* The built-in fallbacks. */ -static void not_found(ioma_ctx *ctx) -{ - ctx->res.status = 404; - ioma_text(ctx, "404 Not Found\n"); -} -static void not_allowed(ioma_ctx *ctx) /* status and allow are set before its chain */ -{ - ioma_text(ctx, "405 Method Not Allowed\n"); -} - -/* Find the request's endpoint and run it behind its chain; the fallbacks run behind the root's. */ -void ioma__dispatch(ioma_ctx *ctx) -{ - ioma_request *req = &ctx->req; - const struct node *seen = nullptr; - req->n_route_params = 0; - const ioma_endpoint *ep = walk(&g_tree, req->path.p, req->path.p + req->path.len, req, &seen); - if (ep) { - for (size_t i = 0; i < req->n_route_params; i++) - req->route_params[i].key = ep->names[i]; - run(ctx, ep->chain, ep->n_chain, ep->fn); - return; - } - req->n_route_params = 0; - if (seen) { /* the path is known, the method is not */ - ctx->res.status = 405; - ioma_header(ctx, "allow", seen->allow); - run(ctx, g_root.mws, g_root.n_mws, not_allowed); - return; - } - run(ctx, g_root.mws, g_root.n_mws, g_fallback); -} diff --git a/src/http/run.c b/src/http/run.c deleted file mode 100644 index 206bc75..0000000 --- a/src/http/run.c +++ /dev/null @@ -1,103 +0,0 @@ -/* - * run.c - ioma_run: one proactor thread per core serving HTTP, until SIGINT/SIGTERM. - */ -#include "http/internal.h" - -#include -#include -#include -#include -#include -#include - -static volatile sig_atomic_t g_stop; - -/* SIGINT/SIGTERM: raise the flag every worker loop polls. */ -static void on_signal(int sig) -{ - (void)sig; - g_stop = 1; -} - -/* pthread entry: the worker's whole life. */ -static void *worker_thread(void *arg) -{ - proactor_run(arg); - return nullptr; -} - -/* CPUs this process may run on (its cpuset), so the default is one worker per available core - - * never a fixed count that would oversubscribe a small cpuset. */ -static int cpu_count(void) -{ - cpu_set_t set; - CPU_ZERO(&set); - if (sched_getaffinity(0, sizeof set, &set) == 0) { - int n = CPU_COUNT(&set); - if (n > 0) - return n; - } - long n = sysconf(_SC_NPROCESSORS_ONLN); - return n > 0 ? (int)n : 1; -} - -/* Lift the soft fd limit to the hard one: open connections and the registered file table are - * both checked against it, and the default soft limit is often 1024. */ -static void raise_nofile(void) -{ - struct rlimit rl; - if (getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur < rl.rlim_max) { - rl.rlim_cur = rl.rlim_max; - setrlimit(RLIMIT_NOFILE, &rl); - } -} - -/* Start the workers (workers <= 0: one per available core) and block until a stop signal. */ -int ioma_run(int workers, int port) -{ - if (workers <= 0) - workers = cpu_count(); - if (port < 1 || port > 65535) { - fprintf(stderr, "ioma_run: 1<=port<=65535 required\n"); - return 2; - } - - raise_nofile(); - signal(SIGPIPE, SIG_IGN); - struct sigaction sa; - memset(&sa, 0, sizeof sa); - sa.sa_handler = on_signal; - sigemptyset(&sa.sa_mask); - sigaction(SIGINT, &sa, nullptr); - sigaction(SIGTERM, &sa, nullptr); - - ioma__router_build(); /* the routes, resolved once, shared read-only */ - - proactor_t *ws = calloc((size_t)workers, sizeof *ws); - pthread_t *th = calloc((size_t)workers, sizeof *th); - if (!ws || !th) { - perror("calloc"); - free(ws); - free(th); - return 1; - } - - for (int i = 0; i < workers; i++) { - ws[i].id = i; - ws[i].cpu = i; - ws[i].port = (uint16_t)port; - ws[i].handler = ioma__serve; - ws[i].stop = &g_stop; - if (pthread_create(&th[i], nullptr, worker_thread, &ws[i]) != 0) { - perror("pthread_create"); - return 1; - } - } - fprintf(stderr, "ioma: %d workers on :%d\n", workers, port); - - for (int i = 0; i < workers; i++) - pthread_join(th[i], nullptr); - free(th); - free(ws); - return 0; -} diff --git a/src/io/bufring.c b/src/io/bufring.c deleted file mode 100644 index 5d6c9f5..0000000 --- a/src/io/bufring.c +++ /dev/null @@ -1,86 +0,0 @@ -/* - * bufring.c - the provided buffer ring: a slab of BUF_COUNT x BUF_SIZE bytes that the kernel - * picks from when a multishot recv delivers data, and how buffers go back to it. - */ -#include "io/internal.h" - -#include -#include - -/* Map anonymous read/write pages, or abort. */ -static void *map_pages(size_t bytes) -{ - void *m = mmap(nullptr, bytes, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (m == MAP_FAILED) { - perror("mmap"); - abort(); - } - return m; -} - -/* Map the slab and the ring, register the ring as buffer group BGID, and offer every buffer. */ -void ioma__bufring_init(proactor_t *p) -{ - p->buf_ring = map_pages((size_t)BUF_COUNT * sizeof(struct io_uring_buf)); - p->slab = map_pages((size_t)BUF_COUNT * BUF_SIZE); - - struct io_uring_buf_reg reg; - memset(®, 0, sizeof reg); - reg.ring_addr = (uint64_t)(uintptr_t)p->buf_ring; - reg.ring_entries = BUF_COUNT; - reg.bgid = BGID; - int rc = uring_register(&p->ring, IORING_REGISTER_PBUF_RING, ®, 1); - if (rc < 0) { - fprintf(stderr, "[w%d] register pbuf ring: %s\n", p->id, strerror(-rc)); - abort(); - } - - /* Fill every slot, then publish the tail once. bufs[0] overlaps the ring header and the tail - * sits in bufs[0].resv, so writing only addr/len/bid leaves it untouched. */ - for (unsigned i = 0; i < BUF_COUNT; i++) { - struct io_uring_buf *b = &p->buf_ring->bufs[i]; - b->addr = (uint64_t)(uintptr_t)(p->slab + (size_t)i * BUF_SIZE); - b->len = BUF_SIZE; - b->bid = (uint16_t)i; - } - p->buf_tail = BUF_COUNT; - __atomic_store_n(&p->buf_ring->tail, (uint16_t)p->buf_tail, __ATOMIC_RELEASE); -} - -/* Stage a buffer's return to the ring. The loop publishes the tail once per batch: one atomic - * release for many returns, and the kernel is not re-reading a hot tail per request. */ -void ioma__return_buf(proactor_t *p, uint16_t buf_id) -{ - struct io_uring_buf *b = &p->buf_ring->bufs[p->buf_tail & BUF_MASK]; - b->addr = (uint64_t)(uintptr_t)(p->slab + (size_t)buf_id * BUF_SIZE); - b->len = BUF_SIZE; - b->bid = buf_id; - p->buf_tail++; - p->buffers_returned = true; /* lets the loop re-arm starved recvs */ - p->buf_dirty = true; /* tail needs publishing before the enter */ -} - -/* Publish staged returns to the kernel. */ -void ioma__bufring_publish(proactor_t *p) -{ - if (!p->buf_dirty) - return; - __atomic_store_n(&p->buf_ring->tail, (uint16_t)p->buf_tail, __ATOMIC_RELEASE); - p->buf_dirty = false; -} - -/* Unregister the group. Call before uring_exit. */ -void ioma__bufring_unregister(proactor_t *p) -{ - struct io_uring_buf_reg reg; - memset(®, 0, sizeof reg); - reg.bgid = BGID; - uring_register(&p->ring, IORING_UNREGISTER_PBUF_RING, ®, 1); -} - -/* Unmap the ring and the slab. Call after uring_exit, once no in-flight op can reference them. */ -void ioma__bufring_unmap(proactor_t *p) -{ - munmap(p->buf_ring, (size_t)BUF_COUNT * sizeof(struct io_uring_buf)); - munmap(p->slab, (size_t)BUF_COUNT * BUF_SIZE); -} diff --git a/src/io/conn.c b/src/io/conn.c deleted file mode 100644 index b6e57c0..0000000 --- a/src/io/conn.c +++ /dev/null @@ -1,328 +0,0 @@ -/* - * conn.c - one connection's life on its worker: the pooled conn_t, its two owners' refcount, the - * multishot recv and the queue of slices it delivers, parking on -ENOBUFS, closing, and the two - * awaits a handler coroutine calls. - */ -#include "io/internal.h" - -#include -#include -#include -#include -#include -#include - -/* Stage a one-shot op and park until its CQE. The loop fills op->res and resumes us. */ -static int await_op(struct io_uring_sqe *sqe, op_t *op) -{ - op->waiter = coro_current(); - sqe->user_data = UD(op, TAG_OP); - coro_yield(); - return op->res; /* NOLINT(clang-analyzer-core.uninitialized.UndefReturn): set by the loop before it resumed us */ -} - -/* Ask the kernel to cancel the op carrying that user_data. The acknowledgement CQE is ignored. */ -static void submit_cancel(proactor_t *p, uint64_t target_user_data) -{ - struct io_uring_sqe *sqe = ioma__sqe(p); - sqe->opcode = IORING_OP_ASYNC_CANCEL; - sqe->fd = -1; - sqe->addr = target_user_data; - sqe->user_data = TAG_IGNORE; -} - -/* ── the object ────────────────────────────────────────────────────────────────────────── */ - -/* Take a conn_t from the pool (or calloc one) and reset it for a fresh fd. Two owners hold it: - * the handler coroutine and the multishot recv, so refs starts at 2. */ -conn_t *ioma__conn_new(proactor_t *p, int fd) -{ - conn_t *c = p->conn_free; - if (c) { - p->conn_free = c->pool_next; - p->conn_free_count--; - } else { - c = calloc(1, sizeof *c); - if (!c) { - perror("calloc"); - abort(); - } - } - c->fd = fd; - c->p = p; - c->waiter = nullptr; - c->rx_head = 0; - c->rx_tail = 0; - c->recv = RECV_ARMED; - c->refs = 2; - c->closed = false; - c->eof = false; - c->err = 0; - c->pool_next = nullptr; - p->live++; - return c; -} - -/* Drop one owner's ref. At zero the conn holds nothing (fd closed, buffers returned) and goes - * back to the pool, or is freed past the cap. */ -static void conn_unref(conn_t *c) -{ - if (--c->refs != 0) - return; - proactor_t *p = c->p; - p->live--; - if (p->conn_free_count < CONN_POOL_MAX) { - c->pool_next = p->conn_free; - p->conn_free = c; - p->conn_free_count++; - } else { - free(c); - } -} - -/* Free the pool at worker teardown. */ -void ioma__conn_pool_drain(proactor_t *p) -{ - while (p->conn_free) { - conn_t *c = p->conn_free; - p->conn_free = c->pool_next; - free(c); - } - p->conn_free_count = 0; -} - -/* ── the recv side ─────────────────────────────────────────────────────────────────────── */ - -/* Arm the multishot recv: one SQE, then a CQE per arrival, each in a buffer the kernel picks. */ -void ioma__arm_recv(proactor_t *p, conn_t *c) -{ - struct io_uring_sqe *sqe = ioma__sqe(p); - sqe->opcode = IORING_OP_RECV; - sqe->fd = c->fd; - sqe->flags = IOSQE_BUFFER_SELECT | (p->ring.fixed_files ? IOSQE_FIXED_FILE : 0); - sqe->ioprio = IORING_RECV_MULTISHOT; - sqe->buf_group = BGID; - sqe->user_data = UD(c, TAG_RECV); - c->recv = RECV_ARMED; -} - -/* Resume the coroutine parked in await_recv, if there is one. It pops the queue itself. */ -static void wake_reader(conn_t *c) -{ - coro_t *waiter = c->waiter; - if (waiter) { - c->waiter = nullptr; - coro_resume(waiter); - } -} - -/* Count a recv that found the buffer ring empty, and say so on stderr at most once a second per - * worker: starvation otherwise shows only as latency, and the cure is a larger -DBUF_COUNT. */ -static void note_starved(proactor_t *p) -{ - struct timespec now; - clock_gettime(CLOCK_MONOTONIC_COARSE, &now); - p->starved_total++; - p->starved_since_log++; - if (now.tv_sec < p->starved_log_at) - return; - fprintf(stderr, "ioma: [w%d] recv found no provided buffer %llu times (%llu in total, %u connections parked): " - "raise BUF_COUNT (-DBUF_COUNT=..., now %d)\n", - p->id, (unsigned long long)p->starved_since_log, (unsigned long long)p->starved_total, p->nstarved + 1, BUF_COUNT); - p->starved_since_log = 0; - p->starved_log_at = now.tv_sec + 1; -} - -/* Park a connection whose recv ended on -ENOBUFS until a buffer comes back. */ -static void starved_push(proactor_t *p, conn_t *c) -{ - if (p->nstarved == p->cap_starved) { - unsigned cap = p->cap_starved ? p->cap_starved * 2 : 64; - conn_t **grown = realloc(p->starved, cap * sizeof *grown); - if (!grown) { - perror("realloc"); - abort(); - } - p->starved = grown; - p->cap_starved = cap; - } - p->starved[p->nstarved++] = c; -} - -/* Forget a parked connection (it closed before any buffer came back). */ -static void starved_remove(proactor_t *p, conn_t *c) -{ - for (unsigned i = 0; i < p->nstarved; i++) { - if (p->starved[i] == c) { - p->starved[i] = p->starved[--p->nstarved]; - return; - } - } -} - -/* A recv CQE: queue the data and wake the reader, or record the end of input and drop the recv's - * ref. -ENOBUFS is not an error: the buffer group ran dry, so park and re-arm later. */ -void ioma__on_recv(proactor_t *p, conn_t *c, int result, unsigned flags) -{ - bool more = flags & IORING_CQE_F_MORE; - bool has_buf = flags & IORING_CQE_F_BUFFER; - uint16_t buf_id = (uint16_t)(flags >> (unsigned)IORING_CQE_BUFFER_SHIFT); - - trace("[w%d] recv fd=%d result=%d more=%d buf=%d buf_id=%u queued=%u state=%d closed=%d eof=%d\n", - p->id, c->fd, result, more, has_buf, buf_id, c->rx_tail - c->rx_head, c->recv, c->closed, c->eof); - - if (result == -ENOBUFS) { - if (c->closed) { - c->recv = RECV_DONE; - conn_unref(c); - return; - } - c->recv = RECV_STARVED; - note_starved(p); - starved_push(p, c); - return; - } - - if (result <= 0) { /* peer FIN (0), an error, or our own cancel */ - if (has_buf) - ioma__return_buf(p, buf_id); - if (!c->eof) { - c->eof = true; - c->err = result; - } - c->recv = RECV_DONE; - wake_reader(c); /* a parked await_recv returns 0 / -errno */ - conn_unref(c); /* the recv's ref; may recycle c */ - return; - } - - if (c->closed) { - ioma__return_buf(p, buf_id); /* the handler is gone; nobody will read it */ - } else if (c->rx_tail - c->rx_head == RX_QUEUE) { - /* The handler is not draining. Rather than let one peer hoard the buffer group, end its - * input: the next read sees -ENOBUFS. */ - ioma__return_buf(p, buf_id); - if (!c->eof) { - c->eof = true; - c->err = -ENOBUFS; - } - if (more) - submit_cancel(p, UD(c, TAG_RECV)); - wake_reader(c); - } else { - struct rx_item *item = &c->rx[c->rx_tail++ & RX_MASK]; - item->ptr = p->slab + (size_t)buf_id * BUF_SIZE; - item->len = (uint32_t)result; - item->buf_id = buf_id; - wake_reader(c); - } - - if (!more) { /* the kernel ended the multishot: re-arm */ - if (!c->closed && !c->eof) { - ioma__arm_recv(p, c); - } else { - c->recv = RECV_DONE; - conn_unref(c); - } - } -} - -/* ── close ─────────────────────────────────────────────────────────────────────────────── */ - -/* Close the socket: a plain close, or under fixed files a CLOSE SQE on its slot, which rides the - * next enter with the rest of the batch instead of costing a syscall here. */ -static void close_socket(proactor_t *p, int fd) -{ - if (!p->ring.fixed_files) { - close(fd); - return; - } - struct io_uring_sqe *sqe = ioma__sqe(p); - sqe->opcode = IORING_OP_CLOSE; - sqe->file_index = (uint32_t)fd + 1; /* slot + 1; 0 would mean "a real fd" */ - sqe->user_data = TAG_IGNORE; -} - -/* Runs once when the handler returns: cancel the recv, hand unread buffers back, close the fd, - * drop the handler's ref. The recv's own ref drops on its terminal CQE. */ -static void conn_close(conn_t *c) -{ - proactor_t *p = c->p; - c->closed = true; - trace("[w%d] close fd=%d state=%d eof=%d err=%d queued=%u\n", - p->id, c->fd, c->recv, c->eof, c->err, c->rx_tail - c->rx_head); - - if (c->recv == RECV_ARMED) { - submit_cancel(p, UD(c, TAG_RECV)); - } else if (c->recv == RECV_STARVED) { - starved_remove(p, c); - c->recv = RECV_DONE; - c->refs--; /* the recv side's reference; ours, dropped last, keeps c alive */ - } - while (c->rx_head != c->rx_tail) - ioma__return_buf(p, c->rx[c->rx_head++ & RX_MASK].buf_id); - - close_socket(p, c->fd); - conn_unref(c); -} - -/* The connection's coroutine: run the worker's handler to completion, then close. */ -void ioma__conn_main(void *arg) -{ - conn_t *c = arg; - c->p->handler(c); - conn_close(c); -} - -/* ── awaits ────────────────────────────────────────────────────────────────────────────── */ - -/* Copy the next delivered slice into buf, parking while the queue is empty. Returns the byte - * count, 0 when the peer closed, or -errno. A fully consumed buffer goes back to the ring. */ -int await_recv(conn_t *c, void *buf, size_t len) -{ - if (len == 0) - return -EINVAL; - for (;;) { - if (c->rx_head != c->rx_tail) { - struct rx_item *item = &c->rx[c->rx_head & RX_MASK]; - size_t n = item->len < len ? item->len : len; - memcpy(buf, item->ptr, n); - item->ptr += n; - item->len -= (uint32_t)n; - if (item->len == 0) { - ioma__return_buf(c->p, item->buf_id); - c->rx_head++; - } - return (int)n; - } - if (c->eof) - return c->err; - c->waiter = coro_current(); - coro_yield(); /* ioma__on_recv wakes us */ - } -} - -/* Send all of buf: a SEND SQE per round, parked until its CQE. Returns len, or -errno. */ -int await_send(conn_t *c, const void *buf, size_t len) -{ - const uint8_t *src = buf; - size_t left = len; - while (left > 0) { - op_t op; - struct io_uring_sqe *sqe = ioma__sqe(c->p); - sqe->opcode = IORING_OP_SEND; - sqe->fd = c->fd; - sqe->flags = c->p->ring.fixed_files ? IOSQE_FIXED_FILE : 0; - sqe->addr = (uint64_t)(uintptr_t)src; - sqe->len = left > UINT32_MAX ? UINT32_MAX : (uint32_t)left; - sqe->msg_flags = MSG_NOSIGNAL | MSG_WAITALL; /* no SIGPIPE; the kernel finishes short sends */ - int n = await_op(sqe, &op); - if (n < 0) - return n; - if (n == 0) - return -EPIPE; - src += n; - left -= (size_t)n; - } - return (int)len; -} diff --git a/src/io/coro.h b/src/io/coro.h deleted file mode 100644 index 5fe003b..0000000 --- a/src/io/coro.h +++ /dev/null @@ -1,40 +0,0 @@ -/* - * coro.h - stackful coroutines for one proactor thread. - * - * A coroutine runs on its own mmap'd stack with a guard page. Suspending saves the callee-saved - * registers and switches to the loop's stack; resuming is the reverse. Everything on the - * coroutine's stack stays exactly where it was while it is parked, which is what lets an - * io_uring completion be routed to a struct that lives in an await's frame. - * - * Discipline: only the loop calls coro_resume, only a coroutine calls coro_yield. A coroutine - * that wants to start another one hands it to the scheduler (proactor_spawn); resuming from - * inside a coroutine would overwrite the loop's saved stack pointer. - */ -#pragma once - -#include - -typedef struct coro { - void *sp; /* saved stack pointer while suspended */ - void *stack; /* mmap base; the lowest page is the guard */ - size_t size; /* mapping size, guard included */ - void (*fn)(void *); - void *arg; - bool done; /* fn returned; the next resume-return frees the stack */ - struct coro *next; /* scheduler's ready-list link */ -} coro_t; - -/* Allocate a stack and forge its first frame. The descriptor lives at the top of that stack. */ -coro_t *coro_create(void (*fn)(void *), void *arg, size_t stack_bytes); - -/* Loop only. Run c until it yields; if it finished, its stack is unmapped before returning. */ -void coro_resume(coro_t *c); - -/* Coroutine only. Back to the loop; returns when the loop resumes this coroutine again. */ -void coro_yield(void); - -/* The running coroutine, nullptr on the loop stack. */ -coro_t *coro_current(void); - -/* Unmap the per-thread free list of pooled stacks. Call at worker teardown, on the worker thread. */ -void coro_pool_drain(void); diff --git a/src/io/internal.h b/src/io/internal.h deleted file mode 100644 index 5337313..0000000 --- a/src/io/internal.h +++ /dev/null @@ -1,71 +0,0 @@ -/* - * io/internal.h - what the I/O plane's files share with each other. Private; not installed. - * - * Exported internals carry an ioma__ prefix so they cannot collide with a user's symbols. Only - * declarations, types and macros live here; LTO inlines the small hot ones across files. - */ -#pragma once - -#include "io/proactor.h" - -#include -#include -#include - -/* ── constants ─────────────────────────────────────────────────────────────────────────── */ - -#define BGID 1 /* the one provided-buffer group per worker */ -#define RX_MASK (RX_QUEUE - 1U) -#define BUF_MASK (BUF_COUNT - 1U) - -#ifndef CONN_POOL_MAX -#define CONN_POOL_MAX 1024 /* idle conn_t kept warm per worker */ -#endif - -/* -DTRACE: one line per completion and lifetime event, for chasing a misbehaving path. */ -#ifdef TRACE -#define trace(...) fprintf(stderr, __VA_ARGS__) -#else -#define trace(...) ((void)0) -#endif - -/* ── completion routing ────────────────────────────────────────────────────────────────── */ - -/* user_data is a pointer with a tag in its low three bits; everything pointed at is 8-aligned. */ -enum { - TAG_OP = 0, - TAG_RECV = 1, - TAG_ACCEPT = 2, - TAG_IGNORE = 3, }; - -#define UD(ptr, tag) ((uint64_t)(uintptr_t)(ptr) | (uint64_t)(tag)) -#define UD_PTR(ud) ((void *)(uintptr_t)((ud) & ~(uint64_t)7)) -#define UD_TAG(ud) ((unsigned)((ud) & 7U)) - -/* A one-shot operation. It lives in the awaiting coroutine's stack frame, which is frozen while - * the coroutine is parked, so its address is valid for exactly as long as the op is in flight. */ -typedef struct op { - coro_t *waiter; - int res; - unsigned flags; -} op_t; - -/* ── shared between the plane's files ──────────────────────────────────────────────────── */ - -/* proactor.c */ -struct io_uring_sqe *ioma__sqe(proactor_t *p); /* claim an SQE; flushes without waiting if the SQ is full */ - -/* bufring.c */ -void ioma__return_buf(proactor_t *p, uint16_t buf_id); /* stage a buffer's return; published per batch */ -void ioma__bufring_publish(proactor_t *p); /* publish staged returns: one atomic release */ -void ioma__bufring_init(proactor_t *p); /* bufring.c: map, register, fill */ -void ioma__bufring_unregister(proactor_t *p); /* bufring.c: before uring_exit */ -void ioma__bufring_unmap(proactor_t *p); /* bufring.c: after uring_exit */ - -/* ── connections (conn.c) ──────────────────────────────────────────────────────────────── */ - -conn_t *ioma__conn_new(proactor_t *p, int fd); -void ioma__conn_main(void *arg); /* the connection's coroutine body */ -void ioma__arm_recv(proactor_t *p, conn_t *c); -void ioma__on_recv(proactor_t *p, conn_t *c, int res, unsigned flags); -void ioma__conn_pool_drain(proactor_t *p); diff --git a/src/io/proactor.c b/src/io/proactor.c deleted file mode 100644 index 471ed22..0000000 --- a/src/io/proactor.c +++ /dev/null @@ -1,260 +0,0 @@ -/* - * proactor.c - the worker: pin to a CPU, own a ring, a buffer ring and a listener, then loop: - * start spawned coroutines, enter once per batch, dispatch every completion. Connections live in - * conn.c and buffers in bufring.c; this file is the loop and what feeds it. - */ -#include "io/internal.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -/* ── submission ────────────────────────────────────────────────────────────────────────── */ - -/* Claim an SQE. If the SQ is full mid-batch, flush without waiting and retry. */ -struct io_uring_sqe *ioma__sqe(proactor_t *p) -{ - struct io_uring_sqe *sqe = uring_get_sqe(&p->ring); - for (int i = 0; !sqe && i < 16; i++) { - uring_submit(&p->ring); - sqe = uring_get_sqe(&p->ring); - } - if (!sqe) { - fprintf(stderr, "[w%d] SQ still full after flushing\n", p->id); - abort(); - } - return sqe; -} - -/* ── accept ────────────────────────────────────────────────────────────────────────────── */ - -/* Arm the multishot accept: one SQE, then a CQE per new connection. */ -static void arm_accept(proactor_t *p) -{ - struct io_uring_sqe *sqe = ioma__sqe(p); - sqe->opcode = IORING_OP_ACCEPT; - sqe->fd = p->listen_fd; - sqe->ioprio = IORING_ACCEPT_MULTISHOT; - sqe->user_data = UD(p, TAG_ACCEPT); - if (p->ring.fixed_files) - sqe->file_index = IORING_FILE_INDEX_ALLOC; /* land each socket in a free slot, not an fd */ -} - -/* An accept CQE: wrap the new fd in a conn, arm its recv, spawn its handler coroutine. */ -static void on_accept(proactor_t *p, int result, unsigned flags) -{ - trace("[w%d] accept result=%d more=%d\n", p->id, result, !!(flags & IORING_CQE_F_MORE)); - if (result >= 0) { - conn_t *c = ioma__conn_new(p, result); /* TCP_NODELAY came with the listener */ - ioma__arm_recv(p, c); - proactor_spawn(p, ioma__conn_main, c); - p->accepted++; - } else { - fprintf(stderr, "[w%d] accept: %s\n", p->id, strerror(-result)); - } - if (!(flags & IORING_CQE_F_MORE)) - arm_accept(p); -} - -/* ── completions ───────────────────────────────────────────────────────────────────────── */ - -/* Route one CQE by the tag in its user_data. Handler coroutines resume inline from here. */ -static void dispatch(proactor_t *p, struct io_uring_cqe *cqe) -{ - void *ptr = UD_PTR(cqe->user_data); - switch (UD_TAG(cqe->user_data)) { - case TAG_OP: { - op_t *op = ptr; - op->res = cqe->res; - op->flags = cqe->flags; - trace("[w%d] op res=%d flags=%#x\n", p->id, cqe->res, cqe->flags); - coro_resume(op->waiter); /* to its next await; op may be gone after */ - break; - } - case TAG_RECV: - ioma__on_recv(p, ptr, cqe->res, cqe->flags); - break; - case TAG_ACCEPT: - on_accept(p, cqe->res, cqe->flags); - break; - default: /* TAG_IGNORE: cancel acknowledgements */ - break; - } -} - -/* ── scheduling ────────────────────────────────────────────────────────────────────────── */ - -/* Queue a new coroutine; the loop starts it on its next iteration. */ -void proactor_spawn(proactor_t *p, void (*fn)(void *), void *arg) -{ - coro_t *c = coro_create(fn, arg, STACK_SIZE); - if (p->ready_tail) - p->ready_tail->next = c; - else - p->ready_head = c; - p->ready_tail = c; -} - -/* Start every coroutine spawned since the last iteration. */ -static void run_ready(proactor_t *p) -{ - while (p->ready_head) { - coro_t *c = p->ready_head; - p->ready_head = c->next; - if (!p->ready_head) - p->ready_tail = nullptr; - c->next = nullptr; - coro_resume(c); - } -} - -/* Re-arm every recv parked on -ENOBUFS, but only once a buffer actually came back, so a parked - * connection never spins the loop. */ -static void rearm_starved(proactor_t *p) -{ - if (p->nstarved == 0 || !p->buffers_returned) - return; - p->buffers_returned = false; - for (unsigned i = 0; i < p->nstarved; i++) - ioma__arm_recv(p, p->starved[i]); /* keeps the ref it already holds */ - p->nstarved = 0; -} - -/* ── listener ──────────────────────────────────────────────────────────────────────────── */ - -/* A SO_REUSEPORT listener on port, one per worker so the kernel spreads connections. TCP_NODELAY - * is set here because Linux accepted sockets inherit it: no setsockopt per accept. */ -static int listener_open(uint16_t port) -{ - int fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); - if (fd < 0) { - perror("socket"); - abort(); - } - int one = 1; - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); - setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); - setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); - - struct sockaddr_in addr; - memset(&addr, 0, sizeof addr); - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - addr.sin_addr.s_addr = htonl(INADDR_ANY); - if (bind(fd, (struct sockaddr *)&addr, sizeof addr) < 0) { /* NOLINT(readability-trailing-comma): glibc's transparent-union sockaddr argument trips the check */ - perror("bind"); - abort(); - } - if (listen(fd, 1024) < 0) { - perror("listen"); - abort(); - } - return fd; -} - -/* ── the loop ──────────────────────────────────────────────────────────────────────────── */ - -/* Pin this thread to the idx-th CPU the process may run on. Reading the inherited affinity mask - * keeps the mapping right under a non-contiguous cpuset, e.g. a container on 0-31,64-95. */ -static void pin_to(int idx) -{ - cpu_set_t allowed; - CPU_ZERO(&allowed); - if (sched_getaffinity(0, sizeof allowed, &allowed) != 0) - return; - - int count = CPU_COUNT(&allowed); - if (count <= 0) - return; - - int target = idx % count; - int seen = 0; - for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { - if (!CPU_ISSET(cpu, &allowed)) - continue; - if (seen == target) { - cpu_set_t one; - CPU_ZERO(&one); - CPU_SET(cpu, &one); - pthread_setaffinity_np(pthread_self(), sizeof one, &one); - return; - } - seen++; - } -} - -/* How many registered file slots to ask for: FIXED_FILES, capped by the fd limit the kernel - * checks the table against. 0 disables the feature. */ -static unsigned fixed_slots(void) -{ - struct rlimit rl; - unsigned n = FIXED_FILES; - if (n && getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur < n) - n = (unsigned)rl.rlim_cur; - return n; -} - -/* The worker's whole life: setup, the loop until *stop, teardown in dependency order. */ -void proactor_run(proactor_t *p) -{ - if (p->cpu >= 0) - pin_to(p->cpu); - - int rc = uring_init(&p->ring, RING_ENTRIES); /* on this thread: DEFER_TASKRUN ties it here */ - if (rc < 0) { - fprintf(stderr, "[w%d] io_uring_setup: %s\n", p->id, strerror(-rc)); - abort(); - } -#ifndef NO_REG_RING - uring_register_ring_fd(&p->ring); /* optional: enter skips an fd lookup */ -#endif - - unsigned slots = fixed_slots(); /* optional: sockets live in a file table */ - if (slots) - uring_register_files_sparse(&p->ring, slots); - ioma__bufring_init(p); - p->listen_fd = listener_open(p->port); - arm_accept(p); - fprintf(stderr, "[w%d] listening on 0.0.0.0:%u (cpu %d, %u x %u B recv buffers, ring %u%s%s%s)\n", - p->id, p->port, p->cpu, BUF_COUNT, BUF_SIZE, p->ring.sq_entries, - p->ring.has_sq_array ? "" : ", no sqarray", - p->ring.enter_flags ? ", registered ring" : "", - p->ring.fixed_files ? ", fixed files" : ""); - - struct __kernel_timespec wait_at_most = { .tv_sec = 0, .tv_nsec = 100000000L }; /* 100 ms: so an idle worker notices *stop */ - while (!*p->stop) { - run_ready(p); - rearm_starved(p); - ioma__bufring_publish(p); - - rc = uring_submit_wait(&p->ring, 1, &wait_at_most); /* one syscall per batch */ - if (rc < 0 && rc != -ETIME && rc != -EINTR && rc != -EAGAIN && rc != -EBUSY) { - fprintf(stderr, "[w%d] io_uring_enter: %s\n", p->id, strerror(-rc)); - break; - } - - unsigned ready = uring_cq_ready(&p->ring); /* read the tail once */ - for (unsigned i = 0; i < ready; i++) - dispatch(p, uring_cqe_at(&p->ring, i)); /* handlers run in here */ - uring_cq_advance(&p->ring, ready); /* publish the head once */ - } - - fprintf(stderr, "[w%d] stopping: %llu accepted, %u still open\n", - p->id, (unsigned long long)p->accepted, p->live); - - /* Sockets, then the ring (which cancels every in-flight op and drops its buffer references), - * then the memory the kernel could still have referenced. */ - close(p->listen_fd); - ioma__bufring_unregister(p); - uring_exit(&p->ring); - ioma__bufring_unmap(p); - free(p->starved); - ioma__conn_pool_drain(p); - coro_pool_drain(); -} diff --git a/src/io/proactor.h b/src/io/proactor.h deleted file mode 100644 index e2e05c2..0000000 --- a/src/io/proactor.h +++ /dev/null @@ -1,109 +0,0 @@ -/* - * proactor.h - one worker: one thread, one io_uring, one SO_REUSEPORT listener, one provided - * buffer ring, and the coroutines that run on it. Thread-per-core, shared-nothing: nothing in - * here is touched by any other thread except the stop flag. - * - * The loop: run freshly spawned coroutines, re-arm recvs parked on -ENOBUFS, publish and enter - * once (submit everything staged, wait for >= 1 completion), dispatch the whole CQ batch, advance - * the head once. Dispatching resumes handler coroutines inline, so the sends they stage ride the - * next enter together with the batch. - */ -#pragma once - -#include -#include -#include - -#include "io/coro.h" -#include "io/uring.h" - -/* tunables (override with -D) */ -#ifndef RING_ENTRIES -#define RING_ENTRIES 4096 /* SQ depth; the CQ is twice that */ -#endif -#ifndef BUF_COUNT -#define BUF_COUNT 4096 /* provided recv buffers per worker, power of two */ -#endif -static_assert(((unsigned)BUF_COUNT & ((unsigned)BUF_COUNT - 1U)) == 0 && BUF_COUNT <= 65536, "BUF_COUNT: a power of two, at most 65536 (16-bit buffer ids)"); -#ifndef BUF_SIZE -#define BUF_SIZE 2048 /* bytes per recv buffer (a request rarely needs more) */ -#endif -#ifndef RX_QUEUE -#define RX_QUEUE 64 /* undelivered slices one connection may hold, pow 2 */ -#endif -#ifndef STACK_SIZE -#define STACK_SIZE (64UL * 1024) /* per coroutine, plus a guard page */ -#endif -#ifndef FIXED_FILES -#define FIXED_FILES 16384 /* registered file slots per worker; 0 disables */ -#endif - -typedef struct proactor proactor_t; -typedef struct conn conn_t; -typedef void (*handler_fn)(conn_t *c); - -/* A slice the kernel delivered into a provided buffer, waiting for the handler to read it. */ -struct rx_item { - uint8_t *ptr; - uint32_t len; - uint16_t buf_id; -}; - -enum recv_state { - RECV_ARMED, /* multishot recv in flight; the kernel may post CQEs */ - RECV_STARVED, /* it ended on -ENOBUFS; re-armed once buffers return */ - RECV_DONE, /* it posted its terminal CQE */ -}; - -struct conn { - int fd; /* the socket, or its file slot under fixed files */ - proactor_t *p; - coro_t *waiter; /* coroutine parked in await_recv, or nullptr */ - struct rx_item rx[RX_QUEUE]; /* delivered while nobody was reading */ - unsigned rx_head, rx_tail; - enum recv_state recv; - int refs; /* the handler coroutine + the armed/starved recv */ - bool closed; /* the handler returned; fd closed */ - bool eof; /* recv ended: peer FIN, error, or queue overflow */ - int err; /* 0 on FIN, else the negative errno */ - struct conn *pool_next; /* free-list link while recycled (not in use) */ -}; - -struct proactor { - /* set by the creator */ - int id; - int cpu; /* pin the thread here; -1 = don't */ - uint16_t port; - handler_fn handler; - volatile sig_atomic_t *stop; - - /* owned by the worker thread */ - struct uring ring; - int listen_fd; - struct io_uring_buf_ring *buf_ring; /* kernel-shared ring of buffer descriptors */ - uint8_t *slab; /* BUF_COUNT x BUF_SIZE */ - unsigned buf_tail; /* local tail, published to buf_ring->tail */ - bool buffers_returned; /* since the last starved sweep */ - bool buf_dirty; /* staged buffer returns awaiting one publish */ - conn_t **starved; /* connections parked on -ENOBUFS */ - unsigned nstarved, cap_starved; - uint64_t starved_total; /* recvs that found the ring empty, ever */ - uint64_t starved_since_log; /* ... since the last log line */ - time_t starved_log_at; /* the next second a log line may go out */ - coro_t *ready_head, *ready_tail; /* spawned, not yet started */ - unsigned live; /* open connections */ - uint64_t accepted; - conn_t *conn_free; /* recycled conn_t objects, reused on accept */ - unsigned conn_free_count; -}; - -/* The worker thread's whole life: ring, buffers, listener, loop until *stop, teardown. */ -void proactor_run(proactor_t *p); - -/* Start a coroutine on this worker. Safe from the loop or from any coroutine on it. */ -void proactor_spawn(proactor_t *p, void (*fn)(void *), void *arg); - -/* Awaits: call from a coroutine on the owning worker. The coroutine parks; the loop resumes it - * when the completion arrives. */ -int await_recv(conn_t *c, void *buf, size_t len); /* >0 bytes, 0 peer closed, <0 -errno */ -int await_send(conn_t *c, const void *buf, size_t len); /* len when all sent, else -errno */ diff --git a/tests/conformance.py b/tests/conformance.py new file mode 100644 index 0000000..c057fc5 --- /dev/null +++ b/tests/conformance.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""HTTP/1.1 conformance at the wire level (RFC 9110/9112), against tests/server.c: the request +framing the engine must refuse, the replies that must carry no body, Expect: 100-continue, the +reply head a handler cannot corrupt, and a declared Content-Length held to. Every case sends raw +bytes and reads raw bytes, so what the client library would hide is visible. + + python3 tests/conformance.py [port] +""" +import socket, sys, time + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 + +results = [] + + +def check(name, cond): + print(f"{'ok ' if cond else 'FAIL'} {name}") + results.append(cond) + return cond + + +def connect(timeout=3): + for attempt in range(20): + try: + s = socket.create_connection(("127.0.0.1", PORT)) + break + except OSError: + if attempt == 19: + raise + time.sleep(0.05) + s.settimeout(timeout) + return s + + +def read_all(s): + """Everything until the peer closes, or a timeout: (bytes, closed).""" + buf = b"" + try: + while True: + c = s.recv(65536) + if not c: + return buf, True + buf += c + except socket.timeout: + return buf, False + + +def exchange(raw, timeout=3): + """Send raw bytes, read until close or timeout.""" + s = connect(timeout) + s.sendall(raw) + data, closed = read_all(s) + s.close() + return data, closed + + +def split_responses(data, head_first=False): + """The responses in a byte stream, as (status, headers, body) triples; the head is parsed + strictly so a malformed head shows up as a failure, not as a guess. head_first: the first + response answers a HEAD, so it ends at its blank line whatever its headers say.""" + out = [] + while data: + if b"\r\n\r\n" not in data: + out.append(("MALFORMED", {}, data)) + break + head, _, rest = data.partition(b"\r\n\r\n") + lines = head.split(b"\r\n") + parts = lines[0].split(b" ", 2) + status = int(parts[1]) if parts[0] == b"HTTP/1.1" and len(parts) >= 2 and parts[1].isdigit() else "MALFORMED" + headers = {} + for line in lines[1:]: + k, sep, v = line.partition(b": ") + if not sep: + status = "MALFORMED" + headers[k.decode(errors="replace").lower()] = v.decode(errors="replace") + if head_first and not out: + body = b"" + elif "transfer-encoding" in headers: + body, rest = dechunk(rest) + else: + n = int(headers.get("content-length", "0")) + body, rest = rest[:n], rest[n:] + out.append((status, headers, body)) + data = rest + return out + + +def dechunk(raw): + out = b"" + while True: + line, _, raw = raw.partition(b"\r\n") + n = int(line.split(b";")[0], 16) + if n == 0: + _, _, raw = raw.partition(b"\r\n") # the empty trailer section + return out, raw + out += raw[:n] + raw = raw[n + 2:] + + +def one(raw, timeout=3): + """One request: its first response and whether the connection closed after the stream.""" + data, closed = exchange(raw, timeout) + rs = split_responses(data) + return (rs[0] if rs else ("NONE", {}, b"")), closed, len(rs) + + +SMUGGLE = b"GET /health HTTP/1.1\r\nHost: x\r\n\r\n" + +# ── request framing the engine must refuse (400 and close), each one a smuggling vector ────── +for name, head in [ + ("Content-Length with a sign", b"Content-Length: +5\r\n"), + ("Content-Length in hex", b"Content-Length: 0x5\r\n"), + ("Content-Length with trailing junk", b"Content-Length: 5abc\r\n"), + ("Content-Length as a list", b"Content-Length: 5, 5\r\n"), + ("Content-Length past 64 bits", b"Content-Length: 18446744073709551621\r\n"), + ("Content-Length empty", b"Content-Length: \r\n"), + ("two Content-Length that disagree", b"Content-Length: 5\r\nContent-Length: 0\r\n"), + ("Transfer-Encoding and Content-Length", b"Transfer-Encoding: chunked\r\nContent-Length: 5\r\n"), + ("an obs-folded Content-Length", b"Content-Length:\r\n 5\r\n"), + ("an obs-folded Transfer-Encoding", b"Transfer-Encoding:\r\n chunked\r\n"), +]: + (st, hd, body), closed, n = one(b"POST /echo HTTP/1.1\r\nHost: x\r\n" + head + b"\r\nAAAAA" + SMUGGLE) + check(f"{name} -> 400 and close, nothing smuggled", st == 400 and closed and n == 1) + +(st, hd, body), closed, n = one(b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: gzip\r\n\r\nAAAAA" + SMUGGLE) +check("an unknown transfer coding -> 501 and close", st == 501 and closed and n == 1) +(st, hd, body), closed, n = one(b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked, gzip\r\n\r\nAAAAA" + SMUGGLE) +check("chunked not the last coding -> 501 and close, nothing smuggled", st == 501 and closed and n == 1) +(st, hd, body), closed, n = one(b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nTransfer-Encoding: identity\r\n\r\nAAAAA" + SMUGGLE) +check("TE.TE: chunked then identity -> 501 and close, nothing smuggled", st == 501 and closed and n == 1) +(st, hd, body), closed, n = one(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\nhello") +check("two Content-Length that agree -> accepted", st == 200 and body == b"hello") + +# ── Host ───────────────────────────────────────────────────────────────────────────────────── +(st, hd, body), closed, n = one(b"GET /health HTTP/1.1\r\n\r\n") +check("HTTP/1.1 without Host -> 400", st == 400 and closed) +(st, hd, body), closed, n = one(b"GET /health HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n") +check("two Host lines -> 400", st == 400 and closed) +(st, hd, body), closed, n = one(b"GET /health HTTP/1.0\r\n\r\n") +check("HTTP/1.0 without Host -> served", st == 200 and body == b"ok") +(st, hd, body), closed, n = one(b"GET http://127.0.0.1:%d/health HTTP/1.1\r\nHost: x\r\n\r\n" % PORT) +check("absolute-form target -> routed by its path", st == 200 and body == b"ok") +(st, hd, body), closed, n = one(b"GET http://x HTTP/1.1\r\nHost: x\r\n\r\n") +check("absolute-form target with no path -> '/'", st == 200 and b"hello" in body) + +# ── Connection: every line counts, close wins ──────────────────────────────────────────────── +data, closed = exchange(b"GET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\nConnection: keep-alive\r\n\r\n" + SMUGGLE) +rs = split_responses(data) +check("Connection: close on an earlier line wins -> one reply, closed", + len(rs) == 1 and rs[0][1].get("connection") == "close" and closed) + +# ── chunked bodies ─────────────────────────────────────────────────────────────────────────── +def chunked_post(body_bytes): + return one(b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n" + body_bytes) + +(st, hd, body), closed, n = chunked_post(b"5\r\nHELLO\r\n0\r\n\r\n") +check("a plain chunked body -> echoed", st == 200 and body == b"HELLO") +(st, hd, body), closed, n = chunked_post(b"5;ext=1\r\nHELLO\r\n0\r\n\r\n") +check("a chunk extension -> accepted", st == 200 and body == b"HELLO") +(st, hd, body), closed, n = chunked_post(b"5 \r\nHELLO\r\n0\r\n\r\n") +check("bare whitespace after the chunk size -> 400", st == 400 and closed) +(st, hd, body), closed, n = chunked_post(b"5;\r\nHELLO\r\n0\r\n\r\n") +check("an empty chunk extension -> 400", st == 400 and closed) +(st, hd, body), closed, n = chunked_post(b"5\r\nHELLO\r\n0\r\nx: 1\r\ny: 2\r\n\r\n") +check("trailers -> taken, body echoed", st == 200 and body == b"HELLO") +(st, hd, body), closed, n = chunked_post(b"0\r\n" + b"x: " + b"y" * 3000 + b"\r\n" + b"z: " + b"w" * 3000 + b"\r\n\r\n") +check("trailers past their cap -> 400 and close", st == 400 and closed) +(st, hd, body), closed, n = one(b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n" + b"5;" + b"e" * 20000 + b"\r\nHELLO\r\n0\r\n\r\n") +check("a chunk size line the reader cannot hold -> 400", st == 400 and closed) + +# ── replies that carry no body ─────────────────────────────────────────────────────────────── +data, closed = exchange(b"HEAD / HTTP/1.1\r\nHost: x\r\n\r\nGET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +rs = split_responses(data, head_first=True) +check("HEAD / -> the GET's head, no body, then the pipelined GET answered", + len(rs) == 2 and rs[0][0] == 200 and rs[0][1].get("content-length") == rs[1][1].get("content-length") + and rs[0][2] == b"" and rs[1][2] == b"hello from ioxd\n") +data, closed = exchange(b"HEAD /stream HTTP/1.1\r\nHost: x\r\n\r\nGET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +rs = split_responses(data, head_first=True) +check("HEAD of a streamed reply -> chunked head, no chunks, next request answered", + len(rs) == 2 and rs[0][0] == 200 and rs[0][1].get("transfer-encoding") == "chunked" and rs[0][2] == b"" + and rs[1][2] == b"ok") +data, closed = exchange(b"HEAD /nope HTTP/1.1\r\nHost: x\r\n\r\nGET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +rs = split_responses(data, head_first=True) +check("HEAD of an unrouted path -> 404 head, no body", len(rs) == 2 and rs[0][0] == 404 and rs[0][2] == b"" and rs[1][2] == b"ok") + +for path, code, framing in [("/status/204", 204, None), ("/status/304", 304, None), ("/status/204?body=1", 204, None)]: + data, closed = exchange(b"GET %s HTTP/1.1\r\nHost: x\r\n\r\nGET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" % path.encode()) + rs = split_responses(data) + check(f"{path} -> {code} with no body, no framing header, the next request answered", + len(rs) == 2 and rs[0][0] == code and rs[0][2] == b"" and "content-length" not in rs[0][1] + and "transfer-encoding" not in rs[0][1] and rs[1][2] == b"ok") + +# ── the reply head a handler cannot corrupt ────────────────────────────────────────────────── +(st, hd, body), closed, n = one(b"GET /reflect?name=x-echo&value=safe HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("a reflected header value -> sent", st == 200 and hd.get("x-echo") == "safe" and body == b"added") +(st, hd, body), closed, n = one(b"GET /reflect?name=x-echo&value=a%0d%0ax-evil:%201 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("a value with CRLF -> refused by ioxd_header, no injected line", + st == 200 and "x-evil" not in hd and "x-echo" not in hd and body == b"refused") +(st, hd, body), closed, n = one(b"GET /reflect?name=x-echo&value=a%00b HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("a value with %00 -> kept literal, sent as text", st == 200 and hd.get("x-echo") == "a%00b") +(st, hd, body), closed, n = one(b"GET /reflect?name=bad%20name&value=v HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("a name that is not a token -> refused", st == 200 and body == b"refused") +(st, hd, body), closed, n = one(b"GET /reflect?name=Content-Length&value=0 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("an engine-owned header -> refused, the real content-length stands", st == 200 and hd.get("content-length") == "7" and body == b"refused") +(st, hd, body), closed, n = one(b"GET /reflect?name=Content-Type&value=text/x-custom HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("content-type through ioxd_header -> the content type", st == 200 and hd.get("content-type") == "text/x-custom") +(st, hd, body), closed, n = one(b"GET /reflect?name=x-local&value=stack HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("a header from a handler-local buffer -> copied, intact on the wire", st == 200 and hd.get("x-local") == "stack") + +# ── the status line ────────────────────────────────────────────────────────────────────────── +(st, hd, body), closed, n = one(b"GET /status/99 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("a status outside 100-999 -> 500", st == 500) +(st, hd, body), closed, n = one(b"GET /status/418 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("an unlisted status -> its number, a reason phrase", st == 418) + +# ── a declared length, held to ─────────────────────────────────────────────────────────────── +(st, hd, body), closed, n = one(b"GET /promise?say=100&write=9 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("a declared length larger than a buffered body -> the real length", st == 200 and hd.get("content-length") == "9" and body == b"123456789") +data, closed = exchange(b"GET /promise?say=3&write=19&stream=1 HTTP/1.1\r\nHost: x\r\n\r\nGET /health HTTP/1.1\r\nHost: x\r\n\r\n") +rs = split_responses(data) +check("a streamed body past its declared length -> cut at the length, connection closed", + len(rs) == 1 and rs[0][1].get("content-length") == "3" and rs[0][2] == b"123" and closed) +data, closed = exchange(b"GET /promise?say=100&write=9&stream=1 HTTP/1.1\r\nHost: x\r\n\r\nGET /health HTTP/1.1\r\nHost: x\r\n\r\n") +check("a streamed body short of its declared length -> the connection closes (the client sees the cut)", + closed and data.startswith(b"HTTP/1.1 200") and len(split_responses(data + b"")) <= 1) + +# ── Expect: 100-continue ───────────────────────────────────────────────────────────────────── +s = connect() +s.sendall(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n") +first = s.recv(4096) +got_continue = first.startswith(b"HTTP/1.1 100 Continue\r\n\r\n") +s.sendall(b"hello") +rest, closed = read_all(s) +s.close() +rs = split_responses(first[len(b"HTTP/1.1 100 Continue\r\n\r\n"):] + rest) if got_continue else [] +check("Expect: 100-continue -> 100 Continue before the body is read, then the reply", + got_continue and len(rs) == 1 and rs[0][0] == 200 and rs[0][2] == b"hello") +s = connect() +s.sendall(b"POST /health HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nExpect: 100-continue\r\n\r\n") +data, closed = read_all(s) +s.close() +rs = split_responses(data) +check("Expect with a handler that never reads the body -> final status, connection closed, no wait", + not data.startswith(b"HTTP/1.1 100") and len(rs) == 1 and rs[0][1].get("connection") == "close" and closed) +(st, hd, body), closed, n = one(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nExpect: 200-please\r\n\r\nhello") +check("an expectation we do not know -> 417", st == 417) + +# ── query parameters: the whole query or nothing ───────────────────────────────────────────── +many = b"&".join(b"p%d=1" % i for i in range(40)) +(st, hd, body), closed, n = one(b"GET /params?" + many + b" HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("more query parameters than fit -> 400, never a partial view", st == 400) +huge = b"v=" + b"%41" * 1500 +(st, hd, body), closed, n = one(b"GET /params?" + huge + b" HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +check("more decoded query than the arena holds -> 414", st == 414) + +failed = results.count(False) +print("all passed" if not failed else f"{failed} FAILED") +sys.exit(1 if failed else 0) diff --git a/tests/ioma-pipe-server b/tests/ioma-pipe-server new file mode 100755 index 0000000..61cbaa3 Binary files /dev/null and b/tests/ioma-pipe-server differ diff --git a/tests/ioma-test-server b/tests/ioma-test-server new file mode 100755 index 0000000..5f3b92b Binary files /dev/null and b/tests/ioma-test-server differ diff --git a/tests/ioma-unit b/tests/ioma-unit new file mode 100755 index 0000000..99300da Binary files /dev/null and b/tests/ioma-unit differ diff --git a/tests/mkcerts.sh b/tests/mkcerts.sh new file mode 100755 index 0000000..9530761 --- /dev/null +++ b/tests/mkcerts.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Self-signed certificates for the test fixture: `default` (CN=localhost), one SNI host, and a +# wildcard host, so the store's exact match, its fallback and its `_.example.com` -> *.example.com +# rule are all exercised. +# +# sh tests/mkcerts.sh [dir] make the three, each only when missing or about to expire +# sh tests/mkcerts.sh [dir] host ... remake exactly those, whatever their state +# +# They are short-lived on purpose, so a stale checkout is not served an expired certificate: one +# within three days of its end date is made again. +set -e +dir=${1:-tests/certs} +[ $# -gt 0 ] && shift || true + +# One host: `default` is RSA (what most clients and test suites assume), the rest ECDSA, so both +# key types serve. $force remakes it even when what is there is still good. +make_host() { + host=$1 + force=${2:-no} + cert="$dir/$host/cert.pem" + if [ "$force" = no ] && [ -f "$cert" ] && openssl x509 -in "$cert" -noout -checkend 259200 >/dev/null 2>&1; then + return 0 # 3 days: still good for a while + fi + mkdir -p "$dir/$host" + case $host in + default) key="-newkey rsa:2048" ; cn=localhost ;; + _.*) key="-newkey ec -pkeyopt ec_paramgen_curve:prime256v1" ; cn="*.${host#_.}" ;; + *) key="-newkey ec -pkeyopt ec_paramgen_curve:prime256v1" ; cn=$host ;; + esac + # shellcheck disable=SC2086 + openssl req -x509 $key -nodes -subj "/CN=$cn" -days 30 \ + -keyout "$dir/$host/key.pem" -out "$dir/$host/cert.pem" 2>/dev/null +} + +if [ $# -gt 0 ]; then + for host in "$@"; do + make_host "$host" force + done + echo "certificates for $* in $dir" +else + make_host default + make_host sni.test + make_host _.example.com + echo "certificates in $dir" +fi diff --git a/tests/pipe-server.c b/tests/pipe-server.c new file mode 100644 index 0000000..467f2c7 --- /dev/null +++ b/tests/pipe-server.c @@ -0,0 +1,120 @@ +/* + * pipe-server.c - a line echo server on ioxd_run_pipes: the pipe API without HTTP. Each line + * comes back as "echo: "; "quit" ends the connection; a line longer than the pipe's + * buffer ends it too (the reader reports no room). Two commands beside the echo take the rest of + * the API: "copy N" reads the next N bytes into a buffer of the handler's own and sends them + * back, "hold N" waits for them where the kernel left them - however many receives that takes - + * keeps them (which consumes them, so nothing is read twice), writes them back out and gives the + * reader its room again. `make check` runs tests/pipes.py against it. + */ +#include + +#include +#include + +/* The count on a " N" line, or -1 when the line is not one of them. */ +static long command(ioxd_slice line, const char *name) +{ + size_t len = strlen(name); + if (line.len < len + 3 || memcmp(line.p, name, len) != 0 || line.p[len] != ' ') + return -1; + char digits[16]; + size_t n = line.len - len - 2; /* without the name, the space, the newline */ + if (n >= sizeof digits) + return -1; + memcpy(digits, line.p + len + 1, n); + digits[n] = '\0'; + char *end; + long value = strtol(digits, &end, 10); + return *end == '\0' && value >= 0 ? value : -1; +} + +/* "copy N": the next n bytes into a buffer of ours. ioxd_pipe_copy hands over what it has, so the + * loop asks again until they are all in; ioxd_pipe_send is the write and the flush in one. */ +static int copy_back(ioxd_pipe *pipe, size_t n) +{ + char out[6 + 256 + 1]; /* "copy: " + the bytes + '\n' */ + if (n > 256) + return -1; + memcpy(out, "copy: ", 6); /* NOLINT(bugprone-not-null-terminated-result): bytes, not a C string */ + size_t got = 0; + while (got < n) { + int rc = ioxd_pipe_copy(pipe, out + 6 + got, n - got); + if (rc <= 0) + return -1; + got += (size_t)rc; + } + out[6 + n] = '\n'; + return ioxd_pipe_send(pipe, out, 6 + n + 1); +} + +/* "hold N": wait until n bytes are live - they may arrive over several receives - then keep them + * where they lie, write them back out and release the run. Keeping consumes them, so the read + * after this waits for new bytes instead of seeing these again. */ +static int hold_back(ioxd_pipe *pipe, size_t n) +{ + const char *kept = NULL; + while (!kept) { + ioxd_slice live = { NULL, 0 }; + if (ioxd_pipe_read(pipe, &live) <= 0) + return -1; + if (live.len < n) { + ioxd_pipe_examine(pipe, live.len); /* seen, not consumed: wait for the rest */ + continue; + } + kept = ioxd_pipe_keep(pipe, n); + if (!kept) + return -1; + } + int rc = ioxd_pipe_write(pipe, "hold: ", 6); + if (rc == 0) + rc = ioxd_pipe_write(pipe, kept, n); + if (rc == 0) + rc = ioxd_pipe_write(pipe, "\n", 1); + if (rc == 0) + rc = ioxd_pipe_flush(pipe); + ioxd_pipe_release(pipe); /* the kept run's room, back to the reader */ + return rc; +} + +/* One connection: lines in, echoes out, until the peer leaves. */ +static void echo(ioxd_pipe *pipe) +{ + for (;;) { + ioxd_slice live = { NULL, 0 }; + if (ioxd_pipe_read(pipe, &live) <= 0) /* the end, or no room for a longer line */ + return; + const char *nl = memchr(live.p, '\n', live.len); + if (!nl) { /* half a line: wait for the rest */ + ioxd_pipe_examine(pipe, live.len); + continue; + } + ioxd_slice line = { live.p, (size_t)(nl - live.p) + 1 }; + if (line.len == 5 && memcmp(line.p, "quit\n", 5) == 0) + return; + long copy = command(line, "copy"); + long hold = command(line, "hold"); + if (copy >= 0 || hold >= 0) { + ioxd_pipe_drop(pipe, line.len); /* the command goes; live.p is stale after this */ + if ((copy >= 0 ? copy_back(pipe, (size_t)copy) : hold_back(pipe, (size_t)hold)) < 0) + return; + continue; + } + char *out = ioxd_pipe_reserve(pipe, 6 + line.len); /* format straight into the slab */ + if (!out) + return; + memcpy(out, "echo: ", 6); /* NOLINT(bugprone-not-null-terminated-result): bytes, not a C string */ + memcpy(out + 6, line.p, line.len); + ioxd_pipe_advance(pipe, 6 + line.len); + ioxd_pipe_drop(pipe, line.len); + if (ioxd_pipe_flush(pipe) < 0) + return; + } +} + +int main(int argc, char **argv) +{ + int port = argc > 1 ? (int)strtol(argv[1], NULL, 10) : 8100; + ioxd_bind(port, NULL); + return ioxd_run_pipes(2, echo); +} diff --git a/tests/pipes.py b/tests/pipes.py new file mode 100644 index 0000000..6f43d3c --- /dev/null +++ b/tests/pipes.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""The pipe API through tests/pipe-server.c, a line echo: whole lines, a line split across +sends, two lines in one packet, bytes copied into a buffer of the handler's own, bytes kept where +the kernel left them, a quit, and a line too long for the pipe's buffer.""" +import socket, sys, time + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8100 + + +def connect(): + for attempt in range(20): + try: + s = socket.create_connection(("127.0.0.1", PORT), timeout=5) + return s + except OSError: + if attempt == 19: + raise + time.sleep(0.05) + + +def read_until(s, want): + data = b"" + while len(data) < want: + chunk = s.recv(65536) + if not chunk: + break + data += chunk + return data + + +def read_to_close(s): + """Everything until the server closes: (data, closed). closed is False when the read timed out + instead - the connection is still open, which is a hang, not a close, and an assertion about a + close must not pass on one.""" + data = b"" + while True: + try: + chunk = s.recv(65536) + except socket.timeout: + return data, False + except ConnectionResetError: + return data, True + if not chunk: + return data, True + data += chunk + + +results = [] + + +def check(name, cond): + print(f"{'ok ' if cond else 'FAIL'} {name}") + results.append(cond) + + +s = connect() +s.sendall(b"hello\n") +check("a line comes back echoed", read_until(s, 12) == b"echo: hello\n") +s.sendall(b"ab") +time.sleep(0.05) +s.sendall(b"c\n") +check("a line split across sends is echoed whole", read_until(s, 10) == b"echo: abc\n") +s.sendall(b"x\ny\n") +check("two lines in one packet -> two echoes", read_until(s, 16) == b"echo: x\necho: y\n") + +# copy: the bytes after the command line, read into the handler's own buffer and sent back +s.sendall(b"copy 5\nhello") +check("copy N -> the next N bytes, copied out and sent back", read_until(s, 12) == b"copy: hello\n") + +# keep: the bytes are waited for where the kernel left them, however many receives that takes, +# then kept - which consumes them, so the line after is read fresh rather than seen twice +s.sendall(b"hold 9\n") +for part in (b"abc", b"def", b"ghi"): + s.sendall(part) + time.sleep(0.05) +check("hold N -> N bytes gathered over three receives, kept and written back", + read_until(s, 16) == b"hold: abcdefghi\n") +s.sendall(b"after\n") +check("the held bytes were consumed: the next line is echoed on its own", read_until(s, 12) == b"echo: after\n") + +s.sendall(b"quit\n") +data, closed = read_to_close(s) +check("quit closes the connection", closed and data == b"") +s.close() + +s = connect() +s.sendall(b"z" * 20000) +data, closed = read_to_close(s) +check("a line longer than the pipe's buffer closes the connection", closed and data == b"") +s.close() + +s = connect() +big = b"w" * 6000 + b"\n" +s.sendall(big) +check("a 6 KB line, spanning kernel buffers, is echoed whole", read_until(s, 6 + len(big)) == b"echo: " + big) +s.close() + +failed = results.count(False) +print("all passed" if not failed else f"{failed} FAILED") +sys.exit(1 if failed else 0) diff --git a/tests/router_test.c b/tests/router_test.c new file mode 100644 index 0000000..5787b77 --- /dev/null +++ b/tests/router_test.c @@ -0,0 +1,325 @@ +/* + * router_test.c - the router without a server: paths joined into the segment tree, the allow + * header of a 405, HEAD answered by GET, decoded captures, the middleware chain, and the + * diagnostics that guard registration. Requests are driven straight through the dispatcher with + * a context built by hand - one whose reply is marked failed, so the body writes of the built-in + * fallbacks go nowhere and nothing here touches the wire. `make check` runs it beside the unit + * test. + */ +#include + +#include +#include +#include +#include + +/* What the engine calls, from the library's private lib/http/router.h. */ +void ioxd__router_build(void); +void ioxd__dispatch(ioxd_ctx *ctx); + +static int checks, failures; + +/* Count a check; report a failed one by line. */ +static void check(const char *what, bool ok, int line) +{ + checks++; + if (!ok) { + failures++; + printf("FAIL line %d: %s\n", line, what); + } +} +#define CHECK(cond) check(#cond, (cond), __LINE__) + +/* A slice over a C string. */ +static ioxd_slice S(const char *cstr) +{ + return (ioxd_slice){ cstr, strlen(cstr) }; +} + +/* ── the routes under test ─────────────────────────────────────────────────────────────── */ + +static const char *ran; /* the handler the request reached */ +static char trace[64]; /* the chain, in the order it ran */ +static size_t n_trace; + +/* One letter of the trace: a middleware's on the way in, its capital on the way out. */ +static void mark(char c) +{ + if (n_trace < sizeof trace - 1) + trace[n_trace++] = c; + trace[n_trace] = '\0'; +} + +/* Every handler is the same: it says which one ran and puts an 'h' in the trace. */ +#define HANDLER(name) static void name(ioxd_ctx *ctx) { (void)ctx; ran = #name; mark('h'); } +HANDLER(h_health) +HANDLER(h_new) +HANDLER(h_user) +HANDLER(h_patch) +HANDLER(h_api_users) +HANDLER(h_items) +HANDLER(h_leak) +HANDLER(h_after) +HANDLER(h_twice) +HANDLER(h_late) /* registered too late: never reached */ + +static void mw_root(ioxd_ctx *ctx, ioxd_next *next) { mark('r'); ioxd_next_run(ctx, next); mark('R'); } +static void mw_api (ioxd_ctx *ctx, ioxd_next *next) { mark('a'); ioxd_next_run(ctx, next); mark('A'); } +static void mw_own (ioxd_ctx *ctx, ioxd_next *next) { mark('o'); ioxd_next_run(ctx, next); mark('O'); } +static void mw_late(ioxd_ctx *ctx, ioxd_next *next) { mark('!'); ioxd_next_run(ctx, next); } + +/* A middleware that runs the rest of the chain twice; the second call must run nothing. */ +static void mw_twice(ioxd_ctx *ctx, ioxd_next *next) +{ + mark('t'); + ioxd_next_run(ctx, next); + ioxd_next_run(ctx, next); + mark('T'); +} + +static ioxd_endpoint *g_health; /* for a middleware added after the build */ + +/* The whole table, registered as a script. The order is the order the allow header lists. */ +static void register_routes(void) +{ + IOXD_USE(mw_root); + g_health = IOXD_GET("/health", h_health); /* no HEAD: its GET answers one */ + IOXD_POST("/users/new", h_new); + IOXD_GET("/users/:id", h_user); + IOXD_PATCH("/users/new", h_patch); + IOXD_POST("/users/:id", h_user); /* a method on both nodes: listed once */ + IOXD_GET("/twice", h_twice, mw_twice); + + IOXD_GROUP("/api", mw_api) { + IOXD_GET("users", h_api_users, mw_own); /* neither side brings a '/': "/api/users" */ + IOXD_GROUP("/v2/") { + IOXD_GET("/items", h_items); /* both do: "/api/v2/items" */ + } + } + + ioxd_group *outer = ioxd__group_current(); + IOXD_GROUP("/leak") { + IOXD_GET("/x", h_leak); + break; /* leaving early must still close the group */ + } + CHECK(ioxd__group_current() == outer); + IOXD_GET("/after", h_after); /* so this is "/after", not "/leak/after" */ +} + +/* ── driving one request ───────────────────────────────────────────────────────────────── */ + +static ioxd_ctx g_ctx; + +/* The connection, stood in for. ioxd_write takes the address of the writer inside the pipe that + * the engine's private state points at, and only then sees res.failed and gives up, so ctx.priv + * needs something shaped like that state: one pointer, to something big enough that the writer's + * address lands inside it. With failed set nothing is ever read or written through it. */ +static alignas(max_align_t) char g_pipe[8192]; +static void *g_priv[1] = { g_pipe }; + +/* Dispatch one request against a context shaped like the engine's, minus the connection: the + * reply is failed from the start, so the body a handler or a built-in fallback writes is + * dropped and nothing here touches the wire. */ +static void request(const char *method, const char *path) +{ + memset(&g_ctx, 0, sizeof g_ctx); + g_ctx.req.method = S(method); + g_ctx.req.path = S(path); + g_ctx.res.status = 200; + g_ctx.res.failed = true; + g_ctx.priv = g_priv; + ran = ""; + n_trace = 0; + trace[0] = '\0'; + ioxd__dispatch(&g_ctx); +} + +/* Did the request reach this handler? */ +static bool reached(const char *name) +{ + return strcmp(ran, name) == 0; +} + +/* Is the reply's header this text? */ +static bool replied_header(const char *name, const char *value) +{ + for (size_t i = 0; i < g_ctx.res.n_headers; i++) + if (ioxd_slice_eq(g_ctx.res.headers[i].key, name)) + return ioxd_slice_eq(g_ctx.res.headers[i].value, value); + return false; +} + +/* Is the capture of this name this text? */ +static bool captured(const char *name, const char *value) +{ + for (size_t i = 0; i < g_ctx.req.n_route_params; i++) + if (ioxd_slice_eq(g_ctx.req.route_params[i].key, name)) + return ioxd_slice_eq(g_ctx.req.route_params[i].value, value); + return false; +} + +/* Run fn with stderr in a temporary file and leave what it printed in buf: the diagnostics are + * part of what is being tested. */ +static void capture_stderr(void (*fn)(void), char *buf, size_t cap) +{ + buf[0] = '\0'; + fflush(stderr); + FILE *tmp = tmpfile(); + int saved = tmp ? dup(STDERR_FILENO) : -1; + if (saved < 0) { /* no capture; run it anyway */ + if (tmp) + fclose(tmp); + fn(); + return; + } + dup2(fileno(tmp), STDERR_FILENO); + fn(); + fflush(stderr); + dup2(saved, STDERR_FILENO); + close(saved); + if (fseek(tmp, 0, SEEK_SET) == 0) + buf[fread(buf, 1, cap - 1, tmp)] = '\0'; + fclose(tmp); +} + +/* ── the checks ────────────────────────────────────────────────────────────────────────── */ + +/* A group opened and never closed: everything after it nested inside, so ioxd_run says so. */ +static void build_with_a_group_left_open(void) +{ + ioxd__group_begin((struct ioxd_group_args){ "/stray", { NULL } }); + ioxd__router_build(); +} + +static void test_build(void) +{ + char said[512]; + capture_stderr(build_with_a_group_left_open, said, sizeof said); + CHECK(strstr(said, "1 group(s) still open") != NULL); + CHECK(strstr(said, "/stray") != NULL); +} + +static void test_paths(void) +{ + request("GET", "/api/users"); /* "/api" and "users", joined */ + CHECK(reached("h_api_users")); + request("GET", "/apiusers"); /* what the missing '/' used to make */ + CHECK(g_ctx.res.status == 404); + request("GET", "/api/v2/items"); /* "/v2/" and "/items": one slash */ + CHECK(reached("h_items")); + request("GET", "/api/v2//items"); /* a repeated slash counts once */ + CHECK(reached("h_items")); + request("GET", "/health/"); /* a trailing slash is tolerated */ + CHECK(reached("h_health")); + + request("GET", "/leak/x"); /* the block's own endpoint is still there */ + CHECK(reached("h_leak")); + request("GET", "/after"); /* and the one after it did not nest */ + CHECK(reached("h_after")); + request("GET", "/leak/after"); + CHECK(g_ctx.res.status == 404); +} + +static void test_captures(void) +{ + request("GET", "/users/7"); + CHECK(reached("h_user") && captured("id", "7")); + request("GET", "/users/a%2Fb"); /* an encoded '/' does not split the segment */ + CHECK(reached("h_user") && captured("id", "a/b")); + request("GET", "/users/%65"); + CHECK(reached("h_user") && captured("id", "e")); + request("GET", "/users/a%2b"); /* no '+' rules in a path */ + CHECK(captured("id", "a+")); + request("GET", "/users/100%25"); + CHECK(captured("id", "100%")); + request("GET", "/users/%zz"); /* a malformed escape stays as it came */ + CHECK(captured("id", "%zz")); + + request("GET", "/us%65rs/7"); /* static segments match the raw bytes */ + CHECK(g_ctx.res.status == 404); + request("GET", "/users/new"); /* a static path without GET falls through */ + CHECK(reached("h_user") && captured("id", "new")); + request("POST", "/users/new"); /* while the static one wins where it has it */ + CHECK(reached("h_new")); +} + +static void test_allow(void) +{ + /* "/users/new" ends the static route and the capture route both, so the 405 allows the + * methods of each: POST and PATCH here, GET (and the HEAD its GET answers) there. */ + request("DELETE", "/users/new"); + CHECK(g_ctx.res.status == 405); + CHECK(replied_header("allow", "POST, GET, HEAD, PATCH")); + + request("DELETE", "/health"); /* HEAD is listed behind the GET that serves it */ + CHECK(g_ctx.res.status == 405 && replied_header("allow", "GET, HEAD")); + + request("DELETE", "/users/7"); /* the capture node alone */ + CHECK(g_ctx.res.status == 405 && replied_header("allow", "GET, HEAD, POST")); + + request("DELETE", "/nowhere"); /* no path at all: the fallback, no allow */ + CHECK(g_ctx.res.status == 404 && g_ctx.res.n_headers == 0); +} + +static void test_head(void) +{ + request("HEAD", "/health"); /* served by the GET, method left alone */ + CHECK(reached("h_health") && g_ctx.res.status == 200); + CHECK(ioxd_slice_eq(g_ctx.req.method, "HEAD")); + request("HEAD", "/api/users"); + CHECK(reached("h_api_users")); + request("HEAD", "/users/7"); /* through a capture too */ + CHECK(reached("h_user") && captured("id", "7")); + request("HEAD", "/nowhere"); + CHECK(g_ctx.res.status == 404); +} + +static void test_chain(void) +{ + request("GET", "/api/users"); /* root, group, own, handler, and back out */ + CHECK(strcmp(trace, "raohOAR") == 0); + request("GET", "/twice"); /* the second ioxd_next_run runs nothing */ + CHECK(strcmp(trace, "rthTR") == 0); + request("GET", "/nowhere"); /* the fallbacks run behind the root's only */ + CHECK(strcmp(trace, "rR") == 0); +} + +/* Everything the guards must refuse once ioxd_run has resolved the table. */ +static void register_after_the_build(void) +{ + CHECK(ioxd_route(NULL, "GET", "/late", h_late) == NULL); + ioxd_use(mw_late); + ioxd_group_use(NULL, mw_late); + ioxd_endpoint_use(g_health, mw_late); + ioxd_default(h_late); +} + +static void test_registration_is_closed(void) +{ + char said[1024]; + capture_stderr(register_after_the_build, said, sizeof said); + int ignored = 0; + for (const char *at = said; (at = strstr(at, "; ignored")); at++) + ignored++; + CHECK(ignored == 5); /* one line for each of the five */ + + request("GET", "/late"); /* and none of them took */ + CHECK(g_ctx.res.status == 404 && !reached("h_late")); + CHECK(strcmp(trace, "rR") == 0); /* the built-in fallback, behind the root's */ + request("GET", "/health"); + CHECK(reached("h_health") && strcmp(trace, "rhR") == 0); /* the endpoint's chain is as it was */ +} + +int main(void) +{ + register_routes(); + test_build(); + test_paths(); + test_captures(); + test_allow(); + test_head(); + test_chain(); + test_registration_is_closed(); + printf("router: %d checks, %d failed\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/run-suites.sh b/tests/run-suites.sh new file mode 100755 index 0000000..9ad2ecd --- /dev/null +++ b/tests/run-suites.sh @@ -0,0 +1,173 @@ +#!/bin/sh +# run-suites.sh - the check sequence, in one place: the unit test, then the HTTP fixture with the +# smoke, conformance, stress and early-TLS suites against it, then the pipe fixture with the pipe suite. Both +# `make check` and CMake's `check` target run this, so the sequence lives here and nowhere else. +# +# sh tests/run-suites.sh --unit tests/ioxd-unit --server tests/ioxd-test-server \ +# --pipe-server tests/ioxd-pipe-server [--port 8099] [--pipe-port 8102] +# +# --port N the fixture's first port: N and N+1 plain, N+2 TLS [8099] +# --pipe-port N the pipe fixture's port [8102] +# --unit PATH the unit test binary +# --server PATH the HTTP fixture (tests/server.c) +# --pipe-server PATH the pipe fixture (tests/pipe-server.c) +# --python PY the python the suites run under [python3] +# --tls-python PY the python for tls_early.py (needs tlslite-ng) [--python] +# --work DIR scratch: the fixture's log and the certificates it serves [obj/check] +# --suite NAME run only this one (repeatable): unit smoke conformance stress tls pipes +# +# The suites named in one run share the fixture they talk to, and are meant to: a fixture bound to +# a port the suite before it left full of TIME_WAIT connections has some of its new connections +# reset, so restarting one per suite on the same port is not the same thing at all. +# +# A fixture's output goes to a log in the work directory instead of /dev/null, and the log is +# printed - and the run fails - when the fixture exits non-zero, when a worker reported an error, +# or when a worker stopped with connections still open. The certificates the fixture serves are a +# copy of tests/certs, so a suite may rewrite them (smoke.py's TLS reload does). +set -u + +tests=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +root=$(dirname "$tests") + +port=8099 +pipe_port=8102 +unit= +server= +pipe_server= +python=python3 +tls_python= +work= +suites= + +while [ $# -gt 0 ]; do + case $1 in + --port) port=$2; shift 2 ;; + --pipe-port) pipe_port=$2; shift 2 ;; + --unit) unit=$2; shift 2 ;; + --server) server=$2; shift 2 ;; + --pipe-server) pipe_server=$2; shift 2 ;; + --python) python=$2; shift 2 ;; + --tls-python) tls_python=$2; shift 2 ;; + --work) work=$2; shift 2 ;; + --suite) suites="$suites $2"; shift 2 ;; + -h|--help) sed -n '2,26p' "$0"; exit 0 ;; + *) echo "run-suites: unknown option $1" >&2; exit 2 ;; + esac +done +[ -n "$tls_python" ] || tls_python=$python +[ -n "$work" ] || work=$root/obj/check +[ -n "$suites" ] || suites="unit smoke conformance stress tls pipes" +tls_port=$((port + 2)) + +wanted() { + for s in $suites; do + [ "$s" = "$1" ] && return 0 + done + return 1 +} + +# A socket probe: the only honest answer to "is it listening". +port_open() { + "$python" -c "import socket,sys +s = socket.socket() +s.settimeout(0.5) +sys.exit(0 if s.connect_ex(('127.0.0.1', $1)) == 0 else 1)" 2>/dev/null +} + +# Up when the port answers AND every worker has said it is listening: each worker opens its own +# SO_REUSEPORT socket, and a reuseport group that grows while connections are arriving resets some +# of them, so waiting for the first worker alone would hand a suite a fixture that is still +# assembling itself. +workers=2 + +wait_for_fixture() { + i=0 + while [ $i -lt 100 ]; do + if port_open "$1" && [ "$(grep -c 'listening on' "$2")" -ge $workers ]; then + return 0 + fi + i=$((i + 1)) + sleep 0.1 + done + return 1 +} + +# What a worker prints when something went wrong, and the shutdown line with connections left on +# it. A dropped TLS connection is not one of these: tls_early.py corrupts a record on purpose. +log_faults() { + grep -nE 'io_uring_(setup|enter):|SQ still full|register pbuf ring:|accept :[0-9]+:' "$1" + grep -nE 'stopping: .* still open' "$1" | grep -v ', 0 still open' +} + +rc=0 +mkdir -p "$work" + +# --- the unit test: no fixture needed --- +if wanted unit && [ -n "$unit" ]; then + "$unit" || rc=1 +fi + +# --- the HTTP fixture, with every suite that talks to it --- +if wanted smoke || wanted stress || wanted tls; then + [ -n "$server" ] || { echo "run-suites: --server is required for the http suites" >&2; exit 2; } + certs= + if wanted smoke || wanted tls; then + sh "$tests/mkcerts.sh" "$tests/certs" >/dev/null || rc=1 + rm -rf "$work/certs" + cp -r "$tests/certs" "$work/certs" # the fixture serves a copy: a suite may rewrite it + certs=$work/certs + fi + log=$work/fixture.log + if [ -n "$certs" ]; then # unset means no TLS listener, not an empty path + IOXD_WORKERS=$workers IOXD_PORT=$port IOXD_CERTS="$certs" "$server" >"$log" 2>&1 & + else + IOXD_WORKERS=$workers IOXD_PORT=$port "$server" >"$log" 2>&1 & + fi + pid=$! + if wait_for_fixture "$port" "$log"; then + wanted smoke && { IOXD_CERTS="$certs" "$python" "$tests/smoke.py" "$port" || rc=1; } + wanted conformance && { "$python" "$tests/conformance.py" "$port" || rc=1; } + wanted stress && { "$python" "$tests/stress.py" "$port" || rc=1; } + if wanted tls; then + if port_open "$tls_port"; then + "$tls_python" "$tests/tls_early.py" "$tls_port" || rc=1 + else + echo "skip tls_early: nothing listening on $tls_port (a TLS=0 build?)" + fi + fi + else + echo "FAIL the fixture never listened on $port" + rc=1 + fi + kill -INT "$pid" 2>/dev/null + wait "$pid"; status=$? + [ $status -eq 0 ] || { echo "FAIL the fixture exited $status"; rc=1; } + grep -q 'stopping:' "$log" || { echo "FAIL no worker reported stopping"; rc=1; } + faults=$(log_faults "$log") + [ -z "$faults" ] || { echo "FAIL the fixture's log: $faults"; rc=1; } + [ $rc -eq 0 ] || { echo "--- $log ---"; cat "$log"; echo "--- end of $log ---"; } +fi + +# --- the pipe fixture --- +if wanted pipes; then + [ -n "$pipe_server" ] || { echo "run-suites: --pipe-server is required for the pipe suite" >&2; exit 2; } + log=$work/pipe.log + inner=0 + "$pipe_server" "$pipe_port" >"$log" 2>&1 & + pid=$! + if wait_for_fixture "$pipe_port" "$log"; then + "$python" "$tests/pipes.py" "$pipe_port" || inner=1 + else + echo "FAIL the pipe fixture never listened on $pipe_port" + inner=1 + fi + kill -INT "$pid" 2>/dev/null + wait "$pid"; status=$? + [ $status -eq 0 ] || { echo "FAIL the pipe fixture exited $status"; inner=1; } + grep -q 'stopping:' "$log" || { echo "FAIL no pipe worker reported stopping"; inner=1; } + faults=$(log_faults "$log") + [ -z "$faults" ] || { echo "FAIL the pipe fixture's log: $faults"; inner=1; } + [ $inner -eq 0 ] || { echo "--- $log ---"; cat "$log"; echo "--- end of $log ---"; rc=1; } +fi + +exit $rc diff --git a/tests/server.c b/tests/server.c index 7f88258..79619cf 100644 --- a/tests/server.c +++ b/tests/server.c @@ -2,90 +2,91 @@ * tests/server.c - the server smoke.py and stress.py talk to: one route per feature of the * request/response model. `make check` builds it, runs both suites against it and stops it. */ -#include +#include #include +#include /* GET / */ -static void home(ioma_ctx *ctx) +static void home(ioxd_ctx *ctx) { - ioma_text(ctx, "hello from ioma\n"); /* 200, text/plain: the defaults */ + ioxd_text(ctx, "hello from ioxd\n"); /* 200, text/plain: the defaults */ } /* GET /health */ -static void health(ioma_ctx *ctx) +static void health(ioxd_ctx *ctx) { - ioma_text(ctx, "ok"); + ioxd_text(ctx, "ok"); } /* GET /whoami?x=1&y=2 - everything the request carries, read straight from its fields and * arrays: the raw query, then each parameter split and decoded, then every header (names are - * lower-cased). ioma_printf formats straight into the reply buffer. */ -static void whoami(ioma_ctx *ctx) + * lower-cased). ioxd_printf formats straight into the reply buffer. */ +static void whoami(ioxd_ctx *ctx) { - ioma_request *r = &ctx->req; - ioma_printf(ctx, "method = %.*s\npath = %.*s\nquery = %.*s\nkeep-alive = %s\n", + ioxd_request *r = &ctx->req; + ioxd_printf(ctx, "method = %.*s\npath = %.*s\nquery = %.*s\nkeep-alive = %s\n", (int)r->method.len, r->method.p, (int)r->path.len, r->path.p, (int)r->query.len, r->query.p, r->keep_alive ? "yes" : "no"); for (size_t i = 0; i < r->n_params; i++) - ioma_printf(ctx, "param %.*s = %.*s\n", + ioxd_printf(ctx, "param %.*s = %.*s\n", (int)r->params[i].key.len, r->params[i].key.p, (int)r->params[i].value.len, r->params[i].value.p); for (size_t i = 0; i < r->n_headers; i++) - ioma_printf(ctx, "header %.*s: %.*s\n", + ioxd_printf(ctx, "header %.*s: %.*s\n", (int)r->headers[i].key.len, r->headers[i].key.p, (int)r->headers[i].value.len, r->headers[i].value.p); - ioma_header(ctx, "X-Powered-By", "ioma"); /* fine: nothing has gone out yet */ + ioxd_header(ctx, "X-Powered-By", "ioxd"); /* fine: nothing has gone out yet */ } /* GET /users/:id?fields=... - the :id capture is req.route_params[0]. A query parameter is found by * walking req.params: there are few, so the loop is the lookup. */ -static void user(ioma_ctx *ctx) +static void user(ioxd_ctx *ctx) { - ioma_slice id = ctx->req.route_params[0].value; - ioma_slice fields = { "", 0 }; + ioxd_slice id = ctx->req.route_params[0].value; + ioxd_slice fields = { "", 0 }; for (size_t i = 0; i < ctx->req.n_params; i++) - if (ioma_slice_eq(ctx->req.params[i].key, "fields")) + if (ioxd_slice_eq(ctx->req.params[i].key, "fields")) fields = ctx->req.params[i].value; - ioma_printf(ctx, "user %.*s fields=%.*s\n", (int)id.len, id.p, (int)fields.len, fields.p); + ioxd_printf(ctx, "user %.*s fields=%.*s\n", (int)id.len, id.p, (int)fields.len, fields.p); } -/* GET /users/:id/posts/:post - captures come in pattern order; ioma_to_i64 reads a whole number +/* GET /users/:id/posts/:post - captures come in pattern order; ioxd_to_i64 reads a whole number * or fails, so a non-numeric id is a 400 instead of a silent zero. */ -static void post(ioma_ctx *ctx) +static void post(ioxd_ctx *ctx) { int64_t user_id, post_id; - if (!ioma_to_i64(ctx->req.route_params[0].value, &user_id) || - !ioma_to_i64(ctx->req.route_params[1].value, &post_id)) { + if (!ioxd_to_i64(ctx->req.route_params[0].value, &user_id) || + !ioxd_to_i64(ctx->req.route_params[1].value, &post_id)) { ctx->res.status = 400; - ioma_text(ctx, "ids must be integers\n"); + ioxd_text(ctx, "ids must be integers\n"); return; } - ioma_printf(ctx, "post %lld of user %lld\n", (long long)post_id, (long long)user_id); + ioxd_printf(ctx, "post %lld of user %lld\n", (long long)post_id, (long long)user_id); } /* GET /convert?i=..&d=..&b=.. - the typed conversions; a value that does not parse is a 400. */ -static void convert(ioma_ctx *ctx) +static void convert(ioxd_ctx *ctx) { for (size_t k = 0; k < ctx->req.n_params; k++) { - ioma_kv p = ctx->req.params[k]; + ioxd_kv p = ctx->req.params[k]; int64_t i; double d; bool b; - if (ioma_slice_eq(p.key, "i") && ioma_to_i64(p.value, &i)) { - ioma_printf(ctx, "i=%lld\n", (long long)i); - } else if (ioma_slice_eq(p.key, "d") && ioma_to_double(p.value, &d)) { - ioma_printf(ctx, "d=%g\n", d); - } else if (ioma_slice_eq(p.key, "b") && ioma_to_bool(p.value, &b)) { - ioma_printf(ctx, "b=%s\n", b ? "true" : "false"); + if (ioxd_slice_eq(p.key, "i") && ioxd_to_i64(p.value, &i)) { + ioxd_printf(ctx, "i=%lld\n", (long long)i); + } else if (ioxd_slice_eq(p.key, "d") && ioxd_to_double(p.value, &d)) { + ioxd_printf(ctx, "d=%g\n", d); + } else if (ioxd_slice_eq(p.key, "b") && ioxd_to_bool(p.value, &b)) { + ioxd_printf(ctx, "b=%s\n", b ? "true" : "false"); } else { ctx->res.status = 400; - ioma_printf(ctx, "bad %.*s\n", (int)p.key.len, p.key.p); + ioxd_printf(ctx, "bad %.*s\n", (int)p.key.len, p.key.p); return; } } @@ -94,158 +95,377 @@ static void convert(ioma_ctx *ctx) /* POST /echo - the body read whole (Content-Length or chunked, decoded) and sent back with the * same content type. The reply's content type is a slice, so the request's header value is * assigned as is; no copy. */ -static void echo(ioma_ctx *ctx) +static void echo(ioxd_ctx *ctx) { - ioma_slice body = ioma_body_all(ctx); - ioma_content_type(ctx, "application/octet-stream"); + ioxd_slice body = ioxd_body_all(ctx); + ioxd_content_type(ctx, "application/octet-stream"); for (size_t i = 0; i < ctx->req.n_headers; i++) - if (ioma_slice_eq(ctx->req.headers[i].key, "content-type")) + if (ioxd_slice_eq(ctx->req.headers[i].key, "content-type")) ctx->res.content_type = ctx->req.headers[i].value; - ioma_write(ctx, body.p, body.len); + ioxd_write(ctx, body.p, body.len); } -/* POST /greet with a form body (name=...) - ioma_kv_parse splits and decodes it like a query +/* POST /greet with a form body (name=...) - ioxd_kv_parse splits and decodes it like a query * string. The decoded values only need to live while the handler runs, so a local arena will do: * the reply is copied into the sink as it is written. */ -static void greet(ioma_ctx *ctx) +static void greet(ioxd_ctx *ctx) { - ioma_slice body = ioma_body_all(ctx); - ioma_kv form[8]; + ioxd_slice body = ioxd_body_all(ctx); + ioxd_kv form[8]; char arena[512]; - size_t n = ioma_kv_parse(body.p, body.len, form, 8, arena, sizeof arena); + size_t n = ioxd_kv_parse(body.p, body.len, form, 8, arena, sizeof arena, NULL); - ioma_slice name = { "stranger", 8 }; + ioxd_slice name = { "stranger", 8 }; for (size_t i = 0; i < n; i++) - if (ioma_slice_eq(form[i].key, "name")) + if (ioxd_slice_eq(form[i].key, "name")) name = form[i].value; - ioma_printf(ctx, "hello %.*s\n", (int)name.len, name.p); + ioxd_printf(ctx, "hello %.*s\n", (int)name.len, name.p); } /* GET /stream?n=lines - a body far larger than the buffer. Nothing special to do: once the * buffer fills, the framework sends the head and streams the rest (chunked on HTTP/1.1), and * each write that reaches the wire just suspends this handler until the send completes. */ -static void stream(ioma_ctx *ctx) +static void stream(ioxd_ctx *ctx) { int64_t n = 1000; for (size_t i = 0; i < ctx->req.n_params; i++) - if (ioma_slice_eq(ctx->req.params[i].key, "n")) - ioma_to_i64(ctx->req.params[i].value, &n); + if (ioxd_slice_eq(ctx->req.params[i].key, "n")) + ioxd_to_i64(ctx->req.params[i].value, &n); for (int64_t i = 1; i <= n; i++) - ioma_printf(ctx, "line %lld of %lld\n", (long long)i, (long long)n); + ioxd_printf(ctx, "line %lld of %lld\n", (long long)i, (long long)n); } -/* POST /upload - a body of any size, streamed: each ioma_body_read_until hands over the next bytes +/* GET /declared?n=N - a body of a length known up front: ioxd_content_length declares it, so the + * reply streams framed by Content-Length instead of chunked, and the handler sends it in four + * ioxd_flush calls rather than waiting for the slab to fill. The bytes are 'a'..'z' cycling, so + * the client can check them exactly. */ +static void declared(ioxd_ctx *ctx) +{ + int64_t n = 4096; + for (size_t i = 0; i < ctx->req.n_params; i++) + if (ioxd_slice_eq(ctx->req.params[i].key, "n")) + ioxd_to_i64(ctx->req.params[i].value, &n); + if (n < 0 || n > (1 << 20)) + n = 4096; + char tile[260]; /* 10 x 'a'..'z': tiles the body exactly */ + for (size_t i = 0; i < sizeof tile; i++) + tile[i] = (char)('a' + i % 26); + + ioxd_content_length(ctx, (size_t)n); + size_t written = 0, quarter = ((size_t)n + 3) / 4, next_flush = quarter; + while (written < (size_t)n) { + size_t take = (size_t)n - written; + if (take > sizeof tile) + take = sizeof tile; + if (ioxd_write(ctx, tile, take) < 0) + return; + written += take; + if (written < (size_t)n && written >= next_flush) { /* another quarter is in: on its way */ + if (ioxd_flush(ctx) < 0) + return; + next_flush += quarter; + } + } +} + +/* GET /raw?n=N - the reply written into the slab directly: reserve the room, fill it, advance by + * what was written. More than the slab holds cannot be reserved, and says so. */ +static void raw(ioxd_ctx *ctx) +{ + int64_t n = 64; + for (size_t i = 0; i < ctx->req.n_params; i++) + if (ioxd_slice_eq(ctx->req.params[i].key, "n")) + ioxd_to_i64(ctx->req.params[i].value, &n); + if (n < 0 || n > (1 << 20)) + n = 64; + char *at = ioxd_reserve(ctx, (size_t)n); + if (!at) { + ctx->res.status = 500; + ioxd_text(ctx, "no room\n"); + return; + } + for (int64_t i = 0; i < n; i++) + at[i] = (char)('0' + i % 10); + ioxd_advance(ctx, (size_t)n); +} + +/* POST /upload - a body of any size, streamed: each ioxd_body_read_until hands over the next bytes * straight from the wire (suspending the handler while they arrive), nothing is buffered. A * handler that never asks for the body does not pay for it either: the framework drains it. */ -static void upload(ioma_ctx *ctx) +static void upload(ioxd_ctx *ctx) { char chunk[4096]; size_t total = 0; int n, reads = 0; - while ((n = ioma_body_read_until(ctx, chunk, sizeof chunk)) > 0) { + while ((n = ioxd_body_read_until(ctx, chunk, sizeof chunk)) > 0) { total += (size_t)n; reads++; } - ioma_printf(ctx, "%zu bytes in %d reads\n", total, reads); + ioxd_printf(ctx, "%zu bytes in %d reads\n", total, reads); } /* POST /chunks[?first=N] - the body chunk by chunk, exactly as the client framed it: each chunk's * length and bytes on a line, "end" after the last. With first=N, N bytes are streamed off first * and the chunk read then gives the rest of the chunk they came from. A chunk that does not fit * the buffer is a 413 (the engine answers it); a body that is not chunked is a 400. */ -static void chunks(ioma_ctx *ctx) +static void chunks(ioxd_ctx *ctx) { char buf[1024]; int64_t first = 0; for (size_t i = 0; i < ctx->req.n_params; i++) - if (ioma_slice_eq(ctx->req.params[i].key, "first")) - ioma_to_i64(ctx->req.params[i].value, &first); + if (ioxd_slice_eq(ctx->req.params[i].key, "first")) + ioxd_to_i64(ctx->req.params[i].value, &first); if (first > 0 && first <= (int64_t)sizeof buf) - ioma_printf(ctx, "first=%d\n", ioma_body_read_until(ctx, buf, (size_t)first)); + ioxd_printf(ctx, "first=%d\n", ioxd_body_read_until(ctx, buf, (size_t)first)); int n; - while ((n = ioma_body_read_next_chunk(ctx, buf, sizeof buf)) > 0) - ioma_printf(ctx, "%d:%.*s\n", n, n, buf); + while ((n = ioxd_body_read_next_chunk(ctx, buf, sizeof buf)) > 0) + ioxd_printf(ctx, "%d:%.*s\n", n, n, buf); if (n == 0) { - ioma_text(ctx, "end\n"); + ioxd_text(ctx, "end\n"); } else if (ctx->res.status == 200) { /* -1 without a status: not chunked */ ctx->res.status = 400; - ioma_text(ctx, "not chunked\n"); + ioxd_text(ctx, "not chunked\n"); } } /* GET /users/new - a static segment beside the :id capture; the static one wins for GET. */ -static void new_user_form(ioma_ctx *ctx) +static void new_user_form(ioxd_ctx *ctx) { - ioma_text(ctx, "new user form\n"); + ioxd_text(ctx, "new user form\n"); } /* POST /users/:id - and POST /users/new lands here, since the static segment has no POST. */ -static void update_user(ioma_ctx *ctx) +static void update_user(ioxd_ctx *ctx) { - ioma_slice id = ctx->req.route_params[0].value; - ioma_printf(ctx, "updated %.*s\n", (int)id.len, id.p); + ioxd_slice id = ctx->req.route_params[0].value; + ioxd_printf(ctx, "updated %.*s\n", (int)id.len, id.p); } /* GET /api/ping and GET /api/admin/stats - endpoints in groups; the prefixes come from the groups. */ -static void ping(ioma_ctx *ctx) +static void ping(ioxd_ctx *ctx) { - ioma_text(ctx, "pong\n"); + ioxd_text(ctx, "pong\n"); } -static void stats(ioma_ctx *ctx) +static void stats(ioxd_ctx *ctx) { - ioma_text(ctx, "stats\n"); + ioxd_text(ctx, "stats\n"); } /* Middleware on the /api group: every reply below it carries the header. */ -static void api_header(ioma_ctx *ctx, ioma_next *next) +static void api_header(ioxd_ctx *ctx, ioxd_next *next) { - ioma_header(ctx, "x-api", "v1"); - ioma_next_run(ctx, next); + ioxd_header(ctx, "x-api", "v1"); + ioxd_next_run(ctx, next); } /* Middleware on /api/admin: the token, or a 401 without running what is below. */ -static void require_token(ioma_ctx *ctx, ioma_next *next) +static void require_token(ioxd_ctx *ctx, ioxd_next *next) { for (size_t i = 0; i < ctx->req.n_headers; i++) { - if (ioma_slice_eq(ctx->req.headers[i].key, "x-token") && ioma_slice_eq(ctx->req.headers[i].value, "secret")) { - ioma_next_run(ctx, next); + if (ioxd_slice_eq(ctx->req.headers[i].key, "x-token") && ioxd_slice_eq(ctx->req.headers[i].value, "secret")) { + ioxd_next_run(ctx, next); return; } } ctx->res.status = 401; - ioma_text(ctx, "token required\n"); + ioxd_text(ctx, "token required\n"); +} + +/* GET /rooted - in a group whose prefix is "": no path of its own, just middleware around what is + * registered inside it. */ +static void rooted(ioxd_ctx *ctx) +{ + ioxd_text(ctx, "rooted\n"); +} +static void rooted_header(ioxd_ctx *ctx, ioxd_next *next) +{ + ioxd_header(ctx, "x-rooted", "yes"); + ioxd_next_run(ctx, next); } /* Middleware on one endpoint only. */ -static void endpoint_header(ioma_ctx *ctx, ioma_next *next) +static void endpoint_header(ioxd_ctx *ctx, ioxd_next *next) +{ + ioxd_header(ctx, "x-endpoint", "stats"); + ioxd_next_run(ctx, next); +} + +/* GET /json/:id - a small document, written as you go into the reply. */ +static void json_item(ioxd_ctx *ctx) +{ + int64_t id; + if (!ioxd_to_i64(ctx->req.route_params[0].value, &id)) { + ctx->res.status = 400; + ioxd_text(ctx, "id must be an integer\n"); + return; + } + ioxd_json j = ioxd_json_reply(ctx); + ioxd_json_object(&j); + ioxd_json_key(&j, "id"); ioxd_json_int(&j, id); + ioxd_json_key(&j, "name"); ioxd_json_cstr(&j, "Zo\xc3\xab \"Z\" O'Neil\n"); + ioxd_json_key(&j, "ratio"); ioxd_json_double(&j, 0.1); + ioxd_json_key(&j, "ok"); ioxd_json_bool(&j, true); + ioxd_json_key(&j, "none"); ioxd_json_null(&j); + ioxd_json_key(&j, "tags"); ioxd_json_array(&j); + ioxd_json_cstr(&j, "a"); + ioxd_json_cstr(&j, "b"); + ioxd_json_end(&j); + ioxd_json_end(&j); +} + +/* GET /json/big?n=N - N objects in an array, far more than the slab holds: it streams, chunked. */ +static void json_big(ioxd_ctx *ctx) +{ + int64_t n = 2000; + for (size_t i = 0; i < ctx->req.n_params; i++) + if (ioxd_slice_eq(ctx->req.params[i].key, "n")) + ioxd_to_i64(ctx->req.params[i].value, &n); + ioxd_json j = ioxd_json_reply(ctx); + ioxd_json_array(&j); + for (int64_t i = 0; i < n; i++) { + ioxd_json_object(&j); + ioxd_json_key(&j, "i"); ioxd_json_int(&j, i); + ioxd_json_key(&j, "sq"); ioxd_json_int(&j, i * i); + ioxd_json_end(&j); + } + ioxd_json_end(&j); +} + +/* GET /headers?n=N - adds N headers; reports how many the reply took (IOXD_MAX_RESP_HEADERS). */ +static void headers(ioxd_ctx *ctx) +{ + static const char *names[32] = { + "x-h00", "x-h01", "x-h02", "x-h03", "x-h04", "x-h05", "x-h06", "x-h07", "x-h08", "x-h09", "x-h10", + "x-h11", "x-h12", "x-h13", "x-h14", "x-h15", "x-h16", "x-h17", "x-h18", "x-h19", "x-h20", "x-h21", + "x-h22", "x-h23", "x-h24", "x-h25", "x-h26", "x-h27", "x-h28", "x-h29", "x-h30", "x-h31", + }; + int64_t n = 0; + for (size_t i = 0; i < ctx->req.n_params; i++) + if (ioxd_slice_eq(ctx->req.params[i].key, "n")) + ioxd_to_i64(ctx->req.params[i].value, &n); + int taken = 0; + for (int64_t i = 0; i < n && i < 32; i++) + taken += ioxd_header(ctx, names[i], "v"); + ioxd_printf(ctx, "%d\n", taken); +} + +/* GET /bighead?len=N - one header whose value is N bytes: past the reply head's capacity the + * reply cannot be built and the connection closes without one. */ +static void bighead(ioxd_ctx *ctx) { - ioma_header(ctx, "x-endpoint", "stats"); - ioma_next_run(ctx, next); + static char value[8192]; + int64_t len = 0; + for (size_t i = 0; i < ctx->req.n_params; i++) + if (ioxd_slice_eq(ctx->req.params[i].key, "len")) + ioxd_to_i64(ctx->req.params[i].value, &len); + if (len < 0 || len >= (int64_t)sizeof value) + len = sizeof value - 1; + memset(value, 'v', (size_t)len); + value[len] = '\0'; + ioxd_text(ctx, ioxd_header(ctx, "x-big", value) ? "ok\n" : "refused\n"); +} + +/* GET /params?... - how many query parameters the request kept (IOXD_MAX_PARAMS). */ +static void params(ioxd_ctx *ctx) +{ + ioxd_printf(ctx, "%zu\n", ctx->req.n_params); } /* Middleware: stamps a Server header, then runs the rest of the chain. Setting headers before - * calling ioma_next_run means they land even on a reply that streams (afterwards the head may + * calling ioxd_next_run means they land even on a reply that streams (afterwards the head may * already be on the wire); c->status and the rest are there to inspect on the way back out. * One that wanted to block a request (auth, rate limit) would write its reply and return - * without calling ioma_next_run. */ -static void add_server(ioma_ctx *ctx, ioma_next *next) + * without calling ioxd_next_run. */ +static void add_server(ioxd_ctx *ctx, ioxd_next *next) +{ + ioxd_header(ctx, "Server", "ioxd"); + ioxd_next_run(ctx, next); +} + +/* POST /tls/reload - the certificate store read again, registered only when the fixture has one. + * smoke.py rewrites a host's cert.pem and key.pem and calls this, then checks the new certificate + * is what the handshake serves. */ +static ioxd_certs *g_certs; + +static void tls_reload(ioxd_ctx *ctx) { - ioma_header(ctx, "Server", "ioma"); - ioma_next_run(ctx, next); + if (!g_certs || ioxd_certs_reload(g_certs) != 0) { + ctx->res.status = 500; + ioxd_text(ctx, "reload failed\n"); + return; + } + ioxd_text(ctx, "reloaded\n"); +} + +/* --- the routes tests/conformance.py drives --- */ + +/* GET /status/:code[?body=1] - a reply with that status; with body=1 a body is written anyway, + * which the engine must drop on a 204/304. */ +static void status_route(ioxd_ctx *ctx) +{ + int code; + if (!ioxd_to_int(ctx->req.route_params[0].value, &code)) { + ctx->res.status = 400; + return; + } + ctx->res.status = code; + for (size_t i = 0; i < ctx->req.n_params; i++) + if (ioxd_slice_eq(ctx->req.params[i].key, "body")) + ioxd_text(ctx, "a body that must not go out"); +} + +/* GET /reflect?name=..&value=.. - the query names a header to add, as a handler reflecting + * client data would; the body says whether ioxd_header took it. value=stack adds the header + * from a buffer that dies with this frame, which ioxd_header must have copied. */ +static void reflect(ioxd_ctx *ctx) +{ + char name[256] = "", value[4096] = ""; + for (size_t i = 0; i < ctx->req.n_params; i++) { + if (ioxd_slice_eq(ctx->req.params[i].key, "name")) ioxd_cstr(ctx->req.params[i].value, name, sizeof name); + if (ioxd_slice_eq(ctx->req.params[i].key, "value")) ioxd_cstr(ctx->req.params[i].value, value, sizeof value); + } + if (strcmp(value, "stack") == 0) { + char local[16]; + memcpy(local, "stack", 6); + ioxd_text(ctx, ioxd_header(ctx, name, local) ? "added" : "refused"); + memset(local, 'X', sizeof local); /* whatever the engine kept, it was not this */ + return; + } + ioxd_text(ctx, ioxd_header(ctx, name, value) ? "added" : "refused"); +} + +/* GET /promise?say=N&write=M[&stream=1] - declares a Content-Length of N, writes M digits; + * with stream=1 it flushes first so the head goes out before the body is complete. */ +static void promise(ioxd_ctx *ctx) +{ + long say = 0, write = 0; + bool stream = false; + for (size_t i = 0; i < ctx->req.n_params; i++) { + int64_t v; + if (ioxd_slice_eq(ctx->req.params[i].key, "say") && ioxd_to_i64(ctx->req.params[i].value, &v)) say = (long)v; + if (ioxd_slice_eq(ctx->req.params[i].key, "write") && ioxd_to_i64(ctx->req.params[i].value, &v)) write = (long)v; + if (ioxd_slice_eq(ctx->req.params[i].key, "stream")) stream = true; + } + ioxd_content_length(ctx, (size_t)say); + if (stream) + ioxd_flush(ctx); /* the head, with the declared length, now */ + for (long i = 1; i <= write; i++) + ioxd_printf(ctx, "%ld", i % 10); } /* The fallback for anything unrouted, replacing the built-in text 404. */ -static void not_found(ioma_ctx *ctx) +static void not_found(ioxd_ctx *ctx) { ctx->res.status = 404; - ioma_content_type(ctx, "application/json"); - ioma_text(ctx, "{\"error\":\"not found\"}"); + ioxd_content_type(ctx, "application/json"); + ioxd_text(ctx, "{\"error\":\"not found\"}"); } -/* An environment variable as a number, or the fallback when unset or not a whole number. */ +/* An environment variable as a number, or the fallback when unset or not a whole number. Read + * from main, before ioxd_run starts a worker, so the environment is nobody else's yet. */ static long env_number(const char *name, long fallback) { - const char *text = getenv(name); + const char *text = getenv(name); /* NOLINT(concurrency-mt-unsafe): no threads yet */ if (!text) return fallback; char *end; @@ -256,35 +476,69 @@ static long env_number(const char *name, long fallback) int main(void) { - /* the routes as a script: outside any IOMA_GROUP block this is the root */ - IOMA_USE(add_server); /* root middleware: every request */ - IOMA_GET ("/", home); - IOMA_GET ("/health", health); - IOMA_GET ("/whoami", whoami); - IOMA_GET ("/users/:id", user); - IOMA_GET ("/users/new", new_user_form); /* static beside the capture: wins for GET */ - IOMA_POST("/users/:id", update_user); /* so POST /users/new falls through to :id */ - IOMA_GET ("/users/:id/posts/:post", post); - IOMA_GET ("/convert", convert); - IOMA_POST("/echo", echo); - IOMA_POST("/greet", greet); - IOMA_GET ("/stream", stream); - IOMA_POST("/upload", upload); - IOMA_POST("/chunks", chunks); - IOMA_DEFAULT(not_found); - - /* groups: /api with middleware of its own (IOMA_USE inside a block adds to that group; listing + /* the routes as a script: outside any IOXD_GROUP block this is the root */ + IOXD_USE(add_server); /* root middleware: every request */ + IOXD_GET ("/", home); + IOXD_GET ("/health", health); + IOXD_GET ("/whoami", whoami); + IOXD_GET ("/users/:id", user); + IOXD_GET ("/users/new", new_user_form); /* static beside the capture: wins for GET */ + IOXD_POST("/users/:id", update_user); /* so POST /users/new falls through to :id */ + IOXD_GET ("/users/:id/posts/:post", post); + IOXD_GET ("/convert", convert); + IOXD_POST("/echo", echo); + IOXD_POST("/greet", greet); + IOXD_GET ("/stream", stream); + IOXD_GET ("/declared", declared); /* a declared length, streamed in flushes */ + IOXD_GET ("/raw", raw); /* written into the slab directly */ + IOXD_POST("/upload", upload); + IOXD_POST("/chunks", chunks); + IOXD_GET ("/json/:id", json_item); + IOXD_GET ("/json/big", json_big); /* static beside the capture */ + IOXD_GET ("/headers", headers); + IOXD_GET ("/bighead", bighead); + IOXD_GET ("/status/:code", status_route); + IOXD_GET ("/reflect", reflect); + IOXD_GET ("/promise", promise); + IOXD_GET ("/params", params); + IOXD_DEFAULT(not_found); + + /* groups: /api with middleware of its own (IOXD_USE inside a block adds to that group; listing * it after the prefix, as /admin does, is the same), /api/admin below it gated by a token, and * one endpoint with middleware for itself only, listed after its handler */ - IOMA_GROUP("/api") { - IOMA_USE(api_header); - IOMA_GET("/ping", ping); - IOMA_GROUP("/admin", require_token) { - IOMA_GET("/stats", stats, endpoint_header); + IOXD_GROUP("/api") { + IOXD_USE(api_header); + IOXD_GET("/ping", ping); + IOXD_GROUP("/admin", require_token) { + IOXD_GET("/stats", stats, endpoint_header); } } - int workers = (int)env_number("IOMA_WORKERS", 0); /* 0: one per core */ - int port = (int)env_number("IOMA_PORT", 8080); - return ioma_run(workers, port > 0 && port < 65536 ? port : 8080); + /* a group with no prefix of its own: middleware around what is inside it, nothing else */ + IOXD_GROUP("", rooted_header) { + IOXD_GET("/rooted", rooted); + } + + ioxd_config cfg = { /* the runtime's knobs, from the environment */ + .ring_entries = (unsigned)env_number("IOXD_RING_ENTRIES", 0), + .recv_buffers = (unsigned)env_number("IOXD_RECV_BUFFERS", 0), + .recv_buffer_size = (unsigned)env_number("IOXD_RECV_BUFFER_SIZE", 0), + .stack_size = (size_t)env_number("IOXD_STACK_SIZE", 0), + }; + if (ioxd_configure(&cfg) < 0) + return 1; + int workers = (int)env_number("IOXD_WORKERS", 0); /* 0: one per core */ + int port = (int)env_number("IOXD_PORT", 8080); + int p = port > 0 && port < 65536 ? port : 8080; + ioxd_bind(p, NULL); + ioxd_bind(p + 1, NULL); /* a second plain port: the same routes */ + const char *certs = getenv("IOXD_CERTS"); /* NOLINT(concurrency-mt-unsafe): a TLS port, given a certificate directory */ + if (certs && *certs) { + g_certs = ioxd_certs_load(certs); + if (g_certs) { + ioxd_bind(p + 2, g_certs); + IOXD_POST("/tls/reload", tls_reload); /* only a build with certificates has it */ + } + } + return ioxd_run(workers); } diff --git a/tests/smoke.py b/tests/smoke.py index f663552..e5463cf 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Functional checks for the ioma HTTP layer: python3 tests/smoke.py [port]. +"""Functional checks for the ioxd HTTP layer: python3 tests/smoke.py [port]. Talks to tests/server.c (make check builds and runs it): GET /, /health, /whoami, /users/:id, POST /echo, ... """ @@ -50,6 +50,45 @@ def read_response(s): return status, headers, body +def read_to_close(s): + """Everything until the server closes: (data, closed). closed is False when the read timed out + instead - the connection is still open, which is a hang, not a close, and an assertion about a + close must not pass on one.""" + data = b"" + while True: + try: + chunk = s.recv(65536) + except socket.timeout: + return data, False + except ConnectionResetError: + return data, True + if not chunk: + return data, True + data += chunk + + +def port_open(port, timeout=1): + """Whether anything is listening there: the only honest test of a port (a TLS=0 build has no + third one, and the suites that need it say so and skip).""" + try: + socket.create_connection(("127.0.0.1", port), timeout=timeout).close() + return True + except OSError: + return False + + +def dechunk(raw): + """A chunked body, decoded.""" + out = b"" + while True: + line, _, raw = raw.partition(b"\r\n") + n = int(line.split(b";")[0], 16) + if n == 0: + return out + out += raw[:n] + raw = raw[n + 2:] + + def get(path, extra=b"", keep=False): s = connect() conn = "keep-alive" if keep else "close" @@ -67,9 +106,9 @@ def check(name, cond): results = [] st, hd, body = get("/") -results.append(check("GET / -> 200 'hello from ioma'", st == 200 and body == b"hello from ioma\n")) +results.append(check("GET / -> 200 'hello from ioxd'", st == 200 and body == b"hello from ioxd\n")) results.append(check("GET / content-type text/plain", hd.get("content-type") == "text/plain")) -results.append(check("Server header added by middleware", hd.get("server") == "ioma")) +results.append(check("Server header added by middleware", hd.get("server") == "ioxd")) st, hd, body = get("/health") results.append(check("GET /health -> 200 'ok'", st == 200 and body == b"ok")) @@ -77,7 +116,7 @@ def check(name, cond): st, hd, body = get("/whoami?x=1&y=2") results.append(check("GET /whoami -> 200", st == 200)) results.append(check(" parsed path/query in body", b"path = /whoami" in body and b"query = x=1&y=2" in body)) -results.append(check(" custom header X-Powered-By: ioma", hd.get("x-powered-by") == "ioma")) +results.append(check(" custom header X-Powered-By: ioxd", hd.get("x-powered-by") == "ioxd")) # route parameter + percent-decoded query parameter st, hd, body = get("/users/42?fields=a%20b+c&x=1") @@ -95,26 +134,48 @@ def check(name, cond): # a body far larger than the reply buffer streams: chunked on HTTP/1.1 (http.client decodes it) import http.client -hc = http.client.HTTPConnection("127.0.0.1", int(sys.argv[1]), timeout=10) +hc = http.client.HTTPConnection("127.0.0.1", PORT, timeout=10) hc.request("GET", "/stream?n=5000") r = hc.getresponse(); data = r.read(); hc.close() results.append(check("GET /stream -> chunked stream, every line arrives", r.status == 200 and r.getheader("transfer-encoding") == "chunked" and data.count(b"\n") == 5000 and data.endswith(b"line 5000 of 5000\n"))) # the same on HTTP/1.0: no length known, so it streams until close -s = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=10) +s = socket.create_connection(("127.0.0.1", PORT), timeout=10) s.send(b"GET /stream?n=3000 HTTP/1.0\r\nHost: x\r\n\r\n") -raw = b"" -while True: - chunk = s.recv(65536) - if not chunk: break - raw += chunk +raw, closed = read_to_close(s) s.close() head, _, body = raw.partition(b"\r\n\r\n") results.append(check("GET /stream on HTTP/1.0 -> until close, no length, all lines", - b"connection: close" in head and b"content-length" not in head + closed and b"connection: close" in head and b"content-length" not in head and b"chunked" not in head and body.count(b"\n") == 3000)) +# a declared length: the reply streams framed by Content-Length instead of chunked, sent by the +# handler in four ioxd_flush calls (ioxd_content_length + ioxd_flush) +def tiles(n): + """The body /declared writes: 'a'..'z', cycling.""" + return bytes((ord("a") + i % 26) for i in range(n)) + + +s = connect() +s.send(b"GET /declared?n=5000 HTTP/1.1\r\nHost: x\r\n\r\n") +st, hd, body = read_response(s) +results.append(check("GET /declared -> the declared length, framed by it, and every byte in order", + st == 200 and hd.get("content-length") == "5000" + and hd.get("transfer-encoding") is None and body == tiles(5000))) +s.send(b"GET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +st, hd, body = read_response(s) +s.close() +results.append(check(" a request after it on the same connection -> still in sync", st == 200 and body == b"ok")) + +# the reply written into the slab directly (ioxd_reserve + ioxd_advance), and a reserve past it +st, hd, body = get("/raw?n=100") +results.append(check("GET /raw -> the bytes written straight into the reserved room", + st == 200 and hd.get("content-length") == "100" + and body == bytes((ord("0") + i % 10) for i in range(100)))) +st, hd, body = get("/raw?n=20000") +results.append(check("GET /raw past the slab -> the reserve is refused, the handler says so", st == 500)) + # POST /echo reflects the body s = connect() s.send(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 11\r\nConnection: close\r\n\r\nhello world") @@ -131,7 +192,39 @@ def check(name, cond): s.send(b"DELETE / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") st, hd, body = read_response(s) s.close() -results.append(check("DELETE / -> 405 with allow (path known, method not)", st == 405 and hd.get("allow") == "GET")) +results.append(check("DELETE / -> 405 with allow (path known, method not; HEAD rides on GET)", st == 405 and hd.get("allow") == "GET, HEAD")) + +# a path registered for two methods lists both in allow, in the order they were registered +s = connect() +s.send(b"PUT /users/42 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +st, hd, body = read_response(s) +s.close() +results.append(check("PUT /users/:id -> 405 with both methods of that path in registration order, HEAD riding on GET", + st == 405 and hd.get("allow") == "GET, HEAD, POST")) + +# --- HEAD asks for the head of what GET would answer: the same status and length, no body --- +def head_request(path): + """A HEAD read to the close: (status, headers, the bytes after the head).""" + s = connect() + s.send(f"HEAD {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n".encode()) + raw, _ = read_to_close(s) + s.close() + head, _, body = raw.partition(b"\r\n\r\n") + lines = head.split(b"\r\n") + parts = lines[0].split(b" ") + headers = {} + for line in lines[1:]: + k, _, v = line.partition(b": ") + headers[k.decode().lower()] = v.decode() + return (int(parts[1]) if len(parts) > 1 else 0), headers, body + + +for path in ("/", "/declared?n=5000", "/stream?n=50"): + gst, ghd, _ = get(path) + st, hd, body = head_request(path) + results.append(check(f"HEAD {path} -> the GET reply's head, and not one byte of its body", + st == 200 and st == gst + and hd.get("content-length") == ghd.get("content-length") and body == b"")) # keep-alive: five requests on one connection s = connect() @@ -149,7 +242,7 @@ def check(name, cond): st1, _, b1 = read_response(s) st2, _, b2 = read_response(s) s.close() -results.append(check("pipelined two requests", st1 == 200 and b1 == b"ok" and st2 == 200 and b2 == b"hello from ioma\n")) +results.append(check("pipelined two requests", st1 == 200 and b1 == b"ok" and st2 == 200 and b2 == b"hello from ioxd\n")) # request split across writes with pauses s = connect() @@ -179,13 +272,10 @@ def check(name, cond): s = connect(timeout=3) try: s.send(b"GET / HTTP/1.1\r\nX-Pad: " + b"a" * 20000) - got = b"" - try: - got = s.recv(64) - except (ConnectionResetError, socket.timeout): - got = b"" - # either a 431 status or a clean close is acceptable - results.append(check("oversized headers refused/closed", got == b"" or got.startswith(b"HTTP/1.1 431"))) + got, closed = read_to_close(s) + # either a 431 status or a clean close is acceptable; a read that times out is neither + results.append(check("oversized headers refused/closed", + closed and (got == b"" or got.startswith(b"HTTP/1.1 431")))) finally: s.close() @@ -208,17 +298,14 @@ def check(name, cond): # a handler that ignores the body (here the 404 fallback) still leaves the connection in sync s = connect() s.send(b"POST /nope HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhelloGET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") -raw = b"" -while True: # both replies may arrive in one packet: read to the close - chunk = s.recv(65536) - if not chunk: break - raw += chunk +raw, closed = read_to_close(s) # both replies may arrive in one packet: read to the close s.close() results.append(check("unread body drained, pipelined next request served", - raw.startswith(b"HTTP/1.1 404") and raw.count(b"HTTP/1.1 200") == 1 and raw.endswith(b"ok"))) + closed and raw.startswith(b"HTTP/1.1 404") + and raw.count(b"HTTP/1.1 200") == 1 and raw.endswith(b"ok"))) # a 1 MB upload streamed through a 4 KB loop (the request buffer is 16 KB), then chunked -hc = http.client.HTTPConnection("127.0.0.1", int(sys.argv[1]), timeout=10) +hc = http.client.HTTPConnection("127.0.0.1", PORT, timeout=10) hc.request("POST", "/upload", body=b"x" * 1048576) r = hc.getresponse(); data = r.read() results.append(check("POST /upload 1 MB streamed in", r.status == 200 and data == b"1048576 bytes in 256 reads\n")) @@ -233,25 +320,22 @@ def gen(): # --- chunk-exact reads on a raw socket: split size lines, an extension, trailers, pipelining --- def raw_exchange(pieces, pause=0.03): """Send the pieces with a pause between them, read until the server closes, and return the - responses as (status, body) pairs (all these replies carry a Content-Length).""" + responses as (status, body) pairs (all these replies carry a Content-Length). Whether the + server really closed is left in raw_exchange.closed, so an assertion about a close can say so + rather than pass on a read that timed out.""" s = connect() for piece in pieces: s.sendall(piece) time.sleep(pause) - data = b"" - while True: - try: - chunk = s.recv(65536) - except socket.timeout: - break - if not chunk: - break - data += chunk + data, raw_exchange.closed = read_to_close(s) s.close() out = [] while b"\r\n\r\n" in data: head, _, rest = data.partition(b"\r\n\r\n") status = int(head.split(b" ")[1]) + if b"transfer-encoding: chunked" in head.lower(): # a streamed reply: the last on the connection + out.append((status, dechunk(rest))) + break n = 0 for line in head.split(b"\r\n"): if line.lower().startswith(b"content-length:"): @@ -274,6 +358,10 @@ def raw_exchange(pieces, pause=0.03): results.append(check("POST /chunks with a 2000-byte chunk into a 1 KB buffer -> 413", len(rs) == 1 and rs[0][0] == 413)) rs = raw_exchange([b"POST /chunks HTTP/1.1\r\nHost: x\r\nConnection: close\r\nContent-Length: 5\r\n\r\nhello"]) results.append(check("POST /chunks with a Content-Length body -> 400 not chunked", rs == [(400, b"not chunked\n")])) +rs = raw_exchange([b"GET /heal", b"th HTTP/1.1\r\nHost: x\r\nConn", b"ection: close\r\n\r\n"]) +results.append(check("a head split across three sends -> gathered and served", rs == [(200, b"ok")])) +rs = raw_exchange([b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\n", b"hel", b"lo" + pipelined]) +results.append(check("a body arriving after the head, in pieces, then a pipelined request", len(rs) == 2 and rs[0] == (200, b"hello") and rs[1][0] == 200)) rs = raw_exchange([b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhel", b"lo\r\n0\r\n\r\n" + pipelined]) results.append(check("POST /echo chunked read whole (split), then a pipelined request", len(rs) == 2 and rs[0] == (200, b"hello") and rs[1][0] == 200)) @@ -284,7 +372,7 @@ def raw_exchange(pieces, pause=0.03): s.send(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 100000\r\n\r\n" + b"z" * 100000) st, hd, body = read_response(s) s.close() -results.append(check("ioma_body on a 100 KB body -> 413", st == 413)) +results.append(check("ioxd_body on a 100 KB body -> 413", st == 413)) # an ignored body past the drain limit: the reply is served and says close s = connect() @@ -299,10 +387,167 @@ def raw_exchange(pieces, pause=0.03): st, hd, body = None, {}, b"" s.close() results.append(check("ignored 2 MB body -> reply served with Connection: close", st == 404 and hd.get("connection") == "close")) +# --- the limits, at their boundaries: known, and the buffers can grow later --- +# a request head must fit the 16 KB gathering buffer (IOXD_PIPE_GATHER): 431 past it +big = b"x" * 15000 +st, hd, body = get("/health", extra=b"x-pad: " + big + b"\r\n") +results.append(check("a 15 KB request head -> 200 (gathered across receives)", st == 200 and body == b"ok")) +rs = raw_exchange([b"GET /health HTTP/1.1\r\nHost: x\r\nx-pad: " + b"x" * 16400 + b"\r\n\r\n"]) +results.append(check("a request head over 16 KB -> 431", len(rs) == 1 and rs[0][0] == 431)) +# a whole-body read must fit the same buffer with the head: 413 past it (streaming reads have no limit) +rs = raw_exchange([b"POST /echo HTTP/1.1\r\nHost: x\r\nConnection: close\r\nContent-Length: 16000\r\n\r\n" + b"e" * 16000]) +results.append(check("a 16000-byte body read whole -> echoed", rs == [(200, b"e" * 16000)])) +rs = raw_exchange([b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 16400\r\n\r\n" + b"e" * 16400]) +results.append(check("a 16400-byte body read whole -> 413", len(rs) == 1 and rs[0][0] == 413)) +# the reply takes at most 16 headers (IOXD_MAX_RESP_HEADERS); the root middleware's server header +# is one of them, so the handler gets 15, and the 16th is refused while the reply still goes out +st, hd, body = get("/headers?n=15") +results.append(check("15 reply headers beside the middleware's -> all taken", st == 200 and body == b"15\n" and hd.get("x-h14") == "v")) +st, hd, body = get("/headers?n=16") +results.append(check("one more -> refused, the reply still served with 15", st == 200 and body == b"15\n" and hd.get("x-h15") is None)) +# the added headers must fit the reply's head arena (IOXD_RESP_HEAD_CAP, 3 KB): a 3 KB value goes +# minus what the middleware's own header took; a 5 KB one is refused by ioxd_header and the reply +# goes out without it - the head is never left unbuildable +st, hd, body = get("/bighead?len=2900") +results.append(check("a 2.9 KB reply header -> sent", st == 200 and len(hd.get("x-big", "")) == 2900 and body == b"ok\n")) +st, hd, body = get("/bighead?len=5000") +results.append(check("a 5 KB reply header -> refused by ioxd_header, the reply still answered", + st == 200 and "x-big" not in hd and body == b"refused\n")) +st, hd, body = get("/params?" + "&".join(f"p{i}=1" for i in range(40))) +results.append(check("40 query parameters -> 400 (more than fit is never a partial view)", st == 400)) +# more request headers than the table holds is a parse failure: 400 +rs = raw_exchange([b"GET /health HTTP/1.1\r\nHost: x\r\n" + b"".join(b"x-h%d: v\r\n" % i for i in range(70)) + b"\r\n"]) +results.append(check("70 request headers -> 400 (the table holds 64)", len(rs) == 1 and rs[0][0] == 400)) + +# --- TLS: the third port, kernel TLS after an OpenSSL handshake (the certificates the fixture was +# started with, IOXD_CERTS, which run-suites.sh points at a copy of tests/certs) --- +import os, ssl, subprocess +HERE = os.path.dirname(os.path.abspath(__file__)) +CERTS = os.environ.get("IOXD_CERTS") or os.path.join(HERE, "certs") +if port_open(PORT + 2): + def cert_der(host): + return ssl.PEM_cert_to_DER_cert(open(os.path.join(CERTS, host, "cert.pem")).read()) + + def tls_connect(server_hostname="localhost", max_version=None): + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + if max_version: + ctx.maximum_version = max_version + raw = socket.create_connection(("127.0.0.1", PORT + 2), timeout=5) + return ctx.wrap_socket(raw, server_hostname=server_hostname) + + s = tls_connect() + s.send(b"GET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + st, hd, body = read_response(s) + results.append(check("TLS: GET /health -> 200 ok over TLS 1.3 with the default certificate", + st == 200 and body == b"ok" and s.version() == "TLSv1.3" and s.getpeercert(True) == cert_der("default"))) + s.close() + s = tls_connect(server_hostname="sni.test") + results.append(check("TLS: SNI sni.test -> that host's certificate", s.getpeercert(True) == cert_der("sni.test"))) + s.close() + s = tls_connect(server_hostname="a.example.com") + results.append(check("TLS: SNI a.example.com -> the _.example.com wildcard certificate", + s.getpeercert(True) == cert_der("_.example.com"))) + s.close() + s = tls_connect(server_hostname="nobody.example") + results.append(check("TLS: unknown SNI -> the default certificate", s.getpeercert(True) == cert_der("default"))) + s.close() + s = tls_connect() + s.send(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 9\r\n\r\nhello tls") + st, hd, body = read_response(s) + results.append(check("TLS: POST /echo with a body -> echoed (kernel RX)", st == 200 and body == b"hello tls")) + s.send(b"GET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + st, hd, body = read_response(s) + results.append(check("TLS: a second request on the same connection -> 200", st == 200 and body == b"ok")) + s.close() + s = tls_connect() + s.send(b"GET /json/big?n=3000 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + raw = b"" + while True: + try: + piece = s.recv(65536) + except (socket.timeout, ssl.SSLError): + break + if not piece: + break + raw += piece + s.close() + head, _, rest = raw.partition(b"\r\n\r\n") + doc = __import__("json").loads(dechunk(rest)) + results.append(check("TLS: 3000 objects streamed chunked over kernel TX, valid JSON", len(doc) == 3000)) + s = tls_connect() + s.send(b"POST /upload HTTP/1.1\r\nHost: x\r\nConnection: close\r\nContent-Length: 1048576\r\n\r\n") + s.sendall(b"u" * 1048576) + st, hd, body = read_response(s) + results.append(check("TLS: a 1 MB upload streamed through kernel RX -> 256 reads", st == 200 and body == b"1048576 bytes in 256 reads\n")) + s.close() + try: + s = tls_connect(max_version=ssl.TLSVersion.TLSv1_2) + s.close() + results.append(check("TLS: a TLS 1.2 client is refused", False)) + except ssl.SSLError: + results.append(check("TLS: a TLS 1.2 client is refused", True)) + + # --- the store read again while it serves: a host's files are rewritten and POST /tls/reload + # switches to them; what was handed out before the reload came from the table in memory --- + live = tls_connect() # kept open across the reload + live.send(b"GET /health HTTP/1.1\r\nHost: x\r\n\r\n") + st, hd, body = read_response(live) + before = cert_der("sni.test") + subprocess.run(["sh", os.path.join(HERE, "mkcerts.sh"), CERTS, "sni.test"], + check=True, stdout=subprocess.DEVNULL) + after = cert_der("sni.test") + s = tls_connect(server_hostname="sni.test") + stale = s.getpeercert(True) + s.close() + s = tls_connect() + s.send(b"POST /tls/reload HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + st, hd, body = read_response(s) + s.close() + results.append(check("TLS: POST /tls/reload -> the directory read again", st == 200 and body == b"reloaded\n")) + s = tls_connect(server_hostname="sni.test") + fresh = s.getpeercert(True) + s.close() + results.append(check("TLS: new files for sni.test are served only after the reload", + after != before and stale == before and fresh == after)) + live.send(b"GET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + st, hd, body = read_response(live) + live.close() + results.append(check("TLS: the connection open across the reload still answers", st == 200 and body == b"ok")) +else: + print(f"skip TLS: nothing listening on {PORT + 2} (a TLS=0 build has no third port)") + +# --- a second listener: the port after ours serves the same routes --- +s = socket.create_connection(("127.0.0.1", PORT + 1), timeout=5) +s.send(b"GET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +st, hd, body = read_response(s) +s.close() +results.append(check("GET /health on the second listener -> 200 ok", st == 200 and body == b"ok")) + +# --- the JSON writer: a document written as you go, and one that streams --- +st, hd, body = get("/json/42") +results.append(check("GET /json/:id -> escaped document, application/json", + st == 200 and hd.get("content-type") == "application/json" + and body == b'{"id":42,"name":"Zo\xc3\xab \\"Z\\" O\'Neil\\n","ratio":0.1,"ok":true,"none":null,"tags":["a","b"]}')) +st, hd, body = get("/json/x") +results.append(check("GET /json/x -> 400 from the handler", st == 400)) + + +s = connect() +s.send(b"GET /json/big?n=3000 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") +raw, closed = read_to_close(s) +s.close() +head, _, rest = raw.partition(b"\r\n\r\n") +doc = __import__("json").loads(dechunk(rest)) +results.append(check("GET /json/big -> 3000 objects streamed chunked, valid JSON", + closed and b"transfer-encoding: chunked" in head.lower() + and len(doc) == 3000 and doc[2999] == {"i": 2999, "sq": 2999 * 2999})) + # --- groups: prefixes chain, middleware wraps outer to inner, one endpoint's own middleware --- st, hd, body = get("/api/ping") results.append(check("GET /api/ping -> group prefix, group middleware, root middleware", - st == 200 and body == b"pong\n" and hd.get("x-api") == "v1" and hd.get("server") == "ioma")) + st == 200 and body == b"pong\n" and hd.get("x-api") == "v1" and hd.get("server") == "ioxd")) st, hd, body = get("/api/ping/") results.append(check("GET /api/ping/ -> trailing slash tolerated", st == 200 and body == b"pong\n")) st, hd, body = get("/api/admin/stats") @@ -320,6 +565,16 @@ def raw_exchange(pieces, pause=0.03): rs == [(200, b"updated new\n")])) rs = raw_exchange([b"PUT /users/new HTTP/1.1\r\nHost: x\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"]) results.append(check("PUT /users/new -> 405 (the path is known, no PUT anywhere on it)", len(rs) == 1 and rs[0][0] == 405)) +# an empty segment is no segment: the walk skips it, wherever it is +st, hd, body = get("//health") +results.append(check("GET //health -> the empty segment collapses: the same endpoint", st == 200 and body == b"ok")) +st, hd, body = get("/api//admin/stats") +results.append(check("GET /api//admin/stats -> collapsed too, and still gated by the subgroup", + st == 401 and hd.get("x-api") == "v1")) +# a group whose prefix is "": middleware around what is in it, no prefix of its own +st, hd, body = get("/rooted") +results.append(check("GET /rooted -> a group with an empty prefix wraps it and adds no path", + st == 200 and body == b"rooted\n" and hd.get("x-rooted") == "yes" and hd.get("server") == "ioxd")) print("all passed" if all(results) else "FAILURES") sys.exit(0 if all(results) else 1) diff --git a/tests/stress.py b/tests/stress.py index 345303c..7ef7b89 100644 --- a/tests/stress.py +++ b/tests/stress.py @@ -1,13 +1,9 @@ #!/usr/bin/env python3 """Pressure on the ugly paths: python3 tests/stress.py [port]. -Run it against the default build, and against a tiny-buffer build that starves the provided -buffer group on every request and overflows the per-connection queue at the first stall: - - gcc -O2 -g -Wall -Iinclude -Ithird_party/picohttpparser -pthread \\ - -DBUF_COUNT=8 -DBUF_SIZE=64 -DRX_QUEUE=4 \\ - playground/hello/main.c src/*.c src/*.S \\ - third_party/picohttpparser/picohttpparser.c -o ioma-tiny +`make check` runs it against the default build; `make check-tiny` runs it against a build with +-DBUF_COUNT=8 -DBUF_SIZE=64 -DRX_QUEUE=4, which starves the provided buffer group on every +request and overflows the per-connection queue at the first stall. At shutdown the server must report "0 still open" on every worker. """ @@ -18,7 +14,7 @@ PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 REQ = b"GET /health HTTP/1.1\r\nHost: x\r\n\r\n" -OK = b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 2\r\nserver: ioma\r\n\r\nok" +OK = b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 2\r\nserver: ioxd\r\n\r\nok" def connect(timeout=5): @@ -67,7 +63,7 @@ def healthy(): # handlers hand buffers back. Every client still gets its answer. N = 64 conns = [connect() for _ in range(N)] -big = b"GET /health HTTP/1.1\r\nX-A: " + b"a" * 3000 + b"\r\n\r\n" +big = b"GET /health HTTP/1.1\r\nHost: x\r\nX-A: " + b"a" * 3000 + b"\r\n\r\n" for s in conns: s.send(big) good = all(recv_exact(s, len(OK)) == OK for s in conns) @@ -81,19 +77,24 @@ def healthy(): # point the server ends its input and cancels the multishot. Our sendall then stalls (the # server stopped reading); closing with unread data RSTs the socket, which fails the parked # send and lets the handler finish. Nothing may wedge. +FLOOD_LIMIT = 20 # seconds: the send timeout above, with room s = connect(timeout=3) s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4096) t0 = time.time() +outcome = "sent all" # what must not happen: the whole flood taken in try: s.sendall(REQ * 400000) # ~11 MB of requests -> ~28 MB of answers - outcome = "sent all" except socket.timeout: outcome = "stalled (server stopped reading)" except (ConnectionResetError, BrokenPipeError): outcome = "reset by server" +took = time.time() - t0 s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) # RST on close s.close() -results.append(check(f"flood without reading: {outcome} after {time.time() - t0:.1f}s", True)) +# Taking it all would mean ~28 MB of answers buffered for a client that reads none; the server +# must end our input instead, and decide it while we are still sending, not eventually. +results.append(check(f"flood without reading: {outcome} after {took:.1f}s", + outcome != "sent all" and took < FLOOD_LIMIT)) results.append(check("server healthy after flood", healthy())) # 3. Reset in the middle of a request: the multishot recv completes with -ECONNRESET. diff --git a/tests/tls_early.py b/tests/tls_early.py new file mode 100644 index 0000000..0ff49c4 --- /dev/null +++ b/tests/tls_early.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""The TLS handoff at the wire level, with tlslite-ng (pip install tlslite-ng): the request sent +in the same TCP write as the client's Finished, once whole and once with its record cut in two +with a pause, so the server has to decrypt what the kernel already delivered and fetch the rest +of a split record from the socket before kernel RX takes over; then a close_notify, and a +corrupted record. Skips itself when tlslite-ng is not installed.""" +import socket, sys, time + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8101 # the fixture's TLS port + +try: + from tlslite import TLSConnection, HandshakeSettings +except ImportError: + print("skip tls_early: tlslite-ng not installed (pip install tlslite-ng)") + sys.exit(0) + + +class Held: + """A socket whose writes can be held back and released in one or more pieces, so several + TLS records - or half of one - go out in a single TCP write, or with a pause inside.""" + def __init__(self, sock): + self.sock, self.buf, self.hold = sock, bytearray(), False + + def send(self, data): + if self.hold: + self.buf += data + return len(data) + return self.sock.send(data) + + def sendall(self, data): # tlslite sends its Finished this way + if self.hold: + self.buf += data + return None + return self.sock.sendall(data) + + def release(self, n=None): + n = len(self.buf) if n is None else n + self.sock.sendall(bytes(self.buf[:n])) + del self.buf[:n] + + def recv(self, n): + self.release() # anything held goes out before we wait + return self.sock.recv(n) + + def __getattr__(self, name): + return getattr(self.sock, name) + + +def settings(): + s = HandshakeSettings() + s.minVersion = (3, 4) + s.maxVersion = (3, 4) + s.cipherNames = ["aes128gcm"] + return s + + +def connect(): + raw = socket.create_connection(("127.0.0.1", PORT), timeout=5) + held = Held(raw) + held.hold = True # the ClientHello is released by the first recv + conn = TLSConnection(held) + conn.handshakeClientCert(settings=settings(), serverName="localhost") + return conn, held # the client's Finished is still held + + +def read_reply(conn): + data = b"" + while b"\r\n\r\n" not in data: + data += conn.read() + head, _, body = data.partition(b"\r\n\r\n") + n = int([l for l in head.split(b"\r\n") if l.lower().startswith(b"content-length:")][0].split(b":")[1]) + while len(body) < n: + body += conn.read() + return head.split(b"\r\n")[0], body + + +results = [] + + +def check(name, cond): + print(f"{'ok ' if cond else 'FAIL'} {name}") + results.append(cond) + + +REQ = b"GET /health HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + +# 1. the request rides in the same TCP write as the Finished +conn, held = connect() +conn.write(REQ) +held.release() +status, body = read_reply(conn) +check("request in the same write as Finished -> 200 ok", status == b"HTTP/1.1 200 OK" and body == b"ok") +conn.close() + +# 2. the same, with the request's record cut in half and a pause: the tail is still in the socket +# when the server pauses its recv, and it has to fetch it before handing RX to the kernel +conn, held = connect() +conn.write(REQ) +total = len(held.buf) +held.release(total - 10) +time.sleep(0.3) +held.release() +status, body = read_reply(conn) +check("record split with a pause after the Finished -> 200 ok", status == b"HTTP/1.1 200 OK" and body == b"ok") +conn.close() + +# 3. two requests: the first whole with the Finished, the second's record split, keep-alive on the first +conn, held = connect() +conn.write(b"GET /health HTTP/1.1\r\nHost: x\r\n\r\n") +conn.write(REQ) +total = len(held.buf) +held.release(total - 7) +time.sleep(0.3) +held.release() +s1, b1 = read_reply(conn) +s2, b2 = read_reply(conn) +check("two early requests, the second's record split -> both answered", b1 == b"ok" and b2 == b"ok") +conn.close() + +# 4. a plain exchange, then the client's close_notify: the server sees a control record and closes +conn, held = connect() +held.release() +conn.write(b"GET /health HTTP/1.1\r\nHost: x\r\n\r\n") +status, body = read_reply(conn) +conn.closeSocket = False # close() sends close_notify but leaves the socket to us +conn.close() +tail = held.sock.recv(64) # the server's own close_notify (one encrypted record), then EOF +eof = tail == b"" or (tail[0] == 0x17 and held.sock.recv(1) == b"") +check("close_notify from the client -> the server sends its own and closes", body == b"ok" and eof) + +# 5. garbage after the handshake: the kernel cannot decrypt it and the server closes +conn, held = connect() +held.release() +held.sock.sendall(b"\x17\x03\x03\x00\x20" + bytes(range(32))) +held.sock.settimeout(3) +try: + eof = held.sock.recv(1) == b"" +except ConnectionResetError: + eof = True # closed with unread data: a reset, still a close +except socket.timeout: + eof = False +check("a corrupted record after the handshake -> the server closes", eof) + +failed = results.count(False) +print("all passed" if not failed else f"{failed} FAILED") +sys.exit(1 if failed else 0) diff --git a/tests/unit.c b/tests/unit.c index 117a814..bfc612e 100644 --- a/tests/unit.c +++ b/tests/unit.c @@ -1,9 +1,11 @@ /* - * unit.c - the handler helpers of include/ioma.h checked without a server: comparisons, the + * unit.c - the handler helpers of include/ioxd.h checked without a server: comparisons, the * typed conversions and key/value parsing. `make check` runs it before the HTTP suites. */ -#include +#include +#include +#include #include #include @@ -21,22 +23,22 @@ static void check(const char *what, bool ok, int line) #define CHECK(cond) check(#cond, (cond), __LINE__) /* A slice over a C string. */ -static ioma_slice S(const char *cstr) +static ioxd_slice S(const char *cstr) { - return (ioma_slice){ cstr, strlen(cstr) }; + return (ioxd_slice){ cstr, strlen(cstr) }; } /* Each conversion: does text parse to want, and does a failure leave the output alone? */ -static bool i64_is(const char *text, int64_t want) { int64_t v = 7; return ioma_to_i64(S(text), &v) && v == want; } -static bool i64_fails(const char *text) { int64_t v = 7; return !ioma_to_i64(S(text), &v) && v == 7; } -static bool u64_is(const char *text, uint64_t want) { uint64_t v = 7; return ioma_to_u64(S(text), &v) && v == want; } -static bool u64_fails(const char *text) { uint64_t v = 7; return !ioma_to_u64(S(text), &v) && v == 7; } -static bool int_is(const char *text, int want) { int v = 7; return ioma_to_int(S(text), &v) && v == want; } -static bool int_fails(const char *text) { int v = 7; return !ioma_to_int(S(text), &v) && v == 7; } -static bool dbl_is(const char *text, double want) { double v = 7; return ioma_to_double(S(text), &v) && v == want; } -static bool dbl_fails(const char *text) { double v = 7; return !ioma_to_double(S(text), &v) && v == 7; } -static bool bool_is(const char *text, bool want) { bool v = !want; return ioma_to_bool(S(text), &v) && v == want; } -static bool bool_fails(const char *text) { bool v = true; return !ioma_to_bool(S(text), &v) && v; } +static bool i64_is(const char *text, int64_t want) { int64_t v = 7; return ioxd_to_i64(S(text), &v) && v == want; } +static bool i64_fails(const char *text) { int64_t v = 7; return !ioxd_to_i64(S(text), &v) && v == 7; } +static bool u64_is(const char *text, uint64_t want) { uint64_t v = 7; return ioxd_to_u64(S(text), &v) && v == want; } +static bool u64_fails(const char *text) { uint64_t v = 7; return !ioxd_to_u64(S(text), &v) && v == 7; } +static bool int_is(const char *text, int want) { int v = 7; return ioxd_to_int(S(text), &v) && v == want; } +static bool int_fails(const char *text) { int v = 7; return !ioxd_to_int(S(text), &v) && v == 7; } +static bool dbl_is(const char *text, double want) { double v = 7; return ioxd_to_double(S(text), &v) && v == want; } +static bool dbl_fails(const char *text) { double v = 7; return !ioxd_to_double(S(text), &v) && v == 7; } +static bool bool_is(const char *text, bool want) { bool v = !want; return ioxd_to_bool(S(text), &v) && v == want; } +static bool bool_fails(const char *text) { bool v = true; return !ioxd_to_bool(S(text), &v) && v; } static void test_integers(void) { @@ -131,68 +133,327 @@ static void test_bools(void) static void test_strings(void) { - CHECK(ioma_slice_eq(S("abc"), "abc")); - CHECK(!ioma_slice_eq(S("abc"), "ab")); - CHECK(!ioma_slice_eq(S("abc"), "abcd")); - CHECK(ioma_slice_eq(S(""), "")); - CHECK(ioma_slice_eq((ioma_slice){ nullptr, 0 }, "")); /* an absent slice is empty */ + CHECK(ioxd_slice_eq(S("abc"), "abc")); + CHECK(!ioxd_slice_eq(S("abc"), "ab")); + CHECK(!ioxd_slice_eq(S("abc"), "abcd")); + CHECK(ioxd_slice_eq(S(""), "")); + CHECK(ioxd_slice_eq((ioxd_slice){ nullptr, 0 }, "")); /* an absent slice is empty */ - CHECK(ioma_slice_eq_ci(S("Content-Type"), "content-type")); - CHECK(ioma_slice_eq_ci(S("GZIP"), "gzip")); - CHECK(!ioma_slice_eq_ci(S("gzip"), "gzi")); - CHECK(!ioma_slice_eq_ci(S("gzip"), "gzipx")); + CHECK(ioxd_slice_eq_ci(S("Content-Type"), "content-type")); + CHECK(ioxd_slice_eq_ci(S("GZIP"), "gzip")); + CHECK(!ioxd_slice_eq_ci(S("gzip"), "gzi")); + CHECK(!ioxd_slice_eq_ci(S("gzip"), "gzipx")); - CHECK(ioma_slice_starts_with(S("/api/users"), "/api/")); - CHECK(ioma_slice_starts_with(S("/api/users"), "")); - CHECK(!ioma_slice_starts_with(S("/api"), "/api/")); - CHECK(ioma_slice_ends_with(S("data.json"), ".json")); - CHECK(!ioma_slice_ends_with(S("json"), ".json")); + CHECK(ioxd_slice_starts_with(S("/api/users"), "/api/")); + CHECK(ioxd_slice_starts_with(S("/api/users"), "")); + CHECK(!ioxd_slice_starts_with(S("/api"), "/api/")); + CHECK(ioxd_slice_ends_with(S("data.json"), ".json")); + CHECK(!ioxd_slice_ends_with(S("json"), ".json")); - ioma_slice t = ioma_slice_trim(S(" a b \t\r\n")); + ioxd_slice t = ioxd_slice_trim(S(" a b \t\r\n")); CHECK(t.len == 3 && memcmp(t.p, "a b", 3) == 0); - CHECK(ioma_slice_trim(S(" \t ")).len == 0); - CHECK(ioma_slice_trim(S("")).len == 0); - CHECK(ioma_slice_trim(S("x")).len == 1); + CHECK(ioxd_slice_trim(S(" \t ")).len == 0); + CHECK(ioxd_slice_trim(S("")).len == 0); + CHECK(ioxd_slice_trim(S("x")).len == 1); char buf[4]; - CHECK(ioma_cstr(S("abc"), buf, sizeof buf) && strcmp(buf, "abc") == 0); - CHECK(!ioma_cstr(S("abcd"), buf, sizeof buf) && strcmp(buf, "abc") == 0); /* what fit, terminated */ - CHECK(ioma_cstr(S(""), buf, sizeof buf) && buf[0] == '\0'); + CHECK(ioxd_cstr(S("abc"), buf, sizeof buf) && strcmp(buf, "abc") == 0); + CHECK(!ioxd_cstr(S("abcd"), buf, sizeof buf) && strcmp(buf, "abc") == 0); /* what fit, terminated */ + CHECK(ioxd_cstr(S(""), buf, sizeof buf) && buf[0] == '\0'); buf[0] = 'x'; - CHECK(!ioma_cstr(S("a"), buf, 0) && buf[0] == 'x'); /* cap 0 writes nothing */ + CHECK(!ioxd_cstr(S("a"), buf, 0) && buf[0] == 'x'); /* cap 0 writes nothing */ } static void test_kv_parse(void) { - ioma_kv kv[8]; + ioxd_kv kv[8]; char arena[64]; - size_t n = ioma_kv_parse("a=1&b=hello+world&c=%41%zz&&d&e=", 32, kv, 8, arena, sizeof arena); + size_t n = ioxd_kv_parse("a=1&b=hello+world&c=%41%zz&&d&e=", 32, kv, 8, arena, sizeof arena, NULL); CHECK(n == 5); - CHECK(ioma_slice_eq(kv[0].key, "a") && ioma_slice_eq(kv[0].value, "1")); - CHECK(ioma_slice_eq(kv[1].key, "b") && ioma_slice_eq(kv[1].value, "hello world")); - CHECK(ioma_slice_eq(kv[2].key, "c") && ioma_slice_eq(kv[2].value, "A%zz")); /* a bad escape stays */ - CHECK(ioma_slice_eq(kv[3].key, "d") && kv[3].value.len == 0); - CHECK(ioma_slice_eq(kv[4].key, "e") && kv[4].value.len == 0); + CHECK(ioxd_slice_eq(kv[0].key, "a") && ioxd_slice_eq(kv[0].value, "1")); + CHECK(ioxd_slice_eq(kv[1].key, "b") && ioxd_slice_eq(kv[1].value, "hello world")); + CHECK(ioxd_slice_eq(kv[2].key, "c") && ioxd_slice_eq(kv[2].value, "A%zz")); /* a bad escape stays */ + CHECK(ioxd_slice_eq(kv[3].key, "d") && kv[3].value.len == 0); + CHECK(ioxd_slice_eq(kv[4].key, "e") && kv[4].value.len == 0); CHECK(kv[0].value.p != arena && kv[1].value.p >= arena); /* a view when undecoded, else in the arena */ - n = ioma_kv_parse("k%20ey=v&x=y", 12, kv, 8, arena, sizeof arena); - CHECK(n == 2 && ioma_slice_eq(kv[0].key, "k ey")); - n = ioma_kv_parse("a=1&b=x+y&c=3", 13, kv, 8, arena, 0); /* no arena: the pair needing it is skipped */ - CHECK(n == 2 && ioma_slice_eq(kv[1].key, "c")); - n = ioma_kv_parse("a=1&b=2&c=3", 11, kv, 1, arena, sizeof arena); + n = ioxd_kv_parse("k%20ey=v&x=y", 12, kv, 8, arena, sizeof arena, NULL); + CHECK(n == 2 && ioxd_slice_eq(kv[0].key, "k ey")); + n = ioxd_kv_parse("a=1&b=x+y&c=3", 13, kv, 8, arena, 0, NULL); /* no arena: the pair needing it is skipped */ + CHECK(n == 2 && ioxd_slice_eq(kv[1].key, "c")); + n = ioxd_kv_parse("a=1&b=2&c=3", 11, kv, 1, arena, sizeof arena, NULL); CHECK(n == 1); int v; - CHECK(ioma_kv_parse("page=12", 7, kv, 8, arena, sizeof arena) == 1 && ioma_to_int(kv[0].value, &v) && v == 12); + CHECK(ioxd_kv_parse("page=12", 7, kv, 8, arena, sizeof arena, NULL) == 1 && ioxd_to_int(kv[0].value, &v) && v == 12); +} + +/* The JSON writer into memory: what a document looks like, byte for byte. */ +static bool json_is(const char *want, void (*write)(ioxd_json *)) +{ + char buf[512]; + size_t len; + ioxd_json j = ioxd_json_mem(buf, sizeof buf, &len); + write(&j); + return !j.failed && len == strlen(want) && memcmp(buf, want, len) == 0; +} +static void doc_nested(ioxd_json *j) +{ + ioxd_json_object(j); + ioxd_json_key(j, "id"); ioxd_json_int(j, 42); + ioxd_json_key(j, "name"); ioxd_json_cstr(j, "Zo\xc3\xab \"Z\" O'Neil\n\t\x01"); + ioxd_json_key(j, "tags"); ioxd_json_array(j); ioxd_json_cstr(j, "a"); ioxd_json_cstr(j, "b"); ioxd_json_end(j); + ioxd_json_key(j, "empty"); ioxd_json_object(j); ioxd_json_end(j); + ioxd_json_key(j, "none"); ioxd_json_null(j); + ioxd_json_key(j, "ok"); ioxd_json_bool(j, true); + ioxd_json_key(j, "raw"); ioxd_json_raw(j, S("[1,2]")); + ioxd_json_end(j); +} +static void doc_numbers(ioxd_json *j) +{ + ioxd_json_array(j); + ioxd_json_int(j, 0); ioxd_json_int(j, -7); ioxd_json_int(j, INT64_MIN); ioxd_json_int(j, INT64_MAX); + ioxd_json_uint(j, UINT64_MAX); + ioxd_json_double(j, 0.1); ioxd_json_double(j, 2.5); ioxd_json_double(j, -0.0); ioxd_json_double(j, 1e21); + ioxd_json_double(j, 9007199254740993.0); ioxd_json_double(j, 1.0 / 3.0); + ioxd_json_double(j, INFINITY); ioxd_json_double(j, NAN); + ioxd_json_end(j); +} +/* A float is not a double: 6 to 9 significant digits, round-tripped against the float itself. */ +static void doc_floats(ioxd_json *j) +{ + ioxd_json_array(j); + ioxd_json_float(j, 0.1F); ioxd_json_float(j, 1.0F / 3.0F); ioxd_json_float(j, 16777217.0F); + ioxd_json_float(j, -0.0F); ioxd_json_float(j, 1e20F); + ioxd_json_float(j, INFINITY); ioxd_json_float(j, NAN); + ioxd_json_end(j); +} +/* The reals whose text the decimal point of a locale would spoil. */ +static void doc_reals(ioxd_json *j) +{ + ioxd_json_array(j); + ioxd_json_double(j, 0.1); ioxd_json_double(j, -2.5e-7); ioxd_json_float(j, 0.5F); + ioxd_json_end(j); +} +/* Bytes are bytes: what is not valid UTF-8 goes out exactly as it came in. */ +static void doc_bad_utf8(ioxd_json *j) { ioxd_json_string(j, S("\xff\xfe\x80 ok")); } +static void doc_top_level(ioxd_json *j) { ioxd_json_cstr(j, "just a string"); } +static void doc_array_of_arrays(ioxd_json *j) +{ + ioxd_json_array(j); + ioxd_json_array(j); ioxd_json_int(j, 1); ioxd_json_end(j); + ioxd_json_array(j); ioxd_json_end(j); + ioxd_json_end(j); +} + +/* Structs described once: every kind of field, nested twice, arrays of both scalars and objects. */ +#define ADDRESS_FIELDS(X) \ + X(VALUE, const char *, city) \ + X(VALUE, const char *, zip) +IOXD_JSON_STRUCT(address, ADDRESS_FIELDS) + +#define ORDER_FIELDS(X) \ + X(VALUE, int, number) \ + X(VALUE, double, total) \ + X(ARRAY, int, items, n_items) +IOXD_JSON_STRUCT(order, ORDER_FIELDS) + +#define USER_FIELDS(X) \ + X(VALUE, int64_t, id) \ + X(VALUE, const char *, name) \ + X(VALUE, bool, active) \ + X(VALUE, ioxd_slice, handle) \ + X(VALUE, unsigned, visits) \ + X(OBJECT, address, address) \ + X(OPTIONAL, address, billing) \ + X(ARRAY, const char *, tags, n_tags) \ + X(OBJECTS, order, orders, n_orders) +IOXD_JSON_STRUCT(user, USER_FIELDS) + +static void doc_struct(ioxd_json *j) +{ + const char *tags[] = { "new", "vip" }; + int items[] = { 7, 9 }; + struct order orders[] = { { 1, 9.5, items, 2 }, { 2, 0.25, items, 0 } }; + struct address billing = { "Lisboa", "1000-001" }; + struct user u = { + .id = 42, .name = "Zo\xc3\xab \"Z\"", .active = true, .handle = S("zoe"), .visits = 3, + .address = { "Porto", NULL }, .billing = &billing, + .tags = tags, .n_tags = 2, .orders = orders, .n_orders = 2, + }; + user_to_json(j, &u); +} +static void doc_struct_empty(ioxd_json *j) +{ + struct user u = { .id = 1, .name = NULL, .handle = S(""), .address = { NULL, NULL } }; + user_to_json(j, &u); +} +/* The field macro as a statement, which must not be a discarded value, and as a condition. */ +static void doc_field_macro(ioxd_json *j) +{ + ioxd_json_object(j); + IOXD_JSON_FIELD(j, "n", 5); + IOXD_JSON_FIELD(j, "x", 2.5); + IOXD_JSON_FIELD(j, "f", 0.1F); /* a float takes the float writer */ + IOXD_JSON_FIELD(j, "s", "str"); + IOXD_JSON_FIELD(j, "b", false); + if (!IOXD_JSON_FIELD(j, "u", 7U)) + return; + IOXD_JSON_FIELD(j, "sl", S("slice")); + ioxd_json_end(j); +} + +/* Where a value may go: a key only in an object, one key per value, an end only once the pair it + * closes is complete. Every refusal is marked failed, so nothing goes unreported. */ +static void test_json_levels(void) +{ + char buf[128]; + size_t len; + + ioxd_json j = ioxd_json_mem(buf, sizeof buf, &len); + CHECK(!ioxd_json_key(&j, "k") && j.failed); /* a key at the top level */ + + j = ioxd_json_mem(buf, sizeof buf, &len); + CHECK(ioxd_json_array(&j) && !ioxd_json_key(&j, "k") && j.failed); /* a key in an array */ + + j = ioxd_json_mem(buf, sizeof buf, &len); + CHECK(ioxd_json_object(&j) && !ioxd_json_int(&j, 1) && j.failed); /* a value, no key */ + + j = ioxd_json_mem(buf, sizeof buf, &len); + CHECK(ioxd_json_object(&j) && ioxd_json_key(&j, "a") && !ioxd_json_key(&j, "b") && j.failed); + + j = ioxd_json_mem(buf, sizeof buf, &len); + CHECK(ioxd_json_object(&j) && ioxd_json_key(&j, "a") && !ioxd_json_end(&j) && j.failed); + + j = ioxd_json_mem(buf, sizeof buf, &len); + CHECK(!ioxd_json_end(&j) && j.failed); /* nothing open, and it says so */ + CHECK(!ioxd_json_object(&j)); /* failed stays failed */ + + j = ioxd_json_mem(buf, sizeof buf, &len); + CHECK(ioxd_json_array(&j) && !ioxd_json_raw(&j, S("")) && j.failed); /* nothing is no value */ + + j = ioxd_json_mem(buf, sizeof buf, &len); + CHECK(ioxd_json_done(&j)); /* nothing written, none wrong */ + CHECK(ioxd_json_object(&j) && !ioxd_json_done(&j)); /* the object is still open */ + CHECK(ioxd_json_key(&j, "a") && ioxd_json_int(&j, 1) && !ioxd_json_done(&j)); + CHECK(ioxd_json_end(&j) && ioxd_json_done(&j)); /* now it is whole */ + CHECK(!ioxd_json_end(&j) && !ioxd_json_done(&j)); /* one end too many */ +} + +/* The decimal point is '.' whatever LC_NUMERIC says: the numbers are formatted in the writer's + * own "C" locale and the thread's is handed straight back. A locale with a point of its own is + * not installed everywhere, so this says so and skips when there is none. */ +/* NOLINTBEGIN(concurrency-mt-unsafe): the process locale is switched here, single-threaded, before any worker exists */ +static void test_json_locale(void) +{ + static const char *const others[] = { + "ps_AF.UTF-8", "fa_IR.UTF-8", /* a point two bytes long */ + "de_DE.UTF-8", "fr_FR.UTF-8", /* a comma */ + }; + char saved[64]; + const char *was = setlocale(LC_NUMERIC, NULL); + snprintf(saved, sizeof saved, "%s", was ? was : "C"); + + const char *set = NULL; + for (size_t i = 0; i < sizeof others / sizeof *others && !set; i++) + set = setlocale(LC_NUMERIC, others[i]); + if (!set) { + printf("note: no locale with a point of its own installed; that check skipped\n"); + return; + } + CHECK(strcmp(localeconv()->decimal_point, ".") != 0); /* the locale really differs */ + CHECK(json_is("[0.1,-2.5e-07,0.5]", doc_reals)); /* and the JSON does not */ + CHECK(strcmp(localeconv()->decimal_point, ".") != 0); /* the thread's was handed back */ + setlocale(LC_NUMERIC, saved); +} +/* NOLINTEND(concurrency-mt-unsafe) */ + +static void test_json(void) +{ + CHECK(json_is("{\"id\":42,\"name\":\"Zo\xc3\xab \\\"Z\\\"\",\"active\":true,\"handle\":\"zoe\",\"visits\":3," + "\"address\":{\"city\":\"Porto\",\"zip\":null},\"billing\":{\"city\":\"Lisboa\",\"zip\":\"1000-001\"}," + "\"tags\":[\"new\",\"vip\"],\"orders\":[{\"number\":1,\"total\":9.5,\"items\":[7,9]},{\"number\":2,\"total\":0.25,\"items\":[]}]}", doc_struct)); + CHECK(json_is("{\"id\":1,\"name\":null,\"active\":false,\"handle\":\"\",\"visits\":0,\"address\":{\"city\":null,\"zip\":null},\"billing\":null,\"tags\":[],\"orders\":[]}", doc_struct_empty)); + CHECK(json_is("{\"n\":5,\"x\":2.5,\"f\":0.1,\"s\":\"str\",\"b\":false,\"u\":7,\"sl\":\"slice\"}", doc_field_macro)); + + CHECK(json_is("{\"id\":42,\"name\":\"Zo\xc3\xab \\\"Z\\\" O'Neil\\n\\t\\u0001\",\"tags\":[\"a\",\"b\"],\"empty\":{},\"none\":null,\"ok\":true,\"raw\":[1,2]}", doc_nested)); + CHECK(json_is("[0,-7,-9223372036854775808,9223372036854775807,18446744073709551615,0.1,2.5,-0,1e+21,9007199254740992,0.3333333333333333,null,null]", doc_numbers)); + CHECK(json_is("[0.1,0.33333334,16777216,-0,1e+20,null,null]", doc_floats)); + CHECK(json_is("[0.1,-2.5e-07,0.5]", doc_reals)); + CHECK(json_is("\"\xff\xfe\x80 ok\"", doc_bad_utf8)); + CHECK(json_is("\"just a string\"", doc_top_level)); + CHECK(json_is("[[1],[]]", doc_array_of_arrays)); + + char small[8]; + size_t len; + ioxd_json j = ioxd_json_mem(small, sizeof small, &len); + CHECK(ioxd_json_object(&j) && ioxd_json_key(&j, "k")); /* {"k": is 5 bytes */ + CHECK(!ioxd_json_cstr(&j, "too long for what is left") && j.failed); + CHECK(!ioxd_json_int(&j, 1)); /* failed stays failed */ + + /* Every level has a bit of its own, the deepest included: all the way down and out again. */ + char deep[512]; + j = ioxd_json_mem(deep, sizeof deep, &len); + bool ok = true; + for (int i = 0; i < IOXD_JSON_DEPTH; i++) + ok = ok && ioxd_json_array(&j); + for (int i = 0; i < IOXD_JSON_DEPTH; i++) + ok = ok && ioxd_json_end(&j); + CHECK(ok && ioxd_json_done(&j) && len == 2 * (size_t)IOXD_JSON_DEPTH); + + j = ioxd_json_mem(deep, sizeof deep, &len); + ok = true; + for (int i = 0; i < IOXD_JSON_DEPTH; i++) + ok = ok && ioxd_json_array(&j); + ok = ok && ioxd_json_int(&j, 1); /* the deepest level owes a comma */ + size_t written = len; + CHECK(ok && !ioxd_json_array(&j) && len == written); /* too deep: not even that comma */ + + /* Longer than one run of the sink, byte for byte through the run loop. */ + char big[4096]; + char longer[2000]; + memset(longer, 'x', sizeof longer); + j = ioxd_json_mem(big, sizeof big, &len); + CHECK(ioxd_json_string(&j, (ioxd_slice){ longer, sizeof longer }) && ioxd_json_done(&j)); + CHECK(len == sizeof longer + 2 && big[0] == '"' && big[len - 1] == '"' + && memcmp(big + 1, longer, sizeof longer) == 0); + + /* A run the sink refuses whole is asked for again halved, down to 64 bytes: what is left of + * the buffer is filled to within a short run of the end before the writer gives up. */ + char tight[700]; + j = ioxd_json_mem(tight, sizeof tight, &len); + CHECK(!ioxd_json_string(&j, (ioxd_slice){ longer, sizeof longer }) && j.failed); + CHECK(len >= sizeof tight - 64); + + test_json_levels(); + test_json_locale(); +} + +/* ioxd_configure: a zero keeps a default, a bad value is refused and changes nothing. */ +static void test_config(void) +{ + CHECK(ioxd_configure(&(ioxd_config){ 0 }) == 0); + CHECK(ioxd_configure(&(ioxd_config){ .ring_entries = 1024, .recv_buffers = 8192, .recv_buffer_size = 4096, + .stack_size = 256UL * 1024, .idle_stacks = 1, .idle_connections = 1 }) == 0); + CHECK(ioxd_configure(&(ioxd_config){ .ring_entries = 3000 }) == -1); /* not a power of two */ + CHECK(ioxd_configure(&(ioxd_config){ .ring_entries = 65536 }) == -1); + CHECK(ioxd_configure(&(ioxd_config){ .recv_buffers = 65536 }) == -1); /* the kernel refuses it */ + CHECK(ioxd_configure(&(ioxd_config){ .recv_buffers = 12 }) == -1); + CHECK(ioxd_configure(&(ioxd_config){ .recv_buffer_size = 16 }) == -1); + CHECK(ioxd_configure(&(ioxd_config){ .stack_size = 4096 }) == -1); /* the engine's frames alone are 44 KB */ + CHECK(ioxd_configure(&(ioxd_config){ .recv_buffers = 8, .recv_buffer_size = 64 }) == 0); /* the starved test build */ + CHECK(ioxd_configure(&(ioxd_config){ 0 }) == 0); /* back to the defaults */ } int main(void) { test_integers(); + test_config(); + test_json(); test_doubles(); test_bools(); test_strings(); test_kv_parse(); - printf("unit: %d checks, %d failed\n", checks, failures); + printf("unit: %d checks, %d failed\n", checks, failures); /* test_json ran first, above */ return failures ? 1 : 0; }