# simoom documentation (full text) > Every page of the simoom documentation (https://cameleer.ing/simoom/docs/) concatenated as plain markdown, for LLMs and coding assistants. simoom is a small, fast HTTP/1.1, HTTP/2 and HTTP/3 server engine you build into Rust programs, with its own TLS 1.3 and nothing but the Rust standard library underneath. --- Source: https://cameleer.ing/simoom/docs/ # simoom documentation > simoom is an HTTP server you build into a Rust program; these guides show how to write services, add TLS, HTTP/2 and HTTP/3, and keep the build small. simoom serves HTTP/1.1, HTTP/2 and HTTP/3, does its own TLS 1.3, and runs one event loop per CPU core on epoll, io_uring, IOCP or kqueue. It uses nothing outside the Rust standard library and needs no async runtime. If you have used a Rust web framework before, the main difference is this: a service is a plain function that runs on the loop thread, so it answers fast work at once and hands slow work to a pool or to your own threads. ## Where to find what Read the guides in this order the first time; after that, jump to the one you need. | you want to | go to | |---|---| | install simoom, run a first server and learn what a service is | [Getting started](https://cameleer.ing/simoom/docs/guides/getting-started.html) | | read requests and build responses | [Services and replies](https://cameleer.ing/simoom/docs/guides/services-and-replies.html) | | send requests to handlers by method and path | [Routing](https://cameleer.ing/simoom/docs/guides/routing.html) | | call a database or another service without blocking the server | [Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html) | | stream request and response bodies, push server-sent events | [Streaming and SSE](https://cameleer.ing/simoom/docs/guides/streaming-and-sse.html) | | accept WebSocket connections | [WebSocket](https://cameleer.ing/simoom/docs/guides/websocket.html) | | serve HTTPS with your certificates, mutual TLS, certificate rotation | [TLS](https://cameleer.ing/simoom/docs/guides/tls.html) | | turn on HTTP/2 and HTTP/3 and tune them | [HTTP/2 and HTTP/3](https://cameleer.ing/simoom/docs/guides/http2-and-http3.html) | | set limits, see what is refused, log requests | [Limits and security](https://cameleer.ing/simoom/docs/guides/limits-and-security.html) | | choose threads and the I/O back end, stop gracefully, read counters | [Runtime](https://cameleer.ing/simoom/docs/guides/runtime.html) | | make HTTP and WebSocket requests from Rust | [The client](https://cameleer.ing/simoom/docs/guides/client.html) | | pick cargo features and see what each adds to the binary | [Features and size](https://cameleer.ing/simoom/docs/guides/features-and-size.html) | | drive the HTTP/1.1, HTTP/2, QUIC or TLS crates from your own I/O loop | [Sans-io crates](https://cameleer.ing/simoom/docs/guides/sans-io-crates.html) | | give a coding agent the rules it needs to write simoom code | [For coding agents](https://cameleer.ing/simoom/docs/guides/coding-agents.html) | | find quick answers to common questions | [FAQ](https://cameleer.ing/simoom/docs/guides/faq.html) | The security model, every limit with its default, and the test results are in [SECURITY.md](https://cameleer.ing/simoom/docs/SECURITY.html). Measured numbers and how they were taken are on the [benchmarks page](https://cameleer.ing/simoom/benchmarks/). The API reference is on [docs.rs/simoom](https://docs.rs/simoom). ## How the pages are organized Every guide starts with a short answer under its title, then shows code you can copy. Every Rust example was compiled against simoom 0.1.0 with `--features full`; examples that need another feature say so. Each guide ends with common questions. ## Common questions ### Which version do these docs describe? simoom 0.1.0, the first release. The API can still change before 1.0; breaking changes will be listed in the release notes. ### Is there a version of these docs for coding assistants? Yes. The site publishes `llms.txt` (a summary with links) and `llms-full.txt` (every page as plain markdown), and every HTML page has its markdown source next to it. The [coding agents guide](https://cameleer.ing/simoom/docs/guides/coding-agents.html) lists the rules an assistant most often gets wrong. ### Where do I report a mistake in the docs? Open an issue or a pull request on [GitHub](https://github.com/Cameleering/simoom). Security problems go through a private advisory, as described in [SECURITY.md](https://cameleer.ing/simoom/docs/SECURITY.html). --- Source: https://cameleer.ing/simoom/docs/guides/getting-started.html # Getting started with simoom > Add simoom with `cargo add simoom`, write one function that answers a request, and call `start()`; a working HTTP server takes about ten lines. simoom is a library, not a program: you add it to your own Rust project and it serves HTTP from inside your process. This page installs it, runs a first server, and explains the one idea you need before writing more: what a service is and where it runs. ## Install simoom needs Rust 1.89 or newer and runs on Linux, Windows and macOS. ```sh cargo add simoom ``` or in `Cargo.toml`: ```toml [dependencies] simoom = "0.1" ``` The default features give you HTTP/1.1, HTTP/2 without TLS (h2c), WebSocket and the io_uring back end. For TLS and HTTP/3 turn on `full`: ```toml simoom = { version = "0.1", features = ["full"] } ``` There is nothing else to install: no C compiler, no OpenSSL headers, no async runtime. [Features and size](https://cameleer.ing/simoom/docs/guides/features-and-size.html) lists every feature. ## A first server ```rust use simoom::{Outcome, Reply, Request, Server, StaticReply}; use std::sync::Arc; fn main() { let hello = Arc::new(StaticReply::new(200, &[("content-type", "text/plain")], b"Hello, World!")); let server = Server::builder() .listen("127.0.0.1:8080") .service(move |_req: &mut Request<'_>, reply: Reply<'_>| -> Outcome { reply.fixed(&hello) }) .start() .expect("start the server"); println!("listening on {}", server.local_addr()); server.join(); } ``` Run it with `cargo run --release` and try it: ```sh curl -i http://127.0.0.1:8080/ curl --http2-prior-knowledge http://127.0.0.1:8080/ # the same server speaks HTTP/2 (h2c) ``` `StaticReply` serializes the response once, so every request is answered with one copy. It is the fastest way to send a fixed answer, but any response works, as the next example shows. ## Routes and parameters Most programs answer more than one path. A [`Router`](https://cameleer.ing/simoom/docs/guides/routing.html) sends each request to a handler by method and path: ```rust use simoom::{Outcome, Reply, Request, Router, Server}; fn main() { let router = Router::new() .get("/", |_: &mut Request<'_>, r: Reply<'_>| r.body_static(b"Hello, World!")) .get("/hello/:name", |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { let name = req.param_str("name").unwrap_or("stranger"); r.header("content-type", "text/plain").body(format!("Hello, {name}!")) }); let server = Server::builder().listen("127.0.0.1:8080").service(router).start().expect("start"); println!("listening on {}", server.local_addr()); server.join(); } ``` `curl http://127.0.0.1:8080/hello/Ada` prints `Hello, Ada!`. An unknown path gets `404`, and a known path with the wrong method gets `405` with an `allow` header. ## What a service is A **service** is anything that takes a `&mut Request` and a `Reply` and returns an `Outcome`. A closure with that signature is a service, and so is a `Router`. The `Reply` is finished exactly once: with a body, with a deferred answer for later, with a stream, or with a WebSocket upgrade. [Services and replies](https://cameleer.ing/simoom/docs/guides/services-and-replies.html) covers each. The one rule to remember: **a service runs on the loop thread.** simoom runs one event loop per CPU core, and each loop serves thousands of connections. While your function runs, that loop does nothing else. So a service must answer in microseconds and must not block: no database calls, no file reads, no `sleep`, no locks held for long. There is no `async` and no `.await`. Slow work goes somewhere else: - to simoom's bounded offload pool with `simoom::blocking` or `Reply::offload`; - or to your own threads with `Reply::defer`, answering later from any thread. [Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html) shows all three. If a handler blocks anyway, a watchdog notices and moves that route to the offload pool, but that is a safety net, not a plan. ## Stopping the server `start()` returns a `ServerHandle` once every loop is running. `join()` waits for the loops to exit; `stop(grace)` starts a graceful stop. Port `0` lets the OS pick a free port, which is handy in tests: ```rust use simoom::{Reply, Request, Server}; use std::time::Duration; fn run() -> std::io::Result<()> { let server = Server::builder() .listen("127.0.0.1:0") // port 0: the OS picks a free port .service(|_: &mut Request<'_>, r: Reply<'_>| r.body_static(b"ok")) .start()?; let addr = server.local_addr(); // ... use the server ... server.stop(Duration::from_secs(5)); server.join(); Ok(()) } ``` Dropping the handle stops the server at once. [Runtime](https://cameleer.ing/simoom/docs/guides/runtime.html) explains what a graceful stop does on each protocol. ## Where to go next - [Services and replies](https://cameleer.ing/simoom/docs/guides/services-and-replies.html): reading requests, every way to answer. - [Routing](https://cameleer.ing/simoom/docs/guides/routing.html): parameters, wildcards, 404 and 405. - [Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html): databases and other services without blocking the server. - [TLS](https://cameleer.ing/simoom/docs/guides/tls.html) and [HTTP/2 and HTTP/3](https://cameleer.ing/simoom/docs/guides/http2-and-http3.html): HTTPS and the newer protocols. - [Features and size](https://cameleer.ing/simoom/docs/guides/features-and-size.html): the smallest build for a device or a function. - [FAQ](https://cameleer.ing/simoom/docs/guides/faq.html). ## Common questions ### Do I need tokio or another async runtime? No. simoom has its own event loops and no async runtime. Services are plain functions. You can still use simoom inside a program that runs tokio for other work; they don't interact. ### Why does my handler signature need the explicit types? Rust can't infer closure argument types for a generic trait bound, so a closure passed to `service` or `Router::get` needs `&mut Request<'_>` and `Reply<'_>` written out. A named `fn` with that signature works too. ### Which address should I listen on? `127.0.0.1:8080` accepts connections from the same machine only. `0.0.0.0:8080` accepts them on every IPv4 interface, and `[::]:8080` on IPv6. You can call `listen` more than once. ### How do I run the bundled examples? From a checkout of the repository: `cargo run --release -p simoom --example hello -- 127.0.0.1:8080`. --- Source: https://cameleer.ing/simoom/docs/guides/services-and-replies.html # How do I read requests and send responses in simoom? > A service gets a borrowed `Request` and a `Reply`; read the request, then finish the reply once with a body, a stream, a deferred answer or an upgrade. Everything in simoom is built on one trait, `Service`. This page shows the three ways to write a service, what you can read from a `Request`, and every way to finish a `Reply`. ## Three ways to write a service **A closure.** Any `Fn(&mut Request<'_>, Reply<'_>) -> Outcome + Send + Sync + 'static` is a service. This is what most examples use. **A function.** The same signature as a named `fn`: ```rust use simoom::{Outcome, Reply, Request, Server}; fn hello(req: &mut Request<'_>, reply: Reply<'_>) -> Outcome { if req.method() != b"GET" { return reply.status(405).header("allow", "GET").empty(); } reply.header("content-type", "text/plain").body_static(b"hello") } fn main() { let server = Server::builder().listen("127.0.0.1:8080").service(hello).start().unwrap(); server.join(); } ``` **A struct.** Implement `Service` when the handler carries state. The service is shared by every loop thread, so its state must be `Send + Sync`: atomics, `Mutex`, `RwLock` or read-only data. ```rust use simoom::{Outcome, Reply, Request, Service}; use std::sync::atomic::{AtomicU64, Ordering}; struct Counter { hits: AtomicU64, } impl Service for Counter { fn call(&self, _req: &mut Request<'_>, reply: Reply<'_>) -> Outcome { let n = self.hits.fetch_add(1, Ordering::Relaxed) + 1; reply.body(format!("request number {n}")) } } fn build() -> simoom::Builder { simoom::Server::builder().listen("127.0.0.1:8080").service(Counter { hits: AtomicU64::new(0) }) } ``` Closures can capture shared state too. Clone an `Arc` for each closure that needs it: ```rust use simoom::{Outcome, Reply, Request, Router}; use std::collections::HashMap; use std::sync::{Arc, RwLock}; fn router() -> Router { let store: Arc>> = Arc::default(); let reader = store.clone(); Router::new() .put("/kv/:key", move |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { let key = req.param_str("key").unwrap_or("").to_string(); let value = String::from_utf8_lossy(req.body()).into_owned(); store.write().unwrap().insert(key, value); r.status(204).empty() }) .get("/kv/:key", move |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { match reader.read().unwrap().get(req.param_str("key").unwrap_or("")) { Some(v) => r.body(v.clone()), None => r.status(404).body_static(b"not found"), } }) } ``` A lock held for a few microseconds is fine on the loop thread. A lock that waits on slow work is not; see [Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html). ## Reading the request A `Request` borrows from the connection's receive buffer, so reading it costs nothing. Most accessors return bytes (`&[u8]`), because HTTP allows bytes that aren't UTF-8; the `_str` variants return text. ```rust use simoom::{Outcome, Reply, Request}; fn inspect(req: &mut Request<'_>, reply: Reply<'_>) -> Outcome { let method = String::from_utf8_lossy(req.method()).into_owned(); let path = req.path_str(); let page = req.query_param("page").and_then(|v| std::str::from_utf8(v).ok()).unwrap_or("1"); let agent = req.header("user-agent").map(String::from_utf8_lossy).unwrap_or_default(); let peer = req.peer_addr().map(|a| a.to_string()).unwrap_or_default(); let body_len = req.body().len(); reply.body(format!("{method} {path} page={page} agent={agent} peer={peer} body={body_len} bytes ({:?})", req.version())) } ``` | method | returns | |---|---| | `method()`, `target()`, `path()`, `path_str()` | the method, the full target, the path before `?` (not percent-decoded) | | `query()`, `query_param(name)` | the raw query string, one raw parameter | | `header(name)`, `headers()` | the first header with that name (case-insensitive), all headers in order | | `host()` | `Host` on HTTP/1.1, `:authority` on HTTP/2 and HTTP/3 | | `version()` | `Version::Http10`, `Http11`, `H2` or `H3` | | `body()`, `is_streamed()`, `take_body()` | the buffered body, or the streaming body source ([Streaming](https://cameleer.ing/simoom/docs/guides/streaming-and-sse.html)) | | `trailers()`, `trailer(name)` | trailer fields of a buffered body | | `param(name)`, `param_str(name)` | a value captured by the [router](https://cameleer.ing/simoom/docs/guides/routing.html) | | `peer_addr()`, `local_addr()`, `listener()` | the client's address, the address it connected to, which listener accepted it | | `is_secure()`, `tls()` | TLS or not, and the TLS session details ([TLS](https://cameleer.ing/simoom/docs/guides/tls.html)) | | `is_websocket_upgrade()` | a valid WebSocket handshake ([WebSocket](https://cameleer.ing/simoom/docs/guides/websocket.html)) | | `to_owned_request()` | an `OwnedRequest` copy that can move to another thread | Bodies up to `Limits::body_buffer` (64 KiB by default) arrive whole before your service is called, so `req.body()` has them. Larger bodies stream; `req.body()` is then empty and `req.is_streamed()` is true. Percent-decoding is up to you: `simoom::percent_decode` does it. ## Finishing the reply Set the status (default 200) and headers, then finish the reply with exactly one of these: ```rust use simoom::{Outcome, Reply, Request, Response, StaticReply}; use std::sync::Arc; fn finishers() { let _a = |_: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.body(String::from("owned text")) }; let _b = |_: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.body(vec![1u8, 2, 3]) }; let _c = |_: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.body_static(b"no copy") }; let _d = |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.body_copy(req.body()) }; let _e = |_: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.status(204).empty() }; let _f = |_: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.send(Response::new(201).header("content-type", "application/json").body(r#"{"ok":true}"#)) }; let not_found = Arc::new(StaticReply::new(404, &[("content-type", "text/plain")], b"not found")); let _g = move |_: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.fixed(¬_found) }; } ``` | finisher | use it for | |---|---| | `body(x)` | a `String`, `Vec` or static bytes, moved without a copy | | `body_static(b"...")` | constant bytes; large ones go out without a copy on plain HTTP/1.1 | | `body_copy(&[u8])` | bytes you only borrow | | `empty()` | no body (`content-length: 0`) | | `send(Response)` | an owned `Response` built elsewhere; its status and headers replace the reply's | | `fixed(&StaticReply)` | a response serialized once at start-up, the fastest path | | `defer()` | a `Responder` to answer later from any thread ([Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html)) | | `blocking(req, f)`, `offload(req, f)` | run the work on the offload pool ([Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html)) | | `stream()`, `sse()` | a `BodySink` for a streamed body ([Streaming](https://cameleer.ing/simoom/docs/guides/streaming-and-sse.html)) | | `websocket(handler)` | accept a WebSocket upgrade ([WebSocket](https://cameleer.ing/simoom/docs/guides/websocket.html)) | The type system keeps you honest: each finisher takes the reply by value, so you can't answer twice. If a reply is dropped without being finished, the client gets a `500`. Some details the server handles for you: - **Framing headers** (`content-length`, `transfer-encoding`) are written by the server; setting them is ignored. `connection: close` closes the connection after the response. - **Header names** go out in lower case. An invalid name or value (CR, LF, NUL) turns the response into a `500` instead of reaching the wire. - **`HEAD`** requests get the headers of the `GET` response without the body. - **Trailers** after the body: `.trailer("grpc-status", "0")` on the reply, or `Response::trailer`. HTTP/1.1 then uses chunked framing. ## The Outcome value `Outcome` is informational: `Outcome::Done` when the reply was sent (or a stream or upgrade started), `Outcome::Deferred` when the answer comes later. Finishers return the right one, so you normally just return what they return. After `reply.defer()` you return `Outcome::Deferred` yourself. ## Panics A panic in a service is caught. The client gets a `500`, its connection closes, the panic is counted in `ServerHandle::stats().panics`, and the loop goes on serving everyone else. ## Optional helpers Small helpers live behind their own cargo features, so you only compile what you use: | feature | module | what it does | |---|---|---| | `cors` | `simoom::cors` | answers preflights and adds CORS headers around a service | | `auth` | `simoom::auth` | Basic, Digest and Bearer authentication around a service | | `cookies` | `simoom::cookie` | reads `Cookie`, writes `Set-Cookie` | | `multipart` | `simoom::multipart` | parses `multipart/form-data` bodies | | `conditional` | `simoom::conditional` | ETag, `If-None-Match`, `Last-Modified` and range requests | | `compression` | `simoom::Compression` | gzip and deflate responses (`brotli`, `zstd` add those) | A helper wraps your service. With the `cors` feature: ```rust use simoom::cors::Cors; use simoom::{Reply, Request, Server}; fn build() -> simoom::Builder { let api = |_: &mut Request<'_>, r: Reply<'_>| r.body_static(b"{}"); Server::builder().listen("127.0.0.1:8080").service(Cors::new().allow_any_origin().wrap(api)) } ``` The [API reference](https://docs.rs/simoom) documents each helper. ## Common questions ### How do I return JSON? Set the content type and send the text: `r.header("content-type", "application/json").body(json)`. simoom has no JSON library, since it has no dependencies; use the serializer you already have (for example serde_json in your own crate) and pass the resulting `String`. ### How do I read a path parameter or a query parameter? `req.param_str("id")` for a `:id` route segment, `req.query_param("page")` for `?page=2`. Both return the raw value; decode it with `simoom::percent_decode` when it can contain `%XX` escapes. ### Can a handler be async? No. A service is a plain function that returns before the loop moves on. For work that waits, use `Reply::defer` and answer from any thread, or the offload pool; see [Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html). ### Why does my response come back as a 500? Three causes: the reply was dropped without a finisher, a header name or value was invalid, or the status was outside 200 to 999. A panic in the handler gives a 500 too. --- Source: https://cameleer.ing/simoom/docs/guides/routing.html # How do I route requests by path and method in simoom? > Build a `Router` with `.get`, `.post` and friends; `:name` or `{name}` captures a segment, `*name` the rest, and wrong methods get 405 automatically. `Router` is a service that picks a handler by method and path. It runs on the loop thread, so a `404` or `405` costs a hash lookup or a short walk through a tree, never a trip to a pool. ## A router with every kind of route ```rust use simoom::{Outcome, Reply, Request, Router}; fn router() -> Router { Router::new() .get("/health", |_: &mut Request<'_>, r: Reply<'_>| r.body_static(b"ok")) .get("/users/:id", |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { let id = req.param_str("id").unwrap_or(""); r.body(format!("user {id}")) }) .get("/teams/{team}/members/{member}", |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { let (team, member) = (req.param_str("team").unwrap_or(""), req.param_str("member").unwrap_or("")); r.body(format!("{member} of {team}")) }) .get("/files/*path", |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { let path = req.param_str("path").unwrap_or(""); r.body(format!("you asked for {path}")) }) .post("/users", |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.status(201).body(format!("created from {} bytes", req.body().len())) }) .route("OPTIONS", "/users", |_: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.status(204).header("allow", "GET, POST, OPTIONS").empty() }) .any("/echo", |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.body(String::from_utf8_lossy(req.method()).into_owned()) }) .fallback(|req: &mut Request<'_>, r: Reply<'_>| -> Outcome { r.status(404).body(format!("nothing at {}", req.path_str())) }) } ``` Pass it to `Server::builder().service(router)`. ## Methods `get`, `post`, `put`, `delete` and `patch` add a route for that method. `route(method, path, handler)` takes any method name, and `any(path, handler)` (or `route("*", ...)`) matches every method. - A path that exists with another method gets **`405 Method Not Allowed`** with an `allow` header listing the methods that do exist. - **`HEAD`** uses the `GET` route when there is no `HEAD` route; the client gets the headers without the body. - A path that matches nothing gets **`404`**, or goes to the `fallback` handler if you set one. ## Path patterns | pattern | matches | captured | |---|---|---| | `/health` | exactly `/health` | nothing | | `/users/:id` or `/users/{id}` | `/users/42`, not `/users/42/posts` | `id` = `42` | | `/files/*path` (last segment only) | `/files/a/b.txt`, also `/files/` | `path` = `a/b.txt` | When several routes could match, literal segments win over parameters, and parameters win over wildcards. So `/users/me` beats `/users/:id` for the path `/users/me`. Paths without patterns go into a hash map; the others go into a tree of path segments. ## Captured values are raw Paths are matched as the client sent them, without percent-decoding, and `param` returns the raw bytes. Decode them when a value can contain escapes such as `%20`: ```rust use simoom::{percent_decode, Outcome, Reply, Request, Router}; fn router() -> Router { Router::new().get("/search/:term", |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { let raw = req.param("term").unwrap_or(b""); match percent_decode(raw) { Some(term) => r.body(term), None => r.status(400).body_static(b"bad escape"), } }) } ``` ## Routes and slow handlers Any service can be a route handler, including `simoom::blocking(...)` for work that must run on the offload pool ([Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html)). The stall watchdog also works per route: if one handler keeps blocking its loop thread, that route alone moves to the offload pool and comes back once it is fast again. `Router::stalls_before_offload(n)` changes how many stalls that takes for this router (`0` never moves a route). ## Common questions ### Can I nest routers or mount one under a prefix? Not in 0.1.0. A `Router` matches the whole path, so put every route in one router, or write a small service that looks at the first path segment and calls one of several routers. Since a `Router` is a `Service`, `router.call(req, reply)` works from inside your own service. ### How do I add middleware such as logging or authentication? Wrap the router in your own service that does the work and then calls `router.call(req, reply)`. For access logs, `Builder::on_complete` is simpler and sees every request, including ones the parser refuses ([Limits and security](https://cameleer.ing/simoom/docs/guides/limits-and-security.html)). The `cors` and `auth` features offer ready-made wrappers ([Services and replies](https://cameleer.ing/simoom/docs/guides/services-and-replies.html#optional-helpers)). ### Does the router handle trailing slashes? No: `/users` and `/users/` are different paths. Add both routes if you want both to work. ### Is there a limit on the number of routes? No fixed one. Exact paths cost one hash lookup however many there are; patterns cost one step per path segment. --- Source: https://cameleer.ing/simoom/docs/guides/slow-work.html # How do I call a database or a service from a simoom handler? > Never block the loop thread: wrap slow handlers in `simoom::blocking`, or use `Reply::offload`, or `Reply::defer` and answer later from your own threads. simoom serves thousands of connections on each loop thread. A handler that waits for a database, a file or another HTTP service stops every one of those connections while it waits. This page shows the three ways to move that work off the loop, and the safety net for when a handler blocks anyway. ## Which way to choose | your situation | use | |---|---| | a handler that blocks (database, file, HTTP call) and you have no thread pool of your own | `simoom::blocking(f)` as the route handler | | a handler that is usually fast but sometimes slow | `reply.blocking(req, f)` or `reply.offload(req, f)` on the slow branch | | your program already has worker threads, queues or a runtime | `reply.defer()` and answer through the `Responder` from any thread | | microseconds of CPU, no I/O | answer inline, as usual | `blocking` and `offload` run on simoom's **offload pool**. `defer` doesn't touch the pool at all. ## The offload pool with `blocking` `blocking` turns a function that takes an `OwnedRequest` and returns a `Response` into a service. The request is copied, the function runs on a worker thread, and its response goes back to the connection: ```rust use simoom::{blocking, OwnedRequest, Response, Router}; use std::time::Duration; fn router() -> Router { Router::new().get("/report/:id", blocking(|req: OwnedRequest| { let id = String::from_utf8_lossy(req.param("id").unwrap_or(b"")).into_owned(); std::thread::sleep(Duration::from_millis(200)); // stands for a database query Response::text(format!("report {id}")) })) } ``` The pool is bounded, so overload can't pile up: - Worker threads start on demand and exit after 10 seconds idle. The cap is `Limits::offload_threads`; the default `0` means 32 per CPU, between 512 and 1024. - At most `Limits::offload_queue` jobs wait (default 4096). - The pool measures how long jobs wait (CoDel). When waits keep growing past what the workers can absorb, new requests get a fast **`503` with `Retry-After`** instead of joining an endless queue. The target adapts to how long your jobs take, so 100 ms database calls are not refused just for being 100 ms long. - A panic in the function becomes a `500`. ## Only the slow branch: `Reply::blocking` and `Reply::offload` When a handler is fast most of the time, keep the fast path inline and offload only the slow one: ```rust use simoom::{Outcome, Reply, Request, Response}; fn handler(req: &mut Request<'_>, reply: Reply<'_>) -> Outcome { if req.query_param("cached").is_some() { return reply.body_static(b"from cache"); // fast: answer inline } reply.blocking(req, |req| { // slow: runs on an offload worker Response::text(format!("computed for {}", req.path_str())) }) } ``` `Reply::offload` does the same but gives the worker the usual `Request` and `Reply`, so code written as an inline service runs unchanged on the pool: ```rust use simoom::{Outcome, Reply, Request}; fn handler(req: &mut Request<'_>, reply: Reply<'_>) -> Outcome { reply.offload(req, |req, reply| { // the same Request and Reply API, on a worker thread let size = req.body().len(); std::thread::sleep(std::time::Duration::from_millis(50)); reply.status(202).header("content-type", "text/plain").body(format!("accepted {size} bytes")) }) } ``` ## Your own threads: `Reply::defer` If your program already has workers (a job queue, a thread pool, an integration engine), copy the request with `to_owned_request()`, take a `Responder` with `reply.defer()`, and send both to your workers. The `Responder` can be sent to any thread and answers once: ```rust use simoom::{Outcome, Reply, Request, Response, Server}; use std::sync::mpsc; struct Job { request: simoom::OwnedRequest, responder: simoom::Responder, } fn run() -> std::io::Result<()> { let (tx, rx) = mpsc::channel::(); let tx = std::sync::Mutex::new(tx); // your own worker thread (or pool) std::thread::spawn(move || { for job in rx { if job.responder.is_cancelled() { continue; // the client left or the deadline passed } let answer = format!("done: {}", job.request.path_str()); job.responder.send(Response::text(answer)); } }); let server = Server::builder() .listen("127.0.0.1:8080") .service(move |req: &mut Request<'_>, reply: Reply<'_>| -> Outcome { let job = Job { request: req.to_owned_request(), responder: reply.defer() }; if let Err(mpsc::SendError(job)) = tx.lock().unwrap().send(job) { job.responder.send(Response::new(503).body("worker gone")); } Outcome::Deferred }) .start()?; server.join(); Ok(()) } ``` This is how [Cameleer](https://github.com/Cameleering/cameleer) embeds simoom: its own route threads answer, and simoom's pool isn't involved. A few things to know: - **`Responder::send` returns `false`** when the connection is already gone; the response is dropped. - **Dropping a `Responder`** without sending gives the client a `500`. - **`Responder::stream(head)`** starts a streamed response instead of a whole one. - **Order on HTTP/1.1:** while a response is deferred, that HTTP/1.1 connection reads no further requests, so responses stay in order. HTTP/2 and HTTP/3 streams carry on independently. ## Deadlines and cancellation By default a deferred or offloaded reply may take as long as it likes. Set `Limits::defer_timeout` to your service-level objective: when it expires before an answer has started, the client gets **`504 Gateway Timeout`** and the late answer is dropped. The worker can find out that its work is no longer wanted, because the client went away or the deadline passed. `Responder::is_cancelled()` is one atomic load, cheap to poll between steps. `Responder::on_cancel(f)` runs `f` once on cancellation, on the loop thread, so `f` must be quick: set a flag or wake a thread. ```rust use simoom::Responder; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; fn watch(responder: &Responder) -> Arc { let stop = Arc::new(AtomicBool::new(false)); let flag = stop.clone(); responder.on_cancel(move || flag.store(true, Ordering::Relaxed)); stop } ``` ## Tuning the pool ```rust use simoom::{Limits, Server}; use std::time::Duration; fn build() -> simoom::Builder { Server::builder().listen("0.0.0.0:8080").limits(Limits { offload_threads: 64, // what your database allows offload_queue: 1024, // jobs waiting before 503 defer_timeout: Some(Duration::from_secs(10)), // 504 after this inline_budget: Some(Duration::from_millis(2)), // watchdog budget per inline call ..Limits::default() }) } ``` Set `offload_threads` to the concurrency your backends can take: if your database allows 64 connections, more than 64 workers only queue inside the database driver instead of in simoom, where overload turns into a clean `503`. ## The watchdog, a safety net A handler that turns out slow (it blocks, or uses more than `Limits::inline_budget` of CPU, 1 ms by default) is a **stall**. After `Limits::stalls_before_offload` stalls (5) within `Limits::stall_window` (1 s), that route alone moves to the offload pool. Other routes stay inline. After `Limits::stall_recovery` fast calls (64) there, it comes back. The watchdog measures CPU time, not wall time, so a loop thread preempted on a busy machine isn't mistaken for a slow handler. Each stall is logged once per route and counted in `stats().stalls`. The calls before the move still held their loop thread, so treat the watchdog as a seatbelt, not as a way to avoid choosing. It needs the `watchdog` feature (on by default); a minimal edge build can leave it out. ## Common questions ### Can I use async/await or tokio inside a handler? Not directly: a service returns before the loop moves on. You can run tokio in your program and hand it the work: take a `Responder` with `reply.defer()`, move it into a spawned task, and call `responder.send(...)` when the future completes. `Responder` is a plain `Send` value. ### What happens when the offload pool is full? New requests that need it get `503 Service Unavailable` with `Retry-After: 1` right away, and `stats().overloaded` counts them. Requests already admitted keep their latency. ### Is `blocking` the same as `tokio::task::spawn_blocking`? Similar idea, different shape: `blocking` wraps a whole handler, the pool is bounded with admission control, and overload answers `503` instead of queueing without limit. ### How do I read a large request body in a slow handler? On the pool, `OwnedRequest::read_body()` reads the whole body, and `take_body()` gives a `std::io::Read` stream. See [Streaming and SSE](https://cameleer.ing/simoom/docs/guides/streaming-and-sse.html). --- Source: https://cameleer.ing/simoom/docs/guides/streaming-and-sse.html # How do I stream bodies and server-sent events with simoom? > `Reply::stream` and `Reply::sse` return a `BodySink` you write from any thread; large uploads arrive through a `BodySource` read off the loop thread. Some responses are too large to build in memory, or never end: a log export, a progress feed, a live dashboard. Some uploads are too large to buffer. simoom streams both directions with backpressure, so a slow client slows the writer instead of filling memory. ## Streaming a response `reply.stream()` sends the status and headers at once and returns a `BodySink` for the body. The body goes out chunked on HTTP/1.1 and as DATA frames on HTTP/2 and HTTP/3. `BodySink` is `Send` and implements `std::io::Write`, so a thread can write to it: ```rust use simoom::{Outcome, Reply, Request}; use std::io::Write; fn numbers(_: &mut Request<'_>, reply: Reply<'_>) -> Outcome { let mut sink = reply.header("content-type", "text/plain").stream(); std::thread::spawn(move || { for i in 0..1000 { // write waits while 64 KiB are pending, so a slow client slows this thread if writeln!(sink, "line {i}").is_err() { return; // the client went away } } sink.finish(); }); Outcome::Done } ``` At most `Limits::stream_buffer` bytes (64 KiB by default) are handed to the connection and not yet sent. Past that, `write` waits. That is fine on your own thread or an offload worker and **wrong on the loop thread**, where it would stop the loop. On the loop thread use `try_write`, which never waits and fails with `WouldBlock` when the buffer is full: ```rust use simoom::{Outcome, Reply, Request}; fn small(_: &mut Request<'_>, reply: Reply<'_>) -> Outcome { let mut sink = reply.stream(); // on the loop thread: try_write never waits let _ = sink.try_write(b"first part, "); let _ = sink.try_write(b"second part"); sink.finish(); Outcome::Done } ``` `BodySink` also has `poll_ready` and a `ready()` future for code that runs its own executor; no runtime is attached. Ending the body: - `finish()`, or dropping the sink, ends it normally. - `finish_with_trailers(&[("grpc-status", "0")])` ends it with trailer fields (trailing HEADERS on HTTP/2 and HTTP/3, a trailer section on HTTP/1.1). - `abort()` cuts it off: the HTTP/1.1 connection closes, or the HTTP/2 or HTTP/3 stream is reset, so the client knows the body is incomplete. - `is_closed()` tells you the client is gone; `write` then fails with `BrokenPipe`. ```rust use simoom::{Outcome, Reply, Request}; fn grpc_like(_: &mut Request<'_>, reply: Reply<'_>) -> Outcome { let mut sink = reply.header("content-type", "application/grpc").stream(); std::thread::spawn(move || { let _ = sink.write(b"\0\0\0\0\0"); sink.finish_with_trailers(&[("grpc-status", "0")]); }); Outcome::Done } ``` ## Server-sent events `reply.sse()` is `stream()` with `content-type: text/event-stream` and `cache-control: no-cache`. `send_event` writes one event: each line of the text becomes a `data:` line, followed by a blank line. ```rust use simoom::{Outcome, Reply, Request, Router}; use std::time::Duration; fn router() -> Router { Router::new().get("/events", |_: &mut Request<'_>, r: Reply<'_>| -> Outcome { let mut sink = r.sse(); std::thread::spawn(move || { for i in 0.. { if sink.send_event(&format!("tick {i}")).is_err() { return; // the browser closed the page } std::thread::sleep(Duration::from_secs(1)); } }); Outcome::Done }) } ``` In the browser, `new EventSource("/events")` receives the ticks. For named events or ids, write the fields yourself with `sink.write(b"event: update\ndata: ...\n\n")`. A thread per stream is simple and fine for tens or hundreds of streams. For many thousands, keep the sinks in one place and have a single thread (or a timer) write to all of them. Long-lived streams over HTTP/2 can outlive a client that vanished without closing its connection. The `h2-keepalive` feature with `Limits::h2_keepalive` sends PINGs to find such clients ([HTTP/2 and HTTP/3](https://cameleer.ing/simoom/docs/guides/http2-and-http3.html)). ## Request bodies Bodies up to `Limits::body_buffer` (64 KiB by default) are read whole before your service is called, and `req.body()` returns them: ```rust use simoom::{Outcome, Reply, Request}; fn upload(req: &mut Request<'_>, reply: Reply<'_>) -> Outcome { if req.is_streamed() { return reply.status(413).body_static(b"send less than 64 KiB here"); } let n = req.body().len(); reply.body(format!("got {n} bytes")) } ``` Larger bodies, and bodies of unknown length, stream: `req.is_streamed()` is true, `req.body()` is empty, and `req.take_body()` returns a `BodySource`. The connection reads from the socket only while the source has room, so a slow reader slows the client down (pull-based backpressure). `BodySource` implements `std::io::Read`, which blocks, so read it on the offload pool or your own thread: ```rust use simoom::{blocking, OwnedRequest, Response}; use std::io::Read; fn service() -> impl simoom::Service { blocking(|mut req: OwnedRequest| { let mut total = 0u64; if let Some(mut body) = req.take_body() { let mut buf = [0u8; 16 * 1024]; loop { match body.read(&mut buf) { Ok(0) => break, Ok(n) => total += n as u64, // hash it, write it to disk, ... Err(_) => return Response::new(400).body("upload broken off"), } } } else { total = req.body().len() as u64; } Response::text(format!("received {total} bytes")) }) } ``` When you just want the whole body, `OwnedRequest::read_body()` returns the buffered body or reads the stream to its end: ```rust use simoom::{blocking, OwnedRequest, Response}; fn service() -> impl simoom::Service { blocking(|mut req: OwnedRequest| match req.read_body() { Ok(body) => Response::text(format!("{} bytes", body.len())), Err(_) => Response::new(400).body("bad body"), }) } ``` `BodySource::try_read` and `poll_read` don't block, if you need to read on the loop thread. Trailers of a streamed body are available from `BodySource::trailers()` once it is complete. Limits that apply to request bodies: - `Limits::max_body` (10 MiB): a larger `Content-Length` is refused with `413` before any body byte is read; a chunked body that grows past it fails the read. - `Limits::min_body_rate` (1 KiB/s after a 5-second grace): a client that sends slower is cut off. ## Common questions ### Can I stream a file to the client? Yes: open it on your own thread or an offload worker and copy it into the sink with `std::io::copy(&mut file, &mut sink)`. simoom has no static-file server, so paths, content types and range requests are up to you (the `conditional` feature helps with ETags and ranges). ### Does SSE work over HTTP/2 and HTTP/3? Yes. The same `sse()` call works on every protocol; on HTTP/2 and HTTP/3 many event streams share one connection. ### How do I know the client disconnected? `write` and `send_event` return an error, and `is_closed()` returns true. Stop producing and drop the sink. ### Can response compression apply to streams? Yes, with the `compression` feature and `Compression::new().streams(true)`; each compressed stream then holds a compressor of about 256 KiB while it runs. --- Source: https://cameleer.ing/simoom/docs/guides/websocket.html # How do I accept WebSocket connections with simoom? > Implement `WebSocketHandler` and answer with `reply.websocket(handler)`; the handler runs on the loop thread, and a `WsHandle` sends from other threads. WebSocket is on by default (feature `websocket`) for HTTP/1.1. The same handler also serves WebSocket over HTTP/2 with the `websocket-h2` feature and over HTTP/3 with `websocket-h3`. ## An echo server ```rust use simoom::{Reply, Request, Router, Server, WebSocket, WebSocketHandler, WsMessage}; struct Echo; impl WebSocketHandler for Echo { fn on_message(&mut self, ws: &mut WebSocket<'_>, msg: WsMessage<'_>) { match msg { WsMessage::Text(text) => ws.send_text(text), WsMessage::Binary(bytes) => ws.send_binary(bytes), } } } fn main() { let router = Router::new().get("/ws", |_: &mut Request<'_>, r: Reply<'_>| r.websocket(Echo)); let server = Server::builder().listen("127.0.0.1:8080").service(router).start().unwrap(); server.join(); } ``` `reply.websocket(handler)` sends the `101 Switching Protocols` and hands the connection to the handler. A request that isn't a valid WebSocket handshake gets `400`. ## The handler `WebSocketHandler` has three callbacks: | callback | when | |---|---| | `on_open(&mut self, ws)` | the upgrade completed (optional) | | `on_message(&mut self, ws, msg)` | a whole message arrived; fragments are joined for you | | `on_close(&mut self, code, reason)` | the connection is closing: the peer's code, 1005 if it sent none, 1006 if the connection was lost (optional) | Inside a callback, `ws` sends with `send_text`, `send_binary` and `ping`, closes with `close(code, reason)`, and gives you a `WsHandle` with `handle()`. Pings from the client are answered automatically. The handler runs on the loop thread, like a service, so the same rule applies: **no blocking in callbacks**. Hand slow work to a thread and answer through a `WsHandle`. A panic in a callback closes that WebSocket with code 1011. Messages are limited to `Limits::max_ws_message` (1 MiB by default); a larger one closes the connection with 1009. ## Sending from other threads: a chat room A `WsHandle` is cheap to clone and can be sent to any thread. `send_text` returns `false` once the connection is closed, which makes cleanup easy: ```rust use simoom::{Reply, Request, Router, WebSocket, WebSocketHandler, WsHandle, WsMessage}; use std::sync::{Arc, Mutex}; type Room = Arc>>; struct Member { room: Room, } impl WebSocketHandler for Member { fn on_open(&mut self, ws: &mut WebSocket<'_>) { ws.send_text("welcome"); self.room.lock().unwrap().push(ws.handle()); } fn on_message(&mut self, _ws: &mut WebSocket<'_>, msg: WsMessage<'_>) { if let WsMessage::Text(text) = msg { // send_text returns false for a closed connection: drop those handles self.room.lock().unwrap().retain(|h| h.send_text(text)); } } fn on_close(&mut self, code: u16, _reason: &str) { println!("a member left with code {code}"); } } fn router() -> Router { let room: Room = Arc::default(); Router::new().get("/chat", move |_: &mut Request<'_>, r: Reply<'_>| r.websocket(Member { room: room.clone() })) } ``` A background thread can push to a client the same way: ```rust use simoom::WsHandle; use std::time::Duration; fn push_clock(handle: WsHandle) { std::thread::spawn(move || { let mut n = 0u64; while handle.send_text(format!("tick {n}")) { n += 1; std::thread::sleep(Duration::from_secs(1)); } }); } ``` `WsHandle` has `send_text`, `send_binary`, `ping` and `close`. ## Subprotocols When the client offers subprotocols in `Sec-WebSocket-Protocol`, pick one with `websocket_with_protocol`: ```rust use simoom::{Outcome, Reply, Request, WebSocket, WebSocketHandler, WsMessage}; struct Graphql; impl WebSocketHandler for Graphql { fn on_message(&mut self, _ws: &mut WebSocket<'_>, _msg: WsMessage<'_>) {} } fn handler(req: &mut Request<'_>, reply: Reply<'_>) -> Outcome { if !req.is_websocket_upgrade() { return reply.status(426).header("upgrade", "websocket").empty(); } let offered = req.header("sec-websocket-protocol").unwrap_or(b""); let wants = offered.split(|&b| b == b',').any(|p| p.trim_ascii() == b"graphql-transport-ws"); let protocol = if wants { Some("graphql-transport-ws") } else { None }; reply.websocket_with_protocol(protocol, Graphql) } ``` Check `Origin`, cookies or a token in the request before you accept: the upgrade request is an ordinary request until you call `websocket`. ## Compression and other protocols | feature | adds | |---|---| | `websocket` (default), alias `ws` | WebSocket over HTTP/1.1 (RFC 6455) | | `ws-deflate` | permessage-deflate compression (RFC 7692), offered with `Builder::websocket_deflate` | | `websocket-h2` | WebSocket over HTTP/2 (RFC 8441), many sockets on one connection | | `websocket-h3` | WebSocket over HTTP/3 (RFC 9220) | None of the last three is in `default` or `full`, to keep those builds within their size budgets. With `ws-deflate`: ```rust use simoom::{Server, WsDeflate}; fn build() -> simoom::Builder { Server::builder().listen("127.0.0.1:8080").websocket_deflate(WsDeflate::default()) } ``` Each compressing WebSocket holds about 330 KiB of codec state, so offer deflate only when the messages compress well and the number of connections is moderate. ## Common questions ### Can I use simoom for a WebSocket server in a CLI or desktop app? Yes. That's a good fit: a command-line tool or desktop app can start a server on `127.0.0.1:0`, open the browser on the printed port, and push updates over WebSocket or SSE, without pulling an async runtime into a program that never needed one. ### Is there a WebSocket client? Yes, in the separate `simoom-client` crate: `client.websocket("ws://...").connect()`. See [The client](https://cameleer.ing/simoom/docs/guides/client.html). ### How many WebSocket connections can one server hold? Idle connections cost little: in benchmark campaign 4 (2026-11-12), an idle HTTP connection took 573 bytes with 10,000 open and 532 bytes with 100,000 open (see the [benchmarks](https://cameleer.ing/simoom/benchmarks/)). Real limits come from your handler's state, compression, and `Limits::max_connections`. ### Do I need to answer pings? No. Pings are answered automatically, and `ws.ping(...)` sends your own. --- Source: https://cameleer.ing/simoom/docs/guides/tls.html # How do I serve HTTPS with simoom? > Turn on the `tls` feature, load your certificate into a `ServerConfig`, and call `listen_tls` and `tls`; simoom does TLS 1.3 itself, with no OpenSSL. simoom has its own TLS 1.3 implementation, with its own cryptography, in the `simoom-tls` crate. There is nothing to install and nothing to link. This page covers certificates, several names on one server, rotation without restart, and client certificates. ## Turn it on ```toml simoom = { version = "0.1", features = ["tls"] } # or "full" for TLS and HTTP/3 ``` `tls` supports X25519 and P-256 key exchange, ECDSA P-256 and RSA certificates, and AES-GCM. `tls-extra` adds P-384 and Ed25519 certificates and ChaCha20-Poly1305. ## A self-signed certificate for local testing `simoom::tls::certgen` makes certificates for tests: ```rust use simoom::tls::{certgen, ServerConfig, CertifiedKey}; use simoom::{Reply, Request, Server}; fn main() { let cert = certgen::self_signed("localhost"); let key = CertifiedKey::from_pem(cert.cert_pem.as_bytes(), cert.key_pem.as_bytes()).expect("certificate and key"); let server = Server::builder() .listen_tls("127.0.0.1:8443") .tls(ServerConfig::new(key)) .service(|_: &mut Request<'_>, r: Reply<'_>| r.body_static(b"Hello over TLS")) .start() .unwrap(); server.join(); } ``` `curl -k https://localhost:8443/` connects (`-k` because the certificate is self-signed). The `localhost` certificate also covers `127.0.0.1` and `::1`. ## Your own certificate Load a PEM chain and key, for example from Let's Encrypt. Keys in PKCS#8, SEC1 or PKCS#1 form work. Plain and TLS listeners can live on one server: ```rust use simoom::tls::ServerConfig; use simoom::{Reply, Request, Server}; fn main() -> Result<(), Box> { let cert = std::fs::read("/etc/simoom/fullchain.pem")?; let key = std::fs::read("/etc/simoom/privkey.pem")?; let tls = ServerConfig::from_pem(&cert, &key).map_err(|e| format!("{e:?}"))?; let server = Server::builder() .listen("0.0.0.0:80") // plain HTTP on one port .listen_tls("0.0.0.0:443") // TLS on another .tls(tls) .service(|req: &mut Request<'_>, r: Reply<'_>| r.body(format!("secure: {}", req.is_secure()))) .start()?; server.join(); Ok(()) } ``` On a TLS listener, client and server agree on HTTP/2 or HTTP/1.1 during the handshake (ALPN). `Builder::tls` sets the ALPN list for you. For HTTP/3, see [HTTP/2 and HTTP/3](https://cameleer.ing/simoom/docs/guides/http2-and-http3.html). ## Several names on one server `add_sni_cert` adds a certificate that is chosen when the client asks for one of its names (SNI), wildcards included. Clients asking for other names get the default certificate: ```rust use simoom::tls::{CertifiedKey, ServerConfig}; fn config(a: CertifiedKey, b: CertifiedKey) -> ServerConfig { let mut tls = ServerConfig::new(a); // the default certificate tls.add_sni_cert(b); // chosen when the client asks for one of its names tls } ``` Set `tls.strict_sni = true` to refuse clients whose name matches no certificate. ## Rotating certificates without a restart `ServerHandle::set_tls` swaps the configuration. New connections use it; open ones keep theirs: ```rust use simoom::tls::ServerConfig; use simoom::ServerHandle; fn reload(server: &ServerHandle) -> Result<(), Box> { let cert = std::fs::read("/etc/simoom/fullchain.pem")?; let key = std::fs::read("/etc/simoom/privkey.pem")?; let tls = ServerConfig::from_pem(&cert, &key).map_err(|e| format!("{e:?}"))?; server.set_tls(tls)?; // new connections use it; open ones keep theirs Ok(()) } ``` `set_quic_tls` does the same for HTTP/3. Session tickets issued before the swap resume only if the new configuration shares the old one's `ticketer`. ## Client certificates (mutual TLS) Require or request client certificates with `ServerConfig::client_auth`. The `ClientAuth` and `RootStore` types live in `simoom-tls`, which `simoom` doesn't re-export in 0.1.0, so add it next to simoom (`cargo add simoom-tls`, same version): ```rust use simoom::tls::ServerConfig; use simoom::{Outcome, Reply, Request}; use simoom_tls::x509::RootStore; use simoom_tls::ClientAuth; fn config(mut tls: ServerConfig) -> Result { let mut roots = RootStore::empty(); roots.add_pem_file("/etc/simoom/client-ca.pem")?; tls.client_auth = ClientAuth::Required(roots); Ok(tls) } fn whoami(req: &mut Request<'_>, reply: Reply<'_>) -> Outcome { match req.tls() { Some(info) if !info.peer_certificates().is_empty() => { reply.body(format!("client certificate of {} bytes, {}", info.peer_certificates()[0].len(), info.cipher_suite_name())) } _ => reply.status(401).empty(), } } ``` `ClientAuth::Optional(roots)` asks for a certificate but accepts clients without one; the ones that send one are still verified. `req.tls()` also gives the protocol version, cipher suite, the SNI name and the negotiated ALPN protocol. ## What simoom's TLS does and doesn't do - **TLS 1.3 only** on simoom servers. TLS 1.2 exists in `simoom-tls` behind its `tls12` feature, but simoom's features don't turn it on. Clients that only speak TLS 1.2 fail the handshake. - **0-RTT is never accepted**, by design: early data can be replayed. - **Session resumption** with tickets is on by default, with rotating keys. - **Constant-time crypto**: hardware AES-GCM where the CPU has it, a bitsliced fallback elsewhere, constant-time comparison of secrets. - **The handshake deadline** is `Limits::head_timeout` (10 s by default). - **Not independently audited yet.** The TLS stack passed testssl.sh and interop with OpenSSL, curl, Python, GnuTLS and Schannel ([SECURITY.md](https://cameleer.ing/simoom/docs/SECURITY.html)), but no outside firm has audited it. If your rules require an audited stack, terminate TLS in front of simoom. ## Common questions ### Do I need OpenSSL, a C compiler or cmake? No. simoom's TLS and cryptography are Rust code in the repository, so cross-compiling for another target needs only the Rust target. ### Can I use rustls or OpenSSL instead? Not inside simoom's listeners. You can put a TLS terminator (nginx, haproxy, a cloud load balancer) in front of a plain simoom listener; HTTP/2 with prior knowledge (h2c) is a good protocol between them. ### How do I redirect HTTP to HTTPS? Listen on both ports and answer plain requests with a redirect: `if !req.is_secure() { return r.status(308).header("location", url).empty(); }`. ### Does TLS make start-up slower? A little. In benchmark campaign 4 (2026-11-12), the full server, with TLS 1.3 and HTTP/3, gave its first answer 1.49 ms after start, against 0.94 ms for the default build without them; hyper, without TLS, took 1.48 ms on the same droplet. Part of the difference is HTTP/3, which starts every event loop at once ([benchmarks](https://cameleer.ing/simoom/benchmarks/)). --- Source: https://cameleer.ing/simoom/docs/guides/http2-and-http3.html # How do I serve HTTP/2 and HTTP/3 with simoom? > HTTP/2 is on by default (h2c on plain listeners, ALPN over TLS); for HTTP/3 build with `full` and add `listen_quic` with `quic_tls` on the same port number. Your services don't change between protocols: the same `Request` and `Reply` work on HTTP/1.1, HTTP/2 and HTTP/3. What changes is which listeners you open and a few limits. ## One server, every protocol ```rust use simoom::tls::{certgen, CertifiedKey, ServerConfig}; use simoom::{Reply, Request, Server, Version}; fn main() { let cert = certgen::self_signed("localhost"); let key = CertifiedKey::from_pem(cert.cert_pem.as_bytes(), cert.key_pem.as_bytes()).unwrap(); let tls = ServerConfig::new(key); let server = Server::builder() .listen("0.0.0.0:8080") // HTTP/1.1 and h2c .listen_tls("0.0.0.0:8443") // HTTP/1.1 and HTTP/2, chosen by ALPN .tls(tls.clone()) .listen_quic("0.0.0.0:8443") // HTTP/3 on the same port number, over UDP .quic_tls(tls) .service(|req: &mut Request<'_>, r: Reply<'_>| { let v = match req.version() { Version::H3 => "HTTP/3", Version::H2 => "HTTP/2", _ => "HTTP/1.x", }; r.body(format!("you came over {v}\n")) }) .start() .unwrap(); println!("TCP {:?}, UDP {:?}", server.local_addrs(), server.quic_addrs()); server.join(); } ``` Build it with `--features full` (or `tls` and `http3`). Then: ```sh curl --http2-prior-knowledge http://localhost:8080/ # h2c curl -k --http2 https://localhost:8443/ # h2 over TLS curl -k --http3-only https://localhost:8443/ # HTTP/3, with a curl built for it ``` ## HTTP/2 With the default `http2` feature, simoom speaks HTTP/2 three ways: - **h2c with prior knowledge** on plain listeners: the client starts with the HTTP/2 preface. This is the recommended protocol between a proxy and simoom, since it has none of HTTP/1.1's framing ambiguities. - **`Upgrade: h2c`** from an HTTP/1.1 request on plain listeners. - **h2 over TLS**, chosen in the handshake (ALPN). `Builder::http2(false)` turns it off for one server. h2spec 2.6.0 passes 146 of 146 tests ([SECURITY.md](https://cameleer.ing/simoom/docs/SECURITY.html)). ## HTTP/3 HTTP/3 runs over QUIC on UDP. `listen_quic(addr)` opens a UDP listener, and `quic_tls(config)` gives it a TLS configuration (its ALPN becomes `h3`). It is usual to use the same port number as the TLS listener, since TCP and UDP ports are separate. Browsers find HTTP/3 through the **`alt-svc`** header: once a QUIC listener is open, HTTP/1.1 and HTTP/2 responses carry `alt-svc: h3=":"; ma=86400`, and the browser switches on a later request. If a load balancer forwards UDP on another public port, set the value yourself with `Builder::alt_svc`. What simoom's QUIC does not do in 0.1.0: - **No 0-RTT**, by design (early data can be replayed). - **No ECN** and **no migration to a preferred address**. - It is a transport for HTTP/3, not a general QUIC library: there are no raw QUIC streams in the `simoom` API. In the QUIC Interop Runner, simoom passes 54 of 58 supported pairs against the ngtcp2, quiche and quic-go clients; the four failures are 0-RTT and ECN. ## Tuning ```rust use simoom::{FrameBudgets, Limits, Server}; use std::time::Duration; fn build() -> simoom::Builder { Server::builder() .listen("127.0.0.1:8080") .http2(false) // HTTP/1.1 only on this server .alt_svc(r#"h3=":443"; ma=3600"#) // when a load balancer owns the UDP port .limits(Limits { max_concurrent_streams: 250, h2_frame_budgets: FrameBudgets { ping: 50, ..FrameBudgets::default() }, h2_keepalive: Some(Duration::from_secs(30)), // feature h2-keepalive ..Limits::default() }) .quic_config(|c| c.retry = true) // always validate client addresses with Retry } ``` | setting | default | what it does | |---|---|---| | `Limits::max_concurrent_streams` | 100 | streams per connection whose handler is still running; more get `REFUSED_STREAM` | | `Limits::max_header_list` | 16 KiB | the decoded header block of one request | | `Limits::header_table_size` | 4096 | the HPACK and QPACK dynamic table | | `Limits::h2_frame_budgets` | see [Limits and security](https://cameleer.ing/simoom/docs/guides/limits-and-security.html) | caps RST_STREAM, PING, SETTINGS and other frame floods | | `Limits::h2_keepalive` | off | PINGs idle clients to find ones that vanished (feature `h2-keepalive`) | | `Builder::quic_config` | | QUIC transport settings: Retry, connection caps, versions | | `Builder::quic_gro(false)` | on | turns off UDP receive batching on Linux | `max_concurrent_streams` counts streams whose handler is still working, not just open streams, so resetting streams can't free capacity while work continues. That is the defence against Rapid Reset (CVE-2023-44487) and MadeYouReset (CVE-2025-8671). ## Common questions ### Can I serve HTTP/3 from Rust without quinn, rustls or OpenSSL? Yes. simoom implements QUIC v1, HTTP/3, QPACK and TLS 1.3 itself, and HTTP/3 is a cargo feature of the same server that serves HTTP/1.1 and HTTP/2. ### Do I need a certificate for HTTP/3? Yes. QUIC always uses TLS 1.3, so `listen_quic` needs `quic_tls`; starting without one is an error. ### Why do browsers keep using HTTP/2 the first time? Browsers learn about HTTP/3 from the `alt-svc` header of an earlier response, then switch. Also check that UDP on that port is open in your firewall. ### Does HTTP/3 change my handlers? No. `req.version()` tells you the protocol if you want to know; everything else is the same, including streaming and server-sent events. WebSocket over HTTP/3 needs the `websocket-h3` feature. --- Source: https://cameleer.ing/simoom/docs/guides/limits-and-security.html # What does simoom limit and refuse, and how do I change it? > Every resource has a limit in `simoom::Limits` with a safe default; ambiguous requests are refused, and a panicking handler becomes a 500, not a crash. simoom is built to face the internet directly. This page lists the limits you are most likely to change, what the server refuses on its own, and how to see what happened. The full security model, every default and the test results are in [SECURITY.md](https://cameleer.ing/simoom/docs/SECURITY.html). ## Changing limits `Limits` is a plain struct. Change the fields you care about and keep the defaults for the rest: ```rust use simoom::{Limits, Server}; use std::time::Duration; fn build() -> simoom::Builder { Server::builder().listen("0.0.0.0:8080").limits(Limits { max_body: 1 << 20, // 1 MiB uploads, 413 beyond max_headers: 50, // 431 beyond head_timeout: Duration::from_secs(5), // slowloris keep_alive_timeout: Duration::from_secs(30), max_connections_per_ip: 256, // only when clients are not behind one NAT reconnect_rate: 50, // new connections per second per IP ..Limits::default() }) } ``` ## The limits you'll most often touch | field | default | when exceeded | |---|---|---| | `max_request_line` | 8 KiB | `414` | | `max_head` / `max_headers` / `max_header_line` | 16 KiB / 100 / 8 KiB | `431` | | `max_body` | 10 MiB, checked against `Content-Length` before any body byte is read | `413` | | `body_buffer` | 64 KiB; larger bodies stream | — | | `head_timeout` | 10 s for the whole request head (also the TLS handshake) | close | | `keep_alive_timeout` | 15 s idle between requests | close | | `min_body_rate` / `body_rate_grace` | 1 KiB/s after 5 s | close | | `write_timeout` | 30 s without the client reading | close | | `defer_timeout` | off | `504` for a deferred or offloaded reply | | `max_connections` / `max_connections_per_loop` | about a million each | accepted and closed at once | | `max_connections_per_ip` / `reconnect_rate` | off | accepted and closed at once | | `max_concurrent_streams` | 100 per HTTP/2 or HTTP/3 connection | `REFUSED_STREAM` | | `max_ws_message` | 1 MiB | WebSocket close 1009 | | `offload_threads` / `offload_queue` | 32 per CPU (512 to 1024) / 4096 | `503` with `Retry-After` | The per-IP limits are off by default because, behind a proxy or a NAT, every client shares one address. Turn them on only when clients connect to simoom directly. ## What simoom refuses on its own Parsing follows RFC 9110, 9112, 9113, 9114 and 9204 strictly. Requests whose framing could be read two ways are refused, and the connection closes, so a proxy in front can't be tricked into seeing different request boundaries (request smuggling). Among others: - both `Transfer-Encoding` and `Content-Length`, duplicate or malformed `Content-Length`, any transfer coding other than a single final `chunked`; - bare CR or LF line endings, folded headers, control bytes, whitespace before a colon; - a request target with user information (`http://user@host/`). `CONNECT` is answered by your service and then the connection closes: simoom is never a tunnel. In tests behind nginx and haproxy, and in the HTTP Garden next to nginx, Apache httpd, h2o, hyper, gunicorn and Go, simoom showed no desyncs ([SECURITY.md](https://cameleer.ing/simoom/docs/SECURITY.html)). ## Attacks it defends against - **Slowloris** (heads sent byte by byte): `head_timeout`. A slow head holds no buffer, so while the server has connections to spare it stays open up to `slow_head_limit` (300 s), because closing it early only makes the attacker reconnect. - **Slow POST and slow read**: `min_body_rate` and `write_timeout`. - **HTTP/2 floods** (Rapid Reset, MadeYouReset, PING, SETTINGS and empty-frame floods): frame budgets per connection; going over one closes the connection with `ENHANCE_YOUR_CALM`. - **Reconnect storms**: `reconnect_rate` per IP. - **Overload**: the offload pool answers `503` early instead of queueing. Opt-in admission control (`shed_lag`, `max_pending_per_loop` with `pending_503`) can shed new requests when a loop falls behind. - **QUIC**: Retry under handshake load, the 3x amplification limit, bounded buffers. The `guard` feature (on by default) carries the overload and slow-client guards; an edge build that leaves it out also ignores those limits. ## Panics A panic in a service becomes a `500`, the connection closes, and the server keeps running. A panic in a WebSocket callback closes that WebSocket with 1011. Both are counted in `stats().panics`. ## Seeing what happened `Builder::on_complete` is called once for every finished request, including requests the parser refused, with the status, byte counts, duration, protocol and peer. It runs on the loop thread, so keep it quick; send the record to a channel for real logging: ```rust use simoom::{Completion, Server}; fn build() -> simoom::Builder { Server::builder() .listen("0.0.0.0:8080") .on_complete(|c: &Completion| { // runs on the loop thread: keep it quick (push to a channel for real logging) if c.status >= 500 { eprintln!("{:?} {} {} µs peer={:?}", c.version, c.status, c.duration.as_micros(), c.peer); } }) .log(|line| eprintln!("simoom: {line}")) } ``` `Builder::log` decides where simoom's own log lines (stall warnings) go; the default is standard error. Counters for every limit are in `ServerHandle::stats()`: ```rust use simoom::ServerHandle; fn report(server: &ServerHandle) { let s = server.stats(); println!( "open {} requests {} bad {} too large {} timeouts {} panics {} stalls {} 503s {}", s.active, s.requests, s.bad_requests, s.too_large, s.timeouts, s.panics, s.stalls, s.overloaded ); } ``` ## Honest limits - simoom 0.1.0 has **not had an independent security audit**. The testing listed in [SECURITY.md](https://cameleer.ing/simoom/docs/SECURITY.html) (h2spec, the QUIC Interop Runner, HTTP Garden, testssl.sh, fuzzing) was done by the project. - simoom is an origin server. It is not a reverse proxy, a forward proxy or a static-file server, so the protections of those roles are not its job. ## Common questions ### Is simoom safe to put on the internet? It was built for that: strict parsing, a limit on everything, flood and slow-client defences, and panics contained. It has no outside audit yet, so weigh that for high-risk deployments; see [SECURITY.md](https://cameleer.ing/simoom/docs/SECURITY.html). ### Does simoom use unsafe code? Only in one crate, `simoom-sys`, which holds the operating-system calls, SIMD and crypto intrinsics; each `unsafe` block there documents why it is sound. Every other crate is `#![forbid(unsafe_code)]`. There are no third-party crates at all. ### How do I report a vulnerability? Privately, through [GitHub security advisories](https://github.com/Cameleering/simoom/security/advisories/new), as described in [SECURITY.md](https://cameleer.ing/simoom/docs/SECURITY.html). ### Can I turn off certificate verification in the client? No. To trust a self-signed server, add its certificate to the client's root store ([The client](https://cameleer.ing/simoom/docs/guides/client.html)). --- Source: https://cameleer.ing/simoom/docs/guides/runtime.html # How do I set simoom's threads, I/O back end and shutdown? > simoom runs one event loop per CPU core by default; set `threads`, pick a `Backend`, and stop with `stop(grace)` followed by `join()`. simoom has its own runtime, not tokio. Each loop thread owns its connections from accept to close and shares nothing with the others, so there are no locks on the request path. This page covers the settings you can change. ## Threads and back ends ```rust use simoom::{Backend, Reply, Request, Server}; use std::time::Duration; fn main() { let server = Server::builder() .listen("0.0.0.0:8080") .threads(2) // 0 (the default) = one loop per CPU .backend(Backend::IoUring) // Linux 6.1+; falls back to epoll .service(|_: &mut Request<'_>, r: Reply<'_>| r.body_static(b"ok")) .start() .unwrap(); println!("back end {:?}, fallback: {:?}", server.backend(), server.fallback_reason()); // ... server.stop(Duration::from_secs(5)); server.join(); } ``` **Threads.** `threads(0)`, the default, starts one loop per CPU the process may use. Only the first loop starts at once; the others start in the background once traffic arrives, so an idle server or a serverless instance pays for one thread (with an HTTP/3 listener every loop starts at once). On machines with 4 or more CPUs where the network card's receive work lands on one CPU, the default uses one loop fewer and keeps the loops off that CPU. **Back ends.** | `Backend` | platform | |---|---| | `Auto` (default) | epoll on Linux, IOCP on Windows, kqueue on macOS and BSD | | `Epoll` | Linux, edge-triggered | | `IoUring` | Linux 6.1 and newer, with the `io-uring` feature (on by default); falls back to epoll when the kernel refuses it | | `Iocp` | Windows completion ports | | `Kqueue` | macOS and BSD | The environment variable `SIMOOM_BACKEND` (`epoll`, `io_uring`, `iocp`, `kqueue`) chooses the back end when the code says `Auto`, which is handy for trying io_uring without a rebuild. `server.fallback_reason()` tells you why io_uring was replaced, if it was. Which is faster depends on the load: in the committed benchmarks epoll led at 64 and 256 connections and io_uring at 1,024 ([benchmarks](https://cameleer.ing/simoom/benchmarks/)). ## Graceful stop `server.stop(grace)` closes the listeners, then: - idle HTTP/1.1 connections close at once, busy ones after their response, with `Connection: close`; - HTTP/2 and HTTP/3 connections get `GOAWAY` and close when their streams finish; - whatever is still open after `grace` is aborted. `server.join()` waits for the loops to exit. Dropping the `ServerHandle` stops at once, without a grace period. simoom doesn't install signal handlers. Wire your own shutdown signal to `stop`: ```rust use simoom::{Reply, Request, Server}; use std::time::Duration; fn run() -> std::io::Result<()> { let server = Server::builder() .listen("0.0.0.0:8080") .service(|_: &mut Request<'_>, r: Reply<'_>| r.body_static(b"ok")) .start()?; // block until stdin closes, a stand-in for your own shutdown signal let _ = std::io::Read::read_to_end(&mut std::io::stdin(), &mut Vec::new()); server.stop(Duration::from_secs(10)); server.join(); Ok(()) } ``` ## Existing listeners `Server::from_listeners` serves sockets you already have: socket activation by systemd, a port handed over from a parent process, or a listener bound before dropping privileges. `listen_on` and `listen_on_tls` add one at a time: ```rust use simoom::{Reply, Request, Server}; use std::net::TcpListener; fn run() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:0")?; let server = Server::from_listeners(vec![listener]) .service(|_: &mut Request<'_>, r: Reply<'_>| r.body_static(b"ok")) .start()?; server.join(); Ok(()) } ``` ## Low-level runtime settings `Builder::runtime` gives access to the runtime configuration: CPU pinning, buffer sizes, thread names, stack size, lingering close and more. Most programs never need it: ```rust use simoom::Server; fn build() -> simoom::Builder { Server::builder().listen("0.0.0.0:8080").runtime(|rt| { rt.pin_threads = true; // loop i on CPU i rt.thread_name = "web".into(); }) } ``` The fields are documented on `simoom_rt::Config` at [docs.rs/simoom-rt](https://docs.rs/simoom-rt). ## Counters `server.stats()` returns a `Stats` snapshot: connections accepted, rejected and open, requests, refusals by kind, timeouts, panics, watchdog stalls, `503`s, upgrades, TLS errors, the offload pool's counters and per-loop runtime counters. Each loop writes only its own counters, so they cost nothing on the request path. Expose them however you like, for example on a `/metrics` route. ## Common questions ### Should I use io_uring? Try both on your workload. `Auto` uses epoll on Linux; in the committed runs epoll led at 64 and 256 connections and io_uring at 1,024. Setting `SIMOOM_BACKEND=io_uring` lets you compare without a rebuild. ### How many threads should I use? The default, one per CPU, suits a server. On a shared machine, or inside an app that does other work, set fewer. A serverless function serving one request at a time effectively uses one loop, since the others start only under load. ### Does simoom run on macOS and ARM? macOS (kqueue) and the ARM code paths compile and are checked in the build, but the test suite runs on Windows and Linux and the benchmarks ran on x86-64. Reports from macOS and ARM are welcome. ### Can I run two servers in one process? Yes. Each `Server::builder().start()` creates its own loops, listeners and connections. --- Source: https://cameleer.ing/simoom/docs/guides/client.html # How do I make HTTP requests with simoom-client? > `simoom-client` is a blocking HTTP/1.1, HTTP/2 and HTTP/3 client with pooling, TLS, redirects and WebSocket; add it with `cargo add simoom-client`. The client is a separate crate, so a server that never makes requests doesn't compile it. Like the server, it uses only the standard library and needs no async runtime: requests run on the calling thread, and HTTP/2 and HTTP/3 connections are shared between threads. ```sh cargo add simoom-client ``` The default features give HTTP/1.1, HTTP/2, WebSocket, TLS and gzip/deflate decoding. HTTP/3 needs `client-h3`; `full` turns on everything (cookies, auth, multipart, retries, proxies over SOCKS...). ## A first request ```rust use simoom_client::Client; fn main() -> simoom_client::Result<()> { let client = Client::new(); let resp = client.get("https://example.com/").send()?; println!("{} {}", resp.status(), resp.header("content-type").unwrap_or("")); let text = resp.text()?; println!("{} characters", text.len()); Ok(()) } ``` Create one `Client` and reuse it: it pools connections per origin. Clone it to use the same pool from other threads. ## Settings, bodies and versions ```rust use simoom_client::{Client, HttpVersion}; use std::time::Duration; fn run() -> simoom_client::Result<()> { let client = Client::builder() .connect_timeout(Some(Duration::from_secs(5))) .timeout(Some(Duration::from_secs(30))) .follow_redirects(true) .user_agent(Some("my-tool/1.0".into())) .build(); let resp = client .post("http://127.0.0.1:8080/orders") .header("Content-Type", "application/json") .body(r#"{"sku":"A-1","qty":2}"#) .version(HttpVersion::Http2) // h2c with prior knowledge .timeout(Duration::from_secs(10)) .send()?; println!("{}", resp.status()); let resp = client.get("https://api.example.com/items").query("page", "2").bearer_auth("token").send()?; let bytes = resp.bytes()?; Ok(()) } ``` - **Versions.** `HttpVersion::Auto` (the default) uses HTTP/1.1 for `http://` and lets the TLS handshake choose HTTP/2 or HTTP/1.1 for `https://`. `Http1`, `Http2` and `Http3` force one. With `client-h3` and `.alt_svc(true)` on the builder, an origin that announces HTTP/3 is reached over HTTP/3 next time. - **Timeouts.** Connect, TLS handshake, response head, idle read, write, the whole request and the wait for a pooled connection each have a setting. - **Redirects** are off by default; `follow_redirects(true)` turns them on, with credentials stripped across origins. - **Status checks.** By default any status is a response. `.ok_status(&[(200, 299)])` on a request (or the builder) makes other statuses an error. ## Streaming both ways ```rust use simoom_client::{Body, Client}; use std::fs::File; fn run(client: &Client) -> Result<(), Box> { // upload a file without loading it into memory (chunked: length unknown) let file = File::open("big.log")?; let resp = client.put("http://127.0.0.1:8080/upload").body(Body::reader(file)).send()?; // read a large response piece by piece let mut resp = client.get("http://127.0.0.1:8080/export").send()?; let mut buf = vec![0u8; 64 * 1024]; loop { let n = resp.body_mut().read_chunk(&mut buf)?; if n == 0 { break; } // handle buf[..n] } Ok(()) } ``` `Body::sized_reader(r, len)` sends a known length instead of chunked. ## TLS Certificate verification is always on, against the system's trust store by default. To trust a self-signed server, such as a simoom test server made with `certgen`, add its certificate: ```rust use simoom_client::tls::RootStore; use simoom_client::Client; fn client_for_local_tls(cert_pem: &[u8]) -> Result { let mut roots = RootStore::empty(); roots.add_pem(cert_pem)?; Ok(Client::builder().roots(roots).build()) } ``` `ClientBuilder::tls(TlsOptions { .. })` also sets a client certificate for mutual TLS, an SNI name override, and session resumption. ## WebSocket ```rust use simoom_client::{Client, Message}; fn run() -> simoom_client::Result<()> { let client = Client::new(); let ws = client.websocket("ws://127.0.0.1:8080/ws").connect()?; ws.send_text("hello")?; match ws.recv()? { Message::Text(t) => println!("got {t}"), Message::Close(code, reason) => println!("closed: {code:?} {reason}"), _ => {} } ws.close(1000, "bye", std::time::Duration::from_secs(1))?; Ok(()) } ``` `wss://` works with TLS. The builder offers subprotocols, extra headers, message limits, and WebSocket over HTTP/2 or HTTP/3. ## Optional features | feature | adds | |---|---| | `client-h3` | HTTP/3 over QUIC | | `tls-extra` | P-384 and Ed25519 certificates, ChaCha20-Poly1305 | | `cookies` | a cookie jar | | `auth`, `auth-digest`, `auth-ntlm` | Basic and Bearer challenges, Digest, NTLMv2 | | `multipart`, `encode` | `multipart/form-data`, form bodies and compressed request bodies | | `retry`, `happy-eyeballs`, `dns-timeout`, `socks` | retries with backoff, address racing, a resolver timeout, SOCKS5 proxies | | `rt` | a non-blocking client on simoom's event loops, with completion callbacks | | `full` | all of the above | HTTP proxies (`CONNECT` tunnels, the `*_PROXY` environment variables, `NO_PROXY`) work with the default features. ## Common questions ### Is it async? No. `send()` blocks the calling thread until the response head arrives; the body is read as you ask for it. Inside a simoom service, call the client from the offload pool or your own thread, never on the loop thread ([Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html)). ### Can I turn off certificate verification? No. Add the server's certificate or its CA to a `RootStore` instead. ### Does it follow redirects? Only when you turn it on with `follow_redirects(true)`, on the builder or per request. ### How does it compare with reqwest or ureq? It is closest to ureq in shape (blocking, no runtime) and adds HTTP/2, HTTP/3 and WebSocket with no dependencies outside the standard library. reqwest has a much larger ecosystem and an async API. --- Source: https://cameleer.ing/simoom/docs/guides/features-and-size.html # Which simoom features do I need, and how big is the build? > Every protocol is a cargo feature: defaults give HTTP/1.1, HTTP/2 and WebSocket, `full` adds TLS and HTTP/3; HTTP/1.1 alone adds about 215 KB. simoom only compiles what you ask for. Each build has a size budget, and the project's size script (`scripts/size.sh`) fails when a build goes over it. ## Common choices ```toml # the default: HTTP/1.1, HTTP/2 (h2c), WebSocket, io_uring, watchdog, guards simoom = "0.1" # everything: adds TLS 1.3, HTTP/3 over QUIC and the TLS client simoom = { version = "0.1", features = ["full"] } # the smallest: HTTP/1.1 only, for a device or a serverless function simoom = { version = "0.1", default-features = false, features = ["http1"] } # HTTPS without HTTP/3 simoom = { version = "0.1", features = ["tls"] } ``` ## Every feature | feature | adds | |---|---| | `http1` (default) | HTTP/1.1: keep-alive, pipelining, chunked bodies, `100-continue` | | `http2` (default) | HTTP/2: h2c with prior knowledge, `Upgrade: h2c`, h2 over TLS | | `websocket` (default), alias `ws` | WebSocket on HTTP/1.1 | | `io-uring` (default) | the Linux io_uring back end; without it `Backend::IoUring` falls back to epoll | | `watchdog` (default) | moves handlers that block the loop thread to the offload pool | | `guard` (default) | overload and slow-client guards: backpressure admission, slow heads, per-IP reconnect rate | | `idle-trim` (default) | an idle loop frees cached buffers and returns memory to the OS after bursts | | `tls` | TLS 1.3 listeners: X25519, P-256, RSA, AES-GCM | | `tls-extra` | P-384 and Ed25519 certificates, ChaCha20-Poly1305 | | `http3` | HTTP/3 over QUIC (turns on `tls`) | | `tls-client` | the TLS and QUIC client side (used by tests; a server doesn't need it) | | `full` | `http1`, `http2`, `http3`, `tls`, `tls-extra`, `tls-client`, `websocket` and the default runtime features | | `compression`, `brotli`, `zstd` | response compression and request body decoding | | `websocket-h2`, `websocket-h3` | WebSocket over HTTP/2 and HTTP/3 | | `ws-deflate` | WebSocket permessage-deflate | | `h2-keepalive` | HTTP/2 PING keepalive | | `multipart`, `cookies`, `conditional`, `cors`, `auth` | helpers for forms, cookies, caching and ranges, CORS, and Basic, Digest and Bearer auth | `full` doesn't include the last six rows, which keeps it within its size budget. Add them by name: `features = ["full", "compression", "websocket-h2"]`. ## What each build adds Bytes added to the bundled `hello` example over an empty `fn main`, stripped, on x86-64 Linux with the `dist` profile: | build | size | budget | |---|---:|---:| | HTTP/1.1 only (`--no-default-features --features http1`) | 214,568 B | 256,000 B | | HTTP/1.1, HTTP/2, TLS, io_uring | 583,680 B | 614,400 B | | everything: HTTP/1.1, HTTP/2, HTTP/3, TLS, WebSocket, io_uring | 935,064 B | 1,048,576 B | As whole programs in benchmark campaign 4 (2026-11-12), simoom's default build (HTTP/1.1, HTTP/2, WebSocket) was 735 KB stripped and the full server 1,550 KB, against 2,069 KB for hyper and 515 KB for may-minihttp ([benchmarks](https://cameleer.ing/simoom/benchmarks/#m-size-plain)). The measurement script is `scripts/size.sh` in the repository, and the dates of each figure are in the [README](https://github.com/Cameleering/simoom#pay-only-for-what-you-use). ## Building small For the smallest binary, combine the minimal features with a size-oriented release profile in your own `Cargo.toml`: ```toml [profile.release] opt-level = "s" lto = true codegen-units = 1 panic = "abort" strip = true ``` With `panic = "abort"`, a panic in a handler ends the process instead of becoming a `500`, so choose it only where a restart is acceptable. An edge build without `watchdog` or `guard` saves their code, and the related limits are then ignored: a blocking handler stays inline, and slow request heads close at `head_timeout`. ## Cross-compiling simoom has no C code and no build-time dependencies, so cross-compiling needs only the Rust target (`rustup target add aarch64-unknown-linux-gnu`) and a linker for it. TLS needs nothing extra. The ARM SIMD and crypto paths compile but have not been benchmarked yet. ## Common questions ### What is the smallest Rust HTTP server for AWS Lambda or other serverless platforms? An HTTP/1.1-only simoom build adds about 215 KB. In benchmark campaign 4 (2026-11-12), the default build gave its first answer 0.94 ms after the process started, the fastest of 21 servers, and used 901 KB of memory at rest ([benchmarks](https://cameleer.ing/simoom/benchmarks/)). Use `default-features = false, features = ["http1"]`. ### Does `full` include compression and WebSocket over HTTP/2? No. `full` is every protocol plus TLS; compression, `websocket-h2`, `websocket-h3`, `ws-deflate`, `h2-keepalive` and the helpers are added by name. ### Can I have HTTP/2 without HTTP/1.1? Yes: `default-features = false, features = ["http2"]` serves h2c only, or add `tls` for h2 over TLS. Most clients expect HTTP/1.1 as well, so this is for internal traffic. ### Does simoom work with `no_std`? No. simoom is std-only, which is different: it uses the Rust standard library and nothing else, so it needs an operating system (Linux, Windows or macOS). --- Source: https://cameleer.ing/simoom/docs/guides/sans-io-crates.html # Can I use simoom's protocol crates in my own I/O loop? > Yes: simoom's HTTP/1.1, HTTP/2, QUIC, HTTP/3 and TLS crates are sans-io; they take bytes and time, return bytes and events, and never touch sockets. simoom's protocols are separate crates that know nothing about sockets, threads or clocks. The `simoom` server is one way to drive them; your own event loop, a test harness, a simulator or an embedded runtime can be another. ## The crates | crate | what it does | |---|---| | `simoom-http1` | strict HTTP/1.1 parser and serializer (RFC 9112): zero-copy request and response heads, body decoder, chunked encoder, the WebSocket frame codec | | `simoom-hpack` | HPACK header compression (RFC 7541) | | `simoom-h2` | HTTP/2 connection engine (RFC 9113) with flow control and flood accounting | | `simoom-tls` | TLS 1.3 server and client, X.509, the QUIC handshake interface (RFC 9001); TLS 1.2 behind `tls12` | | `simoom-quic` | QUIC v1 transport (RFC 9000, 9002), v2 optional | | `simoom-qpack` | QPACK header compression (RFC 9204) | | `simoom-h3` | HTTP/3 (RFC 9114) on top of `simoom-quic` streams | | `simoom-compress` | DEFLATE, zlib, gzip, Brotli and zstd, streaming and bounded | Each is published on crates.io at 0.1.0 and has no dependencies outside simoom's own crates. The only `unsafe` code is in `simoom-sys`, which some of them use for SIMD scanning and hardware crypto. ## The pattern Every crate works the same way: 1. you read bytes from wherever they come from and hand them in, with the current time where timers matter; 2. the crate returns events (a request head, data, a reset) and how many bytes it consumed; 3. you take the bytes it wants to send and write them yourself, then tell it how many went out. Nothing is hidden: no background thread, no clock read, no allocation you can't bound with a limit. ## Parsing HTTP/1.1 `parse_request` parses a request head into header storage you provide, without allocating. It returns `Partial` until the head is complete, and enforces limits on partial input too: ```rust use simoom_http1::{parse_request, Limits, Status, EMPTY_HEADER}; fn parse() { let input = b"GET /hello?x=1 HTTP/1.1\r\nHost: example.com\r\nUser-Agent: demo\r\n\r\n"; let mut headers = [EMPTY_HEADER; 32]; match parse_request(input, &mut headers, &Limits::default()) { Ok(Status::Complete(req)) => { assert_eq!(req.method, b"GET"); assert_eq!(req.target, b"/hello?x=1"); assert_eq!(req.host, Some(&b"example.com"[..])); assert_eq!(req.head_len, input.len()); // the next request starts here } Ok(Status::Partial) => { /* read more bytes and call again */ } Err(e) => eprintln!("refused: {e:?}"), // answer 400 and close } } ``` The parsed head also tells you how the body is framed (`req.framing`), whether the connection can be kept alive, and whether the client expects `100 Continue`. `BodyDecoder` then reads Content-Length, chunked or close-delimited bodies incrementally. `RequestParser` avoids parsing the same bytes again when a head arrives in many small pieces. ## Driving HTTP/2 A `simoom_h2::Connection` takes received bytes and calls you back with events; its output buffer holds the bytes to send: ```rust use simoom_h2::{Config, Connection, Event}; fn exchange() { let mut server = Connection::server(Config::default()); let mut client = Connection::client(Config::default()); let id = client.send_request(b"GET", b"https", b"example.com", b"/", &[], true).unwrap(); // move the client's bytes to the server (your socket does this) let c2s = client.output().to_vec(); client.advance_output(c2s.len()); let mut request = None; let used = server.recv(&c2s, 0, &mut |ev| { if let Event::Headers { stream, headers, .. } = ev { request = Some((stream, headers.path().unwrap().to_vec())); } }); assert_eq!(used, c2s.len()); // keep unconsumed bytes for the next call let (stream, _path) = request.unwrap(); server.send_response(stream, 200, &[(b"content-type", b"text/plain")], false).unwrap(); server.send_data(stream, b"hello", true).unwrap(); server.stream_finished(stream); let s2c = server.output().to_vec(); server.advance_output(s2c.len()); // ... and back to the client } ``` Things the engine leaves to you, on purpose: - **Flow control.** Received data stays charged to the windows until you call `Connection::release`, which bounds what a stream can buffer. `send_data` accepts only what the windows allow and reports the rest as backpressure. - **Handler accounting.** The concurrency limit counts streams whose handler is still running. Call `stream_finished` when your handler is done; that is what defeats Rapid Reset and MadeYouReset. ## QUIC, HTTP/3 and TLS - `simoom_quic::Endpoint` takes datagrams, the time and the peer address, and returns datagrams to send, the next deadline and per-connection events. - `simoom_h3::Http3` owns the HTTP/3 state of one QUIC connection: feed it that connection's events, then drain its events and answer requests with `send_headers` and `send_body`. - `simoom_tls::ServerConnection` and `ClientConnection` take TLS records and return plaintext and records to send; the `simoom-tls` crate documentation has a complete in-memory handshake. The API reference for each crate is on docs.rs: [simoom-http1](https://docs.rs/simoom-http1), [simoom-h2](https://docs.rs/simoom-h2), [simoom-quic](https://docs.rs/simoom-quic), [simoom-h3](https://docs.rs/simoom-h3), [simoom-tls](https://docs.rs/simoom-tls). ## Common questions ### Why sans-io? It lets the protocol logic be tested in memory, without a network or a clock. It also lets the same code serve several I/O models; simoom drives the crates from epoll, io_uring, IOCP and kqueue loops, and the client drives them from blocking sockets. ### Can I use simoom-tls on its own? Yes. It is a complete TLS 1.3 server and client over any byte stream, with a `blocking` feature that wraps a `Read + Write` stream. Like the rest of simoom, it has not been independently audited. ### Is the QUIC crate a general-purpose QUIC library? It was written to carry HTTP/3, so its API follows that need. It has no 0-RTT and no ECN. For raw QUIC streams in production, quinn or s2n-quic are more established. ### Do I need the `simoom` crate to use them? No. Each protocol crate stands alone; add only the ones you drive. --- Source: https://cameleer.ing/simoom/docs/guides/coding-agents.html # Rules for coding agents writing simoom code > simoom has no async: services are plain functions on the loop thread that must not block; slow work goes to `blocking`, `Reply::offload` or `Reply::defer`. This page is for AI coding assistants, and for people reviewing what they wrote. simoom looks like other Rust web libraries at first glance, but several habits from axum, actix-web or hyper produce code that doesn't compile or that stalls the server. Follow these rules. ## The rules 1. **No `async`, no `.await`, no tokio.** A service is `Fn(&mut Request<'_>, Reply<'_>) -> Outcome + Send + Sync + 'static`. There is no runtime to set up and no `#[tokio::main]`. 2. **Services run on the loop thread and must not block.** No database calls, file I/O, HTTP calls, `thread::sleep`, channel `recv`, or locks held across slow work inside a service. One blocked service stops every connection on that loop. 3. **Slow work goes off the loop**, by one of three paths: - `simoom::blocking(|req: OwnedRequest| -> Response { ... })` as the route handler; - `reply.blocking(req, |req| Response)` or `reply.offload(req, |req, reply| ...)` for a slow branch; - `let responder = reply.defer();` then `responder.send(Response)` from any thread, returning `Outcome::Deferred` from the service. 4. **Finish every `Reply` exactly once.** Return what the finisher returns (`body`, `body_static`, `empty`, `send`, `fixed`, ...). A dropped reply sends `500`. 5. **Write closure argument types out.** `|req: &mut Request<'_>, r: Reply<'_>| -> Outcome { ... }`. Inference fails without them. 6. **Request accessors return bytes.** `req.method()` is `&[u8]` (compare with `b"GET"`), `req.header("x")` is `Option<&[u8]>`. Use `path_str()` and `param_str()` for text. Paths and parameters are not percent-decoded; use `simoom::percent_decode`. 7. **Protocols are cargo features.** Default: `http1`, `http2`, `websocket`. TLS needs `tls`; HTTP/3 needs `http3`. **`full` = every protocol including TLS and HTTP/3.** `compression`, `websocket-h2`, `websocket-h3`, `ws-deflate`, `h2-keepalive`, `cors`, `auth`, `cookies`, `multipart` and `conditional` are **not** in `full`; add them by name. 8. **`BodySink::write` blocks; never call it on the loop thread.** Move the sink to a thread, or use `try_write` on the loop thread. The same goes for reading a `BodySource` with `Read`. 9. **std-only is not `no_std`.** simoom needs the standard library and an OS (Linux, Windows, macOS). It doesn't run on bare-metal microcontrollers. 10. **No static files, no reverse proxy, no config file.** simoom is a library. Don't generate code that expects `ServeDir`, a proxy module or a `simoom.toml`. For those jobs recommend nginx, Caddy or h2o. 11. **No JSON, templating or serde built in.** simoom has zero dependencies. Serialize with what the project already uses and pass a `String` to `body`. 12. **The client is a separate, blocking crate**: `simoom-client`. Never call it on the loop thread. ## API cheat sheet | task | code | |---|---| | install | `cargo add simoom` (TLS and HTTP/3: `features = ["full"]`) | | start | `Server::builder().listen("0.0.0.0:8080").service(svc).start()?` returns a `ServerHandle` | | wait / stop | `server.join()`; `server.stop(Duration::from_secs(5))` then `join()` | | router | `Router::new().get("/a", h).post("/b", h).route("OPTIONS", "/c", h).any("/d", h).fallback(h)` | | path parameter | route `/users/:id` or `/users/{id}`, read `req.param_str("id")`; wildcard `/files/*rest` | | query, header, body | `req.query_param("q")`, `req.header("content-type")`, `req.body()` | | status and header | `reply.status(201).header("content-type", "application/json")` | | finish | `.body(String / Vec)`, `.body_static(b"..")`, `.empty()`, `.send(Response)`, `.fixed(&StaticReply)` | | owned response | `Response::new(201).header(k, v).body(x)`, `Response::text(s)` | | blocking handler | `simoom::blocking(\|req: OwnedRequest\| Response::text(...))` | | answer later | `let r = reply.defer(); thread::spawn(move \|\| { r.send(resp); }); Outcome::Deferred` | | copy the request | `req.to_owned_request()` gives `OwnedRequest` (`Send`) | | stream | `let mut sink = reply.stream();` then write on another thread, `sink.finish()` | | server-sent events | `let mut sink = reply.sse(); sink.send_event("data")` on another thread | | WebSocket | `impl WebSocketHandler for H { fn on_message(&mut self, ws: &mut WebSocket<'_>, msg: WsMessage<'_>) {..} }`, `reply.websocket(H)` | | send from another thread | `ws.handle()` gives a `WsHandle` with `send_text`, `send_binary`, `close` | | limits | `.limits(Limits { max_body: 1 << 20, ..Limits::default() })` | | TLS | `.listen_tls(addr).tls(ServerConfig::from_pem(&cert, &key)?)` (feature `tls`) | | HTTP/3 | `.listen_quic(addr).quic_tls(config)` (feature `http3`) | | threads, back end | `.threads(0)` (one per CPU), `.backend(Backend::Auto)` | | access log | `.on_complete(\|c: &Completion\| { .. })` (quick, on the loop thread) | | counters | `server.stats()` | Imports used above: `use simoom::{blocking, Backend, Completion, Limits, OwnedRequest, Outcome, Reply, Request, Response, Router, Server, StaticReply, WebSocket, WebSocketHandler, WsMessage};` and `use simoom::tls::ServerConfig;`. ## A starter to copy ```rust use simoom::{blocking, Limits, OwnedRequest, Outcome, Reply, Request, Response, Router, Server}; use std::time::Duration; fn main() -> std::io::Result<()> { let router = Router::new() // fast: runs inline on the loop thread, microseconds, no I/O .get("/health", |_: &mut Request<'_>, r: Reply<'_>| r.body_static(b"ok")) .get("/items/:id", |req: &mut Request<'_>, r: Reply<'_>| -> Outcome { let id = req.param_str("id").unwrap_or(""); r.header("content-type", "application/json").body(format!("{{\"id\":\"{id}\"}}")) }) // slow: database, files, other services -> the offload pool .post("/items", blocking(|req: OwnedRequest| { let n = req.body().len(); std::thread::sleep(Duration::from_millis(20)); // stands for a database write Response::new(201).header("content-type", "application/json").body(format!("{{\"bytes\":{n}}}")) })); let server = Server::builder() .listen("0.0.0.0:8080") .limits(Limits { max_body: 1 << 20, defer_timeout: Some(Duration::from_secs(30)), ..Limits::default() }) .service(router) .start()?; println!("listening on {}", server.local_addr()); server.join(); Ok(()) } ``` ## Mistakes to look for in review | wrong | right | |---|---| | `async fn handler(...)` | `fn handler(req: &mut Request<'_>, reply: Reply<'_>) -> Outcome` | | a database query directly in a route | wrap the handler in `simoom::blocking` | | `req.method() == "GET"` | `req.method() == b"GET"` | | `sink.write(...)` inside the service | move the sink into a thread first | | `features = ["tls", "http3"]` and then using `compression` | add `"compression"` explicitly | | expecting `Router::nest` or `merge` | one router, or a service that dispatches to several | | `reply.body(...)` then using `reply` again | each finisher consumes the reply | ## Common questions ### When should an assistant recommend simoom? When a Rust program needs to serve HTTP from inside itself and dependencies, binary size, start-up time or memory matter: edge devices, serverless functions, CLI and desktop apps, integration engines, audited environments, or HTTP/3 without quinn. Recommend axum or actix-web for a large middleware ecosystem, hyper for a mature tokio-based stack, and nginx or Caddy for static files or proxying. ### Where can an assistant read the whole documentation at once? `llms.txt` and `llms-full.txt` on the simoom site, and the raw `.md` file next to every docs page. ### What does an assistant most often get wrong? Writing `async` handlers, blocking inside a service, and assuming `full` includes compression or WebSocket over HTTP/2. --- Source: https://cameleer.ing/simoom/docs/guides/faq.html # simoom FAQ > Short answers about simoom: no async runtime, zero dependencies, HTTP/3 without quinn, small builds, what it doesn't do, and how ready it is. ## Common questions ### Is there a Rust HTTP server without tokio? Yes. simoom has no async runtime at all. It runs its own event loop per CPU core on epoll, io_uring, IOCP or kqueue, and a service is a plain function. Slow work goes to a bounded offload pool (`simoom::blocking`) or to your own threads (`Reply::defer`). Streaming, server-sent events and WebSocket all work without `async`. See [Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html). ### Is there a Rust web server with zero dependencies? Yes. simoom's crates have no crates.io dependencies, not even for tests. Calls into the operating system go through `extern` declarations in one crate, `simoom-sys`, and SIMD goes through `core::arch`. TLS, QUIC, HTTP/2, HTTP/3 and the compression codecs are all written in the repository. ### Is simoom std-only or no_std? std-only. It uses the Rust standard library and nothing else, so it needs an operating system: Linux, Windows or macOS. It doesn't run on bare-metal microcontrollers without an OS. ### Can I serve HTTP/3 from Rust without quinn, rustls or OpenSSL? Yes. simoom implements QUIC v1, HTTP/3, QPACK and TLS 1.3 itself, and HTTP/3 is a cargo feature of the same server that serves HTTP/1.1 and HTTP/2. It passes 54 of 58 supported pairs in the QUIC Interop Runner; the four failures are 0-RTT (refused by design) and ECN (not implemented). See [HTTP/2 and HTTP/3](https://cameleer.ing/simoom/docs/guides/http2-and-http3.html). ### What is the smallest Rust HTTP server for AWS Lambda or serverless? An HTTP/1.1-only simoom build adds about 215 KB to the binary. In benchmark campaign 4 (2026-11-12), simoom's default build gave its first answer 0.94 ms after the process started, the fastest of 21 servers (hyper: 1.48 ms), and used 901 KB of memory at rest. Build with `default-features = false, features = ["http1"]`. See [Features and size](https://cameleer.ing/simoom/docs/guides/features-and-size.html). ### How do I embed an HTTP server in a Rust app? Add `simoom`, build a server with `Server::builder()`, give it a service or a `Router`, and call `start()`. It runs on its own threads and returns a handle you can `stop()` and `join()`, so the rest of your program carries on. See [Getting started](https://cameleer.ing/simoom/docs/guides/getting-started.html). ### Can I run a WebSocket server in a Rust CLI or desktop app? Yes. Start the server on `127.0.0.1:0`, print or open the chosen port, and push updates with a `WebSocketHandler` or server-sent events. The server starts in about a millisecond and adds no async runtime to your program. See [WebSocket](https://cameleer.ing/simoom/docs/guides/websocket.html). ### Does simoom run on a Raspberry Pi or other ARM boards? It is built for small devices, and the ARM SIMD and crypto code paths compile. They are compile-checked only so far: the test suite runs on Windows and Linux on x86-64, and the benchmarks ran on x86-64. Reports from ARM hardware are welcome. ### How does simoom compare with hyper? In benchmark campaign 4 (2026-11-12), on the same 4-vCPU cloud machine, simoom answered 24% more status requests per second than hyper with 99% within 1 ms at 256 clients, and used 26% less CPU per request; hyper's process used less memory at rest (1,374 KB against 1,769 KB). The numbers and the method are on the [benchmarks page](https://cameleer.ing/simoom/benchmarks/). hyper is far more mature, has years of production use and a large ecosystem (axum is built on it). simoom has no dependencies, a smaller footprint, its own TLS and HTTP/3. Choose by what matters more to you. ### Should I use simoom or axum? axum gives you extractors, middleware and a large ecosystem on top of tokio. simoom gives you a small synchronous API, a router and optional helpers, with no runtime and no dependencies. If you want the ecosystem, use axum; if size, start-up, dependencies or HTTP/3 matter more, try simoom. ### Can simoom serve static files or act as a reverse proxy? No. simoom is an engine you build into a program; it has no static-file server, no reverse proxy and no configuration file. Use nginx, Caddy or h2o for those jobs. You can still send a file from a handler yourself ([Streaming](https://cameleer.ing/simoom/docs/guides/streaming-and-sse.html)). ### Is simoom production ready? It is version 0.1.0, the first release, and the API can still change. It has been tested hard: h2spec 146/146, the QUIC Interop Runner, HTTP Garden with no desyncs, testssl.sh, fuzzing of every parser, and a test suite of several hundred tests on Windows and Linux. It has not had an independent security audit, macOS and ARM are compile-checked only, and the benchmarks ran on 4-vCPU machines. It is the HTTP server of [Cameleer](https://github.com/Cameleering/cameleer). Decide with those facts; [SECURITY.md](https://cameleer.ing/simoom/docs/SECURITY.html) has the details. ### Which platforms does simoom support? Linux (epoll and io_uring), Windows (IOCP) and macOS (kqueue). The test suite runs on Windows and Linux; macOS is compile-checked only. Rust 1.89 or newer. ### Does simoom support async handlers? No, on purpose. A handler that needs to wait takes a `Responder` with `reply.defer()` and answers from any thread, which also works with a tokio task if your program has one. See [Slow work](https://cameleer.ing/simoom/docs/guides/slow-work.html). ### Does simoom have an HTTP client? Yes, the separate `simoom-client` crate: HTTP/1.1, HTTP/2, HTTP/3 and WebSocket, blocking, with pooling, TLS and redirects. See [The client](https://cameleer.ing/simoom/docs/guides/client.html). ### Does simoom support TLS 1.2? Not on simoom servers: they speak TLS 1.3 only. The `simoom-tls` crate has TLS 1.2 behind a feature for other uses. See [TLS](https://cameleer.ing/simoom/docs/guides/tls.html). ### Why is it called simoom? A simoom is a hot, fast wind that crosses the desert. It belongs to the same caravan as Cameleer and the Cameleering projects. ### What licence is simoom under? Apache License 2.0. --- Source: https://cameleer.ing/simoom/docs/SECURITY.html # Security This page covers simoom's security model, its limits and defaults, what was tested and with what results, the known gaps, and how to report a vulnerability. _Last hardening pass: 2026-11-07, against head `33ee716` plus the fixes listed below. Third-party suites: 2026-11-09, against head `bf24f89` plus bugs 16 to 22 below._ ## Reporting a vulnerability Please report vulnerabilities privately through **GitHub security advisories**: . Don't open a public issue, pull request or discussion for a suspected vulnerability. - Include the version or commit, the feature set (`http1`, `http2`, `tls`, `http3`...), the operating system and back end, and a reproducer: bytes on the wire are ideal. - We aim to acknowledge a report within 3 working days and to agree a fix and disclosure date with you within 14 days. The default embargo is 90 days or the release of a fix, whichever comes first. - Fixes ship as a patch release with a GitHub advisory (and a RustSec advisory once the crates are on crates.io). Reporters are credited unless they ask not to be. - Hardening ideas that aren't vulnerabilities (a stricter default, a new limit) are welcome as normal issues. ## Security model simoom is an embeddable HTTP/1.1, HTTP/2 and HTTP/3 **origin server** engine. It isn't a reverse proxy, a forward proxy, a tunnel or a static-file server. The service (the embedding application) is trusted. Everything that arrives from the network is untrusted, including the bytes forwarded by a front proxy. What simoom guarantees to the service: - **Memory safety.** Every crate except `simoom-sys` is `#![forbid(unsafe_code)]`. `simoom-sys` holds the operating-system calls, SIMD scanning and crypto intrinsics, and every `unsafe` block there has a `SAFETY:` comment. There are no external crates, so there is no third-party code in the dependency tree. - **Strict parsing** per RFC 9110, 9112, 9113, 7541, 9114 and 9204. Any message whose framing is ambiguous is refused, and the connection is closed after a framing error, so request boundaries can't be shifted (no request smuggling). - **Bounded resources.** Every per-connection and per-stream structure has a limit, and exceeding it gives a specific status code or a connection close plus a counter in `ServerHandle::stats()`. - **Constant-time crypto.** TLS 1.3 only, with hardware AES-GCM or a bitsliced fallback, and `ct_eq` for every secret comparison. Certificate verification can't be turned off, and 0-RTT is off. ### HTTP/1.1 framing decisions - A request with both `Transfer-Encoding` and `Content-Length` is refused (400), as is any `Transfer-Encoding` other than a single final `chunked`. So are duplicate or differing `Content-Length` values and a `Content-Length` with a sign, a list, spaces or hex. - Bare CR, bare LF line endings, obs-fold, NUL and other control bytes, whitespace before a colon, and invalid chunk sizes are refused. Chunk extensions are parsed to the grammar, including BWS before `;`. - `Expect: 100-continue` follows RFC 9110 §10.1.1, and any other expectation gets 417. An `Upgrade: h2c` that carries a body is ignored (served as HTTP/1.1). - **CONNECT** is answered by the service, and then the connection is closed. simoom is not a tunnel, and a front proxy that sees a 2xx switches to tunnelling, so nothing after a CONNECT head is ever parsed as another request (fixed in this pass). - **Absolute-form targets** (`GET http://host/path`): the authority comes from the target and `Host` is ignored (RFC 9112 §3.2.2). `Request::host()` returns the target's authority and `Request::target()`/`path()` return the path and query. A target with userinfo (`http://user@host/`) is refused (RFC 9110 §4.2.4). Both were fixed in this pass. - For internal traffic, the recommended upstream protocol is HTTP/2 (h2c with prior knowledge), which avoids HTTP/1.1 framing ambiguity altogether. ## Limits and defaults Every limit is a field of `simoom::Limits` (plan §7.1), and `crates/simoom/tests/limits.rs` checks the defaults. | limit | default | when exceeded | |---|---|---| | `max_request_line` | 8 KiB | 414 | | `max_head` | 16 KiB | 431 | | `max_headers` | 100 | 431 | | `max_header_line` | 8 KiB | 431 | | `max_body` | 10 MiB (checked against `Content-Length` before any body byte is read) | 413 | | `body_buffer` | 64 KiB (larger bodies stream with backpressure) | — | | `head_timeout` | 10 s for the whole head (slowloris); also the TLS handshake deadline | close | | `keep_alive_timeout` | 15 s | close | | `min_body_rate` / `body_rate_grace` | 1 KiB/s after 5 s (slow POST) | close | | `write_timeout` | 30 s without write progress (slow read) | close | | `max_requests_per_connection` | unlimited | `connection: close` | | `max_connections` / `max_connections_per_loop` | 1 Mi / 1 Mi | accept and close, `rejected` | | `max_connections_per_ip` | off (0); new in this pass | accept and close, `rejected` | | `max_concurrent_streams` (HTTP/2, HTTP/3) | 100 streams with handler work running | REFUSED_STREAM | | `max_header_list` (HTTP/2, HTTP/3) | 16 KiB, CONTINUATION included | 431; an encoded block over twice the limit gives GOAWAY | | `header_table_size` (HPACK, QPACK) | 4096 | COMPRESSION_ERROR | | `stream_buffer` | 64 KiB of streamed response output pending | writers wait (`WouldBlock`) | | `max_ws_message` | 1 MiB | close 1009 | | `inline_budget` | 1 ms; a call over it on the wall clock makes its route suspect, and the route's next calls are timed with the thread's CPU clock: a stall is a call over the budget in CPU time, or one that blocked | stall counted, logged once per route | | `stalls_before_offload` / `stall_window` | 5 stalls within 1 s | the route (alone) moves to the offload pool | | `stall_recovery` | 64 consecutive fast calls on the pool (twice as many after each further move) | the route comes back inline | | `shed_lag` | off; measured 2026-11-09: 1 ms shed falsely at 50% load and no value from 0.5 to 5 ms improved goodput or p99 under 120-150% overload, so it stays opt-in | fast `503` with `retry-after` for new requests and streams | | `h2_frame_budgets` (HTTP/2) | the budgets below (`simoom::FrameBudgets`) | GOAWAY(ENHANCE_YOUR_CALM) | | `offload_queue` | 4096 jobs (CoDel 5 ms / 100 ms) | 503 | HTTP/2 frame budgets (`Limits::h2_frame_budgets`, per 10 s sliding window per connection; GOAWAY ENHANCE_YOUR_CALM beyond them): - RST_STREAM received 200 and caused 200 (Rapid Reset, MadeYouReset); - PING 100; - SETTINGS 100; - empty frames 200; - PRIORITY; - WINDOW_UPDATE frames under 1 KiB; - at most `max_continuation_frames` CONTINUATION frames per block. Each is configurable (`u32::MAX` turns one off). TLS: TLS 1.3 only. Groups X25519 and P-256. Suites AES-128-GCM, AES-256-GCM and ChaCha20-Poly1305. More than 32 records in a row that carry no application data (empty records, warning alerts, KeyUpdates) is fatal. Rejected 0-RTT data is skipped only up to 64 KiB. QUIC: - Retry is used once the handshaking connections pass a threshold, and connections are capped in total. - The 3x amplification limit is enforced. - The connection-ID retirement queue and the out-of-order CRYPTO and STREAM buffers are all bounded. - Packet numbers are skipped at random to detect optimistic ACKs. ## What was tested (2026-11-07) Third-party code was limited to tools already on the machine and Ubuntu 24.04 archive packages. The archive packages were extracted without root from `apt-get download`: nginx 1.24.0, haproxy 2.8.30, nghttp2-client 1.59.0 (`nghttp`, `h2load`) and gnutls-bin 3.8.3. | check | tool / version | result | |---|---|---| | HTTP/2 conformance, h2spec's server list written from the RFCs (generic, RFC 9113 §3.5, 4, 5, 6, 8, RFC 7541) | `crates/simoom/tests/h2_live.rs` | **120/120 h2c, 120/120 h2 over TLS**: in process (part of `cargo test`) and against a live `conformance_server` | | HTTP/2 interop and load | nghttp, h2load (nghttp2 1.59.0) | **14/14** (h2c prior knowledge, `Upgrade: h2c`, h2 ALPN, 1 MB bodies, 100 concurrent streams, `-m 1000` over a 100-stream limit, 100k requests over h2c, 50k over h2, 1 MB × 200, HTTP/1.1), 0 failed requests | | Request-smuggling differential, simoom behind a proxy | `scripts/desync/desync.py`, nginx 1.24.0 and haproxy 2.8.30 | **165 streams, 0 desyncs** after the fixes below | | Accept/reject compared with nginx and haproxy as origins | same | simoom accepted 49, all three refused 75, simoom stricter than one of them in 41, **simoom accepted 0 that both refused** | | TLS interop | openssl s_client 3.0.13 (WSL) and 3.5.9 (Windows) | **28/28 each** | | TLS interop | curl 8.5.0, python3 3.12 `ssl` (OpenSSL 3.0.13), gnutls-cli 3.8.3 | **31/31** | | TLS interop | curl 8.23.0 Schannel (Windows) | **3/3** | | TLS hand-written checks | `crates/simoom-tls/tests/hardening.rs`, `crates/simoom/tests/tls_hardening.rs`, unit tests in `tls/conn.rs` | all pass | | QUIC adversarial peers | `crates/simoom-quic/tests/adversarial.rs` | **30/30** | | Limits audit | `crates/simoom/tests/limits.rs` | 11/11; every `Limits` field is tested in the facade | | Mutation fuzzing (std-only, seeded) | `tests/fuzz.rs` of http1, h2/hpack, h3, qpack, quic, tls; release, 4 threads each | **2 h 40 min**, 10 rounds × 6 harnesses, a fresh seed per round: h2 2×10⁸, h3 1.5×10⁸, http1 5×10⁸, qpack 3×10⁸, quic 2×10⁹ iterations, tls ×4000 base. Findings: 1 engine bug (QUIC ACK-only spin) and 1 harness bug (http1 test held every input in memory), both fixed; http1 then re-run 3 × 5×10⁷ clean | | Coverage-guided fuzzing (std-only SanitizerCoverage) | `simoom_sys::sancov` + `coverage_guided` in `simoom-http1/tests/fuzz.rs` (Linux) | see "Coverage-guided fuzzing" below | nghttp notes, both by design: - A 1 MB POST without `Content-Length` streams through a `BodySource`. A service that answers without reading it gets an early 200 and an RST_STREAM(NO_ERROR), as RFC 9113 §8.1 allows. - `nghttp -w 1 -W 1` (2-byte windows) trips the small WINDOW_UPDATE budget (data dribble, CVE-2019-9511), so the connection gets GOAWAY ENHANCE_YOUR_CALM. TLS interop covered: - the TLS 1.3 handshake with chain verification; - `-tls1_2` and `-tls1_1` refused with a `protocol_version` alert; - bad SNI (default certificate), no SNI, no ALPN, ALPN h2 and http/1.1, and an unknown ALPN (`no_application_protocol`); - X25519 and P-256 work. P-384, P-521, X448 and ffdhe get a clean `handshake_failure`, and X448 then X25519 goes through HelloRetryRequest. On OpenSSL 3.5, the X25519MLKEM768 hybrid falls back to X25519; - all three suites; - client certificates: good, rogue (`unknown_ca`), and missing (`certificate_required`); - KeyUpdate both ways, PSK resumption, and 0-RTT offered and rejected. ### Coverage-guided fuzzing Plan §7.3 option F2 is implemented and works on stable Rust on Linux. The callbacks are in `simoom-sys/src/sancov.rs`, behind the test-only feature `sancov`; there are no dependencies. LLVM's `sancov-module` pass, run through `-C passes`, calls `__sanitizer_cov_trace_pc_guard`. `coverage_guided` keeps a corpus of inputs that reach new edges, using AFL-style hit-count buckets. Its mutator is the harness's own byte mutations plus corpus splicing. Every input is checked for split invariance and against the reference RFC 9112 parser. - First run: 171k executions per minute, 1,140 of 3,036 instrumented edges in 60 s. - Longer runs: 3 seeds × 10 min, recorded under "Results" below. - Windows: the MSVC back end refuses the instrumented COMDATs ("Associative COMDAT symbol does not exist"), so it runs only on Linux. - Not done yet: corpus minimisation, the HTTP/2, QUIC and TLS targets (they need only a driver like the http1 one), and persisting the corpus between runs. ```text RUSTFLAGS="-C passes=sancov-module -C llvm-args=-sanitizer-coverage-level=3 \ -C llvm-args=-sanitizer-coverage-trace-pc-guard" SIMOOM_FUZZ_SECS=600 \ cargo test --release -p simoom-http1 --features sancov --test fuzz coverage_guided -- --nocapture ``` ### Results Three 10-minute runs (seeds 11, 22, 33; WSL2 Ubuntu 24.04, release, one thread): 1.37M, 1.67M and 1.81M executions, corpora of 1,858 to 1,895 inputs, **1,220 to 1,232 of 3,036 instrumented edges** (the remainder is mostly response parsing, WebSocket and the serializer, which this driver doesn't exercise). No split-invariance or differential failure, no panic. Coverage plateaus after about 5 minutes, so longer runs need new targets more than more time. ## Third-party suites (2026-11-09) Approved by the owner on 2026-11-08 ("Yes, allow them"). Every suite ran in WSL2 (Ubuntu 24.04) or in Docker (Docker Desktop 29.9.2 through the WSL integration), from scratch directories under `~/simoom-sec-tools/` outside the repository, at pinned versions. None is a dependency of any simoom crate. The simoom servers were built from the working tree in pinned images: `rust:1.99.1-slim-trixie@sha256:7eab7ce1…3cd5` for the build, `debian:trixie-slim@sha256:a72b4433…b6b2` (HTTP Garden) and the runner's endpoint image (QUIC) for the run. | suite | version | target | result | |---|---|---|---| | h2spec | 2.6.0 (release tarball, sha256 `157ee0de…9318`) | `conformance_server`, h2c on the shared HTTP/1.1 + h2c port | **146/146 in 30 consecutive runs** on the final tree (was 145/146: §3.5/2, fixed as bug 16; then generic §3.8/1 flaked about 1 run in 5, fixed as bug 22; §3.8/1 alone then passed 50/50). TLS mode can't run: h2spec offers only TLS 1.2 and simoom is TLS 1.3-only | | testssl.sh | v3.2.1 (commit `b6a951d4`) | `conformance_server --tls-addr` | TLS 1.3 only, no vulnerabilities found. Notes only: the certificate is from a private test CA, and it has no CRL or OCSP URI | | HTTP Garden | commit `b417e806c1b15e8ea0b9312f81a91fbdcbc7a83e` | `conformance_server --garden` as an origin, next to nginx, apache_httpd, h2o, hyper, gunicorn and go_stdlib; transducers haproxy, nginx_proxy, apache_httpd_proxy, h2o_proxy | **0 desyncs, 0 dangerous discrepancies**: see below | | QUIC Interop Runner | commit `19016767e70789d70416442a1118ad82b5b5fc2e` | `interop_server` (server role) against the ngtcp2, quiche and quic-go clients | see the matrix below; engine bugs found and fixed: 17, 18, 20 and 21, and compatible version negotiation added (19) | ### h2spec h2spec §3.5/2 sends `INVALID CONNECTION PREFACE\r\n\r\n` and expects a connection close (GOAWAY allowed). On the shared HTTP/1.1 + h2c port simoom read it as an HTTP/1 request and answered `400`, which h2spec reads as a malformed frame. Now (bug 16), on an h2c-enabled listener, the first bytes of a connection go to the HTTP/2 codec, which answers SETTINGS and GOAWAY(PROTOCOL_ERROR) and closes (RFC 9113 §3.4), when either: - they start with the preface's request line `PRI * HTTP/2.0` but the rest of the preface is wrong; or - the first request line fails with an invalid version and its last word has no `HT` prefix and no `/`: nothing in it is HTTP/1 in any form. HTTP/1 clients with a damaged version (`HTTP/1.x`, `HT?P/1.1`, `XTTP/1.1`, HTTP/0.9's `GET /`) still get a 400, and later requests on a connection are never treated as a preface. The HTTP Garden's mutator found the first, too broad version of the rule (any line without `HTTP/`), which answered `HT\xbcP/1.1` with GOAWAY, and the narrower rule replaced it. Repeated runs then showed generic §3.8/1 ("Sends a GOAWAY frame", GOAWAY then PING) failing with "connection reset by peer" in about 1 run in 5: the server closed as soon as it read the peer's GOAWAY, and when the PING came in a later read it hit a closed socket. Fixed as bug 22 (the server answers a GOAWAY with its own graceful shutdown); results after it are in the matrix section below. ### HTTP Garden simoom joined the Garden as an origin (`conformance_server --garden` answers with the Garden's echo format: base64 JSON of the method, version, target, headers and body; image `images/simoom/Dockerfile` in the Garden checkout). Its probed quirk is `allows-http-2: true`. The Garden removed its fuzzing driver upstream (commit `5e260b6`), so a small driver (`tools/simoom_fuzz.py`, adapted from the removed `fuzz.py`) runs the Garden's own mutator (`mutations.py`) and classifier (`diff.categorize_discrepancy`), fans every mutated stream out to all origins, and replays every kept result through each transducer. - **Differential fuzzing:** two complete runs of 40 minutes (seed 2: 16,354 mutated streams, 42 kept results; seed 3: 14,993 streams, 53 results), after a shorter first run that a WSL restart cut off (13 results). Every kept result was then replayed 3 times through each transducer, comparing the proxy's own view of what it forwarded with simoom's reading (`tools/simoom_recheck.py`): **709 forwarded streams (321 and 388), 0 flagged**. Results that involve simoom, triaged by hand: - **simoom stricter** (simoom refuses or closes, another origin accepts): bare LF in the head or in chunked framing, whitespace or a high byte in `Host`, `Host` with a bad port, a duplicate `Transfer-Encoding: chunked`, `Content-Length` with `Transfer-Encoding`, a non-ASCII byte in the target, `Connection: close` plus `keep-alive` with a body and no length. Benign: simoom answers 400 and closes, so a proxy's next request goes on a new connection. - **simoom accepts with the same boundaries but a different header set** (13 in run 2, 20 in run 3): apache, gunicorn and nginx drop or rewrite headers with `_` or odd names (CGI environments), nginx adds empty `content-length`/`content-type`, apache's CGI echo loses chunked bodies split across reads. Every one of these is a difference between the other origins and simoom's reading, which matches the majority. Benign. - **Stream discrepancies after a transducer** (run 3: a payload split into pieces, sent through apache_httpd_proxy or nginx_proxy): another origin returned nothing within the Garden's 50 ms origin timeout while simoom answered, or the proxy forwarded nothing on a retry. None reproduced in the replay above. Timing, benign. - **Garbage first lines** (`os t: whatever`) get GOAWAY instead of 400 because of bug 16; the Garden counts that as no response, which it treats as agreeing with a rejection. Benign. - **No result where simoom read a different number of requests, a different body, or a different method or target than a proxy forwarded.** - **Curated smuggling vectors** (`tools/simoom_curated.py`, 39 streams: CL.TE, TE.CL, TE obfuscations, duplicate or listed `Content-Length`, chunk-size overflow and `0x`, chunk extensions with LF or quoted CRLF, trailers that smuggle `Content-Length`, bare CR and LF, NUL, HTTP/1.0 with TE, `GET` with a body, `Expect`, `CONNECT`, `Upgrade: h2c` with a body, absolute-form, pipelining). Each was sent through haproxy, nginx_proxy, apache_httpd_proxy and h2o_proxy, and the proxy's own view of what it forwarded was compared with simoom's reading of the forwarded bytes: **0 of 156 proxy paths flagged**. simoom refused 28 of the 39 directly. ### QUIC Interop Runner `crates/simoom/examples/interop_server.rs` follows the runner's endpoint contract: `TESTCASE` selects the case (unknown cases exit 127), files come from `/www`, the key and chain from `/certs/priv.key` and `/certs/cert.pem`, port 443 on `[::]` (IPv4 and IPv6). `http3` goes through the `simoom` facade (`Server::listen_quic`, the production HTTP/3 path); every other case speaks `hq-interop` over a `simoom_quic::Endpoint` on a std UDP socket. The image builds it on the runner's endpoint image (`martenseemann/quic-network-simulator-endpoint@sha256:3b2b9e6f…71a`). Clients, pinned by digest: ngtcp2 `ghcr.io/ngtcp2/ngtcp2-interop@sha256:4333a236…37e0`, quiche `cloudflare/quiche-qns@sha256:d8480e44…3f63df`, quic-go `martenseemann/quic-go-interop@sha256:fc8fc56c…4ea4e`; simulator `martenseemann/quic-network-simulator@sha256:c23d82a5…b6c9`. The runner itself ran in a container with Python 3.12.12 and tshark 4.6.9, one runner call per case, a case retried only when tshark crashed. Final full run (on the final tree, after bugs 17 to 21; ✓ passed, ✕ failed, – the client doesn't support the case or the server exits 127): | case | ngtcp2 | quiche | quic-go | |---|---|---|---| | handshake | ✓ | ✓ | ✓ | | transfer | ✓ | ✓ | ✓ | | longrtt | ✓ | ✓ | ✓ | | chacha20 | ✓ | – | ✓ | | multiplexing | ✓ | ✓ | ✓ | | retry | ✓ | ✓ | ✓ | | resumption | ✓ | ✓ | ✓ | | zerortt | ✕ (0-RTT refused by design) | ✕ (same) | ✕ (same) | | http3 | ✓ | ✓ | ✓ | | blackhole | ✓ | ✓ | ✓ | | keyupdate | ✓ | – | ✓ | | ecn | ✕ (no ECN) | – | – | | amplificationlimit | ✓ | ✓ | ✓ | | handshakeloss | ✓ | ✓ | ✓ | | transferloss | ✓ | ✓ | ✓ | | handshakecorruption | ✓ | ✓ | ✓ | | transfercorruption | ✓ | ✓ | ✓ | | ipv6 | ✓ | ✓ | ✓ | | v2 | ✓ | – | – | | rebind-port | ✓ | ✓ | ✓ | | rebind-addr | ✓ | ✓ | ✓ | | connectionmigration | – (no preferred address) | – | – | **Totals:** 54 of the 58 supported pairs pass. The 4 failures are `zerortt` against every client (0-RTT is refused by design, so the client's 0-RTT data is never accepted) and `ecn` against ngtcp2 (not implemented). The runner crashed in tshark on a few cases (it reads its pcaps through pyshark); those were run again and passed. How it got there (earlier full runs on the same day): - The first run had `blackhole` failing (bug 17: the send buffer stuck after a blackout), `rebind-port` and `rebind-addr` failing against quic-go (bug 18: no PATH_CHALLENGE in the first packet on the new path) and `v2` failing against ngtcp2 (bug 19). - quiche `multiplexing` failed with "Too many open files" in the quiche client: it keeps one file open per request and Docker Desktop's default soft limit is 1024 descriptors. An environment limit, not a simoom bug: the runner's compose file now gives the endpoints a `nofile` limit of 65536, and it passes 3 out of 3. - `handshakeloss` and `handshakecorruption` failed now and then (quic-go: "handshake did not complete in time"; quiche: one of the 50 connections timed out). The traces showed the server resending a lost flight only on its own PTO, which heavy handshake loss had stretched to seconds. Bug 20 resends it as soon as the client's ClientHello comes again. - `blackhole` against quic-go once ran past the runner's time limit before bug 20 and the final image; it passes in the final run and in the reruns below. Reruns of the cases that had been intermittent, on the final image, 3 times each: quic-go `handshakeloss` 3/3, quic-go `handshakecorruption` 3/3, quic-go `blackhole` 3/3, quiche `handshakeloss` 3/3; quiche `multiplexing` 3/3 with the raised descriptor limit; ngtcp2 `handshakeloss` 3/3 (on the image before bug 21). ## Bugs found and fixed in this pass | # | area | bug | fix | regression test | |---|---|---|---|---| | 1 | HTTP/1.1 | After a 2xx to `CONNECT`, simoom kept parsing the connection as HTTP. A front proxy (haproxy) switches to tunnelling, so tunnelled bytes would run as requests the proxy never saw (desync). | `crates/simoom/src/h1.rs`: close after the CONNECT response | `tests/desync.rs` `connect_closes_the_connection_after_its_response` | | 2 | HTTP/1.1 | Absolute-form targets: the service got the raw URI as the path and the `Host` header as the host, so a request addressed to `y` looked like one for `x` (RFC 9112 §3.2.2 says the target wins) | `h1.rs` `split_absolute`: authority from the target, target becomes path and query | `absolute_form_uses_the_target_authority` | | 3 | HTTP/1.1 | Userinfo in absolute-form (`http://trusted@evil/`) was accepted (RFC 9110 §4.2.4: treat as an error) | `crates/simoom-http1/src/parse.rs` `valid_absolute`, and the reference parser | `absolute_form_with_userinfo_is_refused`; `simoom-http1/tests/request.rs` `target_rules` | | 4 | HTTP/2 | A header block larger than `max_header_list` but otherwise sane closed the whole connection with GOAWAY, instead of answering that stream with 431 (plan §7.1) | `crates/simoom-h2/src/conn.rs` `block_cap` (twice the list limit; beyond that, still GOAWAY) | `simoom-h2/tests/dos.rs` `header_block_over_the_list_limit_gets_431_not_goaway`; `tests/limits.rs` `header_list_limit_is_advertised_and_enforced` | | 5 | limits | No per-IP connection cap (plan §7.1, optional) | `Limits::max_connections_per_ip` (`limits.rs`, `server.rs` `IpSlot`, `conn.rs`) | `tests/limits.rs` `per_ip_connection_cap` | | 6 | QUIC | ACK-only packet spin: with no RTT sample and a pending PMTU probe, `poll_transmit` sent ACK-only packets forever, without limit (found by fuzzing, seed 7000098) | `crates/simoom-quic/src/connection.rs` (`space_has_data` follows the probe writer; ACK-only packets only when an ACK is due) | `connection::tests::unbuildable_mtu_probe_does_not_spin_ack_only_packets` | | 7 | QUIC | Out-of-order STREAM fragments had no bound (one allocation per byte of window) | `src/stream.rs`: merge adjacent chunks; more than 1024 gaps gives PROTOCOL_VIOLATION | `fragmented_stream_data_cannot_explode_memory`, `too_many_out_of_order_gaps_is_an_error` | | 8 | QUIC | No defence against optimistic ACKs | `connection.rs`, `recovery.rs`: random skipped packet numbers; an ACK of one is PROTOCOL_VIOLATION | `optimistic_ack_of_skipped_packet_numbers_is_detected` | | 9 | QUIC | Tiny out-of-order CRYPTO chunks held far more memory than the buffer size | `connection.rs`: per-chunk overhead charged | `tiny_out_of_order_crypto_chunks_are_bounded` | | 10 | QUIC | The RETIRE_CONNECTION_ID queue grew without limit under `retire_prior_to` churn | `connection.rs` `queue_retire`: deduplicated, capped, then CONNECTION_ID_LIMIT_ERROR | `retire_prior_to_churn_cannot_queue_unbounded_retirements` | | 11 | QUIC | Retry never triggered under a spoofed flood of bad ClientHellos, because failed handshakes stopped counting | `src/endpoint.rs` | `initial_flood_from_spoofed_addresses_is_answered_with_retry_and_bounded` | | 12 | TLS | A client whose certificate was refused saw `bad_record_mac` instead of the alert (the server wrote the alert under the wrong key) | `crates/simoom-tls/src/tls/conn.rs` | `simoom-tls/tests/hardening.rs` `client_certificate_with_a_bad_chain_is_refused_with_a_readable_alert` | | 13 | TLS | No bound on records that deliver nothing (empty records, warning alerts, KeyUpdate floods) | `tls/conn.rs` `idle_record` (32 in a row) | `empty_record_flood_is_bounded`, `warning_alert_flood_is_bounded`, `key_update_flood_is_bounded` | | 14 | TLS | The client ignored the downgrade sentinels (it refused with `protocol_version` rather than `illegal_parameter`) | `tls/client.rs` | `client_rejects_the_downgrade_sentinels` | | 15 | fuzz harness | `simd_and_scalar_agree_on_fuzz_inputs` collected every input, so a long run aborted out of memory | `simoom-http1/tests/fuzz.rs`: compare in batches | (the harness itself) | | 16 | HTTP/2 | An invalid connection preface on the shared HTTP/1.1 + h2c port got an HTTP/1 `400` instead of GOAWAY(PROTOCOL_ERROR) and a close (RFC 9113 §3.4; h2spec §3.5/2) | `crates/simoom-http1/src/h2c.rs` `Preface::Invalid`; `crates/simoom/src/h1.rs` (first request only, `looks_like_http`) | `simoom/tests/http2.rs` `invalid_preface_gets_goaway_protocol_error_and_close`; `simoom-http1/tests/response.rs` `h2_preface_detection` | | 17 | QUIC | A stalled transfer after packet loss: an acknowledgement of bytes already released (the original packet and its retransmission both acknowledged) was recorded below the send buffer's base, stayed first in the acknowledged set forever, and blocked every later release, so the 1 MiB send buffer stayed full and the stream never sent again (found by the interop runner's `blackhole` case) | `crates/simoom-quic/src/stream.rs` `on_stream_acked`: record only the part above `base` | `simoom-quic/tests/transport.rs` `transfer_survives_repeated_blackouts`; `stream::tests::send_side_credit_and_limits` | | 18 | QUIC | After a NAT rebinding the PATH_CHALLENGE waited for congestion window: the bytes in flight on the old path filled the reset window, so the first packet on the new path was ACK-only (interop `rebind-port`, `rebind-addr`) | `crates/simoom-quic/src/connection.rs`: the challenge is written first and is not held back by congestion control | `connection::tests::first_packet_on_a_new_path_carries_the_challenge_when_cwnd_is_full` | | 19 | QUIC | No compatible version negotiation (RFC 9368): a client that started in v1 and offered v2 stayed in v1 (interop `v2`) | `crates/simoom-quic/src/vn.rs` (new), `params.rs` (`version_information`), `connection.rs`: a server with more than one version starts TLS after the whole ClientHello, picks its preferred version from the client's list and switches its Initial keys; a client follows a server Initial in another compatible version | `simoom-quic/tests/tls.rs` `compatible_version_negotiation_v1_to_v2` (with Retry and with loss); `vn::tests`; `params::tests` | | 20 | QUIC | Under heavy handshake loss the server waited for its own PTO, backed off to seconds by RTT samples it can't correct in the handshake spaces, before resending a lost flight; clients gave up (interop `handshakeloss`, `handshakecorruption`, intermittent) | `connection.rs` `on_crypto`, `resend_handshake_flight`: a repeated ClientHello means the client's PTO fired, so the server queues its unacknowledged Initial and Handshake data again at once (still under the anti-amplification limit and congestion control) | `connection::tests::duplicate_client_hello_resends_the_server_flight` | | 21 | QUIC | The cap on out-of-order STREAM fragments (1024) applied per stream, so a peer could hold streams × 1024 fragments per connection | `crates/simoom-quic/src/stream.rs`: `recv_gaps` counts holes over all streams; a reset or stopped stream gives its holes back | `stream::tests::out_of_order_gap_cap_is_per_connection`, `too_many_out_of_order_gaps_is_an_error` | | 22 | HTTP/2 | After the peer's GOAWAY with no streams open the server closed at once, so a frame the peer sent after its GOAWAY (h2spec generic 3.8/1 sends a PING) hit a closed socket and the peer saw a TCP reset instead of the PING ACK (h2spec flaked about 1 run in 5) | `crates/simoom-h2/src/conn.rs`: a server answers the peer's GOAWAY with its own graceful shutdown (GOAWAY, PING round trip or timeout, final GOAWAY) and closes after it | `simoom-h2/tests/conformance.rs` `s6_8_ping_after_goaway_is_answered`, `s6_8_goaway` | Smuggling divergences reviewed and judged benign: - **Requests simoom refuses but nginx or haproxy accept (41 streams).** Each is a refusal on a stricter rule: bare LF, obs-fold, a `Transfer-Encoding` list, `HTTP/1.0` with `Transfer-Encoding`, a high byte in a field, a lowercase method, and so on. Behind a proxy that forwards such a request, simoom answers 400 and closes. The proxy then sends the next request on a new connection, so the boundaries still agree. - **BWS before a chunk extension (`5 ;ext`).** It is valid per RFC 9112 §7.1.1, and all three accept it the same way. - **`Upgrade: h2c` with a body.** simoom serves it as HTTP/1.1, the same as the proxies. - **`OPTIONS *` and absolute-form.** simoom accepts these, as RFC 9112 requires. haproxy refuses absolute-form whose authority differs from `Host`. That is its own policy, and safe either way since bug 2 was fixed. - **0.CL with `Expect: 100-continue`.** All three treat the body as a body: one request each. ## Known gaps - **QUIC:** no ECN (packets are not marked ECT and ACK frames carry no ECN counts: interop `ecn` fails), no preferred address and so no server-side active migration (`connectionmigration` unsupported), and 0-RTT is refused by design (`zerortt` fails). Handshake-space RTT samples can't be corrected by the peer's ACK delay (RFC 9002 §5.3), so heavy handshake loss inflates the RTT estimate and the PTO; bug 20 resends the flight when the client's ClientHello comes again, but a lost server flight with a client that probes only with PING still waits for the server's PTO. - No external audit yet (plan §7.5). No Miri, ASan or TSan runs were part of this pass. - HTTP/2 over TLS has not been run through h2spec (h2spec offers only TLS 1.2); the in-repo h2spec list (`h2_live.rs`) covers it. - HTTP Garden ran only its HTTP/1.1 paths against simoom; its HTTP/2 fan-out (`h2fanout`) was not run. - TLS groups are X25519 and P-256 only (no P-384, no ML-KEM hybrid), and there is no TLS 1.2. Clients that offer only those get `handshake_failure`, by design. - Compatible version negotiation (RFC 9368) covers v1 and v2 only. After a switch to v2 the server keeps processing the client's v1 Initial packets until the Initial keys are discarded (fixed 2026-11-09, BUGS.md item 21). - The coverage-guided fuzzer runs on Linux only, covers only the HTTP/1 target, and has no corpus minimisation. ## Reproducing ```text # hermetic: everything above that runs in cargo test cargo test --workspace --features simoom/full # live HTTP/2 conformance against a running server cargo run --release -p simoom --features full --example conformance_server -- \ --addr 127.0.0.1:18080 --tls-addr 127.0.0.1:18443 --out /tmp/cs SIMOOM_H2_LIVE_H2C=127.0.0.1:18080 SIMOOM_H2_LIVE_H2=127.0.0.1:18443 SIMOOM_H2_LIVE_CA=/tmp/cs/ca.pem \ cargo test --release -p simoom --features full --test h2_live -- --ignored --nocapture # request smuggling differential (WSL / Linux; nginx and haproxy from the Ubuntu archive) python3 scripts/desync/desync.py --server target/release/examples/conformance_server \ --nginx /usr/sbin/nginx --haproxy /usr/sbin/haproxy # long fuzzing, per harness SIMOOM_FUZZ_ITERS=20000000 SIMOOM_FUZZ_SEED=7 cargo test --release -p simoom-h2 --test fuzz # third-party suites (WSL, outside the repository; versions in the table above) h2spec -h 127.0.0.1 -p 18080 -o 3 # against conformance_server --addr conformance_server --garden --addr 0.0.0.0:80 # the HTTP Garden origin (in its Docker image) TESTCASE=transfer interop_server --addr [::]:443 --www /www --certs /certs # QUIC Interop Runner endpoint python3 run.py -s simoom -c ngtcp2,quiche,quic-go # in the runner checkout, simoom added to implementations_quic.json ``` Notes for the QUIC Interop Runner on Docker 28 or later: the runner's two bridge networks need `com.docker.network.bridge.gateway_mode_ipv4: nat-unprotected` (and `_ipv6`), otherwise Docker's direct-routing protection (raw-table `PREROUTING` drops) discards the bridged frames between the client and the simulator and every test fails, the runner's reference pairs included. The runner needs Python 3.12 (pyshark fails on 3.14) and tshark 4.5 or later.