feat(endpoint): urus is a supervisable child — endpoint gen_server owns listeners + conns

The v0.3 shape from the spec: an app owns its runtime and root supervisor
and places urus in it as one ordered child among its own.

  your root sup
  └── ChildSpec(Permanent, urus::endpoint(cfg, pipeline)?)  <- Endpoint
      └── listener_sup  OneForOne over N listeners
          └── plain connection actors

- src/conn_registry.rs -> src/endpoint.rs. The registry gains the listener
  pool it registers for and becomes the Endpoint gen_server; ConnRegistry
  -> Endpoint. It runs inline as the ChildSpec's actor
  (NamedGenServerBuilder::run), so supervisor shutdown arrives as
  handle_shutdown and a restart re-runs init on the same still-open fds.
- Endpoint spawns its OWN listener sup in init rather than being its
  sibling: smarm's supervisor start order is not start *readiness* (spawn
  is fire-and-forget), so a sibling listener could whereis the name before
  the registry actor ran. Registrar-spawns-consumers makes it program
  order inside one init. Gap filed in smarm ROADMAP (readiness ack);
  making spawn blocking would only shrink the window, not close it —
  'has begun executing' is not 'has bound its name'.
- Listener sup is monitored: death outside shutdown = panic (loud, the
  app's supervisor decides) instead of a zombie on a dead port. Death
  during shutdown is the 'no new connections' barrier.
- DELETED: the shutdown AtomicBool, LISTENER_TICK (250ms wake per listener
  per tick, now an untimed wait_readable park), SHUTDOWN_POLL (100ms root
  poll — the root parks on the signal channel now), Restart::Transient
  (listeners are Permanent: they only exit by supervisor action, so a
  self-exit always means breakage). Verified against current smarm:
  request_stop unwinds an untimed wait_readable park, is no longer lossy
  against a QUEUED actor, and supervisor shutdown joins in ~200us.
- Config: scheduler_threads/max_actors removed (runtime knobs an
  endpoint-as-child cannot honour) -> serve_with(cfg, smarm::Config, pipe)
  and serve_with_shutdown(cfg, smarm::Config, pipe, signal). Added
  Config.name (default 'urus'): the endpoint's registry name, unique per
  endpoint, and the introspection handle via endpoint::whereis(name).
- serve* keep their meaning as the batteries-included path: they build a
  one-child tree around endpoint() with Shutdown::Infinity. Handle stays
  (a serve* caller has no RuntimeHandle to reach for) and now backs a real
  park instead of a poll.

Tests: 4 drain tests ported onto a real endpoint (bound socket, supervised
child, request_shutdown driven); new integration test boots an app tree
with an ordered sibling and asserts serve-then-drain, reverse-order
teardown and a closed port. 106 lib + 50 integration green.
This commit is contained in:
Claude
2026-08-20 12:59:57 +00:00
parent 5014b870d6
commit 099b6bc320
6 changed files with 840 additions and 689 deletions
+109 -16
View File
@@ -31,10 +31,9 @@ fn spawn_server(pipeline: Pipeline) -> u16 {
std::thread::spawn(move || {
let cfg = Config {
listener_pool: 2,
scheduler_threads: Some(2),
..Config::new(addr)
};
serve_with(cfg, pipeline).unwrap();
serve_with(cfg, smarm::Config::exact(2), pipeline).unwrap();
});
// Wait for the server to actually be listening.
for _ in 0..50 {
@@ -252,10 +251,9 @@ fn panicking_listener_restarts() {
std::thread::spawn(move || {
let cfg = Config {
listener_pool: 1,
scheduler_threads: Some(2),
..Config::new(addr)
};
serve_with(cfg, pipe).unwrap();
serve_with(cfg, smarm::Config::exact(2), pipe).unwrap();
});
for _ in 0..50 {
if TcpStream::connect(addr).is_ok() {
@@ -271,13 +269,13 @@ fn panicking_listener_restarts() {
// Arm the fault: the listener's next accept-loop iteration panics
// *before* accepting, so our connection waits in the kernel backlog
// until the restarted listener picks it up.
urus::serve::INJECT_LISTENER_PANICS.store(1, std::sync::atomic::Ordering::Relaxed);
urus::endpoint::INJECT_LISTENER_PANICS.store(1, std::sync::atomic::Ordering::Relaxed);
let resp = send_request(port, b"GET /ping HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
assert_eq!(http_status(&resp), 200, "request pending across the panic was not served");
assert_eq!(http_body(&resp), b"pong");
assert_eq!(
urus::serve::INJECT_LISTENER_PANICS.load(std::sync::atomic::Ordering::Relaxed),
urus::endpoint::INJECT_LISTENER_PANICS.load(std::sync::atomic::Ordering::Relaxed),
0,
"fault was never consumed — listener didn't wake for the connection"
);
@@ -306,11 +304,10 @@ fn spawn_server_with_handle(
std::thread::spawn(move || {
let cfg = Config {
listener_pool: 2,
scheduler_threads: Some(2),
drain_timeout: drain,
..Config::new(addr)
};
urus::serve_with_shutdown(cfg, pipeline, signal).unwrap();
urus::serve_with_shutdown(cfg, smarm::Config::exact(2), pipeline, signal).unwrap();
let _ = done_tx.send(());
});
for _ in 0..50 {
@@ -445,13 +442,12 @@ fn spawn_server_with_timeouts(
std::thread::spawn(move || {
let cfg = Config {
listener_pool: 2,
scheduler_threads: Some(2),
keep_alive_timeout: keep_alive,
head_timeout: head,
body_timeout: body,
..Config::new(addr)
};
serve_with(cfg, pipeline).unwrap();
serve_with(cfg, smarm::Config::exact(2), pipeline).unwrap();
});
for _ in 0..50 {
if TcpStream::connect(addr).is_ok() {
@@ -476,7 +472,6 @@ fn spawn_server_with_body_gate(
std::thread::spawn(move || {
let cfg = Config {
listener_pool: 2,
scheduler_threads: Some(2),
keep_alive_timeout: Duration::from_secs(30),
head_timeout: head,
body_timeout: body,
@@ -484,7 +479,7 @@ fn spawn_server_with_body_gate(
body_stall_timeout: stall,
..Config::new(addr)
};
serve_with(cfg, pipeline).unwrap();
serve_with(cfg, smarm::Config::exact(2), pipeline).unwrap();
});
for _ in 0..50 {
if TcpStream::connect(addr).is_ok() {
@@ -813,11 +808,10 @@ fn stalled_reader_killed_at_write_timeout() {
std::thread::spawn(move || {
let cfg = Config {
listener_pool: 2,
scheduler_threads: Some(2),
write_timeout: Duration::from_millis(300),
..Config::new(addr)
};
serve_with(cfg, pipe).unwrap();
serve_with(cfg, smarm::Config::exact(2), pipe).unwrap();
});
for _ in 0..50 {
if TcpStream::connect(addr).is_ok() {
@@ -903,11 +897,10 @@ fn chunked_request_over_limit_413() {
std::thread::spawn(move || {
let cfg = Config {
listener_pool: 2,
scheduler_threads: Some(2),
max_body_bytes: 8, // tiny
..Config::new(addr)
};
serve_with(cfg, pipe).unwrap();
serve_with(cfg, smarm::Config::exact(2), pipe).unwrap();
});
for _ in 0..50 {
if TcpStream::connect(addr).is_ok() { break; }
@@ -1816,3 +1809,103 @@ mod channels_wire {
.expect("detached session outlived the drain: the registry-drop chain is broken");
}
}
// ---------------------------------------------------------------------------
// Endpoint as a supervised child (v0.3) — the spec §6 tree shape.
// ---------------------------------------------------------------------------
/// The whole point of v0.3: the APP owns the runtime and the root
/// supervisor; urus is one ordered child among the app's own. A
/// `RuntimeHandle::request_shutdown` on the root sup (the SIGTERM shape)
/// winds the tree down in reverse start order and `rt.run` returns.
#[test]
fn endpoint_as_supervised_child_serves_and_drains() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let port = free_port();
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
let app_child_shut_down = Arc::new(AtomicBool::new(false));
let pipeline = Pipeline::new()
.plug(Router::new().get("/", |c: Conn, _n: Next| c.put_status(200).put_body("app+urus")));
// Endpoint construction is eager about the bind: errors surface here,
// on the app's thread, not inside some actor.
let endpoint = urus::endpoint(
Config {
listener_pool: 2,
drain_timeout: Duration::from_secs(5),
..Config::new(addr)
},
pipeline,
)
.expect("bind");
let (handle_tx, handle_rx) = std::sync::mpsc::channel();
let (pid_tx, pid_rx) = std::sync::mpsc::channel();
let (done_tx, done_rx) = std::sync::mpsc::channel();
let flag = app_child_shut_down.clone();
std::thread::spawn(move || {
let rt = smarm::init(smarm::Config::exact(2));
handle_tx.send(rt.handle()).unwrap();
rt.run(move || {
let sup = smarm::spawn(move || {
smarm::OneForOne::new()
.strategy(smarm::Strategy::RestForOne)
// An app child started BEFORE the endpoint: reverse-order
// shutdown must take the endpoint down first, so when
// this child's guard runs the port must already be dead.
.child(smarm::ChildSpec::new(smarm::Restart::Permanent, {
let flag = flag.clone();
move || {
struct G(Arc<AtomicBool>);
impl Drop for G {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let _g = G(flag.clone());
loop {
smarm::sleep(Duration::from_secs(3600));
}
}
}))
.child(smarm::ChildSpec::new(smarm::Restart::Permanent, endpoint.clone()))
.run()
});
// Export the sup pid for the "signal thread" below.
pid_tx.send(sup.pid()).unwrap();
sup.join().expect("root sup returns normally after shutdown");
});
let _ = done_tx.send(());
});
// Stand-in signal thread state.
let handle = handle_rx.recv().unwrap();
let sup_pid = pid_rx.recv().unwrap();
// Server is up and serving through the supervised endpoint.
for _ in 0..50 {
if TcpStream::connect(addr).is_ok() {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
let resp = send_request(port, b"GET / HTTP/1.1\r\nhost: x\r\nconnection: close\r\n\r\n");
assert!(resp.windows(8).any(|w| w == b"app+urus"), "endpoint serves");
// SIGTERM shape: an outside thread shuts the root sup down.
handle.request_shutdown(sup_pid);
done_rx
.recv_timeout(Duration::from_secs(5))
.expect("rt.run returned after root-sup shutdown");
assert!(
app_child_shut_down.load(Ordering::SeqCst),
"app sibling wound down"
);
assert!(
TcpStream::connect(addr).is_err(),
"listen fds closed: no new connections after shutdown"
);
}