# serve-net - Roadmap
> Path from scaffold to a stable 1.0. Hard parts are front-loaded; each phase has hard exit criteria.
> Design and decisions: [`PLAN.md`](./PLAN.md). The public API, item by item with the version that ships it: [`../docs/API.md`](../docs/API.md).
>
> **Anti-deferral rule:** no listed hard task moves to a later phase unless this file records the move and the reason.
>
> **After every version:** the release routine (section "Release routine" below), then the next version starts.
## Upstream versions each serve-net version needs
A serve-net version is published only when every first-party crate it depends on is on crates.io at the version it needs. serve-net never uses a `path` dependency to get around a wait: a version that is built but blocked is finished, recorded here, and released the day its dependency is published.
| serve-net | Needs |
|---|---|
| 0.2 | net-io 0.2 design lock (published 2026-10-23); http-net 0.6 (published) |
| 0.3 | **net-io 0.3 published** (TCP, the blocking driver, sessions, completer, control line, events); http-net 0.6 |
| 0.4 | net-io 0.4 (PROXY, Unix sockets, the TLS seam with ALPN, inherited and handed-off listeners, descriptor 0); fcgi-net 0.3; ws-net 0.2; tls-net for direct TLS; net-io runtime listener add and remove for reload |
| 0.5 | net-io 0.5 (the reactor, Windows AF_UNIX): published 2026-11-02 |
| 0.6 | http-net 0.6 or later (reply reuse: done); ws-net 0.2 (published); fcgi-net and tls-net for the items moved from 0.5 (D-078) |
| 0.7 to 1.0 | net-io 0.7 or later (its hardening), so serve-net's soak runs on hardened transport |
## Reconciliation with the 0.1 roadmap (recorded 2026-10-23)
| 0.1 roadmap | Now | Change and reason |
|---|---|---|
| 0.2 design lock with pipeline slots and a core contract | 0.2 design lock | the fixed security pipeline and the service core are no longer serve-net's (D-006); the lock is reconciled with net-io's 0.2 lock instead (D-007 to D-035) |
| 0.3 thin slice behind Apache with door, egress, and every pipeline slot | 0.3 core | the gate and the pipeline are the owner's; 0.3 serves a browser on localhost on Windows and Linux, behind a proxy with forwarded headers |
| 0.4 every ingress mode including CGI | 0.4 extended | CGI dropped (D-006); PROXY, Unix, FastCGI, TLS, WebSocket, hand-off, systemd, reload |
| 0.5 scale | 0.5 feature complete | adds every remaining P0 to P2 item |
| (none) | 0.6 optimisation | new, required by the portfolio standard |
| 0.9 hardening | 0.7 hardening | moved earlier, where the standard places it; 0.9 becomes beta |
| (none) | 0.8 alpha, 0.9 beta | new |
No hard task moved later.
---
## v0.1.0 - Scaffold (DONE, 2026-10-13)
- [x] Manifest, README, CHANGELOG, REPS, dual license, CI, deny, clippy, rustfmt, DIRECTIVES, ROADMAP, PLAN.
## v0.2.0 - Design lock (DONE, 2026-10-29)
Exit: James approves `docs/API.md`; every open decision answered or carried with a reason; CI green.
- [x] `docs/API.md`: every public item with signature, behaviour, errors, cost, grade, tier, version, examples.
- [x] `dev/PLAN.md` rewritten: role (D-006), the division of work with net-io (8.2), requirements FR-1 to FR-69 and NFR-1 to NFR-24, threats T-1 to T-40, validation rules, enterprise and distributed (section 6), decisions D-006 to D-035.
- [x] Stale text removed: CGI and the fixed security pipeline out of `Cargo.toml`, `README.md`, `src/lib.rs`, `dev/PLAN.md`, `dev/DIRECTIVES.md`, this file.
- [x] Engine and family traits checked against http-net's `ServerConn`, ws-net's `Connection`, fcgi-net's planned responder, and several requests per connection (HTTP/2, multiplexed FastCGI).
- [x] Hook names agreed with net-io's 0.2 API (`ControlLine`, `Gate`, `EventSink`, `Lane`, `TrustedProxies`, `ListenerHandle`, `Completer`).
- [x] Upstream requests written (PLAN section 12): `TrustedProxies` in net-io 0.3; a per-connection idle override; runtime listener add and remove; per-listener PROXY trust; the raw monotonic clock. http-net reply reuse (OD-3) found already done in 0.5 and 0.6.
- [ ] James approves `docs/API.md` (PLAN O-4).
- [ ] Release routine: 0.2.0 tagged, released, published to claim the name (PLAN O-5).
## v0.3.0 - Core (DONE, 2026-10-29)
Exit: a browser and curl get a page from `serve_net::http("127.0.0.1:8080", ...)` on Windows and WSL Ubuntu; malformed requests are refused; a drain with a slow request in flight works; every state of the connection state machine is covered by the simulated-connection suite; CI green on three OSes.
**Released 2026-10-29.** net-io 0.3.0 reached crates.io on 2026-10-26. The parts that did not need net-io's code were built first on branch `feature/0.3-core`; the wire-up followed on `feature/0.3-net-io` (PR #2), written against net-io's 0.3 code before it was published and then built against the published crate. 0.2.0 was released first (PR #1), the same day.
| Area | Items (`docs/API.md`) | Needs net-io code |
|---|---|---|
| Tier 1 | `http`, `http_behind_proxy`, `handler`, `VERSION` | yes (start) |
| Building | `Server`, `ServerBuilder` (configuration and hooks), `build`, `Built`, `ConfigWarning` | no |
| Running | `bind`, `bind_family`, `Bound`, `open`, `serve`, `Running`, `close_door`, `drain`, `wait`, `DrainHandle`, `DrainReport`, `ListenerInfo`, `health`, `stats` | yes |
| Listeners | `ListenerSpec::tcp` and options, `ListenerSource` (`Tcp`) | no (the translation into net-io's specs: yes) |
| Ingress | `Ingress::Direct`, `Ingress::Proxy` with forwarded headers, `ProxySource`, `ForwardedSource`, `MissingHeader`, `UnknownClient` | no (the trust list type: net-io's `TrustedProxies`, requested for 0.3) |
| Normalization | `ClientInfo`, `Scheme`, `IngressKind`, the trusted walk | no |
| Requests | `Envelope`, `RequestSeq`, `HttpService`, `HttpHead`, `Fields`, `HeadAction`, `BodyPolicy` (`Buffer`, `Discard`), `HttpRequest`, `HttpBody`, `HttpReply`, `Outcome`, `Finished`, `RequestEnd`, `WorkClass`, `DetachedHead` | no |
| Deferral | `ReplyToken`, `CompleteError` | the completer: yes |
| Profiles | `Profile`, `ProfileId`, `Budget`, `ProfileTable`, `EngineChoice` (`Http1`, `Custom`) | no |
| Limits | `Limits`, `KeepAlive`, `Workers` (blocking, auto) | no (mapping onto net-io limits: yes) |
| Hooks | `Hooks`, `HookSet`, `DefaultHooks`, `Admit`, `InFlightCap`, `Dispatch`, `Inline`, `Checkpoint`, `NoCheckpoint`, `Observer`, `NoObserver` | no |
| Errors | `ServeError`, `ConfigErrors`, `ConfigError`, `RuleId`, `Subject`, `EndReason` | `ServeError::Bind` carries net-io's error |
| Engines | `Engine`, `EngineEvent`, `EngineProgress`, `EngineError`, `Refusal`, `RefusalKind`, `OutBuf`, `Family`, `HttpFamily`, `Reply`, `Service`, `NoCustom`, `engine::Http1` | no |
| Connection | the state machine (events in, actions out), deadlines, body buffering, pipelining, refusals | no |
| Session | the net-io `Session` and `SessionFactory` over the state machine; soft caps; the merged control line | yes |
| Snapshots | `Snapshot`, `SnapshotReader` | no |
| Testing | `testing::respond`, `TestRequest`, `TestReply` | no |
Tests: threats T-1 to T-9, T-11 to T-17, T-22 to T-24, T-26, T-28, T-29, T-32 to T-36, T-40; invariants 1 to 10 of PLAN section 11; the simulated-connection suite; validation rules; fuzz targets `forwarded_xff`, `forwarded_rfc7239`, `forwarded_proto_host`, `conn_sim`, `config` in CI smoke mode; loom models for `Snapshot`, the deferred-reply slot, the soft-cap counters, and drain; the counting-allocator test; benchmarks for the walk, the checkpoint, `InFlightCap`, `Snapshot`, and the null-engine loop.
net-io answered every request (2026-10-23, net-io D-081): `TrustedProxies` and `SessionContext::set_idle_timeout` ship in net-io 0.3.0; listener add and remove, per-listener PROXY trust, and the raw monotonic clock in 0.4.0. Keep-alive under pressure is now as specified (D-043).
**Progress (branch `feature/0.3-core`, 2026-10-23).** Built and tested without net-io: the forwarded-header parser and trusted walk; `ClientInfo`, `Scheme`, `IngressKind`; `Limits`, `KeepAlive`, `Profile`, `Budget`, `ProfileTable`, `EngineChoice`, `Workers`, `ListenerSpec`, `Ingress`, `ProxySource`; every validation rule that applies to 0.3; `Server::builder()` to `build()` with every hook setter; `Envelope`; the engine and family traits, `OutBuf`, refusals; `engine::Http1` over http-net 0.6; the HTTP family (`HttpService`, `HttpHead`, `HttpRequest`, `HttpReply`, `handler`); deferred replies (`Reply`, `ReplyToken`, `CompleteError`); `InFlightCap`, `Inline`, `Checkpoint`, `Observer`; `Snapshot`; the connection state machine with its simulated-connection suite (T-1, T-2, T-5, T-7, T-8, T-9, T-22, T-23, T-24, T-26, T-28, T-36; invariants 1, 4, 7); `testing` with `TestConnection`. Also done: the counting-allocator tests (zero allocations per request in steady state: GET, 4 KiB POST, pipelined batches, refusals; NFR-2, P-14); property tests for the walk and the machine (invariants 1 and 6 under arbitrary splits and output budgets); fuzz targets `forwarded_xff`, `forwarded_rfc7239`, `conn_sim`, `config` (clean after 60 s each, and a CI smoke job); loom models for `Snapshot`, the deferred-reply slot (complete against cancel, drop), and the shared in-flight counter, with a CI job; criterion benchmarks for the request path, the walk, the snapshot reader, and the control check. Measured on a shared development machine (only for comparing builds): keep-alive GET about 0.9 µs through engine and machine, the 3-entry walk about 165 ns (target 100 ns [est.], met or explained at 0.6), snapshot read and control check about 1 ns. Local gate green on Windows and WSL, stable and 1.85.
**Wire-up items.** On branch `feature/0.3-net-io` (stacked on `feature/0.3-core`). Written against net-io's 0.3 code before it reached crates.io with a command-line `[patch]` (never a path dependency in the repository); since 2026-10-26 it builds against the published net-io 0.3.0.
- [x] `net-io = "0.3"` in `Cargo.toml`; `src/net.rs` is `pub use net_io::{..}` (D-037); the `allow(dead_code)` notes that named net-io are gone. The crate-level `cfg_attr(not(feature = "http1"), allow(dead_code))` stays for another reason: without the HTTP family the forwarded walk has no reader.
- [x] `conn/session.rs`: net-io `Session` and `SessionFactory` over `Conn` (maps `Step`, `Phase`, `Timeout`, `set_idle_timeout`); soft caps (global, listener, profile) taken in `SessionFactory::open` and given back on drop; one `Completer` waker per worker; queued output flushed before `Read` or `Park` (D-045).
- [x] The merged control line (D-032) with `wake_all` after a drain request; `soft cap + headroom` workers on the blocking driver (D-043, D-044); the per-source cap behind a front end (D-046).
- [x] `Built::bind`, `bind_family`, `Bound` (`local_addrs`, `open`), `ServerBuilder::serve`, `Running` (`local_addr`, `local_addrs`, `close_door`, `drain`, `drain_handle`, `wait`, `server_handle`, abortive `Drop`), `DrainHandle`, `DrainReport`; `ServeError::Bind` naming the listener, `ServeError::Start`.
- [x] Tier 1 `http`.
- [x] Loopback tests (`tests/loopback.rs`): routes and keep-alive, pipelining, sequential connections, drain through `DrainHandle` closing an idle keep-alive connection, a bind conflict naming the listener, 24 connections from one peer behind a proxy.
- [x] A browser-style check: a local demo (not in the repository) served a page, `/health`, and a 404 on 127.0.0.1 with keep-alive, about 17,000 requests per second on one connection and 190,000 on 15 (p99 0.37 ms), and stopped cleanly on Ctrl+C.
- [x] Gate denials: every reply is marked as a response start, and a denial before its first byte is answered with the engine's refusal for the gate's code (D-050); gate and event-sink hooks (`gate`, `events`) passed to net-io, the event sink behind serve-net's health tap; `EndReason::Transport(CloseReason)`.
- [x] `Running::stats` and `Stats` (per-worker request counters plus net-io's, D-048), `health` (D-049), `listeners` and `Bound::listeners` (`ListenerInfo`, `ListenerState`); `DrainReport::requests_finished` and `refused_while_draining`; replies in flight during a drain carry the close signal (D-047).
- [x] Tier 1 `http_behind_proxy`; the `hello` and `behind_proxy` examples.
- [x] Loopback tests on Windows, Linux, and macOS in CI: Tier 1 behind a proxy, stats, health and listener state, a malformed request refused and counted, a gate denial on the second reply of a keep-alive connection, net-io's events and end reasons, a drain with a slow request in flight, and the leak test (rounds at the soft cap: every round fully served, the one over the cap answered 503, no connection left open).
- [x] `InFlightCap` benchmarks through the request path: per-worker shares about 4 ns over no cap, the shared counter about 14 ns (keep-alive GET about 330 ns on a shared machine; numbers for comparing builds only).
## v0.4.0 - Extended (DONE, 2026-11-02)
Exit: the ingress cases run for direct, PROXY v1 and v2, forwarded headers, and Unix sockets; the restart test passes with a shared socket (zero refused or reset); the interop CI job (nginx, Apache, HAProxy containers) is green.
- API: `ServerConfig`, `ServerBuilder::config`, `reload`, `ReloadSummary`, `ListenerSpec::{unix, inherited, handed_off}`, `fallback_bind`, `unix_mode`, `UnixMode`, `Ingress::{DirectTls, Unix}`, `ProxySource::proxy_protocol`, `UnixPeerTrust`, `ServerBuilder::{tls, notify}`, `Notify`, `NoNotify`, `SystemdNotify`, `Finished::head_reads`, `ClientInfo::peer_credentials`, `ServeError::{Inherited, HandOff, Reload, ReloadBind}`; and what 0.3.0 missed: `transport_limits`, `clock`, `listen_options`, `detach_head`, `DetachedHead`, the observer's lifecycle events (D-061, D-065).
- Threat tests T-10, T-18, T-20, T-30, T-31, T-38, T-39.
- `docs/DEPLOYMENT.md`: Apache, nginx, HAProxy, Unix sockets, systemd, reload, the restart model.
- Moved to 0.5.0 (D-066, a hard reason: the crates do not exist yet): FastCGI, WebSocket upgrades, `Http1OrH2ByAlpn`, threat tests T-19 and T-21, the `fastcgi_mapping` fuzz target, a TLS test with a real engine, a `Type=notify-reload` run under real systemd.
## v0.5.0 - Reactor (DONE, 2026-11-03)
Exit: on net-io's reactor, 10,000 idle keep-alive connections with bounded memory on Linux, macOS, and Windows; deferred replies through an `Offload` pool; every P0, P1, and P2 item of `docs/API.md` implemented and documented; `docs/PLATFORM-NOTES.md` written.
Released as "Reactor", not "Feature complete": the items over other crates moved to 0.6.0 (D-078). The rest of the exit is met.
- [x] net-io 0.5's reactor (D-075): `Workers::reactor`, and `Workers::auto()` picks it; `max_deferred_per_worker` reaches its completion queue. CI runs the whole suite on both drivers.
- [x] 10,000 idle keep-alive connections in CI on Linux, macOS, and Windows: 76 to 90 MiB, every connection answering afterwards (`tests/idle.rs`). Bounded, at about 9 KB each against NFR-3's 4 KiB, due at 0.6 (D-076).
- [x] API done: `Budget::dedicated_workers` (blocking driver, D-074), `BodyPolicy::Stream`, `HttpBody::Chunk`, `HttpReply::stream`, `ReplyStream`, `Outcome::More` (D-073), `catch_service_panics`, `InFlightCap::reserve` (D-069), `Offload`, `OffloadPool`, `OffloadJob` (D-071), `WatchdogPinger` (D-068).
- [x] Threat tests T-25, T-27, T-37.
- [x] `docs/PLATFORM-NOTES.md`.
- Moved to 0.6.0 (D-078): WebSocket upgrades, FastCGI, `Http1OrH2ByAlpn` with an end-to-end TLS test, a `Type=notify-reload` run under real systemd.
- Carried, each in the register below: the drain workaround of D-072 until net-io fixes the accept race; `dedicated_workers` on the reactor is a connection seat, not a worker set of its own, because net-io 0.5 has none.
- Open on Windows, named in the release notes: T-30. `tests/restart.rs` passes on a 24-core machine and fails on CI's small runners with connection resets and connects that time out, in net-io 0.5's in-process hand-off of one socket to two servers. Reported to net-io; the test stays Unix only until it passes there. Closed in 0.7.0 (D-100).
## v0.6.0 - Upgrades (DONE, 2026-11-04)
Exit: NFR-1, NFR-2, NFR-3, NFR-5 met, or each gap recorded in `docs/PERFORMANCE.md` with its cause and the plan to close it before 1.0 (a missed 20% target blocks 1.0); `docs/PERFORMANCE.md` published; every item moved from 0.5 done, or recorded here as waiting for a crate that is not on crates.io.
- [x] WebSocket upgrades over ws-net 0.2 (D-079): `HeadAction::Upgrade`, `UpgradeTarget`, `UpgradedConn`, `HttpService::upgraded`, `WebSocketTarget`, `WebSocketService`, `WebSocketConn`, `WsMessage`, `WsSendError`, `Family::upgrade`, `RequestEnd::Upgraded`, feature `ws`; T-21 (`tests/websocket.rs`, on both drivers).
- [x] A `Type=notify-reload` unit reloading under load under real systemd, in CI on Linux: `examples/systemd.rs` as a transient unit with `WatchdogSec=2`, ten reloads under 2,000 requests, every answer 200, a clean stop (`.github/systemd/run.sh`). Every external call in the script and every CI job has a time limit (D-080).
- [x] `docs/PERFORMANCE.md` with the method, the hardware, and every target's status (D-081). NFR-1: 172 ns against 150 ns, down from about 220 ns; NFR-2 met; NFR-3 waits on net-io (D-076); NFR-11 met (0.32 ns).
- [x] Idea I-3 measured and decided: the copy is 1.7 ns per head and about 6.5 ns per KiB, not worth an http-net API for heads (D-081).
- Benchmarks against hyper with hyper-util, actix-web, may-minihttp, nginx, h2o, and Pingora (restart and idle memory only), the overload targets P-6 and P-7, P-12, and idea I-6: waiting for reference hardware (a load generator on a separate machine, PLAN section 10.2), a hard reason: a shared development machine measures its own load generator.
- Released as "Upgrades" with the items over other crates moved to 0.7.0 (D-082): FastCGI and TLS with ALPN wait for fcgi-net and tls-net, which are not on crates.io; the idle trim and WebSocket push wait for net-io 0.6.0, which carries the fixes. The competitor benchmarks (NFR-5) are a 1.0 blocker in `docs/STABILITY-1.0.md`.
- Settled during the release: a local run of the systemd job that looked stuck for over 280 s. A stale failed unit left the user manager `degraded`, the script fell back to `sudo`, and sudo's password prompt under `timeout` stopped the job with `SIGTTIN`. Fixed in the script and with a time limit on every CI job (D-080); CI was not affected.
## v0.7.0 - Hardening (DONE, 2026-11-07)
Exit: zero open findings; the 24 h soak clean (NFR-18); `docs/SECURITY.md` written; every item moved from 0.6 done, or recorded here as waiting for a crate that is not on crates.io.
- FastCGI moves to 0.8.0 (D-097): fcgi-net is not on crates.io, a hard reason.
- [x] Moved from 0.6 (D-082): an end-to-end TLS test (tls-net 0.5 on ring, `tests/tls_net.rs`) and `EngineChoice::Http1OrH2ByAlpn`, with every profile's ALPN result checked at open (D-085).
- [x] Moved from 0.6 (D-082): the idle trim (D-087), on net-io 0.6.1: 4,049 bytes per idle connection, NFR-3 met.
- [x] Moved from 0.6 (D-082): WebSocket push from another thread through a bounded queue (`WsSender`), and a 1001 Close at drain start (D-092).
- [x] 0.6.2 (D-084): T-17 fixed (an untrusted peer could set the authority `call` saw with `trust_host` on), found by the new `forwarded_proto_host` fuzz target; attack tests for T-3, T-6, T-8, T-13, T-17, T-29, T-34, T-35 in `tests/threats.rs`.
- [x] Attack tests for T-15 (a dual-stack listener reports and caps `::ffff:127.0.0.1` as `127.0.0.1`; IPv6 listeners are v6-only unless asked) and T-18 (the default socket mode is `0o660`).
- [x] Attack tests for T-32 (descriptor exhaustion: no accept spin, serving resumes; Linux, under `ulimit -n 128`), T-33 (64 connections of 256 KiB replies, the heap flat across rounds), T-40 (one deadline per connection, a fixed number of sets per request). Every threat now has an attack test except T-19, which waits for FastCGI. `RuleId::FastCgiTrust` lands with FastCGI; `RuleId::ForwardedOnDirect` cannot occur because `Ingress::Direct` carries no forwarded source, so it is documented as such or removed at the 0.9 API review.
- [x] NFR-5 tooling (D-086): `compare/` against hyper, pinned on the development host and on a hosted runner; first hosted numbers in `docs/PERFORMANCE.md` (about 5% behind hyper, against 20% ahead). The pinned run waits for a quiet window.
- [x] The 24 h soak (`compare soak`, D-086) on net-io 0.6.2 (D-099): clean (D-101). 24.0 h, 183,854,328 requests, 0 unexpected, 0 transient; RSS 18,700 KiB at the 10-minute baseline, worst 20,736 KiB; descriptors 143 at the baseline, worst 148. The run on net-io 0.6.1 as well: 134,516,832 requests, 0 transient, RSS 15,584 KiB at the baseline falling to about 8.7 MiB.
- [x] Loom models complete (D-096): two concurrent drains, a drain against an upgraded connection closing (which found a race, fixed), a sender against the close, with the snapshot, deferral slot, and in-flight cap.
- [x] Kill-at-every-step restart testing (idea I-9, D-095): four kill points under load; nothing refused, nothing begun after the kill lost.
- [x] The leak test (NFR-19, D-093): 100,000 connections opened and closed, the heap flat (0 bytes over the second half at 40,000).
- [x] The adversarial review (0.7): seven findings, each fixed with a test that fails without it. Three affected released versions and shipped in 0.6.3 (D-088, GHSA-jcp7-qqq6-xw24); V-24 and the `AnyLocalPeer` warning (D-089); a reload's limit for streamed bodies (D-090); a time bound per WebSocket message (D-091).
- [x] `docs/SECURITY.md` (the security model, defaults, a deployment checklist, what is not defended, testing, advisories) and a root `SECURITY.md` (reporting and supported versions).
- [x] Every fuzz target running with an out-of-repo corpus (CI's fuzz smoke, `RUNNER_TEMP`); every threat with its attack test except T-19, which waits for FastCGI; loom models complete; the leak test; kill-at-every-step restart testing (idea I-9); an adversarial review with every finding fixed test-first.
- 0.7.1 (D-102): the busy refusal at the soft cap closed without the linger and drew a reset (Windows: an aborted connection instead of the 503); found by main CI after 0.7.0, fixed with a test that fails without it.
## v0.8.0 - Alpha
Exit: a production site served for two weeks without a serve-net defect; `docs/ARCHITECTURE.md` draft; no API change needed by the first real wrapper that is not already made.
- Moved from 0.7 (D-097). When fcgi-net is published: FastCGI on Linux and macOS first (`fastcgi`, `FastCgiListen`, `FastCgiIngress`, `Ingress::FastCgi`, `engine::FastCgi`, `ListenerSpec::fastcgi_descriptor_zero`, T-19, the `fastcgi_mapping` fuzz target, the T-19 attack test, `mod_proxy_fcgi`, nginx `fastcgi_pass`, `mod_fcgid` descriptor 0). Windows waits for the decision on a Windows FastCGI deployment.
- A real application wrapper built on serve-net's hooks; a custom-family prototype for a database wire protocol.
- Adopt net-io 0.8: the drain grace for a connection that has not sent its first byte (its D-189, `Limits::first_request_grace`), `Session::on_drain`, `ListenerSpec::workers`, and the linger after a reply sent while idle (its D-192), which makes D-102's `set_phase(Sending)` redundant; keep it (harmless) unless the tests show a reason. Rerun `tests/restart.rs` and the soft-cap loopback test on it.
## v0.9.0 - Beta
Exit: no API change across two beta releases; every document complete.
- API freeze candidate; one non-HTTP service end to end; the Windows service status adapter (outside serve-net) exercised.
## v0.9.x - Pre-1.0 polish (before any 1.0 release candidate)
James's standing rule (2026-10-29): "make sure to go back and fix any issues / perf tune / etc before we get close to release." Nothing accepted during the 0.x series becomes final silently: each item below is fixed, measured, or written into the 1.0 readiness note for James with the reason it stays.
Exit: every item in the register below is closed or carried into `docs/STABILITY-1.0.md` (the 1.0 readiness note) with a reason and a cost; the performance pass and the security and edge-case review are recorded; no `-rc` tag before this exit.
**1. Revisit every accepted gap, deferred item, known issue, and missed target.** The register (kept current as versions land; add to it instead of accepting a gap quietly):
| Item | From | What to decide or do |
|---|---|---|
| The forwarded walk measured about 165 ns for three entries against a 100 ns target [est.]; 137 ns at 0.6 (`docs/PERFORMANCE.md`) | 0.3 progress, D-040 | meet the target or record why not, with numbers |
| Closed in 0.7: the two manual-clock loopback tests (`test_loopback_a_slow_body_is_answered_by_the_request_deadline`, `test_loopback_manual_clock_drives_the_head_deadline`) failed under full machine load because a wake could land before the worker waited and the test trusted one wake inside a 5 s window | 0.6.1 and 0.7 gates | both now wake again until the reply arrives, up to 30 s; 8 of 8 runs passed at 100% CPU |
| Windows timing tests on the local host fail at load averages near 500 (16 runnable threads per hardware thread): the two manual-clock loopback tests in most full-suite runs, and up to four loopback tests in one run. A/B on 2026-11-06, alternating runs: net-io 0.6.1 failed 3 of 4, 0.6.2 failed 3 of 4, both pass alone and pass at lower load | the 0.7.0 gate on net-io 0.6.2 | not a defect in serve-net or net-io: the same runs fail on both net-io versions. At that load the local Windows gate is not evidence; the hosted Windows CI is. Before 1.0, decide whether these tests should wait on an event instead of a 30 s bound, so a loaded gate stays usable |
| Closed in 0.7 (D-100): the I-9 kill tests failed on hosted Windows CI on the blocking driver (a request begun after the kill unanswered past the client's 5 s read timeout; once a connect timeout). net-io traced it: nothing was lost, the new server's 256-thread pool took 11 s to start on a starved core while the requests waited in the backlog | the 0.7.0 CI | the test servers run a 64-thread pool on the blocking driver and the clients wait 30 s; every restart test runs on every system and driver. A connect that times out is not counted as a loss: with four clients the backlog cannot fill, so it is the host's TIME_WAIT (net-io R-44), seen once on the Windows reactor in CI; refusals and resets still fail the test. net-io adds the case as a regression test (its D-188) |
| net-io: a graceful drain on Linux can close a just-accepted connection whose first request has not arrived (the client sees EOF with no reply); net-io saw it in 1 of 10 runs of its in-process restart test | net-io, 2026-11-06 | fixed in net-io 0.8 (a grace before a new connection counts as idle); serve-net adopts 0.8 in its 0.8.0 and reruns `tests/restart.rs` on it. Not in 0.7.0 because the fix is not published; serve-net's own restart tests have not hit it |
| Closed in 0.7: the restart test `test_i9_kill_before_the_new_server_starts` failed once on Linux at load 500 because it stopped its clients 300 ms after the kill, before any request begun after the kill had finished | the 0.7.0 gate on net-io 0.6.2 | the I-9 tests now run until a request begun after the kill is served, up to 30 s, then check the same guarantees |
| NFR-18's "RSS growth under 1% after warm-up" is not what `compare soak` checks (it fails at 25% over the baseline), and RSS cannot answer it on a shared host: on net-io 0.6.2 the hourly mean rose from about 18.5 to 19.3 MiB (4%) while on 0.6.1 the same mix fell from 15.2 to 8.7 MiB, so the kernel's and the allocator's page handling dominate a 1% bound (D-101) | the 0.7.0 soak | before 0.9 (when NFR-18's soak job is due): the soak's server reports live heap bytes from a counting allocator on `/stats`, and the soak fails on 1% heap growth after warm-up; RSS stays as a coarse bound |
| Closed in 0.7: the soak's 540 MB under concurrent 256 KiB replies was net-io 0.6.0's buffer-budget defect; on net-io 0.6.1 the same mix holds 15 MB | the 0.7 soak | the static-body copy into the reply buffer stays a performance item |
| serve-net's own work per keep-alive request is 172 ns against NFR-1's 150 ns | D-081 | profile on a Linux host with `perf` (the replied-at clock read, the reply build and encode, the second head view, the counters); meet the target or record why not |
| NFR-5 and the overload targets are not measured | D-081 | run PLAN section 10.2 on reference hardware before 1.0; a missed 20% target blocks 1.0 |
| Every [est.] cost in `dev/PLAN.md` | PLAN section 9 and the threat table | each replaced by a benchmark (due at 0.6), or listed as unmeasured with the reason |
| The engine keeps its buffer between keep-alive requests; idle memory returns only through the reactor's per-worker pools | D-041 | confirm the reactor pools bound idle memory (NFR-1), or shrink after an idle period |
| With no soft cap configured, the blocking pool's soft cap is the pool minus its headroom (224 of 256) | D-044 | confirm the default with a measured overload run (NFR-6), or change it |
| The per-source connection cap behind a front end | D-046, D-053 | applied per listener since 0.4 (net-io 0.4); confirm a mixed direct and proxied server keeps the cap on its direct listeners (a test exists from 0.4) |
| The machine reads the live control line because net-io's `draining()` is fixed for one callback | D-047, hello-serve feedback 6 | if net-io makes `draining()` live, drop the extra load or keep it with a measured reason |
| The timeout-to-close-reason table is copied from net-io (`CloseReason::for_timeout` is crate-private) | hello-serve feedback 7 | use net-io's when public, or keep a test that the tables agree |
| `health`: stall = inside a service call without progress since the previous call; overload = more than 20% busy refusals since the previous call | D-049 | check against a real supervisor and a stalled-worker test under load; tune or document |
| `DrainReport::deadline_hit` is derived from `killed > 0` | 0.3 | report net-io's own deadline state if it exposes one |
| Gate denials: a status outside 400 to 599 becomes 403 | D-050 | confirm with bouncer-io's codes |
| The crate-level `allow(dead_code)` without the `http1` feature | 0.3 | gate the forwarded walk and the HTTP head context on the feature instead |
| `Running` keeps its server in an `Option` and reaches it through `unreachable!()` after `drain` or `wait` took it | 0.3 | a shape with no unreachable path |
| Idle memory with the trim (D-087, D-094): 3,116 bytes on macOS and 3,909 on Linux meet NFR-3's 4 KiB; Windows measures 4,153, about 150 bytes of net-io's completion-port bookkeeping over | D-076, D-094 | ask net-io for a smaller idle footprint on Windows, or move `Request` into a slot reused across requests; until then `tests/idle.rs` allows 4.25 KiB on Windows |
| Closed in 0.7 (D-100): T-30 on Windows. net-io traced the lost requests to the new server refusing at its own caps (the per-source cap of 16 on the blocking driver) while it took over the old server's clients on a slow host; two servers on one socket in one process are supported | CI, 0.4 and 0.5; known issue in the 0.5.0 release notes | `tests/restart.rs` runs on every system with per-source room of 64; `docs/DEPLOYMENT.md` says to start the new server with the old one's caps or more |
| `dedicated_workers` on the reactor keeps connection seats, not workers: a flood on another profile still shares the dedicated profile's worker threads | D-074, 0.5 | a worker set per profile from net-io (asked), or document the seat as the guarantee |
| Closed in 0.7 (D-092): a WebSocket service sends from any thread through `WsSender`, and a drain through `Running::drain` or `DrainHandle` closes open sockets with 1001; a drain started only on the owner's control line still closes a quiet socket without one | D-079 | decide at the 0.9 review whether the owner's line needs a notice hook |
| ws-net keeps message and input buffers that a wiping profile does not zero | D-079 | ws-net to offer a wipe (asked); until then document it next to `wipe_buffers` |
| A gate denial of the 101 closes the connection instead of answering 403 | D-079 | mark the 101 as a reply start (`begin_reply`) and give the upgraded state the HTTP refusal until the 101 leaves |
| `UpgradeTarget::Custom` from the locked text is not built | D-079 | build it with the first custom family that upgrades, or drop it from the API |
| WebSocket idle memory and per-message cost are estimates | D-079 | measure in the 0.6 performance pass beside the 10,000 idle test |
| A drain settles accepts by watching counters (at most 150 ms), and a listener removed by reload can reset connections in the accept window | D-072 | drop the workaround when net-io fixes the race; reported to net-io |
| An offload job copies the head and the body, and the copy outlives the worker's wipe | D-071 | measure the copy; hand the body over without one, and wipe the copy under a wiping profile |
| A service that streams stays on the worker, and a slow generator on the reactor stalls that worker; a streamed reply cannot be deferred once its head has left | D-073 | decide with real use whether parts should go through a pool or a token |
| `DetachedHead` keeps its own copy format while http-net now has `OwnedRequestHead` | D-071 | build one on the other if the public shape allows it |
| `tests/loopback.rs` exhausts loopback ports next to a restart loop, and `Outcome::More` or a reserve under a real flood has no soak yet | 0.5 | cover in the 0.7 soak |
| Two `impl InFlightCap` blocks (the index-based internals for tests) | 0.3 | fold into one |
| serve-net handles no signals; every first program writes a stop channel | OD-8, hello-serve API friction | decide with the examples and a real deployment, as OD-8 planned for 0.9 |
| Showing transport counters by close reason needed hand-built `CloseReason` values | hello-serve API friction | net-io 0.4 added `Stats::closes` and `Stats::refusals`; check serve-net's `Stats` reads as easily |
| `InFlightCap` budgets (`max_in_flight`) keep their start-up values across a reload, and http-net's engine limits apply to new connections only | D-063 | give admission a reload path that costs nothing on the hot path (atomics sized at start), or document it as final |
| `DetachedHead` makes one plain copy per detach; the API describes pooled buffers | D-065 | pool per worker (with `Offload`, 0.5), or correct the API |
| net-io's PROXY listeners and TLS cannot change on a live listener | D-063 | fine as `ReloadListener`, or ask net-io for it if a deployment needs it |
| The test suite needs `RUST_TEST_THREADS=4`: 256 threads per default server exhaust descriptors | 0.4 | the reactor removes the cause; confirm and drop the setting |
| A failed `bind` closes inherited and handed-off sockets instead of releasing them to the owner | 0.4, D-059 | needs net-io to hand adopted sockets back when `build` fails (asked in the shared notes); then release them |
| Anything else listed as "not yet" or "deferred" in this file, `dev/PLAN.md`, or `CHANGELOG.md` | all versions | sweep the documents for open wording before the exit |
**2. A fresh performance tuning pass.** Profile the request path, admission, the forwarded walk, the drain, and connection setup on the reactor and the blocking driver; compare against the 0.6 baselines and the NFR-5 competitor numbers; fix regressions, take the wins that cost no security, and record every change with its security impact (NFR, `docs/PERFORMANCE.md`).
**3. Re-run the security and edge-case review.** Every threat T-1 to T-40 re-tested against the release candidate's code, every validation rule V-1 to V-26, the fuzz targets with a long run on an out-of-repository corpus, the loom models, the leak test at 100k connections (NFR-19), and an adversarial review of every hook seam; findings fixed test first.
## v1.0.0 - Stable
- Release candidates (`1.0.0-rc.N`) only after the v0.9.x polish exit, with no API change across two of them.
- API frozen until 2.0; the full audit (API, docs, tests, benchmarks, security) recorded in `docs/STABILITY-1.0.md`; `dev/DIRECTIVES.md` definition of done item 10 satisfied. James decides the release.
## After 1.0
`engine::H2` (earlier as a minor version if h2-net reaches its 0.3 first), multiplexed FastCGI, systemd `FDSTORE`, lane queues with weighted deficit round robin, mod_fcgid on Windows (named pipes), cleartext HTTP/2 with prior knowledge, `Workers::uring`.
---
## Release routine
1. Finish the version: every item of its entry done; version bumped everywhere it appears (`Cargo.toml`, README install snippet, `docs/API.md`, rustdoc examples); `CHANGELOG.md` rolled; `docs/release/vX.Y.Z.md` written; README and `docs/API.md` checked against the build.
2. `python dev/api-audit.py`: every item `docs/API.md` tags for this version or earlier exists in the source.
3. Local gate on Windows and WSL, stable and 1.85: fmt, clippy (default, all, no default features), tests, `cargo doc` with default and all features under `-D warnings`, `cargo package` in a fresh target directory.
4. Commit as James Gober and push a `feature/` branch; open a pull request.
5. Wait for CI on every platform; fix in place if it fails (never a follow-up patch release for a red version).
6. Squash-merge; tag `vX.Y.Z` (lightweight) and push it.
7. `gh release create vX.Y.Z --title "vX.Y.Z - <Name>" --notes-file docs/release/vX.Y.Z.md --prerelease` (0.x).
8. `cargo doc --no-deps` with default and all features: no warnings.
9. `cargo publish --dry-run`, then `cargo publish`.
10. Confirm the version on crates.io and the docs.rs build.