- Board renamed docs/plan/00-kanban.md -> docs/00-status.md and rebuilt: ▶ NEXT PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets — stories, in progress, done, pending, discarded, learnings. It covered only the Rust runtime before, so the whole OOP track was invisible. All 16 inbound refs repointed; `Kanban:` banners renamed to `Status:`. - New discarded.md (settled rejections with reasons: inheritance, `abstract`, Money/SKU/Float, Dynamic/cast/macro/extern, AOT-to-C, Menhir, shared engine state) and learnings.md (plumbed≠enforced, vacuous goldens, exit-0-wrong- output, malloc-path ASan trick, deferred checks that never reach the VM). - RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk, never committed (gitignored path), cited by five docs incl. principle 12. Root cause was broader: all seven forward-roadmap plans in docs/superpowers/plans/ were untracked and ignored, on one disk only. Dropped the docs ignore rules with a do-not-re-add note; added __pycache__/*.pyc. - Repaired broken links across docs/, 270 -> 36: fixes a regression from the earlier reference/ -> .dev/reference/ move (relative paths at ../../ and deeper were skipped), plus depth and reorg drift. The 36 residual point at content that does not exist and need decisions, not paths. - New spec docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md, applied: `and`/`or` verdict row; Part 3 gains `env` (six modules), swaps time.mono for iso/local, adds 22 bare core builtins; throw/time.mono/is cut (0 uses in the sample). Plan 8: Task 2 gains and/or, Task 5 drops throw, abstract+`is` task deleted, 8/9 renumber to 7/8. Plan 9 gains core builtins. Plan 10 gains the 307 -> 0 diagnostic gate. WO-E205 re-filed unreachable-by- design. types.ml header drops its false satisfaction-set claim. 00-code- review.md reduced to a stub — its rival Phase 1-4 roadmap retired.
7.1 KiB
02 — Event Loop on epoll
Context sources: ../01-problem.md, ../02-recovery.md, ./linux/00-linux.md, ./done/01-scafolding-crates.md.
Goal
Land a hand-rolled single-threaded event loop inside crates/rt/ that wraps Linux's file-descriptor primitives directly — without touching tokio, axum, or any other async runtime crate. This is the foundation every later phase builds on: phase 03 puts an HTTP server on top of it, phase 04 retires tokio + axum, phase 07 registers inotify watches on it, phase 08 drives sendfile through it.
Nothing is removed in this phase. The module sits alongside the tokio-backed axum server, unused by the wo binary until phase 04 flips the switch.
Design decisions (locked)
epoll, notio_uring, on day one.epollis ubiquitous (Linux 2.6+), well-understood, and every primitive we need (eventfd, timerfd, signalfd, inotify, accepted sockets) already integrates with it viaepoll_ctl.io_uringis a natural follow-on phase once the event-loop abstraction exists —00-linux.mdcalls it out for that role.- Single-threaded, edge-triggered. Matches 02-wo-language.md § Concurrency Model. Every fd registered with
EPOLLET; the loop reads untilEAGAIN. No worker pool, no cross-thread state. libcis the only new dependency.libc = "0.2"added tocrates/rt/Cargo.toml. Nonix, nomio. Directunsafe extern "C"calls against the kernel surface.- Module, not crate (yet). Lives at
crates/rt/src/runtime/so phase 03 can call into it cheaply. Extraction to the emptycrates/event/sibling is deferred until a second caller appears outsidert— likely whensubstarts consuming the loop for subscription delivery.
Scope
New files inside crates/rt/src/runtime/
| File | Responsibility | Port source |
|---|---|---|
mod.rs |
Re-exports EventLoop, Event, Interest, Token, EventFd, TimerFd, SignalFd |
reference/crates/wo-event/src/lib.rs (9 LOC) |
netpoll_epoll.rs |
EventLoop { fd, events } — new(), register(raw_fd, interest, token), wait_once(timeout) -> &[Event], deregister(raw_fd) |
reference/crates/wo-event/src/epoll.rs (183 LOC); reference/go/src/runtime/netpoll_epoll.go for idiom |
eventfd.rs |
EventFd { fd } — counter semaphore for cross-fd wake-up (subscription dispatch, shutdown signal) |
reference/crates/wo-event/src/eventfd.rs (66 LOC) |
timerfd.rs |
TimerFd { fd } — oneshot + periodic timers as fds for the loop |
reference/crates/wo-event/src/timerfd.rs (91 LOC) |
signalfd.rs |
SignalFd { fd } — SIGINT / SIGTERM / SIGHUP delivered as fd reads for graceful shutdown without a tokio signal handler |
reference/crates/wo-event/src/signalfd.rs (62 LOC) |
Total: ~410 LOC lifted and adapted. The v1 code already compiles standalone in reference/crates/wo-event/ and has unit tests; the port is near-verbatim plus namespace cleanups.
Why runtime/ not event/
Go's equivalent code lives at reference/go/src/runtime/netpoll_epoll.go alongside siblings like netpoll_kqueue.go (macOS/BSD), netpoll_io_uring.go (if/when Go adds it), and the shared netpoll.go interface. The directory name "runtime" signals that this is the layer beneath user code — scheduler / netpoll / syscall shims — and the filename prefix netpoll_<flavour> makes each implementation alternative visible at a glance. Adopting the same convention in writeonce makes porting ideas bidirectional: a reader who knows Go's layout can find the writeonce equivalent by trimming the .go extension and swapping it for .rs. When Phase 3's io_uring arrives it'll land as netpoll_io_uring.rs next to the epoll one; a cross-platform stub would be netpoll.rs. Module boundary and naming both match. See docs/plan/assembly/00-overview.md for why we stop short of mirroring Go's assembly conventions.
Cargo.toml change
[dependencies]
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" # NEW — see docs/plan/02-event-loop-epoll.md
API shape (target — validate against v1 when porting)
use rt::runtime::{EventLoop, EventFd, Interest, Token};
let mut loop_ = EventLoop::new()?;
let ev = EventFd::new()?;
loop_.register(ev.as_raw_fd(), Interest::READABLE, Token(0))?;
ev.write(1)?; // wake the loop from another flow
for event in loop_.wait_once(Some(Duration::from_millis(100)))? {
match event.token() {
Token(0) => { let n = ev.read()?; /* ... */ }
_ => unreachable!(),
}
}
Exit criteria
cargo buildat root compiles cleanly.- A new unit test in
crates/rt/src/runtime/netpoll_epoll.rs:- create an
EventLoop, - register an
EventFd, write(1)to the eventfd from the same thread,wait_once(timeout)returns anEventfor the correct token,read()on the eventfd returns1.
- create an
- A second unit test validates
TimerFd::oneshot(100ms)fires within await_once(500ms)window. - All 14 existing
rttests still pass.cargo run --bin wo -- run docs/examples/blogstill serves (tokio path unchanged). cd reference/crates && cargo build && cargo teststill green (nothing touched).
Non-scope
- No cutover. The
wobinary keeps callingtokio::runtime::Builder::new_current_thread(). That happens in phase 04. - No HTTP. Accepting connections is phase 03's problem. This phase is pure kernel-primitive plumbing.
- No subscription dispatch. The
subcrate doesn't exist yet as real code; phase 07 (inotify) is the first real loop consumer after phase 03. - No crate extraction. Stays at
crates/rt/src/runtime/. Pulling tocrates/event/waits for a second consumer. - No
io_uring. Separate follow-on once the abstraction solidifies.
Verification
cargo build
cargo test --lib runtime # new tests in crates/rt/src/runtime/
cargo test --lib # all 14 existing + new epoll/eventfd/timerfd tests green
cargo run --bin wo -- run docs/examples/blog # axum path unchanged, still serves
cd reference/crates && cargo build && cargo test # v1 untouched
After this phase
Phase 03 puts a non-blocking HTTP/1.1 listener on top of the EventLoop and proves end-to-end I/O without tokio. The two phases together give phase 04 everything it needs to delete the tokio + axum dependencies.