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:
+109
-16
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user