diff --git a/Cargo.lock b/Cargo.lock
index 8c70aa1..9a56072 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,90 +8,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
-[[package]]
-name = "async-trait"
-version = "0.1.89"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "atomic-waker"
-version = "1.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
-
-[[package]]
-name = "axum"
-version = "0.7.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
-dependencies = [
- "async-trait",
- "axum-core",
- "bytes",
- "futures-util",
- "http",
- "http-body",
- "http-body-util",
- "hyper",
- "hyper-util",
- "itoa",
- "matchit",
- "memchr",
- "mime",
- "percent-encoding",
- "pin-project-lite",
- "rustversion",
- "serde",
- "serde_json",
- "serde_path_to_error",
- "serde_urlencoded",
- "sync_wrapper",
- "tokio",
- "tower 0.5.3",
- "tower-layer",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "axum-core"
-version = "0.4.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
-dependencies = [
- "async-trait",
- "bytes",
- "futures-util",
- "http",
- "http-body",
- "http-body-util",
- "mime",
- "pin-project-lite",
- "rustversion",
- "sync_wrapper",
- "tower-layer",
- "tower-service",
- "tracing",
-]
-
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
-[[package]]
-name = "bytes"
-version = "1.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
-
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -126,48 +48,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
-[[package]]
-name = "form_urlencoded"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
-dependencies = [
- "percent-encoding",
-]
-
-[[package]]
-name = "futures-channel"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
-dependencies = [
- "futures-core",
-]
-
-[[package]]
-name = "futures-core"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
-
-[[package]]
-name = "futures-task"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
-
-[[package]]
-name = "futures-util"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
-dependencies = [
- "futures-core",
- "futures-task",
- "pin-project-lite",
- "slab",
-]
-
[[package]]
name = "getrandom"
version = "0.4.2"
@@ -202,86 +82,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-[[package]]
-name = "http"
-version = "1.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
-dependencies = [
- "bytes",
- "itoa",
-]
-
-[[package]]
-name = "http-body"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
-dependencies = [
- "bytes",
- "http",
-]
-
-[[package]]
-name = "http-body-util"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
-dependencies = [
- "bytes",
- "futures-core",
- "http",
- "http-body",
- "pin-project-lite",
-]
-
-[[package]]
-name = "httparse"
-version = "1.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
-
-[[package]]
-name = "httpdate"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
-
-[[package]]
-name = "hyper"
-version = "1.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
-dependencies = [
- "atomic-waker",
- "bytes",
- "futures-channel",
- "futures-core",
- "http",
- "http-body",
- "httparse",
- "httpdate",
- "itoa",
- "pin-project-lite",
- "smallvec",
- "tokio",
-]
-
-[[package]]
-name = "hyper-util"
-version = "0.1.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
-dependencies = [
- "bytes",
- "http",
- "http-body",
- "hyper",
- "pin-project-lite",
- "tokio",
- "tower-service",
-]
-
[[package]]
name = "id-arena"
version = "2.3.0"
@@ -330,53 +130,18 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
-[[package]]
-name = "matchit"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
-
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
-[[package]]
-name = "mime"
-version = "0.3.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
-
-[[package]]
-name = "mio"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
-dependencies = [
- "libc",
- "wasi",
- "windows-sys",
-]
-
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
-[[package]]
-name = "percent-encoding"
-version = "2.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
-
-[[package]]
-name = "pin-project-lite"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
-
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -416,13 +181,10 @@ name = "rt"
version = "0.1.0"
dependencies = [
"anyhow",
- "axum",
"libc",
"serde",
"serde_json",
"tempfile",
- "tokio",
- "tower 0.4.13",
]
[[package]]
@@ -438,18 +200,6 @@ dependencies = [
"windows-sys",
]
-[[package]]
-name = "rustversion"
-version = "1.0.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
-
-[[package]]
-name = "ryu"
-version = "1.0.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
-
[[package]]
name = "semver"
version = "1.0.27"
@@ -499,61 +249,6 @@ dependencies = [
"zmij",
]
-[[package]]
-name = "serde_path_to_error"
-version = "0.1.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
-dependencies = [
- "itoa",
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "serde_urlencoded"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
-dependencies = [
- "form_urlencoded",
- "itoa",
- "ryu",
- "serde",
-]
-
-[[package]]
-name = "signal-hook-registry"
-version = "1.4.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
-dependencies = [
- "errno",
- "libc",
-]
-
-[[package]]
-name = "slab"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
-
-[[package]]
-name = "smallvec"
-version = "1.15.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
-
-[[package]]
-name = "socket2"
-version = "0.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
-dependencies = [
- "libc",
- "windows-sys",
-]
-
[[package]]
name = "syn"
version = "2.0.117"
@@ -565,12 +260,6 @@ dependencies = [
"unicode-ident",
]
-[[package]]
-name = "sync_wrapper"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
-
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -584,91 +273,6 @@ dependencies = [
"windows-sys",
]
-[[package]]
-name = "tokio"
-version = "1.52.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
-dependencies = [
- "libc",
- "mio",
- "pin-project-lite",
- "signal-hook-registry",
- "socket2",
- "tokio-macros",
- "windows-sys",
-]
-
-[[package]]
-name = "tokio-macros"
-version = "2.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "tower"
-version = "0.4.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
-dependencies = [
- "tower-layer",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "tower"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
-dependencies = [
- "futures-core",
- "futures-util",
- "pin-project-lite",
- "sync_wrapper",
- "tokio",
- "tower-layer",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "tower-layer"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
-
-[[package]]
-name = "tower-service"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
-
-[[package]]
-name = "tracing"
-version = "0.1.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
-dependencies = [
- "log",
- "pin-project-lite",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-core"
-version = "0.1.36"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
-dependencies = [
- "once_cell",
-]
-
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -681,12 +285,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
-[[package]]
-name = "wasi"
-version = "0.11.1+wasi-snapshot-preview1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
-
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
diff --git a/crates/rt/Cargo.toml b/crates/rt/Cargo.toml
index 5508e1a..5b5694f 100644
--- a/crates/rt/Cargo.toml
+++ b/crates/rt/Cargo.toml
@@ -16,9 +16,6 @@ path = "src/bin/wo.rs"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-tokio = { version = "1", features = ["rt", "macros", "net", "signal", "sync", "time"] }
-axum = "0.7"
-tower = "0.4"
libc = "0.2" # phase 02 — direct epoll/eventfd/timerfd/signalfd syscalls
[dev-dependencies]
diff --git a/crates/rt/src/bin/wo.rs b/crates/rt/src/bin/wo.rs
index 739e99b..4c62830 100644
--- a/crates/rt/src/bin/wo.rs
+++ b/crates/rt/src/bin/wo.rs
@@ -1,15 +1,23 @@
//! `wo` — the writeonce toolchain binary.
//!
//! Stage 2 scope:
-//! wo run
— discover `.wo` files under , parse the type DSL,
-//! compile a catalog, and serve REST CRUD on :8080.
-//! wo --help — print usage.
+//! wo run discover `.wo` files under , parse the type DSL,
+//! compile a catalog, and serve REST CRUD on :8080.
+//! wo --help print usage.
+//!
+//! After the phase-04 cutover this binary owns one event loop on one
+//! thread. No tokio. The same pattern Redis and TigerBeetle use — see
+//! `docs/runtime/database/02-wo-language.md § Concurrency Model`.
+use std::collections::HashMap;
+use std::os::unix::io::{AsRawFd, RawFd};
use std::path::PathBuf;
use std::process::ExitCode;
-use std::sync::Arc;
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
-use tokio::sync::Mutex;
+use rt::http::{Connection, Listener, Router};
+use rt::runtime::{EventLoop, Interest, SignalFd, Token};
fn usage() {
eprintln!(
@@ -30,38 +38,22 @@ fn main() -> ExitCode {
let args: Vec = std::env::args().skip(1).collect();
let slice: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
match slice.as_slice() {
- [] | ["--help"] | ["-h"] => {
- usage();
- ExitCode::from(0)
- }
+ [] | ["--help"] | ["-h"] => { usage(); ExitCode::from(0) }
["run"] => {
eprintln!("wo run: directory argument required");
usage();
ExitCode::from(2)
}
- ["run", dir] => run(PathBuf::from(dir)),
- _ => {
- usage();
- ExitCode::from(2)
- }
+ ["run", dir] => match run(PathBuf::from(dir)) {
+ Ok(c) => c,
+ Err(e) => { eprintln!("error: {e}"); ExitCode::from(1) }
+ },
+ _ => { usage(); ExitCode::from(2) }
}
}
-fn run(dir: PathBuf) -> ExitCode {
- // Tokio current-thread runtime — matches the single-threaded engine design.
- let rt = match tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- {
- Ok(r) => r,
- Err(e) => { eprintln!("error: build runtime: {e}"); return ExitCode::from(1); }
- };
- rt.block_on(async move { serve(dir).await })
- .unwrap_or_else(|e| { eprintln!("error: {e}"); ExitCode::from(1) })
-}
-
-async fn serve(dir: PathBuf) -> anyhow::Result {
- // 1. Discover
+fn run(dir: PathBuf) -> anyhow::Result {
+ // 1. Discover .wo files.
let files = rt::discover(&dir)?;
if files.is_empty() {
anyhow::bail!("no .wo files found under {}", dir.display());
@@ -72,11 +64,11 @@ async fn serve(dir: PathBuf) -> anyhow::Result {
dir.display(),
);
- // 2. Parse each file into a Schema
+ // 2. Parse each into a Schema.
let mut schemas = Vec::new();
for f in &files {
match rt::parser::parse(&f.src) {
- Ok(s) => {
+ Ok(s) => {
let n = s.types.len();
println!(" parsed {} — {} type{}", f.rel.display(), n, if n == 1 { "" } else { "s" });
schemas.push(s);
@@ -88,39 +80,95 @@ async fn serve(dir: PathBuf) -> anyhow::Result {
}
}
- // 3. Compile a catalog
+ // 3. Compile the catalog.
let catalog = rt::compile::Catalog::from_schemas(schemas)?;
println!("[wo] compiled catalog — {} type{}", catalog.order.len(),
if catalog.order.len() == 1 { "" } else { "s" });
- // 4. Boot the engine
+ // 4. Boot the engine + the router.
let engine = Arc::new(Mutex::new(rt::engine::Engine::new(catalog.clone())));
+ println!();
+ println!("[wo] routes:");
{
- let e = engine.lock().await;
- println!();
- println!("[wo] routes:");
+ let e = engine.lock().unwrap();
print!("{}", rt::server::describe_routes(&e));
}
+ let router = rt::server::router(engine.clone(), &catalog);
- // 5. Bind and serve
+ // 5. Bind and serve.
let addr = std::env::var("WO_LISTEN").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
- let listener = match tokio::net::TcpListener::bind(&addr).await {
- Ok(l) => l,
- Err(e) => anyhow::bail!("bind {addr}: {e}"),
- };
+ let listener = Listener::bind(&addr)
+ .map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;
println!();
- println!("[wo] listening on http://{addr}");
+ println!("[wo] listening on http://{}", listener.local_addr());
println!("[wo] ctrl-C to stop");
- let app = rt::server::router(engine.clone(), &catalog);
-
- // Graceful shutdown on ctrl-C
- axum::serve(listener, app)
- .with_graceful_shutdown(async {
- let _ = tokio::signal::ctrl_c().await;
- println!("\n[wo] shutting down");
- })
- .await?;
-
+ serve_loop(listener, router)?;
Ok(ExitCode::from(0))
}
+
+fn serve_loop(listener: Listener, router: Router) -> anyhow::Result<()> {
+ let mut eloop = EventLoop::new()?;
+ let signals = SignalFd::new()?;
+ let listen_fd = listener.as_raw_fd();
+ let signal_fd = signals.as_raw_fd();
+
+ // Tokens: connection fds carry their own raw fd as the token; the
+ // listener and signalfd use their fds too — they're disjoint by
+ // construction (different fds).
+ eloop.register(listen_fd, Interest::READABLE, Token(listen_fd as u64))?;
+ eloop.register(signal_fd, Interest::READABLE, Token(signal_fd as u64))?;
+
+ let mut conns: HashMap = HashMap::new();
+
+ 'outer: loop {
+ let events = match eloop.wait_once(Some(Duration::from_secs(60))) {
+ Ok(evs) => evs,
+ Err(e) => {
+ eprintln!("[wo] event loop error: {e}");
+ continue;
+ }
+ };
+
+ for ev in events {
+ let fd = ev.token().0 as RawFd;
+
+ if fd == listen_fd {
+ // Drain accept queue (edge-triggered).
+ while let Some(cfd) = listener.accept()? {
+ eloop.register(cfd, Interest::READABLE, Token(cfd as u64))?;
+ conns.insert(cfd, Connection::new(cfd));
+ }
+ continue;
+ }
+
+ if fd == signal_fd {
+ let sig = signals.read().unwrap_or(0);
+ println!();
+ println!("[wo] received signal {sig} — shutting down");
+ break 'outer;
+ }
+
+ // Connection event.
+ let Some(conn) = conns.get_mut(&fd) else { continue };
+ let want_writable = match conn.drive(ev.readable, ev.writable, ev.hangup, ev.error, &router) {
+ Ok(w) => w,
+ Err(_) => { conns.remove(&fd); continue; }
+ };
+
+ if conn.is_done() {
+ eloop.deregister(fd).ok();
+ conns.remove(&fd); // Drop closes the fd.
+ } else if want_writable {
+ let _ = eloop.modify(fd, Interest::READ_WRITE, Token(fd as u64));
+ }
+ }
+ }
+
+ // Tear down outstanding connections cleanly. Dropping Connection closes
+ // each fd; deregistering from the loop is optional (close auto-removes).
+ for (fd, _) in conns.drain() {
+ let _ = eloop.deregister(fd);
+ }
+ Ok(())
+}
diff --git a/crates/rt/src/http/connection.rs b/crates/rt/src/http/connection.rs
new file mode 100644
index 0000000..5abdbf7
--- /dev/null
+++ b/crates/rt/src/http/connection.rs
@@ -0,0 +1,247 @@
+//! Per-connection state machine driven by the phase-02 [`EventLoop`].
+//!
+//! Lifecycle (close-after-response, no keep-alive yet):
+//! Reading → drain `read(2)` to `EAGAIN`, parse, dispatch through the
+//! `Router`, queue the response.
+//! Writing → drain `write(2)` to `EAGAIN`. If a write was partial, the
+//! loop re-arms the fd as `WRITABLE` and we continue on the next event.
+//! Done → loop closes the fd.
+//!
+//! Adapted from `reference/crates/wo-http/src/connection.rs`. The owning
+//! [`EventLoop`] supplies `read`/`write` readiness via edge-triggered
+//! `epoll`; this struct is the per-fd part of the state.
+//!
+//! [`EventLoop`]: crate::runtime::EventLoop
+
+use std::io;
+use std::os::unix::io::{AsRawFd, RawFd};
+
+use super::request::{self, ParseResult};
+use super::response::Response;
+use super::route::Router;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ConnState {
+ Reading,
+ Writing,
+ Done,
+}
+
+pub struct Connection {
+ fd: RawFd,
+ state: ConnState,
+ read_buf: Vec,
+ write_buf: Vec,
+ write_offset: usize,
+}
+
+impl Connection {
+ pub fn new(fd: RawFd) -> Self {
+ Self {
+ fd,
+ state: ConnState::Reading,
+ read_buf: Vec::with_capacity(4096),
+ write_buf: Vec::new(),
+ write_offset: 0,
+ }
+ }
+
+ pub fn state(&self) -> ConnState { self.state }
+ pub fn is_done(&self) -> bool { self.state == ConnState::Done }
+
+ /// Drain the socket into `read_buf` until `EAGAIN` or EOF.
+ /// Returns `false` when the peer closed (connection should be torn down).
+ fn drain_read(&mut self) -> io::Result {
+ let mut tmp = [0u8; 4096];
+ loop {
+ let n = unsafe {
+ libc::read(self.fd, tmp.as_mut_ptr() as *mut libc::c_void, tmp.len())
+ };
+ if n < 0 {
+ let err = io::Error::last_os_error();
+ if err.raw_os_error() == Some(libc::EAGAIN) {
+ return Ok(true);
+ }
+ return Err(err);
+ }
+ if n == 0 {
+ return Ok(false);
+ }
+ self.read_buf.extend_from_slice(&tmp[..n as usize]);
+ // Cap at MAX_BODY_BYTES + headers — refuse pathological requests.
+ if self.read_buf.len() > 32 * 1024 * 1024 {
+ return Err(io::Error::new(io::ErrorKind::InvalidData, "request too large"));
+ }
+ }
+ }
+
+ /// Drain `write_buf[write_offset..]` to the socket until `EAGAIN`.
+ /// Returns `true` once everything has been flushed.
+ fn drain_write(&mut self) -> io::Result {
+ loop {
+ let remaining = &self.write_buf[self.write_offset..];
+ if remaining.is_empty() { return Ok(true); }
+ let n = unsafe {
+ libc::write(
+ self.fd,
+ remaining.as_ptr() as *const libc::c_void,
+ remaining.len(),
+ )
+ };
+ if n < 0 {
+ let err = io::Error::last_os_error();
+ if err.raw_os_error() == Some(libc::EAGAIN) {
+ return Ok(false);
+ }
+ return Err(err);
+ }
+ if n == 0 { return Ok(false); }
+ self.write_offset += n as usize;
+ }
+ }
+
+ fn try_parse(&self) -> ParseResult {
+ request::parse(&self.read_buf)
+ }
+
+ fn queue_response(&mut self, response: &Response) {
+ self.write_buf = response.to_bytes();
+ self.write_offset = 0;
+ self.state = ConnState::Writing;
+ }
+
+ /// One step of the state machine, given a readiness event from the
+ /// loop. Returns `true` if the connection now wants `WRITABLE` (the
+ /// caller should switch interest from `READABLE`); `false` otherwise.
+ pub fn drive(
+ &mut self,
+ readable: bool,
+ writable: bool,
+ hangup: bool,
+ error: bool,
+ router: &Router,
+ ) -> io::Result {
+ if error {
+ self.state = ConnState::Done;
+ return Ok(false);
+ }
+
+ if readable && self.state == ConnState::Reading {
+ let still_open = self.drain_read()?;
+ match self.try_parse() {
+ ParseResult::Complete(req) => {
+ let resp = router.dispatch(&req);
+ self.queue_response(&resp);
+ }
+ ParseResult::Incomplete => {
+ if !still_open {
+ self.state = ConnState::Done;
+ return Ok(false);
+ }
+ }
+ ParseResult::Error(msg) => {
+ let resp = Response::status(super::Status::BAD_REQUEST).text(msg);
+ self.queue_response(&resp);
+ }
+ }
+ }
+
+ if self.state == ConnState::Writing {
+ let flushed = self.drain_write()?;
+ if flushed {
+ self.state = ConnState::Done;
+ return Ok(false);
+ } else if !writable {
+ // We tried, EAGAIN'd; tell the caller to wait for WRITABLE.
+ return Ok(true);
+ }
+ }
+
+ if hangup && self.state != ConnState::Writing {
+ self.state = ConnState::Done;
+ }
+
+ Ok(false)
+ }
+}
+
+impl AsRawFd for Connection {
+ fn as_raw_fd(&self) -> RawFd { self.fd }
+}
+
+impl Drop for Connection {
+ fn drop(&mut self) {
+ unsafe { libc::close(self.fd); }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::http::{Method, Response, Router};
+
+ fn socketpair_nonblock() -> (RawFd, RawFd) {
+ let mut fds = [0i32; 2];
+ let r = unsafe {
+ libc::socketpair(
+ libc::AF_UNIX,
+ libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC,
+ 0,
+ fds.as_mut_ptr(),
+ )
+ };
+ assert_eq!(r, 0);
+ (fds[0], fds[1])
+ }
+
+ #[test]
+ fn connection_handles_a_request() {
+ let (server_fd, client_fd) = socketpair_nonblock();
+
+ let req = b"GET /healthz HTTP/1.1\r\nHost: localhost\r\n\r\n";
+ let n = unsafe {
+ libc::write(client_fd, req.as_ptr() as *const _, req.len())
+ };
+ assert_eq!(n, req.len() as isize);
+
+ let router = Router::new()
+ .route(Method::Get, "/healthz", |_, _| Response::ok().text("ok"));
+
+ let mut conn = Connection::new(server_fd);
+ let want_writable = conn.drive(true, false, false, false, &router).unwrap();
+ assert!(!want_writable, "small response fits in one write");
+ assert!(conn.is_done());
+
+ let mut buf = [0u8; 4096];
+ let n = unsafe {
+ libc::read(client_fd, buf.as_mut_ptr() as *mut _, buf.len())
+ };
+ assert!(n > 0);
+ let s = std::str::from_utf8(&buf[..n as usize]).unwrap();
+ assert!(s.starts_with("HTTP/1.1 200 OK\r\n"), "got: {s}");
+ assert!(s.ends_with("\r\n\r\nok"));
+
+ unsafe { libc::close(client_fd); }
+ // server_fd is closed by Connection::drop.
+ }
+
+ #[test]
+ fn returns_404_for_unknown_path() {
+ let (server_fd, client_fd) = socketpair_nonblock();
+
+ let req = b"GET /missing HTTP/1.1\r\n\r\n";
+ unsafe { libc::write(client_fd, req.as_ptr() as *const _, req.len()); }
+
+ let router = Router::new()
+ .route(Method::Get, "/healthz", |_, _| Response::ok().text("ok"));
+ let mut conn = Connection::new(server_fd);
+ conn.drive(true, false, false, false, &router).unwrap();
+
+ let mut buf = [0u8; 4096];
+ let n = unsafe { libc::read(client_fd, buf.as_mut_ptr() as *mut _, buf.len()) };
+ let s = std::str::from_utf8(&buf[..n as usize]).unwrap();
+ assert!(s.starts_with("HTTP/1.1 404 Not Found\r\n"), "got: {s}");
+
+ unsafe { libc::close(client_fd); }
+ }
+}
diff --git a/crates/rt/src/http/listener.rs b/crates/rt/src/http/listener.rs
new file mode 100644
index 0000000..7fd4fc8
--- /dev/null
+++ b/crates/rt/src/http/listener.rs
@@ -0,0 +1,150 @@
+//! Non-blocking TCP listener — `socket(2)` + `bind(2)` + `listen(2)` + `accept4(2)`.
+//!
+//! Adapted from `reference/crates/wo-http/src/listener.rs`. The v1 hand-rolled
+//! IPv4 parser had a byte-order bug for non-localhost addresses; here we
+//! defer to `std::net::SocketAddr` (stdlib, no extra crate) and convert the
+//! resulting octets to a `sockaddr_in` correctly.
+
+use std::io;
+use std::net::SocketAddr;
+use std::os::unix::io::{AsRawFd, RawFd};
+
+pub struct Listener {
+ fd: RawFd,
+ addr: SocketAddr,
+}
+
+impl Listener {
+ /// Bind to `addr` (IPv4 only for now) and start listening with backlog 128.
+ /// Socket is created `SOCK_NONBLOCK | SOCK_CLOEXEC`.
+ pub fn bind(addr: &str) -> io::Result {
+ let parsed: SocketAddr = addr.parse().map_err(|e| {
+ io::Error::new(io::ErrorKind::InvalidInput, format!("bad addr {addr:?}: {e}"))
+ })?;
+ let SocketAddr::V4(v4) = parsed else {
+ return Err(io::Error::new(io::ErrorKind::InvalidInput, "IPv4 only for now"));
+ };
+
+ let fd = unsafe {
+ libc::socket(
+ libc::AF_INET,
+ libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC,
+ 0,
+ )
+ };
+ if fd < 0 { return Err(io::Error::last_os_error()); }
+
+ // SO_REUSEADDR so the same port restarts cleanly between `wo run`s.
+ let one: libc::c_int = 1;
+ let ret = unsafe {
+ libc::setsockopt(
+ fd, libc::SOL_SOCKET, libc::SO_REUSEADDR,
+ &one as *const _ as *const libc::c_void,
+ std::mem::size_of::() as libc::socklen_t,
+ )
+ };
+ if ret < 0 {
+ let err = io::Error::last_os_error();
+ unsafe { libc::close(fd); }
+ return Err(err);
+ }
+
+ let s_addr = u32::from_be_bytes(v4.ip().octets()).to_be();
+ let sock = libc::sockaddr_in {
+ sin_family: libc::AF_INET as libc::sa_family_t,
+ sin_port: v4.port().to_be(),
+ sin_addr: libc::in_addr { s_addr },
+ sin_zero: [0; 8],
+ };
+ let ret = unsafe {
+ libc::bind(
+ fd,
+ &sock as *const _ as *const libc::sockaddr,
+ std::mem::size_of::() as libc::socklen_t,
+ )
+ };
+ if ret < 0 {
+ let err = io::Error::last_os_error();
+ unsafe { libc::close(fd); }
+ return Err(err);
+ }
+
+ if unsafe { libc::listen(fd, 128) } < 0 {
+ let err = io::Error::last_os_error();
+ unsafe { libc::close(fd); }
+ return Err(err);
+ }
+
+ // Resolve the actual bound address — caller may have asked for port 0.
+ let local = read_local_addr(fd)?;
+ Ok(Self { fd, addr: local })
+ }
+
+ /// Accept the next pending connection. Returns `None` on `EAGAIN`.
+ pub fn accept(&self) -> io::Result