Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab93802d0b | ||
|
|
5442271229 | ||
|
|
e02527a606 | ||
|
|
9eaa85b9df | ||
|
|
8f0da2a806 | ||
|
|
8a568c600c | ||
|
|
b37888ec2c | ||
|
|
099b6bc320 | ||
|
|
5014b870d6 | ||
|
|
8bdec97842 | ||
|
|
f3ccb6e468 | ||
|
|
4f06265338 | ||
|
|
1b1ea124c8 | ||
|
|
b86c64d490 | ||
|
|
394e9b962a | ||
|
|
6f02cec261 | ||
|
|
535f7bcc68 |
+14
-3
@@ -1,27 +1,30 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "urus"
|
name = "urus"
|
||||||
version = "0.2.1"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
description = "Cowboy/bandit-style HTTP library for the smarm actor runtime"
|
description = "Cowboy/bandit-style HTTP library for the smarm actor runtime"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
smarm = { git = "https://git.kalsbeek.dev/Markk116/smarm", tag = "v0.6.0" }
|
smarm = { git = "https://git.kalsbeek.dev/Markk116/smarm", tag = "v0.7.0" }
|
||||||
httparse = "1.9"
|
httparse = "1.9"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
sha1_smol = "1"
|
sha1_smol = "1"
|
||||||
|
|
||||||
# dep #4, ratified 2026-06-12: serde/serde_json behind the opt-in
|
# dep #4, ratified 2026-06-12: serde/serde_json behind the opt-in
|
||||||
# "phoenix" feature only — the "channels" core stays dependency-free.
|
# "phoenix" feature only — the "channels" core stays dependency-free.
|
||||||
serde = { version = "1", optional = true }
|
serde = { version = "1", optional = true, features = ["derive"] }
|
||||||
serde_json = { version = "1", optional = true }
|
serde_json = { version = "1", optional = true }
|
||||||
|
# config-file feature: TOML loader for tuning knobs (dep #5, 2026-08-12)
|
||||||
|
toml = { version = "0.8", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
smarm-trace = ["smarm/smarm-trace"]
|
smarm-trace = ["smarm/smarm-trace"]
|
||||||
smarm-causal = ["smarm/smarm-causal"]
|
smarm-causal = ["smarm/smarm-causal"]
|
||||||
channels = []
|
channels = []
|
||||||
phoenix = ["channels", "dep:serde", "dep:serde_json"]
|
phoenix = ["channels", "dep:serde", "dep:serde_json"]
|
||||||
|
config-file = ["dep:serde", "dep:toml"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -47,3 +50,11 @@ path = "examples/crud.rs"
|
|||||||
name = "channels_chat"
|
name = "channels_chat"
|
||||||
path = "examples/channels_chat.rs"
|
path = "examples/channels_chat.rs"
|
||||||
required-features = ["phoenix"]
|
required-features = ["phoenix"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "serve_toml"
|
||||||
|
required-features = ["config-file"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "load_profile"
|
||||||
|
required-features = ["smarm-causal"]
|
||||||
|
|||||||
@@ -122,7 +122,11 @@ Handlers are closures taking `(Conn, Next) -> Conn`. Call `Next::call(c)` to con
|
|||||||
|
|
||||||
### Server Configuration
|
### Server Configuration
|
||||||
|
|
||||||
`serve(addr, pipeline)` binds and listens on the given address. For more control, use `serve_with(config, pipeline)`:
|
`serve(addr, pipeline)` binds and listens on the given address. For more
|
||||||
|
control, use `serve_with(config, runtime_config, pipeline)` — the urus
|
||||||
|
`Config` holds endpoint knobs, the `smarm::Config` holds runtime knobs
|
||||||
|
(they are separate because an endpoint placed in someone else's tree
|
||||||
|
cannot dictate the runtime):
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use urus::{serve_with, Config};
|
use urus::{serve_with, Config};
|
||||||
@@ -130,7 +134,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
listener_pool: 2, // Supervised accept-loop actors
|
listener_pool: 2, // Supervised accept-loop actors
|
||||||
scheduler_threads: Some(2), // smarm worker threads (None = one per CPU)
|
name: "urus", // Endpoint registry name (unique per endpoint)
|
||||||
keep_alive_timeout: Duration::from_secs(60), // Idle budget between requests
|
keep_alive_timeout: Duration::from_secs(60), // Idle budget between requests
|
||||||
request_timeout: Duration::from_secs(30), // Whole-request READ deadline
|
request_timeout: Duration::from_secs(30), // Whole-request READ deadline
|
||||||
write_timeout: Duration::from_secs(30), // Per-write response budget
|
write_timeout: Duration::from_secs(30), // Per-write response budget
|
||||||
@@ -141,7 +145,7 @@ let cfg = Config {
|
|||||||
..Config::new("127.0.0.1:8080".parse().unwrap())
|
..Config::new("127.0.0.1:8080".parse().unwrap())
|
||||||
};
|
};
|
||||||
|
|
||||||
serve_with(cfg, pipeline).unwrap();
|
serve_with(cfg, smarm::Config::exact(2), pipeline).unwrap();
|
||||||
```
|
```
|
||||||
|
|
||||||
**Timeout semantics:**
|
**Timeout semantics:**
|
||||||
@@ -162,10 +166,51 @@ serve_with(cfg, pipeline).unwrap();
|
|||||||
write stalls past the budget (the write-side twin of slowloris). Streamed
|
write stalls past the budget (the write-side twin of slowloris). Streamed
|
||||||
chunks each get a fresh budget; a stream as a whole has no deadline.
|
chunks each get a fresh budget; a stream as a whole has no deadline.
|
||||||
|
|
||||||
### Graceful Shutdown
|
### Your Own Supervision Tree
|
||||||
|
|
||||||
`serve_with_shutdown` takes a `ShutdownSignal`; the paired `Handle` can be
|
The real API is `urus::endpoint(config, pipeline)`, which binds the socket
|
||||||
triggered from anywhere (another thread, a signal handler):
|
and hands back a supervisable child body. Your application owns the
|
||||||
|
runtime and the root supervisor; urus is one child among your own actors:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use smarm::{ChildSpec, OneForOne, Restart, supervisor::Shutdown};
|
||||||
|
|
||||||
|
// Binds here: an address-in-use error is a startup failure, not an actor
|
||||||
|
// crash. The fds outlive any restart of the endpoint child.
|
||||||
|
let endpoint = urus::endpoint(cfg, pipeline)?;
|
||||||
|
|
||||||
|
let rt = smarm::init(smarm::Config::default());
|
||||||
|
rt.run(move || {
|
||||||
|
let sup = smarm::spawn(move || {
|
||||||
|
OneForOne::new()
|
||||||
|
// Your state actor FIRST: reverse-order shutdown therefore
|
||||||
|
// stops it LAST, after HTTP has finished draining.
|
||||||
|
.child(ChildSpec::new(Restart::Permanent, my_store))
|
||||||
|
.child(ChildSpec::new(Restart::Permanent, endpoint)
|
||||||
|
// The endpoint bounds its own drain with drain_timeout;
|
||||||
|
// a shorter supervisor deadline would cut it in half.
|
||||||
|
.shutdown(Shutdown::Infinity))
|
||||||
|
.run()
|
||||||
|
});
|
||||||
|
let _ = sup.join();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Shutdown is then whatever your app already does — `request_shutdown` on
|
||||||
|
the root supervisor (from a SIGTERM handler via `rt.handle()`, say). The
|
||||||
|
endpoint's `handle_shutdown` stops the listener pool, closes idle
|
||||||
|
keep-alive connections, drains in-flight requests up to `drain_timeout`,
|
||||||
|
force-stops stragglers past it, and exits only when the last connection is
|
||||||
|
gone — so "the endpoint child has stopped" *is* "every connection is
|
||||||
|
gone". See `examples/crud.rs` for a complete app in this shape.
|
||||||
|
|
||||||
|
Address a running endpoint by name for introspection:
|
||||||
|
`urus::endpoint::whereis("urus")`.
|
||||||
|
|
||||||
|
### Graceful Shutdown Without a Tree
|
||||||
|
|
||||||
|
If serving is all your process does, let `serve*` own the runtime and use
|
||||||
|
`Handle`, which triggers the same sequence from any thread:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use urus::{serve_with_shutdown, shutdown_handle, Config};
|
use urus::{serve_with_shutdown, shutdown_handle, Config};
|
||||||
@@ -177,13 +222,13 @@ std::thread::spawn(move || {
|
|||||||
handle.shutdown();
|
handle.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
serve_with_shutdown(cfg, pipeline, signal).unwrap();
|
serve_with_shutdown(cfg, smarm::Config::default(), pipeline, signal).unwrap();
|
||||||
// Returns once the runtime has fully wound down.
|
// Returns once the runtime has fully wound down.
|
||||||
```
|
```
|
||||||
|
|
||||||
`Handle::shutdown()` is idempotent and performs, in order:
|
`Handle::shutdown()` is idempotent and performs, in order:
|
||||||
|
|
||||||
1. Stop accepting — every listener exits; no new connections.
|
1. Stop accepting — the listener pool is stopped; no new connections.
|
||||||
2. Close idle keep-alive connections immediately.
|
2. Close idle keep-alive connections immediately.
|
||||||
3. Drain in-flight requests for up to `Config.drain_timeout`.
|
3. Drain in-flight requests for up to `Config.drain_timeout`.
|
||||||
4. Force-stop any stragglers past the deadline (sockets close cleanly on
|
4. Force-stop any stragglers past the deadline (sockets close cleanly on
|
||||||
@@ -313,10 +358,11 @@ independent of HTTP (it imports only smarm) and built for the WebSocket
|
|||||||
relay pattern:
|
relay pattern:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let bus: PubSub<String> = PubSub::new(); // in-runtime only!
|
const BUS: PubSub<String> = PubSub::new("chat"); // a name; spawns nothing
|
||||||
let rx = bus.subscribe("room:lobby")?; // Receiver<Arc<String>>
|
// BUS.child() goes in your supervision tree (or serve_with*'s child vec)
|
||||||
bus.broadcast("room:lobby", "hi".to_string())?;
|
let rx = BUS.subscribe("room:lobby")?; // Receiver<Arc<String>>
|
||||||
bus.broadcast_from(smarm::self_pid(), "room:lobby", "no echo".into())?;
|
BUS.broadcast("room:lobby", "hi".to_string())?;
|
||||||
|
BUS.broadcast_from(smarm::self_pid(), "room:lobby", "no echo".into())?;
|
||||||
```
|
```
|
||||||
|
|
||||||
One generic instance per message domain; payloads broadcast as `Arc<M>`
|
One generic instance per message domain; payloads broadcast as `Arc<M>`
|
||||||
@@ -328,16 +374,25 @@ explicit pid for relay patterns. Mailboxes are unbounded: `broadcast`
|
|||||||
never blocks the table, and a slow subscriber's memory bill is bounded
|
never blocks the table, and a slow subscriber's memory bill is bounded
|
||||||
by the two cleanup paths above.
|
by the two cleanup paths above.
|
||||||
|
|
||||||
Two composition rules that matter (both enforced by
|
Composition (enforced by `shutdown_with_open_chat_terminates` in the
|
||||||
`shutdown_with_open_chat_terminates` in the integration suite):
|
integration suite):
|
||||||
|
|
||||||
1. `PubSub::new()` spawns an actor, so it must run in-runtime — build it
|
1. **The handle is an address, not the table.** `PubSub<M>` is a name:
|
||||||
lazily from a handler via a **non-static** `Arc<OnceLock<PubSub<M>>>`
|
`const`, `Copy`, spawns nothing, fine in a `static` or outside the
|
||||||
captured by the route closure. A `static` cell pins the table actor
|
runtime. The actor is `PubSub::child()`, a `ChildSpec` for your
|
||||||
forever and graceful shutdown never returns.
|
supervision tree — or for `serve_with*`'s app-children vec, which puts
|
||||||
|
it ahead of the endpoint so it stops only after the endpoint has
|
||||||
|
drained. Operations resolve the name per call, so a restarted table is
|
||||||
|
reached transparently.
|
||||||
2. Relay/producer actors hold the `Receiver` (plus e.g. a `WsSender`
|
2. Relay/producer actors hold the `Receiver` (plus e.g. a `WsSender`
|
||||||
clone) — **never a `PubSub` clone**, or relay and table keep each
|
clone). They may hold the handle too — it pins nothing — but usually
|
||||||
other alive past shutdown.
|
have no use for one.
|
||||||
|
|
||||||
|
Prior to v0.8 both of these read the other way round: `PubSub::new()`
|
||||||
|
spawned the table, the handle owned its life, and a non-static
|
||||||
|
`Arc<OnceLock<PubSub<M>>>` lazily built from the first handler was the
|
||||||
|
required idiom. smarm 0.7 made a server's lifetime its own, and that
|
||||||
|
whole apparatus went away with it.
|
||||||
|
|
||||||
See [`examples/ws_chat.rs`](examples/ws_chat.rs): rooms as topics,
|
See [`examples/ws_chat.rs`](examples/ws_chat.rs): rooms as topics,
|
||||||
`on_open` subscribes + spawns the relay, `on_message` uses
|
`on_open` subscribes + spawns the relay, `on_message` uses
|
||||||
@@ -377,8 +432,10 @@ impl Channel<P> for Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// in-runtime, non-static — the ws_chat OnceLock pattern applies
|
// A description: spawns nothing. `children` are the ChildSpecs it needs
|
||||||
let hub = ChannelHub::new(PrefixRouter::new().channel_default::<Room>("room:*"));
|
// (bus table + one registry per session route) — hand them to serve_with*.
|
||||||
|
let (hub, children) =
|
||||||
|
ChannelHub::new("chat-bus", PrefixRouter::new().channel_default::<Room>("room:*"));
|
||||||
// route handler: hub.upgrade(conn)
|
// route handler: hub.upgrade(conn)
|
||||||
// from anywhere with a hub handle: hub.broadcast("room:lobby", "news", payload)
|
// from anywhere with a hub handle: hub.broadcast("room:lobby", "news", payload)
|
||||||
```
|
```
|
||||||
|
|||||||
+157
-6
@@ -252,6 +252,10 @@ smarm has none). All six design questions ratified by the user pre-code
|
|||||||
(crud's store shape) hangs `serve_with_shutdown`. Corollary: relays
|
(crud's store shape) hangs `serve_with_shutdown`. Corollary: relays
|
||||||
hold `Receiver` only, never a `PubSub` clone (mutual-keepalive cycle).
|
hold `Receiver` only, never a `PubSub` clone (mutual-keepalive cycle).
|
||||||
Proven by `shutdown_with_open_chat_terminates`.
|
Proven by `shutdown_with_open_chat_terminates`.
|
||||||
|
**Superseded by v0.7 where the app owns its tree:** start the table as a
|
||||||
|
supervised sibling of the endpoint and address it by name (what crud now
|
||||||
|
does with its store). The `OnceLock` rule still holds under `serve*`,
|
||||||
|
which owns the runtime and offers no in-runtime moment beforehand.
|
||||||
- **`WsHandler::on_open(&mut self, sender)`** added (defaulted,
|
- **`WsHandler::on_open(&mut self, sender)`** added (defaulted,
|
||||||
non-breaking): without it a listen-only ws client can never be
|
non-breaking): without it a listen-only ws client can never be
|
||||||
subscribed (`on_message` never fires). Same panic contract as
|
subscribed (`on_message` never fires). Same panic contract as
|
||||||
@@ -349,6 +353,153 @@ reconnect cycles.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.7 — Endpoint as a supervised child — DONE (2026-08-20)
|
||||||
|
|
||||||
|
Crate goes 0.2.x -> **0.3.0** (breaking). Unwinds the last deviation from
|
||||||
|
the spec (§2.1/§6): `serve` owning `rt.run`. The app owns the runtime and
|
||||||
|
the root supervisor; urus is one ordered child in it.
|
||||||
|
|
||||||
|
```
|
||||||
|
your root sup
|
||||||
|
└── ChildSpec(Permanent, urus::endpoint(cfg, pipeline)?) <- Endpoint gen_server
|
||||||
|
└── listener_sup OneForOne over N listeners
|
||||||
|
└── plain connection actors
|
||||||
|
```
|
||||||
|
|
||||||
|
- `src/conn_registry.rs` -> `src/endpoint.rs`; `ConnRegistry` -> `Endpoint`.
|
||||||
|
The registry absorbed the listener pool it registers for and 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()` binds eagerly on the caller's thread.
|
||||||
|
- **The endpoint spawns its own listener sup rather than being its
|
||||||
|
sibling.** smarm's supervisor start *order* is not start *readiness*
|
||||||
|
(`start_child` spawns and moves on), so a sibling listener could
|
||||||
|
`whereis` the endpoint name before its actor ran. Registrar-spawns-
|
||||||
|
consumers makes that program order inside one `init`. Filed in smarm's
|
||||||
|
ROADMAP as a readiness-ack item; a blocking `spawn` would only shrink the
|
||||||
|
window ("has begun executing" != "has bound its name") at the cost of a
|
||||||
|
round-trip per accept.
|
||||||
|
- Listener sup is monitored: death outside shutdown = panic (the app's
|
||||||
|
supervisor decides) instead of a zombie on a dead port; death during
|
||||||
|
shutdown is the "no new connections" barrier.
|
||||||
|
- Drain is entirely internal and event-driven: `handle_shutdown` flips
|
||||||
|
draining, shuts the listener sup, stops idle conns, arms one
|
||||||
|
`drain_timeout` timer; conns that register or go idle mid-drain are
|
||||||
|
stopped on the spot; one force sweep at the deadline; the endpoint exits
|
||||||
|
when the sup is down and the set is empty. "Endpoint child stopped" ==
|
||||||
|
"every connection gone".
|
||||||
|
- **Deleted:** the `AtomicBool` listener flag, `LISTENER_TICK` (250ms wake
|
||||||
|
per listener per tick -> untimed `wait_readable` park), `SHUTDOWN_POLL`
|
||||||
|
(100ms root poll -> real park on the signal channel), `Restart::Transient`
|
||||||
|
for listeners (now `Permanent`: they only exit by supervisor action),
|
||||||
|
the root-side drain loop, `Cast::{BeginDrain, ForceStopConns}`.
|
||||||
|
Re-probed 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 split**: `scheduler_threads` / `max_actors` are runtime knobs an
|
||||||
|
endpoint-as-child cannot honour -> `serve_with(cfg, smarm::Config, pipe)`
|
||||||
|
and `serve_with_shutdown(cfg, smarm::Config, pipe, signal)`. New
|
||||||
|
`Config.name` (default `"urus"`) is the endpoint's registry name;
|
||||||
|
`urus::endpoint::whereis(name)` for introspection. Two endpoints in one
|
||||||
|
process need distinct names.
|
||||||
|
- `serve*` stay as thin wrappers building a one-child tree with
|
||||||
|
`Shutdown::Infinity`. `Handle`/`ShutdownSignal` stay: a `serve*` caller
|
||||||
|
never sees the runtime, so it has no `RuntimeHandle` to reach for — but
|
||||||
|
the poll behind it is gone.
|
||||||
|
- `examples/crud.rs` is the demonstrator (app-owned tree, store as an
|
||||||
|
ordered sibling registered under a typed `Name`, shutdown via
|
||||||
|
`rt.handle().request_shutdown(root_sup)`); it loses its `OnceLock` store
|
||||||
|
cell and its `SHUTTING_DOWN` flag + 250ms poll. Other examples stay short
|
||||||
|
on `serve*`.
|
||||||
|
|
||||||
|
**Open after this cycle:**
|
||||||
|
- ~~One unreproduced test failure seen once in ~120 full-suite runs~~
|
||||||
|
**DIAGNOSED AND FIXED** in v0.8 below — it was the pubsub/channels drain
|
||||||
|
hang, not the `free_port()` race. It hid because `hammer.sh` builds with
|
||||||
|
default features while the failure needs `--all-features` load.
|
||||||
|
- `endpoint()` returns `impl Fn()`, so calling it twice = two endpoints
|
||||||
|
contending for one `Config.name` (second panics on the clash). Honest
|
||||||
|
failure, but the type doesn't prevent the mistake; a consume-on-first-use
|
||||||
|
newtype would. `ChannelHub::new` returning `(hub, children)` in v0.8 is
|
||||||
|
the same lesson applied: make the type refuse the mistake.
|
||||||
|
|
||||||
|
## v0.8 — Bus, hub and session registries are supervised — DONE (2026-08-20)
|
||||||
|
|
||||||
|
Same crate version (**0.3.0**); this cycle and v0.7 ship together and are
|
||||||
|
both breaking.
|
||||||
|
|
||||||
|
v0.7 put the endpoint under a supervisor. This puts everything else there
|
||||||
|
too, because smarm 0.7 left it no choice: commit `415effb` ("lifetime is
|
||||||
|
the actor's — refs are addresses") removed the rule the pubsub table,
|
||||||
|
channel hub bus and session registries were built on. A `GenServerRef` no
|
||||||
|
longer owns its server; the loop holds its own inbox sender and the inbox
|
||||||
|
never closes when the last ref drops. Nothing commanded these three to
|
||||||
|
stop, and what still terminated a run was the root-exit sweep racing the
|
||||||
|
drain.
|
||||||
|
|
||||||
|
That is the flake above: `shutdown_with_open_chat_terminates` and
|
||||||
|
`channels_wire::shutdown_with_open_channel_terminates`, 4 failures in 25
|
||||||
|
`--all-features` integration runs, all "serve did not return: ... outlived
|
||||||
|
the drain: Timeout". 0 in 10 full-suite runs after the fix.
|
||||||
|
|
||||||
|
**Description separated from instantiation.** The gotchas here all
|
||||||
|
descended from constructors that spawn — `PubSub::new()`,
|
||||||
|
`ChannelHub::new()` and `PrefixRouter::channel_session()` all started
|
||||||
|
actors, which forced in-runtime-only construction, which forced the
|
||||||
|
`Arc<OnceLock<..>>` lazy-init from the first handler, which forced the
|
||||||
|
"cell must not be `static`" and "a relay must never hold a `PubSub` clone"
|
||||||
|
rules. Five documented rules propping up one inverted dependency.
|
||||||
|
|
||||||
|
```
|
||||||
|
your root sup (RestForOne)
|
||||||
|
├── ChildSpec(Permanent, PubSub::child) <- bus table, named
|
||||||
|
├── ChildSpec(Permanent, session registry) <- one per channel_session
|
||||||
|
└── ChildSpec(Permanent, urus::endpoint(..)) <- starts last, drains first
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`PubSub<M>` is a name, not a ref.** `const`-constructible, `Copy`,
|
||||||
|
spawns nothing, legal outside the runtime and in a `static`. Operations
|
||||||
|
resolve through the registry per call, so a supervisor-restarted table is
|
||||||
|
reached transparently. `PubSub::new()` is gone: `PubSub::new(name)` +
|
||||||
|
`PubSub::child()`.
|
||||||
|
- **`ChannelHub::new(bus, router) -> (hub, Vec<ChildSpec>)`**, `#[must_use]`.
|
||||||
|
Both halves come back together because a hub whose children were never
|
||||||
|
started compiles and fails on the first join. There is no `children()`
|
||||||
|
method to forget to call.
|
||||||
|
- **`channel_session` takes a registry name**; each registry is separately
|
||||||
|
named and supervised.
|
||||||
|
- **`serve_with` / `serve_with_shutdown` take a `Vec<ChildSpec>`** of app
|
||||||
|
children and build `RestForOne[..app children, endpoint]`. App children
|
||||||
|
start before the endpoint and — shutdown being ordered in reverse — stop
|
||||||
|
after it has drained, so a request still in flight can still reach the
|
||||||
|
bus. `RestForOne` because a bus crash leaves live sockets addressing a
|
||||||
|
table that no longer knows them.
|
||||||
|
- Deleted: the `Arc<OnceLock>` idiom from both examples and both test
|
||||||
|
pipelines, and every module rule that existed only to hand-manage a
|
||||||
|
refcount. `ws_chat` lost 21 lines and a struct field; examples net -25.
|
||||||
|
|
||||||
|
**Cost accepted:** one registry resolution per operation, including per
|
||||||
|
broadcast. Caching a `GenServerRef` in the handle would save it and go
|
||||||
|
stale across exactly the restart the supervisor exists to perform. Bench
|
||||||
|
before optimising.
|
||||||
|
|
||||||
|
**Open after this cycle:**
|
||||||
|
- `channel_session("session:*", "chat-sessions", f)` puts two unrelated
|
||||||
|
string literals side by side and nothing catches a transposition. A
|
||||||
|
`RegistryName` newtype makes it a type error. Not done.
|
||||||
|
- `PubSub` cannot be protected the way `ChannelHub` was: a `const` is
|
||||||
|
copied at each use, so it can't be consume-on-first-use. Forgetting
|
||||||
|
`BUS.child()` compiles and fails at runtime with `PubSubDown`. Judged
|
||||||
|
worth it — the `const` is what makes the handle pleasant.
|
||||||
|
- Session actors are still plain-spawned by their registry, not supervised
|
||||||
|
(they're dynamic, one per key — OTP would want `simple_one_for_one`).
|
||||||
|
Their exit chain is now commanded rather than refcounted: the registry is
|
||||||
|
shut down, its state drops, control senders drop, parked sessions wake on
|
||||||
|
the closed arm. Sound, but it is the last place a lifetime is implied by
|
||||||
|
a drop rather than stated.
|
||||||
|
- `hammer.sh` still passes no feature flags, which is why this hid for a
|
||||||
|
whole cycle. A feature matrix is the obvious fix.
|
||||||
|
|
||||||
## Known bugs
|
## Known bugs
|
||||||
|
|
||||||
- ~~**HTTP/1.0 keep-alive: server honors but never advertises**~~ FIXED
|
- ~~**HTTP/1.0 keep-alive: server honors but never advertises**~~ FIXED
|
||||||
@@ -378,9 +529,9 @@ reconnect cycles.
|
|||||||
- **Bench suite** — `urus-bench-spec.md` exists in the artefact store;
|
- **Bench suite** — `urus-bench-spec.md` exists in the artefact store;
|
||||||
wire it up once v0.2 lands (supervision changes the hot path not at all,
|
wire it up once v0.2 lands (supervision changes the hot path not at all,
|
||||||
but prove it).
|
but prove it).
|
||||||
- **Operator introspection via typed names** — the v0.2-era
|
- **Operator introspection via typed names** — partly delivered in v0.7:
|
||||||
`urus.server` / `urus.listener.{i}` pid tags were dropped in the
|
the endpoint is a named gen_server (`Config.name`, default `"urus"`),
|
||||||
RFC 014 port (names are messageable endpoints now, self-registered
|
reachable via `urus::endpoint::whereis(name)`, and answers
|
||||||
with a real `Sender<M>`). If wanted back, do it properly: register the
|
`Call::ConnCount`. Still open: per-listener visibility (the pool is
|
||||||
serve loop's shutdown/control channel under a typed `urus.server`
|
internal and anonymous), and a richer stats call (ws/channel counts,
|
||||||
name instead of faking pid tags with unit channels.
|
request rates) — the endpoint is the place to hang them.
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ fn main() {
|
|||||||
let (handle, signal) = shutdown_handle();
|
let (handle, signal) = shutdown_handle();
|
||||||
let server = std::thread::spawn(move || {
|
let server = std::thread::spawn(move || {
|
||||||
let pipe = Pipeline::new().plug(Router::new().get("/order/:id", order));
|
let pipe = Pipeline::new().plug(Router::new().get("/order/:id", order));
|
||||||
serve_with_shutdown(Config::new(addr), pipe, signal).expect("serve");
|
serve_with_shutdown(Config::new(addr), smarm::Config::default(), pipe, Vec::new(), signal).expect("serve");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait until it's accepting.
|
// Wait until it's accepting.
|
||||||
|
|||||||
+20
-20
@@ -10,10 +10,13 @@
|
|||||||
//!
|
//!
|
||||||
//! The shapes this demonstrates:
|
//! The shapes this demonstrates:
|
||||||
//!
|
//!
|
||||||
//! - **One `ChannelHub` for the whole app**, built lazily on the first
|
//! - **One `ChannelHub` for the whole app**, built up front. The hub is
|
||||||
//! connection via the NON-static `Arc<OnceLock<...>>` pattern (the
|
//! a description — a bus name plus the routing table — and spawns
|
||||||
//! hub spawns the pubsub table; same in-runtime + shutdown laws as
|
//! nothing; `hub.children()` is the vec of actors it needs (the bus
|
||||||
//! `examples/ws_chat.rs`, see the docs there).
|
//! table, plus one registry per session route), handed to
|
||||||
|
//! `serve_with_shutdown` so the supervisor starts them ahead of the
|
||||||
|
//! endpoint and stops them after it has drained. Same shape as
|
||||||
|
//! `examples/ws_chat.rs`, see the docs there.
|
||||||
//!
|
//!
|
||||||
//! - **A channel per joined topic, not per socket.** `Room` never sees
|
//! - **A channel per joined topic, not per socket.** `Room` never sees
|
||||||
//! frames, refs, or the transport heartbeat — the conn-side handler
|
//! frames, refs, or the transport heartbeat — the conn-side handler
|
||||||
@@ -31,8 +34,6 @@
|
|||||||
//! the buffer holds the relay path's own `Arc`s, nothing is copied.
|
//! the buffer holds the relay path's own `Arc`s, nothing is copied.
|
||||||
//! The session is keyed by the `"name"` in the join payload.
|
//! The session is keyed by the `"name"` in the join payload.
|
||||||
|
|
||||||
use std::sync::{Arc, OnceLock};
|
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use urus::channels::phoenix::Json;
|
use urus::channels::phoenix::Json;
|
||||||
use urus::{
|
use urus::{
|
||||||
@@ -85,23 +86,16 @@ impl ChannelSession<P> for BySessionName {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let hub: Arc<OnceLock<ChannelHub<P>>> = Arc::new(OnceLock::new());
|
let (hub, children) = ChannelHub::new(
|
||||||
|
"chat-bus",
|
||||||
let router = Router::new().get("/socket", move |c: Conn, _n: Next| {
|
|
||||||
// Hub construction is in-runtime only (it spawns the pubsub
|
|
||||||
// table and, for session patterns, their registries); the
|
|
||||||
// non-static cell is what lets shutdown drain them all.
|
|
||||||
let hub = hub.get_or_init(|| {
|
|
||||||
ChannelHub::new(
|
|
||||||
PrefixRouter::new()
|
PrefixRouter::new()
|
||||||
.channel_default::<Room>("room:*")
|
.channel_default::<Room>("room:*")
|
||||||
.channel_session::<BySessionName>("session:*", |_: &str| {
|
.channel_session::<BySessionName>("session:*", "chat-sessions", |_: &str| {
|
||||||
Box::new(Room::default()) as Box<dyn Channel<P>>
|
Box::new(Room::default()) as Box<dyn Channel<P>>
|
||||||
}),
|
}),
|
||||||
)
|
);
|
||||||
});
|
|
||||||
hub.upgrade(c)
|
let router = Router::new().get("/socket", move |c: Conn, _n: Next| hub.upgrade(c));
|
||||||
});
|
|
||||||
|
|
||||||
let (handle, signal) = shutdown_handle();
|
let (handle, signal) = shutdown_handle();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -111,7 +105,13 @@ fn main() {
|
|||||||
handle.shutdown();
|
handle.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
serve_with_shutdown(Config::new("0.0.0.0:8080".parse().unwrap()), Pipeline::new().plug(router), signal)
|
serve_with_shutdown(
|
||||||
|
Config::new("0.0.0.0:8080".parse().unwrap()),
|
||||||
|
smarm::Config::default(),
|
||||||
|
Pipeline::new().plug(router),
|
||||||
|
children,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
println!("channels_chat: drained, bye");
|
println!("channels_chat: drained, bye");
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-68
@@ -1,11 +1,18 @@
|
|||||||
//! CRUD example: a tiny user database with JSON persistence.
|
//! CRUD example: a tiny user database with JSON persistence.
|
||||||
//!
|
//!
|
||||||
//! Demonstrates urus and the actor model together:
|
//! Demonstrates urus and the actor model together, in the shape a real
|
||||||
//! - The pipeline is shared (Arc) across all connection actors.
|
//! application should use (v0.3):
|
||||||
//! - Handlers do NOT take a lock or share mutable state directly.
|
//! - The APP owns the smarm runtime and the root supervisor. urus is one
|
||||||
//! - A single "store" actor owns the data; handlers send it a request
|
//! child in that tree — `urus::endpoint(...)` — and the store actor is
|
||||||
//! via a channel and block on the reply. Serialization is structural —
|
//! an ordered sibling started BEFORE it, so the supervisor's
|
||||||
//! the store processes one request at a time, no Mutex needed.
|
//! reverse-order shutdown drains HTTP first and only then stops the
|
||||||
|
//! store. No handler can be mid-request against a store that is gone.
|
||||||
|
//! - Handlers do NOT take a lock or share mutable state directly. A
|
||||||
|
//! single "store" actor owns the data; handlers address it by
|
||||||
|
//! registered name and block on a reply channel. Serialization is
|
||||||
|
//! structural — one request at a time, no Mutex.
|
||||||
|
//! - Both actors are supervised: kill the store (or let it panic) and it
|
||||||
|
//! restarts from the JSON file, with the endpoint left alone.
|
||||||
//! - On every mutating request the store writes the JSON file. Read
|
//! - On every mutating request the store writes the JSON file. Read
|
||||||
//! requests don't touch disk.
|
//! requests don't touch disk.
|
||||||
//!
|
//!
|
||||||
@@ -23,9 +30,8 @@
|
|||||||
//! curl -s http://localhost:8080/users/1
|
//! curl -s http://localhost:8080/users/1
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use smarm::{channel, Sender};
|
use smarm::{channel, ChildSpec, Name, OneForOne, Restart, Sender};
|
||||||
use std::sync::OnceLock;
|
use urus::{Config, Conn, Next, Pipeline, Router};
|
||||||
use urus::{serve_with_shutdown, shutdown_handle, Config, Conn, Next, Pipeline, Router};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Domain
|
// Domain
|
||||||
@@ -66,7 +72,12 @@ const DB_PATH: &str = "/tmp/urus-crud.json";
|
|||||||
// Store actor body
|
// Store actor body
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn store_loop(rx: smarm::Receiver<Request>) {
|
fn store_loop() {
|
||||||
|
let (tx, rx) = channel::<Request>();
|
||||||
|
// Self-registration: the name is bound before the first recv, and it
|
||||||
|
// is re-bound automatically on every restart.
|
||||||
|
smarm::register(STORE, tx).expect("crud.store name already taken");
|
||||||
|
|
||||||
// Load on start. Missing file = empty store. Corrupt file = panic; we
|
// Load on start. Missing file = empty store. Corrupt file = panic; we
|
||||||
// don't auto-rebuild because silently losing data is worse than failing
|
// don't auto-rebuild because silently losing data is worse than failing
|
||||||
// loud.
|
// loud.
|
||||||
@@ -78,19 +89,10 @@ fn store_loop(rx: smarm::Receiver<Request>) {
|
|||||||
let mut next_id: u64 = users.iter().map(|u| u.id).max().unwrap_or(0) + 1;
|
let mut next_id: u64 = users.iter().map(|u| u.id).max().unwrap_or(0) + 1;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// recv with a timeout rather than a bare recv: the store must be
|
// A plain park. The supervisor stops this actor at shutdown (after
|
||||||
// stoppable at shutdown, but a cross-thread Sender::send (from the
|
// the endpoint has drained), so there is nothing to poll for.
|
||||||
// stdin thread) can't wake a parked actor — same smarm limitation
|
let req = match rx.recv() {
|
||||||
// that motivates urus's SHUTDOWN_POLL. So we wake on our own timer
|
|
||||||
// and poll the flag. This poll dies with that limitation too.
|
|
||||||
let req = match rx.recv_timeout(std::time::Duration::from_millis(250)) {
|
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(smarm::channel::RecvTimeoutError::Timeout) => {
|
|
||||||
if SHUTTING_DOWN.load(std::sync::atomic::Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(_) => return, // all senders dropped
|
Err(_) => return, // all senders dropped
|
||||||
};
|
};
|
||||||
match req {
|
match req {
|
||||||
@@ -169,25 +171,29 @@ fn persist(users: &[User]) {
|
|||||||
// Handler helpers
|
// Handler helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Once-cell trick: the store actor is spawned the first time a handler
|
// The store is a supervised child that registers its own inbox under a
|
||||||
// runs (smarm requires `spawn` to be called from inside an actor — which
|
// typed name; handlers resolve it per send. That replaces the old
|
||||||
// connection actors are). After that all handlers share the same Sender.
|
// `OnceLock<Sender>` spawn-on-first-use trick — which had no supervisor,
|
||||||
// Simpler than threading the Sender through the pipeline at startup.
|
// no restart, and no defined shutdown point — with a plain actor whose
|
||||||
|
// lifecycle the tree owns. A restart re-registers the same name, so
|
||||||
|
// in-flight handlers heal on their next send.
|
||||||
|
|
||||||
static STORE_TX: OnceLock<Sender<Request>> = OnceLock::new();
|
const STORE: Name<Request> = Name::new("crud.store");
|
||||||
|
|
||||||
// Set by the stdin thread at shutdown; the store actor polls it (see
|
/// Send to the store and wait for its reply. `Err` only if the store is
|
||||||
// store_loop). An always-on app actor that never returns would otherwise
|
/// between incarnations (restarting); the handler turns that into a 503
|
||||||
// block smarm's AllDone and keep serve_with_shutdown from returning.
|
/// rather than pretending.
|
||||||
static SHUTTING_DOWN: std::sync::atomic::AtomicBool =
|
fn ask(make: impl FnOnce(Sender<(u16, Vec<u8>)>) -> Request) -> Option<(u16, Vec<u8>)> {
|
||||||
std::sync::atomic::AtomicBool::new(false);
|
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||||
|
smarm::send(STORE, make(tx)).ok()?;
|
||||||
|
rx.recv().ok()
|
||||||
|
}
|
||||||
|
|
||||||
fn store() -> &'static Sender<Request> {
|
fn reply(conn: Conn, r: Option<(u16, Vec<u8>)>) -> Conn {
|
||||||
STORE_TX.get_or_init(|| {
|
match r {
|
||||||
let (tx, rx) = channel::<Request>();
|
Some((status, body)) => json(conn, status, body),
|
||||||
smarm::spawn(move || store_loop(rx));
|
None => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||||
tx
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json(conn: Conn, status: u16, body: Vec<u8>) -> Conn {
|
fn json(conn: Conn, status: u16, body: Vec<u8>) -> Conn {
|
||||||
@@ -206,16 +212,13 @@ fn parse_id(s: &str) -> Option<u64> {
|
|||||||
|
|
||||||
/// SSE demo: `curl -N localhost:8080/ticker` streams a tick every second
|
/// SSE demo: `curl -N localhost:8080/ticker` streams a tick every second
|
||||||
/// (with `: keep-alive` comments if it ever goes quiet). The producer
|
/// (with `: keep-alive` comments if it ever goes quiet). The producer
|
||||||
/// exits on SseClosed (client gone / write timeout / shutdown drain) or
|
/// exits on SseClosed — client gone, write timeout, or the drain stopping
|
||||||
/// when the example is shutting down.
|
/// its connection actor. Nothing to flag: the tree's shutdown reaches it.
|
||||||
fn ticker(conn: Conn, _next: Next) -> Conn {
|
fn ticker(conn: Conn, _next: Next) -> Conn {
|
||||||
let (conn, events) = conn.sse();
|
let (conn, events) = conn.sse();
|
||||||
smarm::spawn(move || {
|
smarm::spawn(move || {
|
||||||
let mut n: u64 = 0;
|
let mut n: u64 = 0;
|
||||||
loop {
|
loop {
|
||||||
if SHUTTING_DOWN.load(std::sync::atomic::Ordering::Relaxed) {
|
|
||||||
return; // dropping `events` ends the stream cleanly
|
|
||||||
}
|
|
||||||
if events.send("tick", &n.to_string()).is_err() {
|
if events.send("tick", &n.to_string()).is_err() {
|
||||||
return; // SseClosed
|
return; // SseClosed
|
||||||
}
|
}
|
||||||
@@ -227,18 +230,14 @@ fn ticker(conn: Conn, _next: Next) -> Conn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn list(conn: Conn, _next: Next) -> Conn {
|
fn list(conn: Conn, _next: Next) -> Conn {
|
||||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
let r = ask(|reply| Request::List { reply });
|
||||||
store().send(Request::List { reply: tx }).ok();
|
reply(conn, r)
|
||||||
let (status, body) = rx.recv().expect("store dropped");
|
|
||||||
json(conn, status, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create(conn: Conn, _next: Next) -> Conn {
|
fn create(conn: Conn, _next: Next) -> Conn {
|
||||||
let body = conn.body.as_bytes().to_vec();
|
let body = conn.body.as_bytes().to_vec();
|
||||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
let r = ask(|reply| Request::Create { body, reply });
|
||||||
store().send(Request::Create { body, reply: tx }).ok();
|
reply(conn, r)
|
||||||
let (status, body) = rx.recv().expect("store dropped");
|
|
||||||
json(conn, status, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_one(conn: Conn, _next: Next) -> Conn {
|
fn get_one(conn: Conn, _next: Next) -> Conn {
|
||||||
@@ -246,10 +245,8 @@ fn get_one(conn: Conn, _next: Next) -> Conn {
|
|||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||||
};
|
};
|
||||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
let r = ask(|reply| Request::Get { id, reply });
|
||||||
store().send(Request::Get { id, reply: tx }).ok();
|
reply(conn, r)
|
||||||
let (status, body) = rx.recv().expect("store dropped");
|
|
||||||
json(conn, status, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(conn: Conn, _next: Next) -> Conn {
|
fn update(conn: Conn, _next: Next) -> Conn {
|
||||||
@@ -258,10 +255,8 @@ fn update(conn: Conn, _next: Next) -> Conn {
|
|||||||
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||||
};
|
};
|
||||||
let body = conn.body.as_bytes().to_vec();
|
let body = conn.body.as_bytes().to_vec();
|
||||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
let r = ask(|reply| Request::Update { id, body, reply });
|
||||||
store().send(Request::Update { id, body, reply: tx }).ok();
|
reply(conn, r)
|
||||||
let (status, body) = rx.recv().expect("store dropped");
|
|
||||||
json(conn, status, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete(conn: Conn, _next: Next) -> Conn {
|
fn delete(conn: Conn, _next: Next) -> Conn {
|
||||||
@@ -269,10 +264,8 @@ fn delete(conn: Conn, _next: Next) -> Conn {
|
|||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||||
};
|
};
|
||||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
let r = ask(|reply| Request::Delete { id, reply });
|
||||||
store().send(Request::Delete { id, reply: tx }).ok();
|
reply(conn, r)
|
||||||
let (status, body) = rx.recv().expect("store dropped");
|
|
||||||
json(conn, status, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -305,20 +298,47 @@ fn main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let cfg = Config::new("127.0.0.1:8080".parse().unwrap());
|
let cfg = Config::new("127.0.0.1:8080".parse().unwrap());
|
||||||
|
// Bind here, on this thread: an address-in-use error is a startup
|
||||||
|
// failure, not an actor crash. The fds outlive any restart of the
|
||||||
|
// endpoint child.
|
||||||
|
let endpoint = urus::endpoint(cfg, pipeline).expect("bind 127.0.0.1:8080");
|
||||||
|
|
||||||
println!("urus-crud: DB at {DB_PATH}");
|
println!("urus-crud: DB at {DB_PATH}");
|
||||||
println!("urus-crud: listening on 127.0.0.1:8080 — press Enter to shut down");
|
println!("urus-crud: listening on 127.0.0.1:8080 — press Enter to shut down");
|
||||||
|
|
||||||
|
let rt = smarm::init(smarm::Config::default());
|
||||||
|
let handle = rt.handle();
|
||||||
|
let (sup_tx, sup_rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
// Graceful shutdown on stdin-Enter: a plain OS thread blocks on
|
// Graceful shutdown on stdin-Enter: a plain OS thread blocks on
|
||||||
// read_line and fires the handle. No signal handling crate needed.
|
// read_line and shuts the ROOT SUPERVISOR down. No signal-handling
|
||||||
let (handle, signal) = shutdown_handle();
|
// crate needed, and no urus-specific shutdown plumbing — this is
|
||||||
|
// exactly what a SIGTERM handler would do.
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
let sup: smarm::Pid = sup_rx.recv().expect("supervisor pid");
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
let _ = std::io::stdin().read_line(&mut line);
|
let _ = std::io::stdin().read_line(&mut line);
|
||||||
println!("urus-crud: shutting down (draining in-flight requests)…");
|
println!("urus-crud: shutting down (draining in-flight requests)…");
|
||||||
SHUTTING_DOWN.store(true, std::sync::atomic::Ordering::Relaxed);
|
handle.request_shutdown(sup);
|
||||||
handle.shutdown();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
serve_with_shutdown(cfg, pipeline, signal).unwrap();
|
rt.run(move || {
|
||||||
|
let sup = smarm::spawn(move || {
|
||||||
|
OneForOne::new()
|
||||||
|
// Store FIRST: reverse-order shutdown therefore stops it
|
||||||
|
// LAST, after the endpoint has finished draining.
|
||||||
|
.child(ChildSpec::new(Restart::Permanent, store_loop))
|
||||||
|
.child(
|
||||||
|
ChildSpec::new(Restart::Permanent, endpoint)
|
||||||
|
// The endpoint bounds its own drain with
|
||||||
|
// Config::drain_timeout; a shorter supervisor
|
||||||
|
// deadline would cut that drain in half.
|
||||||
|
.shutdown(smarm::supervisor::Shutdown::Infinity),
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
});
|
||||||
|
let _ = sup_tx.send(sup.pid());
|
||||||
|
let _ = sup.join();
|
||||||
|
});
|
||||||
println!("urus-crud: bye");
|
println!("urus-crud: bye");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ fn main() {
|
|||||||
let (handle, signal) = shutdown_handle();
|
let (handle, signal) = shutdown_handle();
|
||||||
let server = std::thread::spawn(move || {
|
let server = std::thread::spawn(move || {
|
||||||
let pipe = Pipeline::new().plug(Router::new().get("/json/:id", json_id));
|
let pipe = Pipeline::new().plug(Router::new().get("/json/:id", json_id));
|
||||||
serve_with_shutdown(Config::new(addr), pipe, signal).expect("serve");
|
serve_with_shutdown(Config::new(addr), smarm::Config::default(), pipe, Vec::new(), signal).expect("serve");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Readiness is the orchestrator's job (TCP probe); ours is to not
|
// Readiness is the orchestrator's job (TCP probe); ours is to not
|
||||||
|
|||||||
@@ -41,8 +41,13 @@ fn main() {
|
|||||||
// Audit line: lands in each bench cell's server.log so the effective
|
// Audit line: lands in each bench cell's server.log so the effective
|
||||||
// scheduler count is recorded per cell, same discipline as mode-verify.
|
// scheduler count is recorded per cell, same discipline as mode-verify.
|
||||||
eprintln!("plain_serve: scheduler_threads={sched_threads:?}");
|
eprintln!("plain_serve: scheduler_threads={sched_threads:?}");
|
||||||
let mut cfg = Config::new(addr);
|
// Scheduler threads are a RUNTIME knob, so they live in smarm::Config,
|
||||||
cfg.scheduler_threads = sched_threads;
|
// not urus's — an endpoint placed in someone else's tree could not
|
||||||
|
// honour them anyway.
|
||||||
|
let rt_cfg = match sched_threads {
|
||||||
|
Some(n) => smarm::Config::exact(n),
|
||||||
|
None => smarm::Config::default(),
|
||||||
|
};
|
||||||
let pipe = Pipeline::new().plug(Router::new().get("/json/:id", json_id));
|
let pipe = Pipeline::new().plug(Router::new().get("/json/:id", json_id));
|
||||||
serve_with(cfg, pipe).expect("serve");
|
serve_with(Config::new(addr), rt_cfg, pipe, Vec::new()).expect("serve");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//! Serve with an optional TOML config overlay.
|
||||||
|
//!
|
||||||
|
//! urus is a library and never presumes a config path or reads the
|
||||||
|
//! environment for one — the binary decides where the file lives and hands
|
||||||
|
//! the text to `Config::with_toml_str`. Here that's a `--config PATH` flag;
|
||||||
|
//! with no flag, the compiled defaults are used unchanged.
|
||||||
|
//!
|
||||||
|
//! Requires the `config-file` feature:
|
||||||
|
//! cargo run --example serve_toml --features config-file -- --config urus.toml
|
||||||
|
//!
|
||||||
|
//! Example urus.toml (all keys optional, sparse override; seconds):
|
||||||
|
//! head_timeout_secs = 15
|
||||||
|
//! body_timeout_secs = 300
|
||||||
|
//! body_burst_bytes = 4096
|
||||||
|
//! body_stall_timeout_secs = 20
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use urus::{serve_with, Config, Conn, Next, Pipeline, Router};
|
||||||
|
|
||||||
|
fn json_id(conn: Conn, _next: Next) -> Conn {
|
||||||
|
let id: u64 = conn.params.get("id").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
conn.put_status(200)
|
||||||
|
.put_header("content-type", "application/json")
|
||||||
|
.put_body(format!("{{\"id\":{id}}}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let addr: SocketAddr = "0.0.0.0:8080".parse().expect("addr");
|
||||||
|
let mut cfg = Config::new(addr);
|
||||||
|
|
||||||
|
// Minimal flag scan: `--config PATH`. No presumed default location.
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--config" {
|
||||||
|
let path = args.next().expect("--config needs a PATH");
|
||||||
|
let toml = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|e| panic!("reading config {path}: {e}"));
|
||||||
|
cfg = cfg
|
||||||
|
.with_toml_str(&toml)
|
||||||
|
.unwrap_or_else(|e| panic!("invalid config {path}: {e}"));
|
||||||
|
eprintln!("serve_toml: loaded config from {path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pipe = Pipeline::new().plug(Router::new().get("/json/:id", json_id));
|
||||||
|
serve_with(cfg, smarm::Config::default(), pipe, Vec::new()).expect("serve");
|
||||||
|
}
|
||||||
+31
-44
@@ -1,4 +1,4 @@
|
|||||||
//! WebSocket chat rooms (v0.5): `urus::pubsub` wired to the v0.4 duplex.
|
//! WebSocket chat rooms (v0.7): `urus::pubsub` wired to the v0.4 duplex.
|
||||||
//!
|
//!
|
||||||
//! cargo run --example ws_chat
|
//! cargo run --example ws_chat
|
||||||
//! websocat ws://127.0.0.1:8080/chat/lobby (run two of these)
|
//! websocat ws://127.0.0.1:8080/chat/lobby (run two of these)
|
||||||
@@ -6,39 +6,35 @@
|
|||||||
//! The shapes this demonstrates:
|
//! The shapes this demonstrates:
|
||||||
//!
|
//!
|
||||||
//! - **One `PubSub<String>` for the whole app**, topics are rooms
|
//! - **One `PubSub<String>` for the whole app**, topics are rooms
|
||||||
//! (`room:{name}`). Built lazily on the first connection via a
|
//! (`room:{name}`). `BUS` is a `const`: the handle is an address, not
|
||||||
//! NON-static `Arc<OnceLock<PubSub>>` captured by the route closure:
|
//! the table. The table actor is `BUS.child()`, handed to
|
||||||
//! `PubSub::new()` spawns the table actor, which smarm only allows
|
//! `serve_with_shutdown` as an app child, so it starts before the
|
||||||
//! in-runtime — and keeping the cell non-static means the table's
|
//! endpoint and — shutdown being ordered in reverse — stops after the
|
||||||
//! last handle drops in-runtime when the drained pipeline drops, so
|
//! endpoint has drained.
|
||||||
//! `serve_with_shutdown` actually returns. A `static` cell would pin
|
//!
|
||||||
//! the table forever and block smarm's all-done. (Same constraint
|
//! The v0.5 idiom this replaces was a non-static `Arc<OnceLock<PubSub>>`
|
||||||
//! crud's store solves with its OnceLock; the non-static refinement
|
//! lazily initialised from the first connection, because `PubSub::new()`
|
||||||
//! is what makes graceful shutdown compose.)
|
//! used to spawn the table and the handle used to own its life. Neither
|
||||||
|
//! is true any more.
|
||||||
//!
|
//!
|
||||||
//! - **`on_open` subscribes and spawns the relay** — a listen-only
|
//! - **`on_open` subscribes and spawns the relay** — a listen-only
|
||||||
//! client receives the room without ever sending. The subscription is
|
//! client receives the room without ever sending. The subscription is
|
||||||
//! pinned to the CONNECTION actor (`on_open` runs inside it), so the
|
//! pinned to the CONNECTION actor (`on_open` runs inside it), so the
|
||||||
//! monitor cleans up exactly when the connection dies.
|
//! monitor cleans up exactly when the connection dies.
|
||||||
//!
|
//!
|
||||||
//! - **The relay holds the `Receiver` and a `WsSender` clone — and
|
//! - **The relay holds the `Receiver` and a `WsSender` clone.** It may
|
||||||
//! deliberately NOT a `PubSub` handle.** A relay holding the handle
|
//! also hold `BUS` — a handle pins nothing now — but it has no use for
|
||||||
//! keeps the table's inbox open while the table keeps the relay's
|
//! one. Conn dies → monitor prunes → sender drops → relay's recv errs
|
||||||
//! receiver open: neither ever exits, and shutdown hangs. Receiver
|
//! → relay exits.
|
||||||
//! only: conn dies → monitor prunes → sender drops → relay's recv
|
|
||||||
//! errs → relay exits. Every link in that chain is in-runtime.
|
|
||||||
|
|
||||||
use std::sync::{Arc, OnceLock};
|
|
||||||
|
|
||||||
use urus::{
|
use urus::{
|
||||||
serve_with_shutdown, shutdown_handle, Config, Conn, Message, Next, Pipeline, PubSub, Router,
|
serve_with_shutdown, shutdown_handle, Config, Conn, Message, Next, Pipeline, PubSub, Router,
|
||||||
WsHandler, WsSender,
|
WsHandler, WsSender,
|
||||||
};
|
};
|
||||||
|
|
||||||
type Bus = PubSub<String>;
|
const BUS: PubSub<String> = PubSub::new("chat");
|
||||||
|
|
||||||
struct ChatHandler {
|
struct ChatHandler {
|
||||||
bus: Arc<OnceLock<Bus>>,
|
|
||||||
room: String,
|
room: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,32 +42,23 @@ impl ChatHandler {
|
|||||||
fn topic(&self) -> String {
|
fn topic(&self) -> String {
|
||||||
format!("room:{}", self.room)
|
format!("room:{}", self.room)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bus(&self) -> &Bus {
|
|
||||||
// First connection anywhere spawns the table; we are inside the
|
|
||||||
// connection actor here, so the spawn is legal.
|
|
||||||
self.bus.get_or_init(PubSub::new)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WsHandler for ChatHandler {
|
impl WsHandler for ChatHandler {
|
||||||
fn on_open(&mut self, sender: &WsSender) {
|
fn on_open(&mut self, sender: &WsSender) {
|
||||||
let topic = self.topic();
|
let topic = self.topic();
|
||||||
let rx = match self.bus().subscribe(&topic) {
|
let rx = match BUS.subscribe(&topic) {
|
||||||
Ok(rx) => rx,
|
Ok(rx) => rx,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = sender.close(1011, "chat bus down");
|
let _ = sender.close(1011, "chat bus down");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = self
|
let _ = BUS.broadcast_from(smarm::self_pid(), &topic, format!("* someone joined {topic}"));
|
||||||
.bus()
|
|
||||||
.broadcast_from(smarm::self_pid(), &topic, format!("* someone joined {topic}"));
|
|
||||||
|
|
||||||
// The relay: room messages -> this socket. Exits when the
|
// The relay: room messages -> this socket. Exits when the
|
||||||
// subscription is pruned (conn death / unsubscribe) or the
|
// subscription is pruned (conn death / unsubscribe) or the
|
||||||
// socket is gone (WsClosed). See the module docs for why it
|
// socket is gone (WsClosed).
|
||||||
// must not capture a Bus handle.
|
|
||||||
let out = sender.clone();
|
let out = sender.clone();
|
||||||
smarm::spawn(move || {
|
smarm::spawn(move || {
|
||||||
while let Ok(msg) = rx.recv() {
|
while let Ok(msg) = rx.recv() {
|
||||||
@@ -90,16 +77,12 @@ impl WsHandler for ChatHandler {
|
|||||||
// broadcast_from: the sender's own relay is skipped — no echo.
|
// broadcast_from: the sender's own relay is skipped — no echo.
|
||||||
// self_pid() here is the connection actor, the pid on_open
|
// self_pid() here is the connection actor, the pid on_open
|
||||||
// subscribed as.
|
// subscribed as.
|
||||||
let _ = self
|
let _ = BUS.broadcast_from(smarm::self_pid(), self.topic(), text);
|
||||||
.bus()
|
|
||||||
.broadcast_from(smarm::self_pid(), self.topic(), text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_close(&mut self, _code: Option<u16>, _reason: &str) {
|
fn on_close(&mut self, _code: Option<u16>, _reason: &str) {
|
||||||
let topic = self.topic();
|
let topic = self.topic();
|
||||||
let _ = self
|
let _ = BUS.broadcast_from(smarm::self_pid(), &topic, format!("* someone left {topic}"));
|
||||||
.bus()
|
|
||||||
.broadcast_from(smarm::self_pid(), &topic, format!("* someone left {topic}"));
|
|
||||||
// No explicit unsubscribe: the connection actor is about to
|
// No explicit unsubscribe: the connection actor is about to
|
||||||
// exit and the monitor prunes the subscription (which is also
|
// exit and the monitor prunes the subscription (which is also
|
||||||
// what stops the relay).
|
// what stops the relay).
|
||||||
@@ -107,12 +90,9 @@ impl WsHandler for ChatHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let bus: Arc<OnceLock<Bus>> = Arc::new(OnceLock::new());
|
let pipeline = Pipeline::new().plug(Router::new().get("/chat/:room", |c: Conn, _n: Next| {
|
||||||
|
|
||||||
let pipeline = Pipeline::new().plug(Router::new().get("/chat/:room", move |c: Conn, _n: Next| {
|
|
||||||
let room = c.params.get("room").unwrap_or("lobby").to_string();
|
let room = c.params.get("room").unwrap_or("lobby").to_string();
|
||||||
let bus = bus.clone();
|
c.upgrade(ChatHandler { room })
|
||||||
c.upgrade(ChatHandler { bus, room })
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let (handle, signal) = shutdown_handle();
|
let (handle, signal) = shutdown_handle();
|
||||||
@@ -123,6 +103,13 @@ fn main() {
|
|||||||
handle.shutdown();
|
handle.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
serve_with_shutdown(Config::new("0.0.0.0:8080".parse().unwrap()), pipeline, signal).unwrap();
|
serve_with_shutdown(
|
||||||
|
Config::new("0.0.0.0:8080".parse().unwrap()),
|
||||||
|
smarm::Config::default(),
|
||||||
|
pipeline,
|
||||||
|
vec![BUS.child()],
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
println!("ws_chat: drained, bye");
|
println!("ws_chat: drained, bye");
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -98,6 +98,6 @@ fn main() {
|
|||||||
handle.shutdown();
|
handle.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
serve_with_shutdown(cfg, pipeline, signal).unwrap();
|
serve_with_shutdown(cfg, smarm::Config::default(), pipeline, Vec::new(), signal).unwrap();
|
||||||
println!("ws_echo: bye");
|
println!("ws_echo: bye");
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-38
@@ -9,12 +9,12 @@
|
|||||||
//!
|
//!
|
||||||
//! # Topology
|
//! # Topology
|
||||||
//!
|
//!
|
||||||
//! - The app builds one [`ChannelHub`] (a [`TopicRouter`] + a
|
//! - The app builds one [`ChannelHub`] (a [`TopicRouter`] + the name of
|
||||||
//! `PubSub<Broadcast<P>>`). **In-runtime only** — `PubSub::new`
|
//! a `PubSub<Broadcast<P>>`). It is a description and spawns nothing,
|
||||||
//! spawns the table actor — and the hub must be NON-static (the
|
//! so it may be built anywhere; [`ChannelHub::new`] hands back the
|
||||||
//! `Arc<OnceLock<..>>`-in-the-route-closure pattern from v0.5;
|
//! `ChildSpec`s for the actors it needs — the bus table, plus one
|
||||||
//! a `static` hub pins the pubsub table and hangs
|
//! registry per session route — which go in the supervision tree
|
||||||
//! `serve_with_shutdown`).
|
//! ahead of the endpoint.
|
||||||
//! - [`ChannelHub::upgrade`] turns an HTTP `Conn` into a channel
|
//! - [`ChannelHub::upgrade`] turns an HTTP `Conn` into a channel
|
||||||
//! socket: a [`WsHandler`] running in the connection actor that
|
//! socket: a [`WsHandler`] running in the connection actor that
|
||||||
//! decodes frames and routes them by topic.
|
//! decodes frames and routes them by topic.
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
use crate::pubsub::PubSub;
|
use crate::pubsub::PubSub;
|
||||||
use crate::ws::{Message, WsHandler, WsSender};
|
use crate::ws::{Message, WsHandler, WsSender};
|
||||||
|
|
||||||
use smarm::{Pid, Receiver, Sender};
|
use smarm::{ChildSpec, Pid, Receiver, Sender};
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -199,6 +199,16 @@ pub trait Channel<P: Send + Sync + 'static>: Send + 'static {
|
|||||||
pub trait ChannelFactory<P: Send + Sync + 'static>: Send + Sync {
|
pub trait ChannelFactory<P: Send + Sync + 'static>: Send + Sync {
|
||||||
fn create(&self, topic: &str) -> Box<dyn Channel<P>>;
|
fn create(&self, topic: &str) -> Box<dyn Channel<P>>;
|
||||||
|
|
||||||
|
/// Supervised actors this factory needs running before it can
|
||||||
|
/// deploy. Empty for the default (ephemeral) factory, which spawns
|
||||||
|
/// its channel actor per join under the connection; the session
|
||||||
|
/// factory returns its registry's spec. Internal seam, collected by
|
||||||
|
/// [`ChannelHub::children`].
|
||||||
|
#[doc(hidden)]
|
||||||
|
fn children(&self) -> Vec<ChildSpec> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
/// How an accepted-routing join becomes a running channel actor.
|
/// How an accepted-routing join becomes a running channel actor.
|
||||||
/// Internal seam — the default (ephemeral actor, linked to the
|
/// Internal seam — the default (ephemeral actor, linked to the
|
||||||
/// connection, cold start per join) is the contract; only the
|
/// connection, cold start per join) is the contract; only the
|
||||||
@@ -258,6 +268,13 @@ impl<P: Send + Sync + 'static, C: Channel<P> + Default> ChannelFactory<P> for De
|
|||||||
/// rejects the join (status error).
|
/// rejects the join (status error).
|
||||||
pub trait TopicRouter<P: Send + Sync + 'static>: Send + Sync + 'static {
|
pub trait TopicRouter<P: Send + Sync + 'static>: Send + Sync + 'static {
|
||||||
fn route(&self, topic: &str) -> Option<Arc<dyn ChannelFactory<P>>>;
|
fn route(&self, topic: &str) -> Option<Arc<dyn ChannelFactory<P>>>;
|
||||||
|
|
||||||
|
/// Every supervised actor the routes need, gathered for
|
||||||
|
/// [`ChannelHub::children`]. Override only if your router holds
|
||||||
|
/// factories it does not surface through [`route`](Self::route).
|
||||||
|
fn children(&self) -> Vec<ChildSpec> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shipped router: exact topics and `head:*` prefix patterns.
|
/// The shipped router: exact topics and `head:*` prefix patterns.
|
||||||
@@ -301,31 +318,46 @@ impl<P: Send + Sync + 'static> PrefixRouter<P> {
|
|||||||
|
|
||||||
/// [`channel`](Self::channel), with opt-in session persistence
|
/// [`channel`](Self::channel), with opt-in session persistence
|
||||||
/// keyed and configured by `S`'s [`ChannelSession`] impl (usually
|
/// keyed and configured by `S`'s [`ChannelSession`] impl (usually
|
||||||
/// `S` is the channel type itself). **In-runtime only**: this
|
/// `S` is the channel type itself).
|
||||||
/// spawns the pattern's session-registry actor, same law as
|
///
|
||||||
/// [`ChannelHub::new`].
|
/// `registry` names the pattern's session-registry actor, which is
|
||||||
pub fn channel_session<S>(self, pattern: &str, factory: impl ChannelFactory<P> + 'static) -> Self
|
/// supervised: it appears in [`ChannelHub::children`] and must be
|
||||||
|
/// unique across the app. Spawns nothing here.
|
||||||
|
pub fn channel_session<S>(
|
||||||
|
self,
|
||||||
|
pattern: &str,
|
||||||
|
registry: &'static str,
|
||||||
|
factory: impl ChannelFactory<P> + 'static,
|
||||||
|
) -> Self
|
||||||
where
|
where
|
||||||
S: ChannelSession<P>,
|
S: ChannelSession<P>,
|
||||||
S::Key: Clone,
|
S::Key: Clone,
|
||||||
P: Encode + Decode,
|
P: Encode + Decode,
|
||||||
{
|
{
|
||||||
self.channel(pattern, session::SessionFactory::new::<S>(Arc::new(factory)))
|
self.channel(pattern, session::SessionFactory::new::<S>(registry, Arc::new(factory)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`channel_session`](Self::channel_session) with a
|
/// [`channel_session`](Self::channel_session) with a
|
||||||
/// [`Default`]-built impl that is its own session config.
|
/// [`Default`]-built impl that is its own session config.
|
||||||
pub fn channel_session_default<C>(self, pattern: &str) -> Self
|
pub fn channel_session_default<C>(self, pattern: &str, registry: &'static str) -> Self
|
||||||
where
|
where
|
||||||
C: Channel<P> + ChannelSession<P> + Default,
|
C: Channel<P> + ChannelSession<P> + Default,
|
||||||
C::Key: Clone,
|
C::Key: Clone,
|
||||||
P: Encode + Decode,
|
P: Encode + Decode,
|
||||||
{
|
{
|
||||||
self.channel_session::<C>(pattern, DefaultFactory::<C>(PhantomData))
|
self.channel_session::<C>(pattern, registry, DefaultFactory::<C>(PhantomData))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<P: Send + Sync + 'static> TopicRouter<P> for PrefixRouter<P> {
|
impl<P: Send + Sync + 'static> TopicRouter<P> for PrefixRouter<P> {
|
||||||
|
fn children(&self) -> Vec<ChildSpec> {
|
||||||
|
self.exact
|
||||||
|
.values()
|
||||||
|
.chain(self.prefix.values())
|
||||||
|
.flat_map(|f| f.children())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn route(&self, topic: &str) -> Option<Arc<dyn ChannelFactory<P>>> {
|
fn route(&self, topic: &str) -> Option<Arc<dyn ChannelFactory<P>>> {
|
||||||
if let Some(f) = self.exact.get(topic) {
|
if let Some(f) = self.exact.get(topic) {
|
||||||
return Some(f.clone());
|
return Some(f.clone());
|
||||||
@@ -427,13 +459,20 @@ impl<P: Encode + Decode + Send + Sync + 'static> ChannelSocket<P> {
|
|||||||
// ChannelHub — the app-facing entry point
|
// ChannelHub — the app-facing entry point
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// One per app (or per channel namespace): the router plus the pubsub
|
/// One per app (or per channel namespace): the router plus the address
|
||||||
/// bus every channel broadcasts on.
|
/// of the pubsub bus every channel broadcasts on.
|
||||||
///
|
///
|
||||||
/// **In-runtime only** (spawns the pubsub table) and **must not live in
|
/// Spawns nothing — build it wherever you like, including out of the
|
||||||
/// a `static`** — use the non-static `Arc<OnceLock<ChannelHub<P>>>`
|
/// runtime and alongside the pipeline. The actors it needs (the bus
|
||||||
/// captured by the route closure, exactly the v0.5 pubsub pattern, or
|
/// table, plus one registry per session route) come from
|
||||||
/// graceful shutdown will hang waiting for the table to exit.
|
/// [`children`](Self::children); hand that vec to `serve_with*` or splice
|
||||||
|
/// it into your own supervision tree ahead of the endpoint.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let (hub, children) =
|
||||||
|
/// ChannelHub::new("chat-bus", PrefixRouter::new().channel_default::<Room>("room:*"));
|
||||||
|
/// serve_with(cfg, rt_cfg, pipeline, children)?;
|
||||||
|
/// ```
|
||||||
pub struct ChannelHub<P: Send + Sync + 'static> {
|
pub struct ChannelHub<P: Send + Sync + 'static> {
|
||||||
bus: PubSub<Broadcast<P>>,
|
bus: PubSub<Broadcast<P>>,
|
||||||
router: Arc<dyn TopicRouter<P>>,
|
router: Arc<dyn TopicRouter<P>>,
|
||||||
@@ -441,13 +480,27 @@ pub struct ChannelHub<P: Send + Sync + 'static> {
|
|||||||
|
|
||||||
impl<P: Send + Sync + 'static> Clone for ChannelHub<P> {
|
impl<P: Send + Sync + 'static> Clone for ChannelHub<P> {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self { bus: self.bus.clone(), router: self.router.clone() }
|
Self { bus: self.bus, router: self.router.clone() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<P: Encode + Decode + Send + Sync + 'static> ChannelHub<P> {
|
impl<P: Encode + Decode + Send + Sync + 'static> ChannelHub<P> {
|
||||||
pub fn new(router: impl TopicRouter<P>) -> Self {
|
/// Address a hub whose bus table is registered under `bus`, together
|
||||||
Self { bus: PubSub::new(), router: Arc::new(router) }
|
/// with every actor it needs running: the bus table first, then one
|
||||||
|
/// registry per session route. Spawns nothing.
|
||||||
|
///
|
||||||
|
/// The two come back together on purpose. A hub whose children were
|
||||||
|
/// never started compiles fine and fails on the first join, so the
|
||||||
|
/// constructor hands you the vec rather than leaving it behind a
|
||||||
|
/// method you can forget to call. Start them ahead of the endpoint —
|
||||||
|
/// `RestForOne` in that order means a bus crash also restarts the
|
||||||
|
/// endpoint, dropping connections whose subscriptions died with it.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(bus: &'static str, router: impl TopicRouter<P>) -> (Self, Vec<ChildSpec>) {
|
||||||
|
let hub = Self { bus: PubSub::new(bus), router: Arc::new(router) };
|
||||||
|
let mut children = vec![hub.bus.child()];
|
||||||
|
children.extend(hub.router.children());
|
||||||
|
(hub, children)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept the WebSocket upgrade on `conn` and speak channels over
|
/// Accept the WebSocket upgrade on `conn` and speak channels over
|
||||||
@@ -455,7 +508,7 @@ impl<P: Encode + Decode + Send + Sync + 'static> ChannelHub<P> {
|
|||||||
/// [`Conn::upgrade`](crate::Conn::upgrade) apply unchanged.
|
/// [`Conn::upgrade`](crate::Conn::upgrade) apply unchanged.
|
||||||
pub fn upgrade(&self, conn: crate::Conn) -> crate::Conn {
|
pub fn upgrade(&self, conn: crate::Conn) -> crate::Conn {
|
||||||
conn.upgrade(SocketHandler {
|
conn.upgrade(SocketHandler {
|
||||||
bus: self.bus.clone(),
|
bus: self.bus,
|
||||||
router: self.router.clone(),
|
router: self.router.clone(),
|
||||||
joined: HashMap::new(),
|
joined: HashMap::new(),
|
||||||
})
|
})
|
||||||
@@ -567,7 +620,7 @@ impl<P: Encode + Decode + Send + Sync + 'static> WsHandler for SocketHandler<P>
|
|||||||
reference: frame.reference,
|
reference: frame.reference,
|
||||||
payload,
|
payload,
|
||||||
ws: sender.clone(),
|
ws: sender.clone(),
|
||||||
bus: self.bus.clone(),
|
bus: self.bus,
|
||||||
});
|
});
|
||||||
self.joined
|
self.joined
|
||||||
.insert(frame.topic, Joined { join_ref: frame.join_ref, tx: inbox.0 });
|
.insert(frame.topic, Joined { join_ref: frame.join_ref, tx: inbox.0 });
|
||||||
@@ -649,7 +702,7 @@ fn run_channel<P: Encode + Decode + Send + Sync + 'static>(
|
|||||||
|
|
||||||
let socket = ChannelSocket {
|
let socket = ChannelSocket {
|
||||||
ws,
|
ws,
|
||||||
bus: bus.clone(),
|
bus,
|
||||||
topic: topic.clone(),
|
topic: topic.clone(),
|
||||||
join_ref,
|
join_ref,
|
||||||
cur_ref: RefCell::new(None),
|
cur_ref: RefCell::new(None),
|
||||||
@@ -799,12 +852,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn hub(terminated: &Arc<AtomicBool>) -> ChannelHub<TP> {
|
const TEST_BUS: &str = "chan-unit-bus";
|
||||||
|
|
||||||
|
fn hub(terminated: &Arc<AtomicBool>) -> (ChannelHub<TP>, Vec<ChildSpec>) {
|
||||||
ChannelHub::new(
|
ChannelHub::new(
|
||||||
|
TEST_BUS,
|
||||||
PrefixRouter::new().channel("room:*", RoomFactory { terminated: terminated.clone() }),
|
PrefixRouter::new().channel("room:*", RoomFactory { terminated: terminated.clone() }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a hub's children under a supervisor and wait for the bus to
|
||||||
|
/// bind its name (smarm's start-order-is-not-start-readiness gap:
|
||||||
|
/// `start_child` spawns and moves on). Returns the sup's pid.
|
||||||
|
pub(crate) fn start_hub<P: Encode + Decode + Send + Sync + 'static>(
|
||||||
|
hub: &ChannelHub<P>,
|
||||||
|
children: Vec<ChildSpec>,
|
||||||
|
) -> Pid {
|
||||||
|
let sup = smarm::spawn(move || {
|
||||||
|
let mut sup = smarm::OneForOne::new();
|
||||||
|
for c in children {
|
||||||
|
sup = sup.child(c);
|
||||||
|
}
|
||||||
|
sup.run()
|
||||||
|
});
|
||||||
|
let bus = hub.bus;
|
||||||
|
for _ in 0..200 {
|
||||||
|
if bus.pid().is_some() {
|
||||||
|
return sup.pid();
|
||||||
|
}
|
||||||
|
smarm::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
panic!("hub children never came up");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prefix_router_exact_and_prefix_and_miss() {
|
fn prefix_router_exact_and_prefix_and_miss() {
|
||||||
@@ -825,7 +905,8 @@ mod tests {
|
|||||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = hub(&t);
|
let (hub, children) = hub(&t);
|
||||||
|
let _sup = start_hub(&hub, children);
|
||||||
let mut h = Harness::new(&hub);
|
let mut h = Harness::new(&hub);
|
||||||
h.send("j1|r1|room:a|event|phx_join|hi");
|
h.send("j1|r1|room:a|event|phx_join|hi");
|
||||||
out2.lock().unwrap().push(h.recv());
|
out2.lock().unwrap().push(h.recv());
|
||||||
@@ -843,7 +924,8 @@ mod tests {
|
|||||||
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
let t2 = t.clone();
|
let t2 = t.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = hub(&t2);
|
let (hub, children) = hub(&t2);
|
||||||
|
let _sup = start_hub(&hub, children);
|
||||||
let mut h = Harness::new(&hub);
|
let mut h = Harness::new(&hub);
|
||||||
h.send("j1|r1|room:locked|event|phx_join|hi");
|
h.send("j1|r1|room:locked|event|phx_join|hi");
|
||||||
*out2.lock().unwrap() = h.recv();
|
*out2.lock().unwrap() = h.recv();
|
||||||
@@ -859,7 +941,8 @@ mod tests {
|
|||||||
let out = Arc::new(Mutex::new(String::new()));
|
let out = Arc::new(Mutex::new(String::new()));
|
||||||
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = hub(&t);
|
let (hub, children) = hub(&t);
|
||||||
|
let _sup = start_hub(&hub, children);
|
||||||
let mut h = Harness::new(&hub);
|
let mut h = Harness::new(&hub);
|
||||||
h.send("j1|r1|hall:a|event|phx_join|hi");
|
h.send("j1|r1|hall:a|event|phx_join|hi");
|
||||||
*out2.lock().unwrap() = h.recv();
|
*out2.lock().unwrap() = h.recv();
|
||||||
@@ -872,7 +955,8 @@ mod tests {
|
|||||||
let out = Arc::new(Mutex::new(String::new()));
|
let out = Arc::new(Mutex::new(String::new()));
|
||||||
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = hub(&t);
|
let (hub, children) = hub(&t);
|
||||||
|
let _sup = start_hub(&hub, children);
|
||||||
let mut h = Harness::new(&hub);
|
let mut h = Harness::new(&hub);
|
||||||
h.send("-|hb1|phoenix|event|heartbeat|");
|
h.send("-|hb1|phoenix|event|heartbeat|");
|
||||||
*out2.lock().unwrap() = h.recv();
|
*out2.lock().unwrap() = h.recv();
|
||||||
@@ -885,7 +969,8 @@ mod tests {
|
|||||||
let out = Arc::new(Mutex::new(String::new()));
|
let out = Arc::new(Mutex::new(String::new()));
|
||||||
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = hub(&t);
|
let (hub, children) = hub(&t);
|
||||||
|
let _sup = start_hub(&hub, children);
|
||||||
let mut h = Harness::new(&hub);
|
let mut h = Harness::new(&hub);
|
||||||
h.send("j1|r1|room:a|event|ping|x");
|
h.send("j1|r1|room:a|event|ping|x");
|
||||||
*out2.lock().unwrap() = h.recv();
|
*out2.lock().unwrap() = h.recv();
|
||||||
@@ -898,7 +983,8 @@ mod tests {
|
|||||||
let got = Arc::new(Mutex::new(Vec::<(u8, String)>::new()));
|
let got = Arc::new(Mutex::new(Vec::<(u8, String)>::new()));
|
||||||
let (got2, t) = (got.clone(), Arc::new(AtomicBool::new(false)));
|
let (got2, t) = (got.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = hub(&t);
|
let (hub, children) = hub(&t);
|
||||||
|
let _sup = start_hub(&hub, children);
|
||||||
let mut a = Harness::new(&hub);
|
let mut a = Harness::new(&hub);
|
||||||
let mut b = Harness::new(&hub);
|
let mut b = Harness::new(&hub);
|
||||||
a.send("j1|r1|room:a|event|phx_join|A");
|
a.send("j1|r1|room:a|event|phx_join|A");
|
||||||
@@ -930,7 +1016,8 @@ mod tests {
|
|||||||
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
let t2 = t.clone();
|
let t2 = t.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = hub(&t2);
|
let (hub, children) = hub(&t2);
|
||||||
|
let _sup = start_hub(&hub, children);
|
||||||
let mut h = Harness::new(&hub);
|
let mut h = Harness::new(&hub);
|
||||||
h.send("j1|r1|room:a|event|phx_join|hi");
|
h.send("j1|r1|room:a|event|phx_join|hi");
|
||||||
out2.lock().unwrap().push(h.recv());
|
out2.lock().unwrap().push(h.recv());
|
||||||
@@ -954,8 +1041,9 @@ mod tests {
|
|||||||
let t = Arc::new(AtomicBool::new(false));
|
let t = Arc::new(AtomicBool::new(false));
|
||||||
let t2 = t.clone();
|
let t2 = t.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = hub(&t2);
|
let (hub, children) = hub(&t2);
|
||||||
let bus = hub.bus.clone();
|
let _sup = start_hub(&hub, children);
|
||||||
|
let bus = hub.bus;
|
||||||
{
|
{
|
||||||
let mut h = Harness::new(&hub);
|
let mut h = Harness::new(&hub);
|
||||||
h.send("j1|r1|room:a|event|phx_join|hi");
|
h.send("j1|r1|room:a|event|phx_join|hi");
|
||||||
@@ -974,7 +1062,8 @@ mod tests {
|
|||||||
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
let t2 = t.clone();
|
let t2 = t.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = hub(&t2);
|
let (hub, children) = hub(&t2);
|
||||||
|
let _sup = start_hub(&hub, children);
|
||||||
let mut h = Harness::new(&hub);
|
let mut h = Harness::new(&hub);
|
||||||
h.send("j1|r1|room:a|event|phx_join|one");
|
h.send("j1|r1|room:a|event|phx_join|one");
|
||||||
out2.lock().unwrap().push(h.recv());
|
out2.lock().unwrap().push(h.recv());
|
||||||
@@ -998,7 +1087,8 @@ mod tests {
|
|||||||
let out = Arc::new(Mutex::new(String::new()));
|
let out = Arc::new(Mutex::new(String::new()));
|
||||||
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, t) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = hub(&t);
|
let (hub, children) = hub(&t);
|
||||||
|
let _sup = start_hub(&hub, children);
|
||||||
let mut h = Harness::new(&hub);
|
let mut h = Harness::new(&hub);
|
||||||
h.send("j1|r1|room:a|event|phx_join|hi");
|
h.send("j1|r1|room:a|event|phx_join|hi");
|
||||||
h.recv();
|
h.recv();
|
||||||
|
|||||||
+69
-26
@@ -22,10 +22,17 @@
|
|||||||
//! the transport is the point. Its exits: explicit leave, rejected
|
//! the transport is the point. Its exits: explicit leave, rejected
|
||||||
//! (re)join, TTL expiry, buffer cap, pubsub relay death, or the
|
//! (re)join, TTL expiry, buffer cap, pubsub relay death, or the
|
||||||
//! registry's control sender dropping — which is exactly the shutdown
|
//! registry's control sender dropping — which is exactly the shutdown
|
||||||
//! chain (conns die -> router `Arc`s drop -> registry's inbox closes
|
//! chain (the supervisor shuts the registry down after the endpoint
|
||||||
//! -> registry exits -> control senders drop -> every parked session
|
//! has drained -> the registry's state drops -> control senders drop
|
||||||
//! wakes on the closed control arm, terminates, and exits; `AllDone`
|
//! -> every parked session wakes on the closed control arm,
|
||||||
//! composes without links).
|
//! terminates, and exits; `AllDone` composes without links).
|
||||||
|
//!
|
||||||
|
//! Note this is the last lifetime in urus implied by a drop rather
|
||||||
|
//! than stated: sessions are dynamic (one per key), so a fixed
|
||||||
|
//! `ChildSpec` list cannot hold them. The trigger is now a command
|
||||||
|
//! rather than a refcount, which is what the v0.8 cycle was about, but
|
||||||
|
//! a dynamic supervisor (OTP's `simple_one_for_one`) is the honest
|
||||||
|
//! shape if smarm grows one.
|
||||||
//!
|
//!
|
||||||
//! As-landed decisions (veto by diff):
|
//! As-landed decisions (veto by diff):
|
||||||
//! - **Every attach calls `ch.join()` again** on the same instance —
|
//! - **Every attach calls `ch.join()` again** on the same instance —
|
||||||
@@ -48,8 +55,8 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use smarm::gen_server::{self, GenServer, GenServerCtx};
|
use smarm::gen_server::{self, GenServer, GenServerBuilder, GenServerCtx, GenServerName};
|
||||||
use smarm::{Down, GenServerRef, Watcher};
|
use smarm::{Down, Restart, Watcher};
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
@@ -87,7 +94,14 @@ pub trait ChannelSession<P>: Send + 'static {
|
|||||||
pub(super) struct SessionFactory<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> {
|
pub(super) struct SessionFactory<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> {
|
||||||
inner: Arc<dyn ChannelFactory<P>>,
|
inner: Arc<dyn ChannelFactory<P>>,
|
||||||
keyfn: fn(&str, &P) -> K,
|
keyfn: fn(&str, &P) -> K,
|
||||||
registry: GenServerRef<Registry<P, K>>,
|
/// The registry's registered name. Resolved per deploy, so a
|
||||||
|
/// registry restarted by the supervisor is reached transparently —
|
||||||
|
/// with its session map empty, which is the honest outcome: the
|
||||||
|
/// session actors it tracked died with their control senders.
|
||||||
|
registry: GenServerName<Registry<P, K>>,
|
||||||
|
/// Everything the registry's `ChildSpec` needs to build it again.
|
||||||
|
cap: usize,
|
||||||
|
ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The registry's working bounds for a session key.
|
/// The registry's working bounds for a session key.
|
||||||
@@ -95,17 +109,19 @@ pub(super) trait SessionKey: Eq + Hash + Clone + Send + 'static {}
|
|||||||
impl<K: Eq + Hash + Clone + Send + 'static> SessionKey for K {}
|
impl<K: Eq + Hash + Clone + Send + 'static> SessionKey for K {}
|
||||||
|
|
||||||
impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> SessionFactory<P, K> {
|
impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> SessionFactory<P, K> {
|
||||||
/// **In-runtime only** — spawns the registry gen_server.
|
/// Describe the session route. Spawns nothing; the registry actor is
|
||||||
pub(super) fn new<S: ChannelSession<P, Key = K>>(inner: Arc<dyn ChannelFactory<P>>) -> Self {
|
/// started from [`children`](ChannelFactory::children).
|
||||||
let registry = gen_server::start(Registry {
|
pub(super) fn new<S: ChannelSession<P, Key = K>>(
|
||||||
factory: inner.clone(),
|
registry: &'static str,
|
||||||
|
inner: Arc<dyn ChannelFactory<P>>,
|
||||||
|
) -> Self {
|
||||||
|
SessionFactory {
|
||||||
|
inner,
|
||||||
|
keyfn: S::session_key,
|
||||||
|
registry: GenServerName::new(registry),
|
||||||
cap: S::buffer_cap(),
|
cap: S::buffer_cap(),
|
||||||
ttl: S::ttl(),
|
ttl: S::ttl(),
|
||||||
sessions: HashMap::new(),
|
}
|
||||||
pid_key: HashMap::new(),
|
|
||||||
watcher: None,
|
|
||||||
});
|
|
||||||
SessionFactory { inner, keyfn: S::session_key, registry }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +132,23 @@ impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> ChannelFactory<P
|
|||||||
self.inner.create(topic)
|
self.inner.create(topic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn children(&self) -> Vec<ChildSpec> {
|
||||||
|
let (name, factory, cap, ttl) = (self.registry, self.inner.clone(), self.cap, self.ttl);
|
||||||
|
vec![ChildSpec::new(Restart::Permanent, move || {
|
||||||
|
let state = Registry::<P, K> {
|
||||||
|
factory: factory.clone(),
|
||||||
|
cap,
|
||||||
|
ttl,
|
||||||
|
sessions: HashMap::new(),
|
||||||
|
pid_key: HashMap::new(),
|
||||||
|
watcher: None,
|
||||||
|
};
|
||||||
|
if let Err(e) = GenServerBuilder::new(state).named(name).run() {
|
||||||
|
panic!("urus session registry '{}': name already taken: {e:?}", name.as_str());
|
||||||
|
}
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
|
||||||
fn deploy(&self, hs: JoinHandshake<P>) -> ChannelInbox<P>
|
fn deploy(&self, hs: JoinHandshake<P>) -> ChannelInbox<P>
|
||||||
where
|
where
|
||||||
P: Encode + Decode,
|
P: Encode + Decode,
|
||||||
@@ -125,7 +158,7 @@ impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> ChannelFactory<P
|
|||||||
// Cast: the actor acks the join straight to the socket; the
|
// Cast: the actor acks the join straight to the socket; the
|
||||||
// conn actor has nothing to wait for. A dead registry can only
|
// conn actor has nothing to wait for. A dead registry can only
|
||||||
// mean shutdown — the failed join is moot.
|
// mean shutdown — the failed join is moot.
|
||||||
let _ = self.registry.cast(Join { key, hs, inbound: rx });
|
let _ = gen_server::cast(self.registry, Join { key, hs, inbound: rx });
|
||||||
ChannelInbox(tx)
|
ChannelInbox(tx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,10 +601,14 @@ mod tests {
|
|||||||
fn session_hub<S: ChannelSession<TP, Key = String>>(
|
fn session_hub<S: ChannelSession<TP, Key = String>>(
|
||||||
term: &Arc<AtomicBool>,
|
term: &Arc<AtomicBool>,
|
||||||
reject_rejoin: bool,
|
reject_rejoin: bool,
|
||||||
) -> ChannelHub<TP> {
|
) -> (ChannelHub<TP>, Vec<smarm::ChildSpec>) {
|
||||||
ChannelHub::new(
|
ChannelHub::new(
|
||||||
PrefixRouter::new()
|
"sess-unit-bus",
|
||||||
.channel_session::<S>("room:*", counter_factory(term, reject_rejoin)),
|
PrefixRouter::new().channel_session::<S>(
|
||||||
|
"room:*",
|
||||||
|
"sess-unit-registry",
|
||||||
|
counter_factory(term, reject_rejoin),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +617,8 @@ mod tests {
|
|||||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = session_hub::<Cfg>(&term, false);
|
let (hub, children) = session_hub::<Cfg>(&term, false);
|
||||||
|
let _sup = crate::channels::tests::start_hub(&hub, children);
|
||||||
let mut h1 = Harness::new(&hub);
|
let mut h1 = Harness::new(&hub);
|
||||||
h1.send("j1|r1|room:a|event|phx_join|u");
|
h1.send("j1|r1|room:a|event|phx_join|u");
|
||||||
out2.lock().unwrap().push(h1.recv());
|
out2.lock().unwrap().push(h1.recv());
|
||||||
@@ -611,7 +649,8 @@ mod tests {
|
|||||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
let term2 = term.clone();
|
let term2 = term.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = session_hub::<CfgTtl>(&term2, false);
|
let (hub, children) = session_hub::<CfgTtl>(&term2, false);
|
||||||
|
let _sup = crate::channels::tests::start_hub(&hub, children);
|
||||||
let mut h1 = Harness::new(&hub);
|
let mut h1 = Harness::new(&hub);
|
||||||
h1.send("j1|r1|room:a|event|phx_join|u");
|
h1.send("j1|r1|room:a|event|phx_join|u");
|
||||||
out2.lock().unwrap().push(h1.recv());
|
out2.lock().unwrap().push(h1.recv());
|
||||||
@@ -632,7 +671,8 @@ mod tests {
|
|||||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = session_hub::<CfgCap>(&term, false);
|
let (hub, children) = session_hub::<CfgCap>(&term, false);
|
||||||
|
let _sup = crate::channels::tests::start_hub(&hub, children);
|
||||||
let mut h1 = Harness::new(&hub);
|
let mut h1 = Harness::new(&hub);
|
||||||
h1.send("j1|r1|room:a|event|phx_join|u");
|
h1.send("j1|r1|room:a|event|phx_join|u");
|
||||||
out2.lock().unwrap().push(h1.recv());
|
out2.lock().unwrap().push(h1.recv());
|
||||||
@@ -657,7 +697,8 @@ mod tests {
|
|||||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = session_hub::<Cfg>(&term, false);
|
let (hub, children) = session_hub::<Cfg>(&term, false);
|
||||||
|
let _sup = crate::channels::tests::start_hub(&hub, children);
|
||||||
let mut h = Harness::new(&hub);
|
let mut h = Harness::new(&hub);
|
||||||
h.send("j1|r1|room:a|event|phx_join|u");
|
h.send("j1|r1|room:a|event|phx_join|u");
|
||||||
out2.lock().unwrap().push(h.recv());
|
out2.lock().unwrap().push(h.recv());
|
||||||
@@ -681,7 +722,8 @@ mod tests {
|
|||||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = session_hub::<Cfg>(&term, false);
|
let (hub, children) = session_hub::<Cfg>(&term, false);
|
||||||
|
let _sup = crate::channels::tests::start_hub(&hub, children);
|
||||||
let mut h1 = Harness::new(&hub);
|
let mut h1 = Harness::new(&hub);
|
||||||
h1.send("j1|r1|room:a|event|phx_join|u");
|
h1.send("j1|r1|room:a|event|phx_join|u");
|
||||||
out2.lock().unwrap().push(h1.recv());
|
out2.lock().unwrap().push(h1.recv());
|
||||||
@@ -714,7 +756,8 @@ mod tests {
|
|||||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let hub = session_hub::<Cfg>(&term, true);
|
let (hub, children) = session_hub::<Cfg>(&term, true);
|
||||||
|
let _sup = crate::channels::tests::start_hub(&hub, children);
|
||||||
let mut h1 = Harness::new(&hub);
|
let mut h1 = Harness::new(&hub);
|
||||||
h1.send("j1|r1|room:a|event|phx_join|u");
|
h1.send("j1|r1|room:a|event|phx_join|u");
|
||||||
out2.lock().unwrap().push(h1.recv());
|
out2.lock().unwrap().push(h1.recv());
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ impl Harness {
|
|||||||
let (tx, out_rx) = smarm::channel();
|
let (tx, out_rx) = smarm::channel();
|
||||||
Self {
|
Self {
|
||||||
handler: SocketHandler {
|
handler: SocketHandler {
|
||||||
bus: hub.bus.clone(),
|
bus: hub.bus,
|
||||||
router: hub.router.clone(),
|
router: hub.router.clone(),
|
||||||
joined: HashMap::new(),
|
joined: HashMap::new(),
|
||||||
},
|
},
|
||||||
|
|||||||
+144
-55
@@ -14,7 +14,7 @@
|
|||||||
//! during those parks, other connection actors progress freely.
|
//! during those parks, other connection actors progress freely.
|
||||||
|
|
||||||
use crate::conn::{Body, Conn, HttpVersion, RespBody, StreamBody};
|
use crate::conn::{Body, Conn, HttpVersion, RespBody, StreamBody};
|
||||||
use crate::conn_registry::{Cast, ConnRegistry, DeregisterGuard};
|
use crate::endpoint::{Cast, DeregisterGuard, Endpoint};
|
||||||
use crate::net::OwnedFd;
|
use crate::net::OwnedFd;
|
||||||
use crate::parser::{self, ParseError};
|
use crate::parser::{self, ParseError};
|
||||||
use crate::plug::Pipeline;
|
use crate::plug::Pipeline;
|
||||||
@@ -46,14 +46,34 @@ pub struct ConnLimits {
|
|||||||
/// connection). Expiry closes the connection silently — nothing is
|
/// connection). Expiry closes the connection silently — nothing is
|
||||||
/// owed to a client that isn't talking.
|
/// owed to a client that isn't talking.
|
||||||
pub keep_alive_timeout: Duration,
|
pub keep_alive_timeout: Duration,
|
||||||
/// Per-request wall-clock budget, measured from the first byte of a
|
/// Wall-clock budget for reading the request HEAD, measured from the
|
||||||
/// request until the request (head + body) is fully read. Expiry
|
/// first byte of a request until the head is fully parsed. Expiry
|
||||||
/// mid-head gets a best-effort 408; expiry mid-body just closes.
|
/// mid-head gets a best-effort 408. Kept short: an incomplete head is
|
||||||
/// Pipeline run time is NOT covered — that's the handler's business.
|
/// the classic slowloris, and a legitimate client sends its head in a
|
||||||
/// Covers the READ phase only; the write phase has its own
|
/// single burst. The BODY has its own, larger budget (`body_timeout`)
|
||||||
/// per-write budget (`write_timeout`) so a streaming response can
|
/// so a slow-but-legit upload is not judged by the head clock.
|
||||||
/// legitimately outlive any whole-request clock.
|
pub head_timeout: Duration,
|
||||||
pub request_timeout: Duration,
|
/// Absolute wall-clock cap on reading the request BODY, measured from
|
||||||
|
/// the moment the head finished parsing until the body is fully read.
|
||||||
|
/// Sized for slow links (e.g. a trickling cellular IoT client), so it
|
||||||
|
/// is much larger than `head_timeout`. Expiry mid-body just closes —
|
||||||
|
/// nothing is owed to a client this far gone. Pipeline run time is NOT
|
||||||
|
/// covered (that's the handler's business); the write phase has its own
|
||||||
|
/// per-write budget (`write_timeout`).
|
||||||
|
pub body_timeout: Duration,
|
||||||
|
/// Burst-gated body stall eviction: the bytes that must accumulate
|
||||||
|
/// since the last advance to count as a "burst" and reset the stall
|
||||||
|
/// clock. A body that dribbles fewer than this per `body_stall_timeout`
|
||||||
|
/// window is evicted — the discriminator between a slowloris trickle
|
||||||
|
/// (near-zero, smooth) and a slow-but-legit client (delivers real
|
||||||
|
/// bursts). The pair implies an effective floor of
|
||||||
|
/// body_burst_bytes / body_stall_timeout, enforced in bursts.
|
||||||
|
pub body_burst_bytes: usize,
|
||||||
|
/// Max time since the last qualifying burst (`body_burst_bytes`)
|
||||||
|
/// before a stalled body read is evicted. Must comfortably exceed a
|
||||||
|
/// legit client's worst quiet gap (e.g. cellular RRC/handover/DRX
|
||||||
|
/// stalls). The absolute `body_timeout` always backstops it.
|
||||||
|
pub body_stall_timeout: Duration,
|
||||||
/// Per-write budget for response bytes: every `write_all` (the fixed
|
/// Per-write budget for response bytes: every `write_all` (the fixed
|
||||||
/// head+body, and each streamed chunk) must complete within this.
|
/// head+body, and each streamed chunk) must complete within this.
|
||||||
/// A client that stops reading mid-response is dropped when its
|
/// A client that stops reading mid-response is dropped when its
|
||||||
@@ -76,7 +96,10 @@ impl Default for ConnLimits {
|
|||||||
max_head_bytes: 64 * 1024,
|
max_head_bytes: 64 * 1024,
|
||||||
max_body_bytes: 16 * 1024 * 1024,
|
max_body_bytes: 16 * 1024 * 1024,
|
||||||
keep_alive_timeout: Duration::from_secs(60),
|
keep_alive_timeout: Duration::from_secs(60),
|
||||||
request_timeout: Duration::from_secs(30),
|
head_timeout: Duration::from_secs(30),
|
||||||
|
body_timeout: Duration::from_secs(300),
|
||||||
|
body_burst_bytes: 4 * 1024,
|
||||||
|
body_stall_timeout: Duration::from_secs(20),
|
||||||
write_timeout: Duration::from_secs(30),
|
write_timeout: Duration::from_secs(30),
|
||||||
max_frame_payload: 1024 * 1024,
|
max_frame_payload: 1024 * 1024,
|
||||||
max_message_bytes: 4 * 1024 * 1024,
|
max_message_bytes: 4 * 1024 * 1024,
|
||||||
@@ -92,7 +115,7 @@ pub fn run_connection(
|
|||||||
fd: OwnedFd,
|
fd: OwnedFd,
|
||||||
pipeline: Pipeline,
|
pipeline: Pipeline,
|
||||||
limits: ConnLimits,
|
limits: ConnLimits,
|
||||||
registry: GenServerRef<ConnRegistry>,
|
registry: GenServerRef<Endpoint>,
|
||||||
) {
|
) {
|
||||||
// The OwnedFd cleans up via Drop on any exit path (panic, error, or
|
// The OwnedFd cleans up via Drop on any exit path (panic, error, or
|
||||||
// normal close). No explicit close calls below.
|
// normal close). No explicit close calls below.
|
||||||
@@ -102,7 +125,7 @@ pub fn run_connection(
|
|||||||
// Self-register (initially idle: no request head parsed yet) and arm
|
// Self-register (initially idle: no request head parsed yet) and arm
|
||||||
// the deregistration guard. Both casts come from this actor, so
|
// the deregistration guard. Both casts come from this actor, so
|
||||||
// Started always precedes Ended in the registry's inbox — see
|
// Started always precedes Ended in the registry's inbox — see
|
||||||
// conn_registry module docs for why the listener must not do this.
|
// endpoint module docs for why the listener must not do this.
|
||||||
let me = smarm::self_pid();
|
let me = smarm::self_pid();
|
||||||
let _ = registry.cast(Cast::ConnStarted(me));
|
let _ = registry.cast(Cast::ConnStarted(me));
|
||||||
let _guard = DeregisterGuard::new(registry.clone(), me, Cast::ConnEnded);
|
let _guard = DeregisterGuard::new(registry.clone(), me, Cast::ConnEnded);
|
||||||
@@ -111,7 +134,7 @@ pub fn run_connection(
|
|||||||
// ----- 1. Read until we have a full request head. -----
|
// ----- 1. Read until we have a full request head. -----
|
||||||
// We are idle until a head parses: stoppable by a draining
|
// We are idle until a head parses: stoppable by a draining
|
||||||
// registry while parked here.
|
// registry while parked here.
|
||||||
let (parsed, request_deadline) = match read_head(raw, &mut buf, &limits) {
|
let parsed = match read_head(raw, &mut buf, &limits) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(ReadHeadErr::ClientClosed) => {
|
Err(ReadHeadErr::ClientClosed) => {
|
||||||
// Clean EOF between requests (or before any request). Normal.
|
// Clean EOF between requests (or before any request). Normal.
|
||||||
@@ -122,8 +145,8 @@ pub fn run_connection(
|
|||||||
// a request. Nothing is owed; close silently.
|
// a request. Nothing is owed; close silently.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(ReadHeadErr::RequestTimeout) => {
|
Err(ReadHeadErr::HeadTimeout) => {
|
||||||
// request_timeout expired mid-head (slowloris and friends).
|
// head_timeout expired mid-head (slowloris and friends).
|
||||||
// Best-effort 408 WITHOUT parking on writability — a client
|
// Best-effort 408 WITHOUT parking on writability — a client
|
||||||
// that stalls reads must not defeat the timeout by making
|
// that stalls reads must not defeat the timeout by making
|
||||||
// the 408 write park forever.
|
// the 408 write park forever.
|
||||||
@@ -142,6 +165,11 @@ pub fn run_connection(
|
|||||||
let _ = registry.cast(Cast::ConnBusy(me));
|
let _ = registry.cast(Cast::ConnBusy(me));
|
||||||
|
|
||||||
// ----- 2. Read body. -----
|
// ----- 2. Read body. -----
|
||||||
|
// The body has its OWN absolute budget, anchored here (head just
|
||||||
|
// parsed) and independent of the head clock — a slow-but-legit
|
||||||
|
// upload must not be judged by the short head deadline. Expiry
|
||||||
|
// closes the connection (nothing owed mid-body).
|
||||||
|
let body_deadline = Instant::now() + limits.body_timeout;
|
||||||
// Content-Length pre-check only applies to fixed bodies; a chunked
|
// Content-Length pre-check only applies to fixed bodies; a chunked
|
||||||
// body is bounded incrementally by the decoder.
|
// body is bounded incrementally by the decoder.
|
||||||
let body_len = parsed.content_length.unwrap_or(0);
|
let body_len = parsed.content_length.unwrap_or(0);
|
||||||
@@ -169,7 +197,7 @@ pub fn run_connection(
|
|||||||
// bottom of the loop must drop exactly this much to land on the
|
// bottom of the loop must drop exactly this much to land on the
|
||||||
// next pipelined request.
|
// next pipelined request.
|
||||||
let (body, consumed_past_head) = if parsed.chunked {
|
let (body, consumed_past_head) = if parsed.chunked {
|
||||||
match read_chunked_body(raw, &mut buf, parsed.head_len, &limits, request_deadline) {
|
match read_chunked_body(raw, &mut buf, parsed.head_len, &limits, body_deadline) {
|
||||||
Ok(ok) => ok,
|
Ok(ok) => ok,
|
||||||
Err(ChunkedBodyErr::TooLarge) => {
|
Err(ChunkedBodyErr::TooLarge) => {
|
||||||
let _ = write_all(
|
let _ = write_all(
|
||||||
@@ -191,7 +219,7 @@ pub fn run_connection(
|
|||||||
Err(ChunkedBodyErr::Io(_)) => return,
|
Err(ChunkedBodyErr::Io(_)) => return,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match read_body(raw, &mut buf, parsed.head_len, body_len, request_deadline) {
|
match read_body(raw, &mut buf, parsed.head_len, body_len, &limits, body_deadline) {
|
||||||
Ok(b) => (b, body_len),
|
Ok(b) => (b, body_len),
|
||||||
// Timeout mid-body (and any other body io error) -> just
|
// Timeout mid-body (and any other body io error) -> just
|
||||||
// close; there's no point talking HTTP to a client this far
|
// close; there's no point talking HTTP to a client this far
|
||||||
@@ -330,9 +358,9 @@ enum ReadHeadErr {
|
|||||||
/// keep_alive_timeout expired while waiting for the first byte of a
|
/// keep_alive_timeout expired while waiting for the first byte of a
|
||||||
/// request. Close silently.
|
/// request. Close silently.
|
||||||
IdleTimeout,
|
IdleTimeout,
|
||||||
/// request_timeout expired after the request had started arriving.
|
/// head_timeout expired after the request had started arriving but
|
||||||
/// Best-effort 408.
|
/// before the head finished parsing. Best-effort 408.
|
||||||
RequestTimeout,
|
HeadTimeout,
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
Parse(ParseError),
|
Parse(ParseError),
|
||||||
}
|
}
|
||||||
@@ -347,22 +375,22 @@ enum ReadHeadErr {
|
|||||||
/// - while `buf` is empty and nothing has arrived, we are *idle* and the
|
/// - while `buf` is empty and nothing has arrived, we are *idle* and the
|
||||||
/// wait is bounded by `keep_alive_timeout`;
|
/// wait is bounded by `keep_alive_timeout`;
|
||||||
/// - the instant the request has started (first byte read, or pipelined
|
/// - the instant the request has started (first byte read, or pipelined
|
||||||
/// bytes already in `buf` at entry), the *request* clock starts: an
|
/// bytes already in `buf` at entry), the *head* clock starts: an
|
||||||
/// `Instant` deadline of `request_timeout` from that moment, which also
|
/// `Instant` deadline of `head_timeout` from that moment. This budget
|
||||||
/// covers body reads — it is returned alongside the parsed head so the
|
/// covers the HEAD only; the body has its own budget (`body_timeout`),
|
||||||
/// caller can thread it into `read_body`.
|
/// which the caller anchors once the head has parsed.
|
||||||
fn read_head(
|
fn read_head(
|
||||||
fd: RawFd,
|
fd: RawFd,
|
||||||
buf: &mut Vec<u8>,
|
buf: &mut Vec<u8>,
|
||||||
limits: &ConnLimits,
|
limits: &ConnLimits,
|
||||||
) -> Result<(parser::ParsedHead, Instant), ReadHeadErr> {
|
) -> Result<parser::ParsedHead, ReadHeadErr> {
|
||||||
let entry = Instant::now();
|
let entry = Instant::now();
|
||||||
let idle_deadline = entry + limits.keep_alive_timeout;
|
let idle_deadline = entry + limits.keep_alive_timeout;
|
||||||
// Pipelined leftovers count as a started request.
|
// Pipelined leftovers count as a started request.
|
||||||
let mut request_deadline: Option<Instant> = if buf.is_empty() {
|
let mut head_deadline: Option<Instant> = if buf.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(entry + limits.request_timeout)
|
Some(entry + limits.head_timeout)
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -375,11 +403,7 @@ fn read_head(
|
|||||||
parser::parse_head(buf, limits.max_headers)
|
parser::parse_head(buf, limits.max_headers)
|
||||||
};
|
};
|
||||||
match head {
|
match head {
|
||||||
Ok(h) => {
|
Ok(h) => return Ok(h),
|
||||||
let deadline = request_deadline
|
|
||||||
.unwrap_or_else(|| Instant::now() + limits.request_timeout);
|
|
||||||
return Ok((h, deadline));
|
|
||||||
}
|
|
||||||
Err(ParseError::Incomplete) => {} // need more bytes
|
Err(ParseError::Incomplete) => {} // need more bytes
|
||||||
Err(e) => return Err(ReadHeadErr::Parse(e)),
|
Err(e) => return Err(ReadHeadErr::Parse(e)),
|
||||||
}
|
}
|
||||||
@@ -390,19 +414,19 @@ fn read_head(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read more, bounded by whichever budget is active.
|
// Read more, bounded by whichever budget is active.
|
||||||
let deadline = request_deadline.unwrap_or(idle_deadline);
|
let deadline = head_deadline.unwrap_or(idle_deadline);
|
||||||
match read_some(fd, buf, limits.initial_read_buf, deadline) {
|
match read_some(fd, buf, limits.initial_read_buf, deadline) {
|
||||||
Ok(0) => return Err(ReadHeadErr::ClientClosed),
|
Ok(0) => return Err(ReadHeadErr::ClientClosed),
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if request_deadline.is_none() {
|
if head_deadline.is_none() {
|
||||||
// First byte(s) of this request: the request clock
|
// First byte(s) of this request: the head clock
|
||||||
// starts now.
|
// starts now.
|
||||||
request_deadline = Some(Instant::now() + limits.request_timeout);
|
head_deadline = Some(Instant::now() + limits.head_timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) if e.kind() == ErrorKind::TimedOut => {
|
Err(e) if e.kind() == ErrorKind::TimedOut => {
|
||||||
return Err(if request_deadline.is_some() {
|
return Err(if head_deadline.is_some() {
|
||||||
ReadHeadErr::RequestTimeout
|
ReadHeadErr::HeadTimeout
|
||||||
} else {
|
} else {
|
||||||
ReadHeadErr::IdleTimeout
|
ReadHeadErr::IdleTimeout
|
||||||
});
|
});
|
||||||
@@ -412,6 +436,58 @@ fn read_head(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BodyStallGate — burst-gated stall eviction for body reads
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Each body read is bounded by the SOONER of two deadlines: the absolute
|
||||||
|
// body cap (`body_timeout`, passed in as `cap`) and a sliding stall window
|
||||||
|
// (`mark + body_stall_timeout`). The stall mark only advances when the
|
||||||
|
// client delivers a full burst (`body_burst_bytes` accumulated since the
|
||||||
|
// last advance) — so a steady sub-burst trickle never moves the mark and
|
||||||
|
// is evicted at ~body_stall_timeout, while a bursty slow-but-legit client
|
||||||
|
// keeps resetting it and survives up to the absolute cap.
|
||||||
|
//
|
||||||
|
// State is two words (`mark`, `since_mark`); the per-read cost is one add
|
||||||
|
// and one compare. Bytes counted are RAW socket bytes (progress = the
|
||||||
|
// client is sending *something*), so chunked framing counts too, and a
|
||||||
|
// burst that the kernel fragments into several reads still accumulates.
|
||||||
|
struct BodyStallGate {
|
||||||
|
cap: Instant,
|
||||||
|
stall_timeout: Duration,
|
||||||
|
burst_bytes: usize,
|
||||||
|
mark: Instant,
|
||||||
|
since_mark: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BodyStallGate {
|
||||||
|
fn new(cap: Instant, limits: &ConnLimits, now: Instant) -> Self {
|
||||||
|
Self {
|
||||||
|
cap,
|
||||||
|
stall_timeout: limits.body_stall_timeout,
|
||||||
|
burst_bytes: limits.body_burst_bytes,
|
||||||
|
mark: now,
|
||||||
|
since_mark: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deadline for the next read: the sooner of the absolute cap and the
|
||||||
|
/// current stall window.
|
||||||
|
fn deadline(&self) -> Instant {
|
||||||
|
(self.mark + self.stall_timeout).min(self.cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record `n` freshly-read raw body bytes; advance the stall mark if a
|
||||||
|
/// full burst has accumulated since the last advance.
|
||||||
|
fn record(&mut self, n: usize, now: Instant) {
|
||||||
|
self.since_mark += n;
|
||||||
|
if self.since_mark >= self.burst_bytes {
|
||||||
|
self.mark = now;
|
||||||
|
self.since_mark = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// read_body
|
// read_body
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -421,7 +497,8 @@ fn read_body(
|
|||||||
buf: &mut Vec<u8>,
|
buf: &mut Vec<u8>,
|
||||||
head_len: usize,
|
head_len: usize,
|
||||||
body_len: usize,
|
body_len: usize,
|
||||||
deadline: Instant,
|
limits: &ConnLimits,
|
||||||
|
cap: Instant,
|
||||||
) -> io::Result<Vec<u8>> {
|
) -> io::Result<Vec<u8>> {
|
||||||
// Bytes already in `buf` past the head belong to the body.
|
// Bytes already in `buf` past the head belong to the body.
|
||||||
let already = buf.len().saturating_sub(head_len);
|
let already = buf.len().saturating_sub(head_len);
|
||||||
@@ -433,13 +510,17 @@ fn read_body(
|
|||||||
return Ok(buf[head_len..head_len + body_len].to_vec());
|
return Ok(buf[head_len..head_len + body_len].to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read until we have the rest, on the same request budget that the
|
// Read until we have the rest, bounded by the body cap AND the
|
||||||
// head was read under.
|
// burst-gated stall window (whichever is sooner).
|
||||||
|
let mut gate = BodyStallGate::new(cap, limits, Instant::now());
|
||||||
let mut total_read = already;
|
let mut total_read = already;
|
||||||
while total_read < body_len {
|
while total_read < body_len {
|
||||||
match read_some(fd, buf, 8 * 1024, deadline) {
|
match read_some(fd, buf, 8 * 1024, gate.deadline()) {
|
||||||
Ok(0) => return Err(io::Error::new(ErrorKind::UnexpectedEof, "client closed during body")),
|
Ok(0) => return Err(io::Error::new(ErrorKind::UnexpectedEof, "client closed during body")),
|
||||||
Ok(n) => total_read += n,
|
Ok(n) => {
|
||||||
|
total_read += n;
|
||||||
|
gate.record(n, Instant::now());
|
||||||
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,8 +532,9 @@ fn read_body(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Decodes `Transfer-Encoding: chunked` from `buf[head_len..]`, reading more
|
// Decodes `Transfer-Encoding: chunked` from `buf[head_len..]`, reading more
|
||||||
// from the socket as needed on the SAME request deadline the head was read
|
// from the socket as needed on the body deadline (anchored by the caller
|
||||||
// under. Returns (decoded_body, raw_bytes_consumed_past_head) — the raw
|
// when the head finished parsing, independent of the head clock).
|
||||||
|
// Returns (decoded_body, raw_bytes_consumed_past_head) — the raw
|
||||||
// count includes all framing and the trailer section, so the caller's
|
// count includes all framing and the trailer section, so the caller's
|
||||||
// keep-alive drain lands exactly on the next pipelined request.
|
// keep-alive drain lands exactly on the next pipelined request.
|
||||||
//
|
//
|
||||||
@@ -479,23 +561,25 @@ fn read_chunked_body(
|
|||||||
limits: &ConnLimits,
|
limits: &ConnLimits,
|
||||||
deadline: Instant,
|
deadline: Instant,
|
||||||
) -> Result<(Vec<u8>, usize), ChunkedBodyErr> {
|
) -> Result<(Vec<u8>, usize), ChunkedBodyErr> {
|
||||||
// Ensure `buf` holds at least `until` bytes, reading on the request
|
// Ensure `buf` holds at least `until` bytes, reading under the body
|
||||||
// deadline. Io(TimedOut) on expiry, UnexpectedEof on early close.
|
// stall gate (absolute cap AND burst-gated stall window). Io(TimedOut)
|
||||||
|
// on expiry, UnexpectedEof on early close. All chunked socket reads
|
||||||
|
// funnel through here, so recording bytes here covers the whole path.
|
||||||
fn fill_to(
|
fn fill_to(
|
||||||
fd: RawFd,
|
fd: RawFd,
|
||||||
buf: &mut Vec<u8>,
|
buf: &mut Vec<u8>,
|
||||||
until: usize,
|
until: usize,
|
||||||
deadline: Instant,
|
gate: &mut BodyStallGate,
|
||||||
) -> Result<(), ChunkedBodyErr> {
|
) -> Result<(), ChunkedBodyErr> {
|
||||||
while buf.len() < until {
|
while buf.len() < until {
|
||||||
match read_some(fd, buf, 8 * 1024, deadline) {
|
match read_some(fd, buf, 8 * 1024, gate.deadline()) {
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
return Err(ChunkedBodyErr::Io(io::Error::new(
|
return Err(ChunkedBodyErr::Io(io::Error::new(
|
||||||
ErrorKind::UnexpectedEof,
|
ErrorKind::UnexpectedEof,
|
||||||
"client closed during chunked body",
|
"client closed during chunked body",
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(n) => gate.record(n, Instant::now()),
|
||||||
Err(e) => return Err(ChunkedBodyErr::Io(e)),
|
Err(e) => return Err(ChunkedBodyErr::Io(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,7 +594,7 @@ fn read_chunked_body(
|
|||||||
buf: &mut Vec<u8>,
|
buf: &mut Vec<u8>,
|
||||||
from: usize,
|
from: usize,
|
||||||
max_line: usize,
|
max_line: usize,
|
||||||
deadline: Instant,
|
gate: &mut BodyStallGate,
|
||||||
) -> Result<usize, ChunkedBodyErr> {
|
) -> Result<usize, ChunkedBodyErr> {
|
||||||
let mut scan = from;
|
let mut scan = from;
|
||||||
loop {
|
loop {
|
||||||
@@ -523,16 +607,19 @@ fn read_chunked_body(
|
|||||||
return Err(ChunkedBodyErr::Malformed);
|
return Err(ChunkedBodyErr::Malformed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fill_to(fd, buf, buf.len() + 1, deadline)?;
|
fill_to(fd, buf, buf.len() + 1, gate)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `deadline` is the absolute body cap; the gate layers the burst-gated
|
||||||
|
// stall window under it. All reads below go through find_crlf/fill_to.
|
||||||
|
let mut gate = BodyStallGate::new(deadline, limits, Instant::now());
|
||||||
let mut pos = head_len;
|
let mut pos = head_len;
|
||||||
let mut decoded: Vec<u8> = Vec::new();
|
let mut decoded: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// ----- size line: HEX[;extensions]\r\n -----
|
// ----- size line: HEX[;extensions]\r\n -----
|
||||||
let line_end = find_crlf(fd, buf, pos, MAX_SIZE_LINE, deadline)?;
|
let line_end = find_crlf(fd, buf, pos, MAX_SIZE_LINE, &mut gate)?;
|
||||||
let line = &buf[pos..line_end];
|
let line = &buf[pos..line_end];
|
||||||
let size_str = match line.iter().position(|&b| b == b';') {
|
let size_str = match line.iter().position(|&b| b == b';') {
|
||||||
Some(i) => &line[..i], // chunk extensions: ignored
|
Some(i) => &line[..i], // chunk extensions: ignored
|
||||||
@@ -549,7 +636,7 @@ fn read_chunked_body(
|
|||||||
// ----- trailer section: zero or more header lines, then CRLF -----
|
// ----- trailer section: zero or more header lines, then CRLF -----
|
||||||
let trailer_start = pos;
|
let trailer_start = pos;
|
||||||
loop {
|
loop {
|
||||||
let t_end = find_crlf(fd, buf, pos, MAX_SIZE_LINE.max(1024), deadline)?;
|
let t_end = find_crlf(fd, buf, pos, MAX_SIZE_LINE.max(1024), &mut gate)?;
|
||||||
let empty = t_end == pos;
|
let empty = t_end == pos;
|
||||||
pos = t_end + 2;
|
pos = t_end + 2;
|
||||||
if empty {
|
if empty {
|
||||||
@@ -566,7 +653,7 @@ fn read_chunked_body(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----- chunk payload + trailing CRLF -----
|
// ----- chunk payload + trailing CRLF -----
|
||||||
fill_to(fd, buf, pos + size + 2, deadline)?;
|
fill_to(fd, buf, pos + size + 2, &mut gate)?;
|
||||||
decoded.extend_from_slice(&buf[pos..pos + size]);
|
decoded.extend_from_slice(&buf[pos..pos + size]);
|
||||||
if &buf[pos + size..pos + size + 2] != b"\r\n" {
|
if &buf[pos + size..pos + size + 2] != b"\r\n" {
|
||||||
return Err(ChunkedBodyErr::Malformed);
|
return Err(ChunkedBodyErr::Malformed);
|
||||||
@@ -773,6 +860,8 @@ fn emit_error_response(fd: RawFd, err: &ParseError, deadline: Instant) {
|
|||||||
b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||||
ParseError::Unsupported =>
|
ParseError::Unsupported =>
|
||||||
b"HTTP/1.1 411 Length Required\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
b"HTTP/1.1 411 Length Required\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||||
|
ParseError::UnknownTransferCoding =>
|
||||||
|
b"HTTP/1.1 501 Not Implemented\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||||
// Incomplete and Malformed both lead here; Incomplete shouldn't
|
// Incomplete and Malformed both lead here; Incomplete shouldn't
|
||||||
// appear (read_head loops on it).
|
// appear (read_head loops on it).
|
||||||
_ =>
|
_ =>
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
//! Connection registry — the shutdown coordinator (v0.2 chunk 2).
|
|
||||||
//!
|
|
||||||
//! A `gen_server` tracking live connection pids and their busy/idle
|
|
||||||
//! state. Connection actors self-register as their first action and
|
|
||||||
//! self-deregister via a drop guard (so panic unwinds deregister too);
|
|
||||||
//! both casts come from the same sender, so Started always precedes
|
|
||||||
//! Ended in the inbox. (The roadmap sketched the *listener* casting
|
|
||||||
//! `{Started, pid}`, but then a short-lived conn's Ended could overtake
|
|
||||||
//! its Started and leak a dead pid into the set forever. Self-
|
|
||||||
//! registration makes the order a per-sender FIFO guarantee instead of
|
|
||||||
//! a race.)
|
|
||||||
//!
|
|
||||||
//! Listeners are deliberately NOT tracked here: they shut down via a
|
|
||||||
//! shared flag + timed accept-waits (see `serve`), never via
|
|
||||||
//! `request_stop` — stopping pids that announce themselves is racy (the
|
|
||||||
//! spawn-to-registration gap), and smarm's `request_stop` is lossy
|
|
||||||
//! against an actor that is QUEUED and then parks without passing an
|
|
||||||
//! observation point (see the v0.2 shutdown notes in the commit
|
|
||||||
//! message). Connections don't suffer this in practice: every stop the
|
|
||||||
//! registry issues targets a pid that just sent us a cast (so it is
|
|
||||||
//! running or parked, both covered), and the force-stop path re-sweeps
|
|
||||||
//! until the set empties.
|
|
||||||
//!
|
|
||||||
//! Drain protocol: `BeginDrain` stops every idle connection immediately
|
|
||||||
//! and flips the registry into draining mode, in which any connection
|
|
||||||
//! that *becomes* idle (finishes its in-flight request) is stopped on the
|
|
||||||
//! spot. Busy connections are left to finish; `ForceStopConns` (sent by
|
|
||||||
//! `serve` at the drain deadline) stops whatever remains. `request_stop`
|
|
||||||
//! unwinds a conn actor parked in `wait_readable` safely (smarm's 06-10
|
|
||||||
//! io fix) and `OwnedFd::drop` closes its socket on the way out.
|
|
||||||
//!
|
|
||||||
//! This server is also the planned introspection point for ws/channels
|
|
||||||
//! (roadmap v0.4+), which is why it exists as its own module rather than
|
|
||||||
//! being inlined into `serve`.
|
|
||||||
|
|
||||||
use smarm::{GenServer, Pid, GenServerBuilder, GenServerRef};
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Messages
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub enum Cast {
|
|
||||||
/// A connection actor started (self-registered, initially idle: it has
|
|
||||||
/// not parsed a request head yet).
|
|
||||||
ConnStarted(Pid),
|
|
||||||
/// Parsed a request head; a response is now owed.
|
|
||||||
ConnBusy(Pid),
|
|
||||||
/// Response written; parked (or about to park) waiting for the next
|
|
||||||
/// keep-alive request.
|
|
||||||
ConnIdle(Pid),
|
|
||||||
ConnEnded(Pid),
|
|
||||||
/// Stop idle conns now and stop each remaining conn as it goes idle.
|
|
||||||
BeginDrain,
|
|
||||||
/// Drain deadline passed: stop every remaining conn.
|
|
||||||
ForceStopConns,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Call {
|
|
||||||
ConnCount,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Reply {
|
|
||||||
ConnCount(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Server
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum ConnState {
|
|
||||||
Busy,
|
|
||||||
Idle,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ConnRegistry {
|
|
||||||
conns: HashMap<Pid, ConnState>,
|
|
||||||
draining: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GenServer for ConnRegistry {
|
|
||||||
type Call = Call;
|
|
||||||
type Reply = Reply;
|
|
||||||
type Cast = Cast;
|
|
||||||
type Info = ();
|
|
||||||
type Timer = ();
|
|
||||||
|
|
||||||
fn handle_call(&mut self, request: Call) -> Reply {
|
|
||||||
match request {
|
|
||||||
Call::ConnCount => Reply::ConnCount(self.conns.len()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_cast(&mut self, request: Cast) {
|
|
||||||
match request {
|
|
||||||
Cast::ConnStarted(pid) => {
|
|
||||||
self.conns.insert(pid, ConnState::Idle);
|
|
||||||
if self.draining {
|
|
||||||
// Listener-stop race: this conn was accepted just
|
|
||||||
// before its listener died. Drain means no new work.
|
|
||||||
smarm::request_stop(pid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Cast::ConnBusy(pid) => {
|
|
||||||
if let Some(s) = self.conns.get_mut(&pid) {
|
|
||||||
*s = ConnState::Busy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Cast::ConnIdle(pid) => {
|
|
||||||
if let Some(s) = self.conns.get_mut(&pid) {
|
|
||||||
*s = ConnState::Idle;
|
|
||||||
if self.draining {
|
|
||||||
// Finished its in-flight request; nothing more is
|
|
||||||
// owed. The pid leaves the map via its drop
|
|
||||||
// guard's ConnEnded once the unwind completes.
|
|
||||||
smarm::request_stop(pid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Cast::ConnEnded(pid) => { self.conns.remove(&pid); }
|
|
||||||
Cast::BeginDrain => {
|
|
||||||
self.draining = true;
|
|
||||||
for (pid, state) in &self.conns {
|
|
||||||
if *state == ConnState::Idle {
|
|
||||||
smarm::request_stop(*pid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Cast::ForceStopConns => {
|
|
||||||
for pid in self.conns.keys() {
|
|
||||||
smarm::request_stop(*pid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start() -> GenServerRef<ConnRegistry> {
|
|
||||||
GenServerBuilder::new(ConnRegistry::default()).start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Drop guard — self-deregistration on any exit path.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Casts `make(pid)` on drop. Runs on normal return, `request_stop`
|
|
||||||
/// unwind, and panic unwind alike; the cast is infallible from the
|
|
||||||
/// guard's perspective (a dead registry just returns an ignored Err).
|
|
||||||
pub struct DeregisterGuard {
|
|
||||||
registry: GenServerRef<ConnRegistry>,
|
|
||||||
pid: Pid,
|
|
||||||
make: fn(Pid) -> Cast,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeregisterGuard {
|
|
||||||
pub fn new(registry: GenServerRef<ConnRegistry>, pid: Pid, make: fn(Pid) -> Cast) -> Self {
|
|
||||||
Self { registry, pid, make }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DeregisterGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = self.registry.cast((self.make)(self.pid));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+656
@@ -0,0 +1,656 @@
|
|||||||
|
//! The endpoint: one supervised gen_server that owns a listening socket,
|
||||||
|
//! the pool of listener actors accepting on it, and the set of live
|
||||||
|
//! connections.
|
||||||
|
//!
|
||||||
|
//! # Shape (v0.3)
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! your root supervisor
|
||||||
|
//! └── ChildSpec(Permanent, urus::endpoint(config, pipeline)?) <- Endpoint gen_server
|
||||||
|
//! └── listener_sup OneForOne over N listener actors (spawned in init)
|
||||||
|
//! └── (listeners spawn plain connection actors)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The endpoint runs *inline as the ChildSpec's actor*
|
||||||
|
//! (`NamedGenServerBuilder::run`), so your supervisor's ordered shutdown
|
||||||
|
//! reaches it as [`GenServer::handle_shutdown`] and a restart re-runs the
|
||||||
|
//! factory on the same still-open listen fds.
|
||||||
|
//!
|
||||||
|
//! ## Why the endpoint spawns its own listener supervisor
|
||||||
|
//!
|
||||||
|
//! The obvious tree is registry and listener-sup as *siblings* under a
|
||||||
|
//! `RestForOne`, with listeners resolving the registry by name. It has a
|
||||||
|
//! boot race: smarm's supervisor starts children with a fire-and-forget
|
||||||
|
//! spawn, so start *order* is not start *readiness* — a listener can look
|
||||||
|
//! the name up before the registry actor has run. Rather than paper over
|
||||||
|
//! that with a retry loop, the registrar spawns its consumers: everything
|
||||||
|
//! here is program order inside one actor's `init`, not a cross-actor
|
||||||
|
//! guarantee. (The general gap is filed in smarm's ROADMAP as a
|
||||||
|
//! supervisor readiness-ack item; when it lands, the sibling shape becomes
|
||||||
|
//! available, but this one costs nothing and is not waiting on it.)
|
||||||
|
//!
|
||||||
|
//! ## Connection accounting
|
||||||
|
//!
|
||||||
|
//! Connection actors self-register as their first action and self-
|
||||||
|
//! deregister via a drop guard (so panic unwinds deregister too); both
|
||||||
|
//! casts come from the same sender, so `ConnStarted` always precedes
|
||||||
|
//! `ConnEnded` in the inbox. Were the *listener* to announce the pid
|
||||||
|
//! instead, a short-lived conn's `Ended` could overtake its `Started` and
|
||||||
|
//! leak a dead pid into the set forever.
|
||||||
|
//!
|
||||||
|
//! ## Shutdown
|
||||||
|
//!
|
||||||
|
//! `handle_shutdown` (i.e. a `request_shutdown` from your supervisor, or
|
||||||
|
//! from anywhere):
|
||||||
|
//!
|
||||||
|
//! 1. flip `draining` — from here on, a connection that registers or goes
|
||||||
|
//! idle is stopped on the spot;
|
||||||
|
//! 2. `request_shutdown` the listener supervisor, which stops its
|
||||||
|
//! listeners in reverse order and exits; its monitored death is the
|
||||||
|
//! "no new connections can ever be accepted" barrier;
|
||||||
|
//! 3. stop every currently-idle connection;
|
||||||
|
//! 4. arm one `drain_timeout` timer;
|
||||||
|
//! 5. return `Continue` — the endpoint exits normally once the listener
|
||||||
|
//! sup is down *and* the connection set is empty, so "the endpoint
|
||||||
|
//! actor has exited" is exactly "every connection is gone". With no
|
||||||
|
//! connections and (once the Down lands) nothing to wait for, that is
|
||||||
|
//! immediate.
|
||||||
|
//!
|
||||||
|
//! At the drain deadline one force sweep `request_stop`s whatever remains
|
||||||
|
//! (plus, defensively, the listener sup). A single sweep suffices: every
|
||||||
|
//! path a connection can enter the set on afterwards stops it at the point
|
||||||
|
//! of entry. Force-stopped conns unwind safely out of their fd waits and
|
||||||
|
//! close their sockets via `OwnedFd::drop`.
|
||||||
|
//!
|
||||||
|
//! Give the endpoint [`Shutdown::Infinity`](smarm::supervisor::Shutdown) in
|
||||||
|
//! your child spec: it bounds itself with `drain_timeout`, and a
|
||||||
|
//! supervisor-imposed deadline shorter than that would kill the drain
|
||||||
|
//! halfway and orphan connections.
|
||||||
|
//!
|
||||||
|
//! ## Known residual window
|
||||||
|
//!
|
||||||
|
//! A connection *spawned* by a listener in the instant before that
|
||||||
|
//! listener is stopped, which has not yet run, has not registered. If the
|
||||||
|
//! set was already empty the endpoint can exit before its `ConnStarted`
|
||||||
|
//! arrives, and that connection serves on as a forest root until smarm's
|
||||||
|
//! root-exit sweep collects it. Inherent to self-registration (the
|
||||||
|
//! alternative loses `Started`/`Ended` ordering, which is worse);
|
||||||
|
//! documented rather than defended.
|
||||||
|
|
||||||
|
use crate::conn_actor::{run_connection, ConnLimits};
|
||||||
|
use crate::net::{accept_nonblocking, bind_and_listen, OwnedFd};
|
||||||
|
use crate::plug::Pipeline;
|
||||||
|
use crate::serve::Config;
|
||||||
|
|
||||||
|
use smarm::gen_server::{GenServerName, ShutdownAction, StopHandle, TimerHandle};
|
||||||
|
use smarm::{
|
||||||
|
ChildSpec, GenServer, GenServerBuilder, GenServerCtx, GenServerRef, OneForOne, Pid, Restart,
|
||||||
|
Strategy,
|
||||||
|
};
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{self, ErrorKind};
|
||||||
|
use std::os::fd::RawFd;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Messages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub enum Cast {
|
||||||
|
/// A connection actor started (self-registered, initially idle: it has
|
||||||
|
/// not parsed a request head yet).
|
||||||
|
ConnStarted(Pid),
|
||||||
|
/// Parsed a request head; a response is now owed.
|
||||||
|
ConnBusy(Pid),
|
||||||
|
/// Response written; parked (or about to park) waiting for the next
|
||||||
|
/// keep-alive request.
|
||||||
|
ConnIdle(Pid),
|
||||||
|
ConnEnded(Pid),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Call {
|
||||||
|
/// Live connection count — introspection (and the planned hook for
|
||||||
|
/// ws/channels stats).
|
||||||
|
ConnCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Reply {
|
||||||
|
ConnCount(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Endpoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ConnState {
|
||||||
|
Busy,
|
||||||
|
Idle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything an endpoint incarnation needs to (re)build its listener
|
||||||
|
/// pool. Shared behind an `Arc` by the factory closure so a restart reuses
|
||||||
|
/// the same already-bound fds — no re-bind, no window where the port is
|
||||||
|
/// unclaimed.
|
||||||
|
struct Boot {
|
||||||
|
listener_fds: Vec<Arc<OwnedFd>>,
|
||||||
|
pipeline: Pipeline,
|
||||||
|
limits: ConnLimits,
|
||||||
|
conn_stack_reserve: usize,
|
||||||
|
name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Endpoint {
|
||||||
|
boot: Arc<Boot>,
|
||||||
|
conns: HashMap<Pid, ConnState>,
|
||||||
|
draining: bool,
|
||||||
|
drain_timeout: Duration,
|
||||||
|
/// The listener supervisor, spawned in `init` and monitored. `None`
|
||||||
|
/// once it is down.
|
||||||
|
listener_sup: Option<Pid>,
|
||||||
|
stop: Option<StopHandle<Self>>,
|
||||||
|
timer: Option<TimerHandle<Self>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Endpoint {
|
||||||
|
fn name(&self) -> GenServerName<Self> {
|
||||||
|
GenServerName::new(self.boot.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exit normally once nothing can arrive and nothing is left: the
|
||||||
|
/// listener sup is down and the conn set is empty. This exit *is* the
|
||||||
|
/// barrier a supervisor's ordered shutdown waits on.
|
||||||
|
fn stop_if_drained(&self) {
|
||||||
|
if self.draining && self.listener_sup.is_none() && self.conns.is_empty() {
|
||||||
|
self.stop.as_ref().expect("init ran first").stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenServer for Endpoint {
|
||||||
|
type Call = Call;
|
||||||
|
type Reply = Reply;
|
||||||
|
type Cast = Cast;
|
||||||
|
type Info = ();
|
||||||
|
/// One meaning: the drain deadline elapsed — force-stop the stragglers.
|
||||||
|
type Timer = ();
|
||||||
|
|
||||||
|
fn init(&mut self, ctx: &GenServerCtx<Self>) {
|
||||||
|
ctx.trap_exit(); // shutdown arrives as handle_shutdown, not a kill
|
||||||
|
self.stop = Some(ctx.stop_handle());
|
||||||
|
self.timer = Some(ctx.timer());
|
||||||
|
|
||||||
|
// Our own name is bound (from inside `run`) before `init` runs, so
|
||||||
|
// this resolves to us. Listeners are handed the resolved ref, not
|
||||||
|
// the name: no per-connection registry lookup on the accept path.
|
||||||
|
let me: GenServerRef<Self> =
|
||||||
|
smarm::gen_server::whereis_server(self.name()).expect("endpoint name bound before init");
|
||||||
|
|
||||||
|
let boot = self.boot.clone();
|
||||||
|
let sup = smarm::spawn(move || {
|
||||||
|
let mut sup = OneForOne::new().strategy(Strategy::OneForOne);
|
||||||
|
for lfd in &boot.listener_fds {
|
||||||
|
let lfd = lfd.clone();
|
||||||
|
let pipeline = boot.pipeline.clone();
|
||||||
|
let limits = boot.limits;
|
||||||
|
let reserve = boot.conn_stack_reserve;
|
||||||
|
let me = me.clone();
|
||||||
|
// Permanent: a listener only ever exits by supervisor
|
||||||
|
// action now (its normal-exit-as-shutdown flag is gone),
|
||||||
|
// so "exited on its own" always means something broke and
|
||||||
|
// always deserves a restart.
|
||||||
|
sup = sup.child(ChildSpec::new(Restart::Permanent, move || {
|
||||||
|
listener_loop(lfd.clone(), pipeline.clone(), limits, reserve, me.clone());
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Default intensity (3 restarts / 5s) applies; a listener
|
||||||
|
// crash-looping faster than that trips the cap and the pool
|
||||||
|
// tears down — which our monitor turns into a loud endpoint
|
||||||
|
// failure rather than a zombie server on a dead port.
|
||||||
|
sup.run();
|
||||||
|
});
|
||||||
|
ctx.watch(smarm::monitor(sup.pid()));
|
||||||
|
self.listener_sup = Some(sup.pid());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_call(&mut self, request: Call) -> Reply {
|
||||||
|
match request {
|
||||||
|
Call::ConnCount => Reply::ConnCount(self.conns.len()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_cast(&mut self, request: Cast) {
|
||||||
|
match request {
|
||||||
|
Cast::ConnStarted(pid) => {
|
||||||
|
self.conns.insert(pid, ConnState::Idle);
|
||||||
|
if self.draining {
|
||||||
|
// Accepted just before its listener was stopped.
|
||||||
|
// Draining means no new work; it leaves the map via
|
||||||
|
// its guard's ConnEnded.
|
||||||
|
smarm::request_stop(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cast::ConnBusy(pid) => {
|
||||||
|
if let Some(s) = self.conns.get_mut(&pid) {
|
||||||
|
*s = ConnState::Busy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cast::ConnIdle(pid) => {
|
||||||
|
if let Some(s) = self.conns.get_mut(&pid) {
|
||||||
|
*s = ConnState::Idle;
|
||||||
|
if self.draining {
|
||||||
|
smarm::request_stop(pid); // in-flight request finished
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cast::ConnEnded(pid) => {
|
||||||
|
self.conns.remove(&pid);
|
||||||
|
self.stop_if_drained();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_down(&mut self, down: smarm::Down) {
|
||||||
|
if self.listener_sup != Some(down.pid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.listener_sup = None;
|
||||||
|
if !self.draining {
|
||||||
|
// The pool died on its own (restart intensity exceeded, or the
|
||||||
|
// supervisor itself failed). Nothing is listening any more, so
|
||||||
|
// this endpoint is a zombie: fail loudly and let the caller's
|
||||||
|
// supervisor decide (restart re-runs init on the same fds).
|
||||||
|
panic!("urus endpoint '{}': listener pool died: {:?}", self.boot.name, down.reason);
|
||||||
|
}
|
||||||
|
// Expected during shutdown: the accept side is now provably gone.
|
||||||
|
self.stop_if_drained();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_shutdown(&mut self) -> ShutdownAction {
|
||||||
|
self.draining = true;
|
||||||
|
if let Some(sup) = self.listener_sup {
|
||||||
|
// Stops listeners in reverse start order and exits; the
|
||||||
|
// monitored Down is our "no new connections" barrier.
|
||||||
|
smarm::request_shutdown(sup);
|
||||||
|
}
|
||||||
|
for (pid, state) in &self.conns {
|
||||||
|
if *state == ConnState::Idle {
|
||||||
|
smarm::request_stop(*pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Busy conns get until the deadline; then handle_timer sweeps.
|
||||||
|
self.timer
|
||||||
|
.as_ref()
|
||||||
|
.expect("init ran first")
|
||||||
|
.arm_after(self.drain_timeout, ());
|
||||||
|
ShutdownAction::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_timer(&mut self, _deadline: ()) {
|
||||||
|
// Drain deadline: force-stop everything left. One sweep — late
|
||||||
|
// registrants are already stopped on arrival (see module docs).
|
||||||
|
for pid in self.conns.keys() {
|
||||||
|
smarm::request_stop(*pid);
|
||||||
|
}
|
||||||
|
if let Some(sup) = self.listener_sup {
|
||||||
|
// Defensive: a listener wedged past its supervisor's own grace
|
||||||
|
// period must not hold the whole shutdown open.
|
||||||
|
smarm::request_stop(sup);
|
||||||
|
}
|
||||||
|
// The map empties via each conn's guard ConnEnded; stop_if_drained
|
||||||
|
// fires on the last one (or on the sup's Down, whichever is last).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// endpoint() — the public constructor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Bind `config.addr` and return the endpoint's supervisable body: pass it
|
||||||
|
/// to [`ChildSpec::new`] under your own supervisor, alongside your
|
||||||
|
/// application's other children.
|
||||||
|
///
|
||||||
|
/// The bind happens **here**, eagerly, so an address-in-use error surfaces
|
||||||
|
/// on the caller's thread rather than inside an actor — and the fds
|
||||||
|
/// outlive any restart of the child.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let endpoint = urus::endpoint(Config::new(addr), pipeline)?;
|
||||||
|
/// let rt = smarm::init(smarm::Config::default());
|
||||||
|
/// rt.run(move || {
|
||||||
|
/// smarm::OneForOne::new()
|
||||||
|
/// .child(ChildSpec::new(Restart::Permanent, my_app_state))
|
||||||
|
/// .child(ChildSpec::new(Restart::Permanent, endpoint)
|
||||||
|
/// .shutdown(Shutdown::Infinity))
|
||||||
|
/// .run()
|
||||||
|
/// });
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The returned closure is `Clone`, so one endpoint definition can be
|
||||||
|
/// handed to more than one place; each *invocation* is one running
|
||||||
|
/// endpoint, and two live at once under the same [`Config::name`] is a
|
||||||
|
/// name clash (panic on the second).
|
||||||
|
pub fn endpoint(
|
||||||
|
config: Config,
|
||||||
|
pipeline: Pipeline,
|
||||||
|
) -> io::Result<impl Fn() + Clone + Send + Sync + 'static> {
|
||||||
|
// One dup'd fd per listener: `accept4` is thread-safe on a single fd,
|
||||||
|
// but a per-listener RawFd keeps each actor's epoll registration
|
||||||
|
// distinct in smarm's `waiters: HashMap<RawFd, Pid>`.
|
||||||
|
let mut listener_fds = Vec::with_capacity(config.listener_pool);
|
||||||
|
listener_fds.push(Arc::new(bind_and_listen(config.addr)?));
|
||||||
|
for _ in 1..config.listener_pool {
|
||||||
|
let dup = dup_fd(listener_fds[0].as_raw())?;
|
||||||
|
listener_fds.push(Arc::new(dup));
|
||||||
|
}
|
||||||
|
|
||||||
|
let boot = Arc::new(Boot {
|
||||||
|
listener_fds,
|
||||||
|
pipeline,
|
||||||
|
limits: config.to_conn_limits(),
|
||||||
|
conn_stack_reserve: config.conn_stack_reserve,
|
||||||
|
name: config.name,
|
||||||
|
});
|
||||||
|
let drain_timeout = config.drain_timeout;
|
||||||
|
|
||||||
|
Ok(move || {
|
||||||
|
let state = Endpoint {
|
||||||
|
boot: boot.clone(),
|
||||||
|
conns: HashMap::new(),
|
||||||
|
draining: false,
|
||||||
|
drain_timeout,
|
||||||
|
listener_sup: None,
|
||||||
|
stop: None,
|
||||||
|
timer: None,
|
||||||
|
};
|
||||||
|
let name = GenServerName::<Endpoint>::new(state.boot.name);
|
||||||
|
// Inline: this actor *is* the server, so a supervisor's shutdown
|
||||||
|
// reaches handle_shutdown and a restart re-binds the name.
|
||||||
|
if let Err(e) = GenServerBuilder::new(state).named(name).run() {
|
||||||
|
panic!("urus endpoint '{}': name already taken: {e:?}", name.as_str());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a running endpoint by [`Config::name`] — for `ConnCount` and
|
||||||
|
/// other introspection from elsewhere in the app.
|
||||||
|
pub fn whereis(name: &'static str) -> Option<GenServerRef<Endpoint>> {
|
||||||
|
smarm::gen_server::whereis_server(GenServerName::<Endpoint>::new(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Listener actors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn dup_fd(fd: RawFd) -> io::Result<OwnedFd> {
|
||||||
|
let new_fd = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||||
|
if new_fd < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(OwnedFd::from_raw(new_fd))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only fault injection. When nonzero, the next accept-loop iteration
|
||||||
|
/// of whichever listener gets there first decrements this and panics —
|
||||||
|
/// *before* calling `accept`, so a pending connection stays in the kernel
|
||||||
|
/// backlog and must be picked up by the restarted listener. Cost when idle
|
||||||
|
/// is one relaxed load per accept-loop iteration (each of which already
|
||||||
|
/// pays a syscall). Not public API.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub static INJECT_LISTENER_PANICS: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
/// Accept forever, spawning one connection actor per connection. Exits
|
||||||
|
/// only by supervisor action: `request_stop` unwinds the untimed
|
||||||
|
/// `wait_readable` park below (smarm's 06-10 io fix), which is why there
|
||||||
|
/// is no shutdown flag and no tick — the park is genuinely open-ended and
|
||||||
|
/// costs nothing while idle.
|
||||||
|
fn listener_loop(
|
||||||
|
listener: Arc<OwnedFd>,
|
||||||
|
pipeline: Pipeline,
|
||||||
|
limits: ConnLimits,
|
||||||
|
conn_stack_reserve: usize,
|
||||||
|
endpoint: GenServerRef<Endpoint>,
|
||||||
|
) {
|
||||||
|
let fd = listener.as_raw();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if INJECT_LISTENER_PANICS
|
||||||
|
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_sub(1))
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
panic!("urus: injected listener panic (test hook)");
|
||||||
|
}
|
||||||
|
match accept_nonblocking(fd) {
|
||||||
|
Ok(client) => {
|
||||||
|
// Hand the fd off to a new connection actor. spawn() is
|
||||||
|
// cheap on smarm — it's a single Vec push under the
|
||||||
|
// shared lock.
|
||||||
|
let p = pipeline.clone();
|
||||||
|
let l = limits;
|
||||||
|
let e = endpoint.clone();
|
||||||
|
let opts = smarm::SpawnOpts {
|
||||||
|
stack_reserve: Some(conn_stack_reserve),
|
||||||
|
..smarm::SpawnOpts::default()
|
||||||
|
};
|
||||||
|
smarm::spawn_with(opts, move || run_connection(client, p, l, e));
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == ErrorKind::WouldBlock => {
|
||||||
|
if let Err(we) = smarm::wait_readable(fd) {
|
||||||
|
// epoll registration failed — abnormal, so panic: a
|
||||||
|
// transient failure (e.g. EMFILE on the epoll set)
|
||||||
|
// heals by restart instead of silently shrinking the
|
||||||
|
// pool. smarm catches actor panics in the trampoline;
|
||||||
|
// this is a Signal::Panic to the supervisor, not
|
||||||
|
// process noise.
|
||||||
|
panic!("urus: listener wait_readable failed: {we}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
|
||||||
|
Err(e) => {
|
||||||
|
// EMFILE / ENFILE / ECONNABORTED etc. Log and back off
|
||||||
|
// briefly in case the error is sticky; the system may
|
||||||
|
// recover.
|
||||||
|
eprintln!("urus: accept error: {e}");
|
||||||
|
smarm::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The Arc clone we were started with drops on unwind, but the
|
||||||
|
// ChildSpec factory holds another — the fd outlives any one
|
||||||
|
// incarnation of this listener.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Deregistration guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Casts `make(pid)` on drop. Runs on normal return, `request_stop`
|
||||||
|
/// unwind, and panic unwind alike; the cast is infallible from the
|
||||||
|
/// guard's perspective (a dead endpoint just returns an ignored Err).
|
||||||
|
pub struct DeregisterGuard {
|
||||||
|
endpoint: GenServerRef<Endpoint>,
|
||||||
|
pid: Pid,
|
||||||
|
make: fn(Pid) -> Cast,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeregisterGuard {
|
||||||
|
pub fn new(endpoint: GenServerRef<Endpoint>, pid: Pid, make: fn(Pid) -> Cast) -> Self {
|
||||||
|
Self { endpoint, pid, make }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DeregisterGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.endpoint.cast((self.make)(self.pid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests — the drain protocol, driven purely by request_shutdown.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::plug::Pipeline;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
const NAME: &str = "urus.test.endpoint";
|
||||||
|
|
||||||
|
/// Spawn a real endpoint (bound to an ephemeral port) as a supervised
|
||||||
|
/// child, exactly as an application would, and hand back its
|
||||||
|
/// supervisor's pid.
|
||||||
|
fn spawn_endpoint(drain: Duration) -> Pid {
|
||||||
|
let cfg = Config {
|
||||||
|
listener_pool: 1,
|
||||||
|
drain_timeout: drain,
|
||||||
|
name: NAME,
|
||||||
|
..Config::new("127.0.0.1:0".parse().unwrap())
|
||||||
|
};
|
||||||
|
let body = endpoint(cfg, Pipeline::new()).expect("bind");
|
||||||
|
let sup = smarm::spawn(move || {
|
||||||
|
OneForOne::new()
|
||||||
|
.child(
|
||||||
|
ChildSpec::new(Restart::Permanent, body)
|
||||||
|
.shutdown(smarm::supervisor::Shutdown::Infinity),
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
});
|
||||||
|
await_pred("endpoint up", Duration::from_secs(2), || {
|
||||||
|
whereis(NAME).is_some()
|
||||||
|
});
|
||||||
|
sup.pid()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stand-in connection actor: registers with the endpoint, optionally
|
||||||
|
/// reports busy, then parks forever. Only a `request_stop` ends it; the
|
||||||
|
/// guard's ConnEnded runs on the unwind.
|
||||||
|
fn fake_conn(ep: GenServerRef<Endpoint>, busy: bool) {
|
||||||
|
let me = smarm::self_pid();
|
||||||
|
let _ = ep.cast(Cast::ConnStarted(me));
|
||||||
|
let _guard = DeregisterGuard::new(ep.clone(), me, Cast::ConnEnded);
|
||||||
|
if busy {
|
||||||
|
let _ = ep.cast(Cast::ConnBusy(me));
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
smarm::sleep(Duration::from_secs(3600));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_fake_conn(busy: bool) -> Pid {
|
||||||
|
let ep = whereis(NAME).expect("endpoint live");
|
||||||
|
smarm::spawn(move || fake_conn(ep, busy)).pid()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live conn count, or `Err` once the endpoint is gone.
|
||||||
|
fn conn_count() -> Result<usize, ()> {
|
||||||
|
match whereis(NAME) {
|
||||||
|
Some(ep) => match ep.call(Call::ConnCount) {
|
||||||
|
Ok(Reply::ConnCount(n)) => Ok(n),
|
||||||
|
Err(_) => Err(()),
|
||||||
|
},
|
||||||
|
None => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn await_pred(what: &str, deadline: Duration, mut pred: impl FnMut() -> bool) {
|
||||||
|
let end = Instant::now() + deadline;
|
||||||
|
while !pred() {
|
||||||
|
assert!(Instant::now() < end, "timed out waiting for: {what}");
|
||||||
|
smarm::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shutdown_with_no_conns_exits_promptly() {
|
||||||
|
smarm::run(|| {
|
||||||
|
let sup = spawn_endpoint(Duration::from_secs(30));
|
||||||
|
assert_eq!(conn_count(), Ok(0));
|
||||||
|
let t0 = Instant::now();
|
||||||
|
smarm::request_shutdown(sup);
|
||||||
|
// Nothing to drain: gone well inside the (huge) drain window,
|
||||||
|
// i.e. the idle path does not run the clock out.
|
||||||
|
await_pred("endpoint exit", Duration::from_secs(3), || {
|
||||||
|
conn_count().is_err()
|
||||||
|
});
|
||||||
|
assert!(t0.elapsed() < Duration::from_secs(3));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drain_stops_idle_now_busy_at_deadline_then_exits() {
|
||||||
|
smarm::run(|| {
|
||||||
|
let drain = Duration::from_millis(300);
|
||||||
|
let sup = spawn_endpoint(drain);
|
||||||
|
spawn_fake_conn(false); // idle
|
||||||
|
spawn_fake_conn(true); // busy
|
||||||
|
await_pred("both registered", Duration::from_secs(2), || {
|
||||||
|
conn_count() == Ok(2)
|
||||||
|
});
|
||||||
|
|
||||||
|
let t0 = Instant::now();
|
||||||
|
smarm::request_shutdown(sup);
|
||||||
|
|
||||||
|
// Idle conn goes promptly, well before the deadline.
|
||||||
|
await_pred("idle stopped", drain / 2, || conn_count() == Ok(1));
|
||||||
|
// Busy conn holds until the force sweep, then the endpoint winds up.
|
||||||
|
await_pred("busy swept + endpoint exit", drain * 6, || {
|
||||||
|
conn_count().is_err()
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
t0.elapsed() >= drain,
|
||||||
|
"busy conn must not be stopped before the drain deadline"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conn_finishing_mid_drain_is_stopped_on_idle() {
|
||||||
|
smarm::run(|| {
|
||||||
|
// Long deadline: this passes only via the ConnIdle path, not
|
||||||
|
// the force sweep.
|
||||||
|
let sup = spawn_endpoint(Duration::from_secs(30));
|
||||||
|
let conn = spawn_fake_conn(true);
|
||||||
|
await_pred("registered busy", Duration::from_secs(2), || {
|
||||||
|
conn_count() == Ok(1)
|
||||||
|
});
|
||||||
|
smarm::request_shutdown(sup);
|
||||||
|
smarm::sleep(Duration::from_millis(50));
|
||||||
|
assert_eq!(conn_count(), Ok(1), "busy conn survives the idle sweep");
|
||||||
|
|
||||||
|
// "Request finishes": the conn reports idle.
|
||||||
|
let ep = whereis(NAME).expect("endpoint still draining");
|
||||||
|
let _ = ep.cast(Cast::ConnIdle(conn));
|
||||||
|
await_pred("stopped on idle + endpoint exit", Duration::from_secs(3), || {
|
||||||
|
conn_count().is_err()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conn_registering_mid_drain_is_stopped_on_arrival() {
|
||||||
|
smarm::run(|| {
|
||||||
|
let sup = spawn_endpoint(Duration::from_secs(30));
|
||||||
|
let holder = spawn_fake_conn(true); // keeps the drain open
|
||||||
|
await_pred("holder registered", Duration::from_secs(2), || {
|
||||||
|
conn_count() == Ok(1)
|
||||||
|
});
|
||||||
|
smarm::request_shutdown(sup);
|
||||||
|
smarm::sleep(Duration::from_millis(20));
|
||||||
|
|
||||||
|
// The listener-death race: a fresh conn registers mid-drain.
|
||||||
|
spawn_fake_conn(false);
|
||||||
|
// It is stopped on arrival — the count returns to just the holder.
|
||||||
|
await_pred("late arrival stopped", Duration::from_secs(2), || {
|
||||||
|
conn_count() == Ok(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup: release the holder so the run can end.
|
||||||
|
let ep = whereis(NAME).expect("endpoint still draining");
|
||||||
|
let _ = ep.cast(Cast::ConnIdle(holder));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -24,7 +24,7 @@ pub mod router;
|
|||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod net;
|
pub mod net;
|
||||||
pub mod conn_actor;
|
pub mod conn_actor;
|
||||||
pub mod conn_registry;
|
pub mod endpoint;
|
||||||
pub mod serve;
|
pub mod serve;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
@@ -41,6 +41,9 @@ pub use pubsub::{PubSub, PubSubDown};
|
|||||||
#[cfg(feature = "channels")]
|
#[cfg(feature = "channels")]
|
||||||
pub use channels::{Channel, ChannelHub, ChannelSession, ChannelSocket, PrefixRouter, Status, TopicRouter};
|
pub use channels::{Channel, ChannelHub, ChannelSession, ChannelSocket, PrefixRouter, Status, TopicRouter};
|
||||||
pub use ws::{Message, WsClosed, WsHandler, WsSender};
|
pub use ws::{Message, WsClosed, WsHandler, WsSender};
|
||||||
|
pub use endpoint::endpoint;
|
||||||
pub use serve::{
|
pub use serve::{
|
||||||
serve, serve_with, serve_with_shutdown, shutdown_handle, Config, Handle, ShutdownSignal,
|
serve, serve_with, serve_with_shutdown, shutdown_handle, Config, Handle, ShutdownSignal,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "config-file")]
|
||||||
|
pub use serve::ConfigError;
|
||||||
|
|||||||
+231
-12
@@ -9,8 +9,10 @@
|
|||||||
//! - No body header — empty body.
|
//! - No body header — empty body.
|
||||||
//! - `Transfer-Encoding: chunked` (HTTP/1.1) — flagged in `ParsedHead`;
|
//! - `Transfer-Encoding: chunked` (HTTP/1.1) — flagged in `ParsedHead`;
|
||||||
//! the connection actor decodes incrementally (`read_chunked_body`).
|
//! the connection actor decodes incrementally (`read_chunked_body`).
|
||||||
//! Chunked + Content-Length together, or chunked on HTTP/1.0, is
|
//! TE is 1.1-only and overrides Content-Length: TE on HTTP/1.0, or TE
|
||||||
//! Malformed (request-smuggling ambiguity; RFC 7230 §3.3.3).
|
//! together with a Content-Length, is Malformed (400). `chunked` must be
|
||||||
|
//! the final coding (non-final -> 400); any other coding is unimplemented
|
||||||
|
//! (-> 501). Only a sole final `chunked` sets the flag (RFC 9112 §6.1/§6.3).
|
||||||
|
|
||||||
use crate::conn::{Body, Conn, HeaderMap, HttpVersion, Method, RespBody};
|
use crate::conn::{Body, Conn, HeaderMap, HttpVersion, Method, RespBody};
|
||||||
|
|
||||||
@@ -33,6 +35,10 @@ pub enum ParseError {
|
|||||||
/// (chunked decoding landed in v0.3); kept for future unsupported
|
/// (chunked decoding landed in v0.3); kept for future unsupported
|
||||||
/// framings. Connection actor responds 411 + close.
|
/// framings. Connection actor responds 411 + close.
|
||||||
Unsupported,
|
Unsupported,
|
||||||
|
/// `Transfer-Encoding` names a transfer coding we don't implement
|
||||||
|
/// (`chunked` is the only one urus decodes). Connection actor responds
|
||||||
|
/// 501 Not Implemented + close (RFC 9112 §6.1, §7).
|
||||||
|
UnknownTransferCoding,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -100,6 +106,11 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
|||||||
let mut connection_hdr = None;
|
let mut connection_hdr = None;
|
||||||
let mut chunked = false;
|
let mut chunked = false;
|
||||||
let mut expect_100 = false;
|
let mut expect_100 = false;
|
||||||
|
let mut host_count = 0usize;
|
||||||
|
let mut host_ok = true;
|
||||||
|
let mut cl_count = 0usize;
|
||||||
|
let mut te_present = false;
|
||||||
|
let mut te_codings: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for h in req.headers.iter() {
|
for h in req.headers.iter() {
|
||||||
let name_lower = h.name.to_ascii_lowercase();
|
let name_lower = h.name.to_ascii_lowercase();
|
||||||
@@ -107,6 +118,10 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
|||||||
|
|
||||||
match name_lower.as_str() {
|
match name_lower.as_str() {
|
||||||
"content-length" => {
|
"content-length" => {
|
||||||
|
// Count occurrences; duplicates (even equal) are rejected
|
||||||
|
// post-loop. A single value must be one decimal integer —
|
||||||
|
// a comma-list ("5, 5") or non-numeric fails parse here.
|
||||||
|
cl_count += 1;
|
||||||
content_length = Some(
|
content_length = Some(
|
||||||
value.trim()
|
value.trim()
|
||||||
.parse::<usize>()
|
.parse::<usize>()
|
||||||
@@ -114,10 +129,17 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
"transfer-encoding" => {
|
"transfer-encoding" => {
|
||||||
// We only care whether it includes "chunked". Multiple codings
|
// Collect the ordered coding list across any number of TE
|
||||||
// can appear; chunked is the only one we'd need to decode.
|
// headers; finality/known-ness is decided post-loop. Empty
|
||||||
if value.to_ascii_lowercase().split(',').any(|t| t.trim() == "chunked") {
|
// list elements (legacy `#rule`, e.g. a trailing comma) are
|
||||||
chunked = true;
|
// skipped; a wholly empty value leaves te_codings empty and
|
||||||
|
// is caught below.
|
||||||
|
te_present = true;
|
||||||
|
for coding in value.split(',') {
|
||||||
|
let c = coding.trim().to_ascii_lowercase();
|
||||||
|
if !c.is_empty() {
|
||||||
|
te_codings.push(c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"connection" => {
|
"connection" => {
|
||||||
@@ -126,19 +148,68 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
|||||||
"expect" if value.eq_ignore_ascii_case("100-continue") => {
|
"expect" if value.eq_ignore_ascii_case("100-continue") => {
|
||||||
expect_100 = true;
|
expect_100 = true;
|
||||||
}
|
}
|
||||||
|
"host" => {
|
||||||
|
// Presence/uniqueness enforced post-loop; validity here.
|
||||||
|
host_count += 1;
|
||||||
|
if !valid_host(value) {
|
||||||
|
host_ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
headers.append(&name_lower, value.to_string());
|
headers.append(&name_lower, value.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if chunked {
|
// Host (RFC 9112 §3.2): an HTTP/1.1 request MUST carry exactly one valid
|
||||||
// Transfer-Encoding is an HTTP/1.1 mechanism; a 1.0 request
|
// Host; a missing, duplicate, or malformed Host is a 400. HTTP/1.0 may
|
||||||
// carrying it is malformed. And a request carrying BOTH a
|
// omit Host, but a duplicate or invalid one is still rejected on any
|
||||||
// Content-Length and TE: chunked is the classic request-smuggling
|
// version (ambiguous / malformed authority).
|
||||||
// ambiguity — RFC 7230 §3.3.3 lets a server reject it, and we do.
|
if host_count > 1 || !host_ok {
|
||||||
if version == HttpVersion::Http10 || content_length.is_some() {
|
|
||||||
return Err(ParseError::Malformed);
|
return Err(ParseError::Malformed);
|
||||||
}
|
}
|
||||||
|
if version == HttpVersion::Http11 && host_count == 0 {
|
||||||
|
return Err(ParseError::Malformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-Length (RFC 9112 §6.3): more than one Content-Length is an
|
||||||
|
// unrecoverable framing ambiguity (CL.CL request smuggling). We are
|
||||||
|
// strict — reject any duplicate, not only differing values.
|
||||||
|
if cl_count > 1 {
|
||||||
|
return Err(ParseError::BadContentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer-Encoding (RFC 9112 §6.1/§6.3). TE is a 1.1 mechanism and
|
||||||
|
// overrides Content-Length; only `chunked` is implemented here.
|
||||||
|
if te_present {
|
||||||
|
// TE on HTTP/1.0 is malformed (no 1.0 chunked).
|
||||||
|
if version == HttpVersion::Http10 {
|
||||||
|
return Err(ParseError::Malformed);
|
||||||
|
}
|
||||||
|
// TE together with Content-Length is the classic smuggling
|
||||||
|
// ambiguity; TE overrides CL and we reject rather than forward.
|
||||||
|
if content_length.is_some() {
|
||||||
|
return Err(ParseError::Malformed);
|
||||||
|
}
|
||||||
|
// A Transfer-Encoding header that carries no coding frames nothing.
|
||||||
|
if te_codings.is_empty() {
|
||||||
|
return Err(ParseError::Malformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_is_chunked = te_codings.last().map(String::as_str) == Some("chunked");
|
||||||
|
let has_chunked = te_codings.iter().any(|c| c == "chunked");
|
||||||
|
|
||||||
|
if has_chunked && !last_is_chunked {
|
||||||
|
// chunked present but not final: body length isn't reliably
|
||||||
|
// determinable -> 400.
|
||||||
|
return Err(ParseError::Malformed);
|
||||||
|
}
|
||||||
|
if te_codings.iter().any(|c| c != "chunked") {
|
||||||
|
// Some coding we don't implement (chunked is the only decodable
|
||||||
|
// one). Whether or not chunked is final, we can't apply it -> 501.
|
||||||
|
return Err(ParseError::UnknownTransferCoding);
|
||||||
|
}
|
||||||
|
// Sole, final `chunked`: the connection actor decodes the body.
|
||||||
|
chunked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep-alive logic, RFC 7230 §6.3:
|
// Keep-alive logic, RFC 7230 §6.3:
|
||||||
@@ -163,6 +234,32 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Conservative RFC 3986 check for a `Host` field-value: non-empty and every
|
||||||
|
/// byte drawn from the `host[:port]` productions (reg-name / IP-literal
|
||||||
|
/// brackets / port colon). This is charset-level, not full structural
|
||||||
|
/// validation (no bracket matching, no pct-encoding well-formedness) — enough
|
||||||
|
/// to reject the smuggling-relevant garbage (whitespace, controls, `@`, `/`,
|
||||||
|
/// `?`, `#`) while accepting every legitimate host. Tighter structural checks
|
||||||
|
/// (bracketed IPv6, single port colon) are a possible follow-up.
|
||||||
|
fn valid_host(value: &str) -> bool {
|
||||||
|
!value.is_empty()
|
||||||
|
&& value.bytes().all(|b| {
|
||||||
|
b.is_ascii_alphanumeric()
|
||||||
|
|| matches!(
|
||||||
|
b,
|
||||||
|
// unreserved punctuation
|
||||||
|
b'-' | b'.' | b'_' | b'~'
|
||||||
|
// sub-delims
|
||||||
|
| b'!' | b'$' | b'&' | b'\'' | b'(' | b')'
|
||||||
|
| b'*' | b'+' | b',' | b';' | b'='
|
||||||
|
// pct-encoded lead
|
||||||
|
| b'%'
|
||||||
|
// IP-literal brackets + port separator
|
||||||
|
| b'[' | b']' | b':'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Conn assembly
|
// Conn assembly
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -403,6 +500,128 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Host (RFC 9112 §3.2) -------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_missing_host_http11_is_malformed() {
|
||||||
|
let req = b"GET / HTTP/1.1\r\n\r\n";
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::Malformed) => {}
|
||||||
|
_ => panic!("expected Malformed for missing Host on 1.1"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_missing_host_http10_is_allowed() {
|
||||||
|
// Host is optional in HTTP/1.0.
|
||||||
|
let req = b"GET / HTTP/1.0\r\n\r\n";
|
||||||
|
assert!(parse_head(req, 64).is_ok(), "1.0 may omit Host");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_duplicate_host_is_malformed() {
|
||||||
|
let req = b"GET / HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n";
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::Malformed) => {}
|
||||||
|
_ => panic!("expected Malformed for duplicate Host"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_invalid_host_value_is_malformed() {
|
||||||
|
// Embedded whitespace — invalid in an RFC 3986 authority.
|
||||||
|
let req = b"GET / HTTP/1.1\r\nHost: bad host\r\n\r\n";
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::Malformed) => {}
|
||||||
|
_ => panic!("expected Malformed for invalid Host"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_valid_hosts_accepted() {
|
||||||
|
// Positive controls: reg-name, reg-name:port, and IPv6-literal:port.
|
||||||
|
for req in [
|
||||||
|
b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n".as_slice(),
|
||||||
|
b"GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n".as_slice(),
|
||||||
|
b"GET / HTTP/1.1\r\nHost: [::1]:443\r\n\r\n".as_slice(),
|
||||||
|
] {
|
||||||
|
assert!(parse_head(req, 64).is_ok(), "should accept a valid Host");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Content-Length (RFC 9112 §6.3) ---------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_conflicting_content_length_is_rejected() {
|
||||||
|
// Two differing Content-Length values — classic CL.CL smuggling.
|
||||||
|
let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 7\r\n\r\nhello!!";
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::BadContentLength) => {}
|
||||||
|
_ => panic!("expected BadContentLength for conflicting CL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_duplicate_equal_content_length_is_rejected() {
|
||||||
|
// Strict: even identical duplicates are rejected.
|
||||||
|
let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\nhello";
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::BadContentLength) => {}
|
||||||
|
_ => panic!("expected BadContentLength for duplicate CL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_single_content_length_still_ok() {
|
||||||
|
// Regression: the ordinary single-CL path is unchanged.
|
||||||
|
let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello";
|
||||||
|
let head = parse_head(req, 64).unwrap();
|
||||||
|
assert_eq!(head.content_length, Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Transfer-Encoding (RFC 9112 §6.1/§6.3) -------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_non_final_chunked_is_malformed() {
|
||||||
|
// chunked must be the FINAL coding.
|
||||||
|
let req = b"POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked, gzip\r\n\r\n";
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::Malformed) => {}
|
||||||
|
_ => panic!("expected Malformed for non-final chunked"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_transfer_coding_is_unimplemented() {
|
||||||
|
// A coding urus doesn't implement, no chunked at all -> 501.
|
||||||
|
let req = b"POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: nonsense\r\n\r\n";
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::UnknownTransferCoding) => {}
|
||||||
|
_ => panic!("expected UnknownTransferCoding for unknown coding"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_gzip_then_chunked_is_unimplemented() {
|
||||||
|
// chunked IS final, but gzip is still a coding we can't apply -> 501.
|
||||||
|
let req = b"POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: gzip, chunked\r\n\r\n";
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::UnknownTransferCoding) => {}
|
||||||
|
_ => panic!("expected UnknownTransferCoding for gzip,chunked"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_te_with_content_length_is_malformed() {
|
||||||
|
// ANY Transfer-Encoding + Content-Length -> reject (smuggling),
|
||||||
|
// not only chunked+CL. This closes the old TE:unknown + CL gap.
|
||||||
|
let req = b"POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: bogus\r\nContent-Length: 5\r\n\r\nhello";
|
||||||
|
match parse_head(req, 64) {
|
||||||
|
Err(ParseError::Malformed) => {}
|
||||||
|
_ => panic!("expected Malformed for TE + CL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn serialise_basic_200() {
|
fn serialise_basic_200() {
|
||||||
let conn = Conn::new().put_status(200).put_body("hi");
|
let conn = Conn::new().put_status(200).put_body("hi");
|
||||||
|
|||||||
+123
-55
@@ -38,37 +38,46 @@
|
|||||||
//! but stays alive keeps its (one-shot, inert) monitor until death;
|
//! but stays alive keeps its (one-shot, inert) monitor until death;
|
||||||
//! that's a bounded bookkeeping entry, not a leak.
|
//! that's a bounded bookkeeping entry, not a leak.
|
||||||
//!
|
//!
|
||||||
//! # Construction must happen in-runtime
|
//! # The handle is an address, not an owner (v0.7)
|
||||||
//!
|
//!
|
||||||
//! [`PubSub::new`] spawns the table actor, which smarm only permits from
|
//! [`PubSub::new`] takes a **name** and spawns nothing. It is `const`,
|
||||||
//! inside `Runtime::run`. Pipelines are built *before* `serve*` boots the
|
//! costs a `&'static str`, and may be built anywhere — out of the
|
||||||
//! runtime, so the working pattern (same constraint crud's store hits) is
|
//! runtime, in a `static`, in a route closure, held by a long-lived
|
||||||
//! lazy init from the first handler — but with a **non-static**
|
//! relay. Every operation resolves the name through smarm's registry, so
|
||||||
//! `Arc<OnceLock<PubSub<M>>>` captured by the route closure, NOT a
|
//! a table restarted by its supervisor is reached transparently.
|
||||||
//! `static`: when the drained pipeline drops at graceful shutdown, the
|
|
||||||
//! cell (and thus the last handle) drops in-runtime, the table's inbox
|
|
||||||
//! closes, and the table exits — `serve_with_shutdown` returns. A
|
|
||||||
//! `static` pins the table forever and blocks smarm's all-done. The same
|
|
||||||
//! reasoning forbids long-lived consumer actors (relays, producers) from
|
|
||||||
//! holding a `PubSub` clone: relay holds table's inbox open, table holds
|
|
||||||
//! relay's receiver open, neither exits. Relays take the `Receiver` only.
|
|
||||||
//! See `examples/ws_chat.rs` and the `shutdown_with_open_chat_terminates`
|
|
||||||
//! integration test for the full chain.
|
|
||||||
//!
|
//!
|
||||||
//! # Why there is no `register(name)` helper (yet)
|
//! The table actor is started by [`PubSub::child`], a `ChildSpec` you put
|
||||||
|
//! in your supervision tree (or hand to `serve_with*`, which puts it in
|
||||||
|
//! the root ahead of the endpoint). Its lifetime is the supervisor's:
|
||||||
|
//! it stops when the supervisor shuts it down, in reverse start order,
|
||||||
|
//! after the endpoint has drained.
|
||||||
//!
|
//!
|
||||||
//! smarm's registry maps `name → Pid`, but a `Pid` cannot be turned back
|
//! ```ignore
|
||||||
//! into a `GenServerRef` (the ref *is* the inbox sender). A useful named
|
//! const BUS: PubSub<Event> = PubSub::new("events");
|
||||||
//! lookup therefore needs either smarm support (registry-held senders)
|
//! serve_with(cfg, rt_cfg, pipeline, vec![BUS.child()])?;
|
||||||
//! or a process-global type-erased map here — both against the grain of
|
//! ```
|
||||||
//! the ratified design. Deferred; pass the handle.
|
//!
|
||||||
|
//! This replaces the v0.5–v0.6 idiom where `PubSub::new()` spawned the
|
||||||
|
//! table and the handle owned its life — a non-static
|
||||||
|
//! `Arc<OnceLock<PubSub<M>>>` lazily initialised from the first handler,
|
||||||
|
//! with matching rules that the cell must not be `static` and that
|
||||||
|
//! relays must never hold a clone. All of that existed to hand-manage a
|
||||||
|
//! refcount. smarm 0.7 made a server's lifetime its own (refs are
|
||||||
|
//! addresses; the inbox no longer closes when the last ref drops), which
|
||||||
|
//! both removed the mechanism those rules relied on and made the named
|
||||||
|
//! lookup below possible.
|
||||||
|
//!
|
||||||
|
//! Cost: one registry resolution per operation, including per broadcast.
|
||||||
|
//! Caching a `GenServerRef` in the handle would save it and go stale
|
||||||
|
//! across exactly the restart the supervisor exists to perform. Measure
|
||||||
|
//! before optimising — see the bench item in `ROADMAP.md`.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use smarm::gen_server::{self, GenServer, GenServerCtx};
|
use smarm::gen_server::{self, GenServer, GenServerBuilder, GenServerCtx, GenServerName};
|
||||||
use smarm::{channel, Down, Pid, Receiver, Sender, GenServerRef, Watcher};
|
use smarm::{channel, ChildSpec, Down, GenServerRef, Pid, Receiver, Restart, Sender, Watcher};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public handle
|
// Public handle
|
||||||
@@ -87,32 +96,58 @@ impl fmt::Display for PubSubDown {
|
|||||||
|
|
||||||
impl std::error::Error for PubSubDown {}
|
impl std::error::Error for PubSubDown {}
|
||||||
|
|
||||||
/// A clonable handle to one pub/sub instance (one topic table actor).
|
/// The address of one pub/sub instance: a name, resolved per operation.
|
||||||
|
///
|
||||||
|
/// Cheap (`Copy`, a `&'static str`), constructible anywhere including
|
||||||
|
/// `const` context, and owns nothing — the table actor it addresses is
|
||||||
|
/// started by [`child`](Self::child) under a supervisor. Every operation
|
||||||
|
/// returns [`PubSubDown`] if no live table currently holds the name.
|
||||||
///
|
///
|
||||||
/// Payloads are broadcast as `Arc<M>`: one allocation per broadcast, not
|
/// Payloads are broadcast as `Arc<M>`: one allocation per broadcast, not
|
||||||
/// per subscriber.
|
/// per subscriber.
|
||||||
pub struct PubSub<M: Send + Sync + 'static> {
|
pub struct PubSub<M: Send + Sync + 'static> {
|
||||||
server: GenServerRef<Table<M>>,
|
name: GenServerName<Table<M>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<M: Send + Sync + 'static> Clone for PubSub<M> {
|
impl<M: Send + Sync + 'static> Clone for PubSub<M> {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
PubSub { server: self.server.clone() }
|
*self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<M: Send + Sync + 'static> Default for PubSub<M> {
|
impl<M: Send + Sync + 'static> Copy for PubSub<M> {}
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<M: Send + Sync + 'static> PubSub<M> {
|
impl<M: Send + Sync + 'static> PubSub<M> {
|
||||||
/// Start a fresh topic table. Must run inside the smarm runtime (it
|
/// Address the topic table registered under `name`. Spawns nothing
|
||||||
/// spawns the table's gen_server actor). The table lives until the
|
/// and never fails: the name is resolved at each use.
|
||||||
/// last handle is dropped.
|
pub const fn new(name: &'static str) -> Self {
|
||||||
pub fn new() -> Self {
|
PubSub { name: GenServerName::new(name) }
|
||||||
PubSub { server: gen_server::start(Table::new()) }
|
}
|
||||||
|
|
||||||
|
/// The `ChildSpec` that runs this instance's table actor. Put it in
|
||||||
|
/// your supervision tree ahead of anything that broadcasts —
|
||||||
|
/// `serve_with*` takes a `Vec<ChildSpec>` for exactly this.
|
||||||
|
///
|
||||||
|
/// `Permanent`: a table that dies is a bug, and its subscribers'
|
||||||
|
/// receivers died with it, so the restart is only half a repair —
|
||||||
|
/// pair it with a `RestForOne` parent (as `serve_with*` does) so the
|
||||||
|
/// endpoint restarts behind it and connections re-subscribe.
|
||||||
|
pub fn child(&self) -> ChildSpec {
|
||||||
|
let name = self.name;
|
||||||
|
ChildSpec::new(Restart::Permanent, move || {
|
||||||
|
if let Err(e) = GenServerBuilder::new(Table::<M>::new()).named(name).run() {
|
||||||
|
panic!("urus pubsub '{}': name already taken: {e:?}", name.as_str());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registry key this handle resolves.
|
||||||
|
pub const fn name(&self) -> &'static str {
|
||||||
|
self.name.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server(&self) -> Result<GenServerRef<Table<M>>, PubSubDown> {
|
||||||
|
gen_server::whereis_server(self.name).ok_or(PubSubDown)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe the **calling actor** to `topic`. Returns the receiving
|
/// Subscribe the **calling actor** to `topic`. Returns the receiving
|
||||||
@@ -139,7 +174,7 @@ impl<M: Send + Sync + 'static> PubSub<M> {
|
|||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
// A call, not a cast: when this returns the table is updated, so
|
// A call, not a cast: when this returns the table is updated, so
|
||||||
// a broadcast issued right after by the same caller is seen.
|
// a broadcast issued right after by the same caller is seen.
|
||||||
match self.server.call(Call::Subscribe { topic: topic.into(), pid, tx }) {
|
match self.server()?.call(Call::Subscribe { topic: topic.into(), pid, tx }) {
|
||||||
Ok(_) => Ok(rx),
|
Ok(_) => Ok(rx),
|
||||||
Err(_) => Err(PubSubDown),
|
Err(_) => Err(PubSubDown),
|
||||||
}
|
}
|
||||||
@@ -153,7 +188,7 @@ impl<M: Send + Sync + 'static> PubSub<M> {
|
|||||||
|
|
||||||
/// [`unsubscribe`](Self::unsubscribe) for an explicit pid.
|
/// [`unsubscribe`](Self::unsubscribe) for an explicit pid.
|
||||||
pub fn unsubscribe_as(&self, pid: Pid, topic: impl Into<String>) -> Result<(), PubSubDown> {
|
pub fn unsubscribe_as(&self, pid: Pid, topic: impl Into<String>) -> Result<(), PubSubDown> {
|
||||||
self.server
|
self.server()?
|
||||||
.cast(Cast::Unsubscribe { topic: topic.into(), pid })
|
.cast(Cast::Unsubscribe { topic: topic.into(), pid })
|
||||||
.map_err(|_| PubSubDown)
|
.map_err(|_| PubSubDown)
|
||||||
}
|
}
|
||||||
@@ -181,20 +216,21 @@ impl<M: Send + Sync + 'static> PubSub<M> {
|
|||||||
/// table — a subscriber whose receiver was dropped but hasn't been
|
/// table — a subscriber whose receiver was dropped but hasn't been
|
||||||
/// pruned yet (no broadcast since, still alive) is still counted.
|
/// pruned yet (no broadcast since, still alive) is still counted.
|
||||||
pub fn subscriber_count(&self, topic: impl Into<String>) -> Result<usize, PubSubDown> {
|
pub fn subscriber_count(&self, topic: impl Into<String>) -> Result<usize, PubSubDown> {
|
||||||
match self.server.call(Call::Count { topic: topic.into() }) {
|
match self.server()?.call(Call::Count { topic: topic.into() }) {
|
||||||
Ok(Reply::Count(n)) => Ok(n),
|
Ok(Reply::Count(n)) => Ok(n),
|
||||||
Ok(Reply::Subscribed) => unreachable!("Count call answered with Subscribed"),
|
Ok(Reply::Subscribed) => unreachable!("Count call answered with Subscribed"),
|
||||||
Err(_) => Err(PubSubDown),
|
Err(_) => Err(PubSubDown),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The topic table actor's pid — for introspection / registry use.
|
/// The topic table actor's pid, or `None` if no live table holds the
|
||||||
pub fn pid(&self) -> Pid {
|
/// name (not started yet, or between a crash and its restart).
|
||||||
self.server.pid()
|
pub fn pid(&self) -> Option<Pid> {
|
||||||
|
self.server().ok().map(|s| s.pid())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cast_broadcast(&self, topic: String, msg: M, skip: Option<Pid>) -> Result<(), PubSubDown> {
|
fn cast_broadcast(&self, topic: String, msg: M, skip: Option<Pid>) -> Result<(), PubSubDown> {
|
||||||
self.server
|
self.server()?
|
||||||
.cast(Cast::Broadcast { topic, msg: Arc::new(msg), skip })
|
.cast(Cast::Broadcast { topic, msg: Arc::new(msg), skip })
|
||||||
.map_err(|_| PubSubDown)
|
.map_err(|_| PubSubDown)
|
||||||
}
|
}
|
||||||
@@ -328,15 +364,33 @@ mod tests {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start `ps`'s table under a supervisor and hand back the sup's pid.
|
||||||
|
///
|
||||||
|
/// The readiness poll is smarm's "start order is not start readiness"
|
||||||
|
/// gap (see smarm ROADMAP): `start_child` spawns and moves on, so the
|
||||||
|
/// name may not be bound when this returns. Real apps don't hit it —
|
||||||
|
/// a handler only runs once a connection has been accepted, long
|
||||||
|
/// after the tree is up — but a test that broadcasts immediately does.
|
||||||
|
fn start_table<M: Send + Sync + 'static>(ps: PubSub<M>) -> Pid {
|
||||||
|
let sup = smarm::spawn(move || smarm::OneForOne::new().child(ps.child()).run());
|
||||||
|
assert!(eventually(|| ps.pid().is_some()), "table never bound its name");
|
||||||
|
sup.pid()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered shutdown of the tree `start_table` built.
|
||||||
|
fn stop_table(sup: Pid) {
|
||||||
|
smarm::request_shutdown(sup);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn broadcast_reaches_all_subscribers_once() {
|
fn broadcast_reaches_all_subscribers_once() {
|
||||||
let got = Arc::new(Mutex::new(Vec::<(u32, String)>::new()));
|
let got = Arc::new(Mutex::new(Vec::<(u32, String)>::new()));
|
||||||
let got2 = got.clone();
|
let got2 = got.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let ps = PubSub::<String>::new();
|
let ps = PubSub::<String>::new("test-bus");
|
||||||
|
let sup = start_table(ps);
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
for i in 0..2u32 {
|
for i in 0..2u32 {
|
||||||
let ps = ps.clone();
|
|
||||||
let got = got2.clone();
|
let got = got2.clone();
|
||||||
handles.push(smarm::spawn(move || {
|
handles.push(smarm::spawn(move || {
|
||||||
let rx = ps.subscribe("room:a").unwrap();
|
let rx = ps.subscribe("room:a").unwrap();
|
||||||
@@ -351,6 +405,7 @@ mod tests {
|
|||||||
for h in handles {
|
for h in handles {
|
||||||
h.join().unwrap();
|
h.join().unwrap();
|
||||||
}
|
}
|
||||||
|
stop_table(sup);
|
||||||
});
|
});
|
||||||
let mut v = got.lock().unwrap().clone();
|
let mut v = got.lock().unwrap().clone();
|
||||||
v.sort();
|
v.sort();
|
||||||
@@ -362,10 +417,10 @@ mod tests {
|
|||||||
let ptrs = Arc::new(Mutex::new(Vec::<usize>::new()));
|
let ptrs = Arc::new(Mutex::new(Vec::<usize>::new()));
|
||||||
let ptrs2 = ptrs.clone();
|
let ptrs2 = ptrs.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let ps = PubSub::<Vec<u8>>::new();
|
let ps = PubSub::<Vec<u8>>::new("test-bus");
|
||||||
|
let sup = start_table(ps);
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
let ps = ps.clone();
|
|
||||||
let ptrs = ptrs2.clone();
|
let ptrs = ptrs2.clone();
|
||||||
handles.push(smarm::spawn(move || {
|
handles.push(smarm::spawn(move || {
|
||||||
let rx = ps.subscribe("t").unwrap();
|
let rx = ps.subscribe("t").unwrap();
|
||||||
@@ -378,6 +433,7 @@ mod tests {
|
|||||||
for h in handles {
|
for h in handles {
|
||||||
h.join().unwrap();
|
h.join().unwrap();
|
||||||
}
|
}
|
||||||
|
stop_table(sup);
|
||||||
});
|
});
|
||||||
let v = ptrs.lock().unwrap();
|
let v = ptrs.lock().unwrap();
|
||||||
assert_eq!(v.len(), 2);
|
assert_eq!(v.len(), 2);
|
||||||
@@ -389,8 +445,9 @@ mod tests {
|
|||||||
let got = Arc::new(Mutex::new(Vec::<String>::new()));
|
let got = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
let got2 = got.clone();
|
let got2 = got.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let ps = PubSub::<String>::new();
|
let ps = PubSub::<String>::new("test-bus");
|
||||||
let ps_loud = ps.clone();
|
let sup = start_table(ps);
|
||||||
|
let ps_loud = ps;
|
||||||
let got = got2.clone();
|
let got = got2.clone();
|
||||||
let loud = smarm::spawn(move || {
|
let loud = smarm::spawn(move || {
|
||||||
let rx = ps_loud.subscribe("room").unwrap();
|
let rx = ps_loud.subscribe("room").unwrap();
|
||||||
@@ -410,6 +467,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.push(format!("root got {} then {}", *first, *second));
|
.push(format!("root got {} then {}", *first, *second));
|
||||||
loud.join().unwrap();
|
loud.join().unwrap();
|
||||||
|
stop_table(sup);
|
||||||
});
|
});
|
||||||
let v = got.lock().unwrap();
|
let v = got.lock().unwrap();
|
||||||
assert!(v.contains(&"loud got for everyone".to_string()), "{v:?}");
|
assert!(v.contains(&"loud got for everyone".to_string()), "{v:?}");
|
||||||
@@ -426,7 +484,8 @@ mod tests {
|
|||||||
let ok = Arc::new(Mutex::new(false));
|
let ok = Arc::new(Mutex::new(false));
|
||||||
let ok2 = ok.clone();
|
let ok2 = ok.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let ps = PubSub::<u32>::new();
|
let ps = PubSub::<u32>::new("test-bus");
|
||||||
|
let sup = start_table(ps);
|
||||||
let rx = ps.subscribe("t").unwrap();
|
let rx = ps.subscribe("t").unwrap();
|
||||||
ps.unsubscribe("t").unwrap();
|
ps.unsubscribe("t").unwrap();
|
||||||
// Unsubscribe is a cast; the table holds the only sender, so
|
// Unsubscribe is a cast; the table holds the only sender, so
|
||||||
@@ -435,6 +494,7 @@ mod tests {
|
|||||||
assert!(rx.recv().is_err());
|
assert!(rx.recv().is_err());
|
||||||
ps.broadcast("t", 7).unwrap(); // no subscribers: no-op, no panic
|
ps.broadcast("t", 7).unwrap(); // no subscribers: no-op, no panic
|
||||||
*ok2.lock().unwrap() = true;
|
*ok2.lock().unwrap() = true;
|
||||||
|
stop_table(sup);
|
||||||
});
|
});
|
||||||
assert!(*ok.lock().unwrap());
|
assert!(*ok.lock().unwrap());
|
||||||
}
|
}
|
||||||
@@ -444,7 +504,8 @@ mod tests {
|
|||||||
let got = Arc::new(Mutex::new((0u32, false)));
|
let got = Arc::new(Mutex::new((0u32, false)));
|
||||||
let got2 = got.clone();
|
let got2 = got.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let ps = PubSub::<u32>::new();
|
let ps = PubSub::<u32>::new("test-bus");
|
||||||
|
let sup = start_table(ps);
|
||||||
let rx_old = ps.subscribe("t").unwrap();
|
let rx_old = ps.subscribe("t").unwrap();
|
||||||
let rx_new = ps.subscribe("t").unwrap();
|
let rx_new = ps.subscribe("t").unwrap();
|
||||||
assert_eq!(ps.subscriber_count("t").unwrap(), 1, "idempotent per (pid, topic)");
|
assert_eq!(ps.subscriber_count("t").unwrap(), 1, "idempotent per (pid, topic)");
|
||||||
@@ -452,6 +513,7 @@ mod tests {
|
|||||||
let v = *rx_new.recv().unwrap();
|
let v = *rx_new.recv().unwrap();
|
||||||
let old_closed = rx_old.recv().is_err();
|
let old_closed = rx_old.recv().is_err();
|
||||||
*got2.lock().unwrap() = (v, old_closed);
|
*got2.lock().unwrap() = (v, old_closed);
|
||||||
|
stop_table(sup);
|
||||||
});
|
});
|
||||||
assert_eq!(*got.lock().unwrap(), (42, true));
|
assert_eq!(*got.lock().unwrap(), (42, true));
|
||||||
}
|
}
|
||||||
@@ -461,8 +523,9 @@ mod tests {
|
|||||||
let ok = Arc::new(Mutex::new(false));
|
let ok = Arc::new(Mutex::new(false));
|
||||||
let ok2 = ok.clone();
|
let ok2 = ok.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let ps = PubSub::<u32>::new();
|
let ps = PubSub::<u32>::new("test-bus");
|
||||||
let ps2 = ps.clone();
|
let sup = start_table(ps);
|
||||||
|
let ps2 = ps;
|
||||||
let h = smarm::spawn(move || {
|
let h = smarm::spawn(move || {
|
||||||
let _rx = ps2.subscribe("t").unwrap();
|
let _rx = ps2.subscribe("t").unwrap();
|
||||||
// Exit without ever receiving: rx drops with the stack.
|
// Exit without ever receiving: rx drops with the stack.
|
||||||
@@ -471,6 +534,7 @@ mod tests {
|
|||||||
// No broadcast issued — this MUST be the monitor path, not
|
// No broadcast issued — this MUST be the monitor path, not
|
||||||
// prune-on-send-failure.
|
// prune-on-send-failure.
|
||||||
*ok2.lock().unwrap() = eventually(|| ps.subscriber_count("t").unwrap() == 0);
|
*ok2.lock().unwrap() = eventually(|| ps.subscriber_count("t").unwrap() == 0);
|
||||||
|
stop_table(sup);
|
||||||
});
|
});
|
||||||
assert!(*ok.lock().unwrap(), "monitor Down never pruned the dead subscriber");
|
assert!(*ok.lock().unwrap(), "monitor Down never pruned the dead subscriber");
|
||||||
}
|
}
|
||||||
@@ -480,7 +544,8 @@ mod tests {
|
|||||||
let counts = Arc::new(Mutex::new((0usize, 0usize)));
|
let counts = Arc::new(Mutex::new((0usize, 0usize)));
|
||||||
let counts2 = counts.clone();
|
let counts2 = counts.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let ps = PubSub::<u32>::new();
|
let ps = PubSub::<u32>::new("test-bus");
|
||||||
|
let sup = start_table(ps);
|
||||||
let rx = ps.subscribe("t").unwrap();
|
let rx = ps.subscribe("t").unwrap();
|
||||||
drop(rx);
|
drop(rx);
|
||||||
let before = ps.subscriber_count("t").unwrap();
|
let before = ps.subscriber_count("t").unwrap();
|
||||||
@@ -493,6 +558,7 @@ mod tests {
|
|||||||
after = if eventually(|| ps.subscriber_count("t").unwrap() == 0) { 0 } else { after };
|
after = if eventually(|| ps.subscriber_count("t").unwrap() == 0) { 0 } else { after };
|
||||||
}
|
}
|
||||||
*counts2.lock().unwrap() = (before, after);
|
*counts2.lock().unwrap() = (before, after);
|
||||||
|
stop_table(sup);
|
||||||
});
|
});
|
||||||
assert_eq!(*counts.lock().unwrap(), (1, 0));
|
assert_eq!(*counts.lock().unwrap(), (1, 0));
|
||||||
}
|
}
|
||||||
@@ -502,11 +568,13 @@ mod tests {
|
|||||||
let got = Arc::new(Mutex::new(Vec::<u32>::new()));
|
let got = Arc::new(Mutex::new(Vec::<u32>::new()));
|
||||||
let got2 = got.clone();
|
let got2 = got.clone();
|
||||||
smarm::run(move || {
|
smarm::run(move || {
|
||||||
let ps = PubSub::<u32>::new();
|
let ps = PubSub::<u32>::new("test-bus");
|
||||||
|
let sup = start_table(ps);
|
||||||
let rx_a = ps.subscribe("a").unwrap();
|
let rx_a = ps.subscribe("a").unwrap();
|
||||||
ps.broadcast("b", 99).unwrap(); // nobody on b; must not reach a
|
ps.broadcast("b", 99).unwrap(); // nobody on b; must not reach a
|
||||||
ps.broadcast("a", 1).unwrap();
|
ps.broadcast("a", 1).unwrap();
|
||||||
got2.lock().unwrap().push(*rx_a.recv().unwrap());
|
got2.lock().unwrap().push(*rx_a.recv().unwrap());
|
||||||
|
stop_table(sup);
|
||||||
});
|
});
|
||||||
assert_eq!(*got.lock().unwrap(), vec![1]);
|
assert_eq!(*got.lock().unwrap(), vec![1]);
|
||||||
}
|
}
|
||||||
|
|||||||
+245
-290
@@ -1,29 +1,19 @@
|
|||||||
//! Listener pool and the `serve` entry point.
|
//! [`Config`] and the `serve*` entry points.
|
||||||
//!
|
//!
|
||||||
//! A small fixed pool of listener actors share the same TCP listen fd (via
|
//! The listener pool itself lives in [`crate::endpoint`], which is the
|
||||||
//! `dup`) — each blocks in non-blocking `accept4` + `wait_readable` on its
|
//! real API: an endpoint is a supervisable child you place in your own
|
||||||
//! own copy. When a connection arrives the listener spawns a connection
|
//! tree. `serve*` is the batteries-included path for a process whose only
|
||||||
//! actor with the `OwnedFd` and immediately returns to `accept`. No
|
//! job is serving HTTP — it owns the runtime and wraps one endpoint in a
|
||||||
//! coordination needed; the kernel serialises `accept` calls across the fds.
|
//! one-child supervisor.
|
||||||
//!
|
//!
|
||||||
//! Sharing via `dup` rather than the same fd is deliberate — Linux's
|
use crate::conn_actor::ConnLimits;
|
||||||
//! `accept4` is thread-safe on a single fd, but dup'ing per-listener keeps
|
|
||||||
//! each actor's epoll registration local to its own RawFd value (so smarm's
|
|
||||||
//! `waiters: HashMap<RawFd, Pid>` doesn't see collisions between listeners
|
|
||||||
//! waiting on "the same fd").
|
|
||||||
|
|
||||||
use crate::conn_actor::{run_connection, ConnLimits};
|
|
||||||
use crate::conn_registry::{self, Call, Cast, ConnRegistry, Reply};
|
|
||||||
use crate::net::{accept_nonblocking, bind_and_listen, OwnedFd};
|
|
||||||
use crate::plug::Pipeline;
|
use crate::plug::Pipeline;
|
||||||
|
|
||||||
use smarm::{ChildSpec, OneForOne, Restart, GenServerRef, Strategy};
|
use smarm::supervisor::Shutdown;
|
||||||
|
use smarm::{ChildSpec, OneForOne, Restart, Strategy};
|
||||||
|
|
||||||
use std::io::{self, ErrorKind};
|
use std::io::{self, ErrorKind};
|
||||||
use std::net::{SocketAddr, ToSocketAddrs};
|
use std::net::{SocketAddr, ToSocketAddrs};
|
||||||
use std::os::fd::RawFd;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -37,7 +27,21 @@ pub struct Config {
|
|||||||
pub keep_alive_timeout: Duration,
|
pub keep_alive_timeout: Duration,
|
||||||
pub max_header_count: usize,
|
pub max_header_count: usize,
|
||||||
pub read_buf_size: usize,
|
pub read_buf_size: usize,
|
||||||
pub request_timeout: Duration,
|
/// Wall-clock budget for reading the request HEAD (from first byte to
|
||||||
|
/// full head parse). Kept short — an incomplete head is the classic
|
||||||
|
/// slowloris. See `ConnLimits::head_timeout`.
|
||||||
|
pub head_timeout: Duration,
|
||||||
|
/// Absolute wall-clock cap on reading the request BODY (from head-parse
|
||||||
|
/// to full body). Sized for slow links, so much larger than
|
||||||
|
/// `head_timeout`. See `ConnLimits::body_timeout`.
|
||||||
|
pub body_timeout: Duration,
|
||||||
|
/// Burst size that resets the body stall clock. A body dribbling fewer
|
||||||
|
/// than this per `body_stall_timeout` window is evicted — the slowloris
|
||||||
|
/// / slow-legit discriminator. See `ConnLimits::body_burst_bytes`.
|
||||||
|
pub body_burst_bytes: usize,
|
||||||
|
/// Max time since the last qualifying body burst before eviction;
|
||||||
|
/// backstopped by `body_timeout`. See `ConnLimits::body_stall_timeout`.
|
||||||
|
pub body_stall_timeout: Duration,
|
||||||
/// Per-write budget for response bytes (the fixed head+body write, and
|
/// Per-write budget for response bytes (the fixed head+body write, and
|
||||||
/// each streamed chunk). See `ConnLimits::write_timeout`.
|
/// each streamed chunk). See `ConnLimits::write_timeout`.
|
||||||
pub write_timeout: Duration,
|
pub write_timeout: Duration,
|
||||||
@@ -53,12 +57,24 @@ pub struct Config {
|
|||||||
/// WebSocket: cap on a complete reassembled message (spans
|
/// WebSocket: cap on a complete reassembled message (spans
|
||||||
/// fragments; violation closes 1009).
|
/// fragments; violation closes 1009).
|
||||||
pub max_message_bytes: usize,
|
pub max_message_bytes: usize,
|
||||||
/// Number of smarm scheduler OS threads. `None` means smarm's default
|
/// Registry name for this endpoint's gen_server — how it is addressed
|
||||||
/// (one per CPU). Set this to a small fixed number in tests so multiple
|
/// from elsewhere in the app ([`crate::endpoint::whereis`]), and what
|
||||||
/// concurrent test servers don't oversubscribe the host.
|
/// must be unique between two endpoints in one process (a public and
|
||||||
pub scheduler_threads: Option<usize>,
|
/// an admin port, say). Default `"urus"`.
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Stack reserve (RFC 019 `smarm::SpawnOpts::stack_reserve`) given to
|
||||||
|
/// each per-connection actor. Request handlers routinely pull in
|
||||||
|
/// application code — DB drivers, (de)compression, templating — whose
|
||||||
|
/// stack needs comfortably exceed smarm's bare-actor default of 64 KiB
|
||||||
|
/// (the exact shape of bug this exists to head off; see smarm RFC 019).
|
||||||
|
/// Default: 256 KiB. The reserve is virtual/demand-paged, so raising it
|
||||||
|
/// costs address space, not RSS, until a handler actually uses it.
|
||||||
|
pub conn_stack_reserve: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default per-connection actor stack reserve (see [`Config::conn_stack_reserve`]).
|
||||||
|
pub const DEFAULT_CONN_STACK_RESERVE: usize = 256 * 1024;
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn new(addr: SocketAddr) -> Self {
|
pub fn new(addr: SocketAddr) -> Self {
|
||||||
let pool = std::thread::available_parallelism()
|
let pool = std::thread::available_parallelism()
|
||||||
@@ -71,24 +87,31 @@ impl Config {
|
|||||||
keep_alive_timeout: Duration::from_secs(60),
|
keep_alive_timeout: Duration::from_secs(60),
|
||||||
max_header_count: 64,
|
max_header_count: 64,
|
||||||
read_buf_size: 8 * 1024,
|
read_buf_size: 8 * 1024,
|
||||||
request_timeout: Duration::from_secs(30),
|
head_timeout: Duration::from_secs(30),
|
||||||
|
body_timeout: Duration::from_secs(300),
|
||||||
|
body_burst_bytes: 4 * 1024,
|
||||||
|
body_stall_timeout: Duration::from_secs(20),
|
||||||
write_timeout: Duration::from_secs(30),
|
write_timeout: Duration::from_secs(30),
|
||||||
max_body_bytes: 16 * 1024 * 1024,
|
max_body_bytes: 16 * 1024 * 1024,
|
||||||
drain_timeout: Duration::from_secs(30),
|
drain_timeout: Duration::from_secs(30),
|
||||||
max_frame_payload: 1024 * 1024,
|
max_frame_payload: 1024 * 1024,
|
||||||
max_message_bytes: 4 * 1024 * 1024,
|
max_message_bytes: 4 * 1024 * 1024,
|
||||||
scheduler_threads: None,
|
name: "urus",
|
||||||
|
conn_stack_reserve: DEFAULT_CONN_STACK_RESERVE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_conn_limits(&self) -> ConnLimits {
|
pub(crate) fn to_conn_limits(&self) -> ConnLimits {
|
||||||
ConnLimits {
|
ConnLimits {
|
||||||
max_headers: self.max_header_count,
|
max_headers: self.max_header_count,
|
||||||
initial_read_buf: self.read_buf_size,
|
initial_read_buf: self.read_buf_size,
|
||||||
max_head_bytes: 64 * 1024,
|
max_head_bytes: 64 * 1024,
|
||||||
max_body_bytes: self.max_body_bytes,
|
max_body_bytes: self.max_body_bytes,
|
||||||
keep_alive_timeout: self.keep_alive_timeout,
|
keep_alive_timeout: self.keep_alive_timeout,
|
||||||
request_timeout: self.request_timeout,
|
head_timeout: self.head_timeout,
|
||||||
|
body_timeout: self.body_timeout,
|
||||||
|
body_burst_bytes: self.body_burst_bytes,
|
||||||
|
body_stall_timeout: self.body_stall_timeout,
|
||||||
write_timeout: self.write_timeout,
|
write_timeout: self.write_timeout,
|
||||||
max_frame_payload: self.max_frame_payload,
|
max_frame_payload: self.max_frame_payload,
|
||||||
max_message_bytes: self.max_message_bytes,
|
max_message_bytes: self.max_message_bytes,
|
||||||
@@ -97,124 +120,87 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// dup helper
|
// config-file: TOML overlay for tuning knobs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// urus is a library, so it never presumes a config-file path or reads the
|
||||||
|
// environment — the embedding binary decides where a file lives and hands
|
||||||
|
// the text here. This overlays a sparse TOML document onto an existing
|
||||||
|
// `Config` (built with an addr the binary chose): only the keys present are
|
||||||
|
// applied, everything else keeps the compiled default. Durations are
|
||||||
|
// integer seconds. Unknown keys are a hard error so a typo is loud, not a
|
||||||
|
// silent no-op.
|
||||||
|
//
|
||||||
|
// Scope for now: the slowloris-tuning knobs only. Migrating the rest of the
|
||||||
|
// Config surface into the file is a separate, additive job (the loader
|
||||||
|
// mechanism is general — it just extends `TomlOverrides`).
|
||||||
|
|
||||||
fn dup_fd(fd: RawFd) -> io::Result<OwnedFd> {
|
/// Error from [`Config::with_toml_str`]: the TOML failed to parse or carried
|
||||||
let new_fd = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
/// an unknown/mistyped key.
|
||||||
if new_fd < 0 {
|
#[cfg(feature = "config-file")]
|
||||||
return Err(io::Error::last_os_error());
|
#[derive(Debug)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
Toml(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "config-file")]
|
||||||
|
impl std::fmt::Display for ConfigError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ConfigError::Toml(m) => write!(f, "config TOML error: {m}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "config-file")]
|
||||||
|
impl std::error::Error for ConfigError {}
|
||||||
|
|
||||||
|
#[cfg(feature = "config-file")]
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct TomlOverrides {
|
||||||
|
head_timeout_secs: Option<u64>,
|
||||||
|
body_timeout_secs: Option<u64>,
|
||||||
|
body_burst_bytes: Option<usize>,
|
||||||
|
body_stall_timeout_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "config-file")]
|
||||||
|
impl Config {
|
||||||
|
/// Overlay a TOML document of tuning knobs onto this config (sparse:
|
||||||
|
/// only the keys present are applied). Durations are integer seconds.
|
||||||
|
///
|
||||||
|
/// Recognized keys: `head_timeout_secs`, `body_timeout_secs`,
|
||||||
|
/// `body_burst_bytes`, `body_stall_timeout_secs`. Unknown keys error.
|
||||||
|
/// Other `Config` knobs are not yet file-configurable.
|
||||||
|
pub fn with_toml_str(mut self, s: &str) -> Result<Self, ConfigError> {
|
||||||
|
let o: TomlOverrides =
|
||||||
|
toml::from_str(s).map_err(|e| ConfigError::Toml(e.to_string()))?;
|
||||||
|
if let Some(v) = o.head_timeout_secs {
|
||||||
|
self.head_timeout = Duration::from_secs(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = o.body_timeout_secs {
|
||||||
|
self.body_timeout = Duration::from_secs(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = o.body_burst_bytes {
|
||||||
|
self.body_burst_bytes = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = o.body_stall_timeout_secs {
|
||||||
|
self.body_stall_timeout = Duration::from_secs(v);
|
||||||
|
}
|
||||||
|
Ok(self)
|
||||||
}
|
}
|
||||||
Ok(OwnedFd::from_raw(new_fd))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// listener actor body
|
// Handle / ShutdownSignal — graceful shutdown plumbing for the serve* entries.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Test-only fault injection. When nonzero, the next accept-loop iteration
|
/// A clonable trigger for graceful shutdown, usable from any OS thread
|
||||||
/// of whichever listener gets there first decrements this and panics —
|
/// (e.g. a signal-handling thread) — the shutdown path for callers who let
|
||||||
/// *before* calling `accept`, so a pending connection stays in the kernel
|
/// `serve*` own the runtime and so have no [`smarm::RuntimeHandle`] of
|
||||||
/// backlog and must be picked up by the restarted listener. Cost when idle
|
/// their own. If you build the tree yourself with [`crate::endpoint`], use
|
||||||
/// is one relaxed load per accept-loop iteration (each of which already
|
/// `rt.handle().request_shutdown(root_sup)` instead and ignore this.
|
||||||
/// pays a syscall). Not public API.
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub static INJECT_LISTENER_PANICS: AtomicU32 = AtomicU32::new(0);
|
|
||||||
|
|
||||||
fn listener_loop(
|
|
||||||
listener: Arc<OwnedFd>,
|
|
||||||
pipeline: Pipeline,
|
|
||||||
limits: ConnLimits,
|
|
||||||
registry: GenServerRef<ConnRegistry>,
|
|
||||||
shutdown: Arc<AtomicBool>,
|
|
||||||
) {
|
|
||||||
let fd = listener.as_raw();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// Self-termination on shutdown — the ONLY way a listener exits at
|
|
||||||
// shutdown, and deliberately a normal return: under
|
|
||||||
// `Restart::Transient` a normal exit is terminal, so the
|
|
||||||
// supervisor's active-count drains and `sup.run()` returns on its
|
|
||||||
// own. No pid is ever `request_stop`ped, which sidesteps both the
|
|
||||||
// spawn-to-registration race of self-announced pids and smarm's
|
|
||||||
// lossy stop-while-QUEUED window (a flag is wake-free and
|
|
||||||
// race-free; a freshly spawned listener observes it on its very
|
|
||||||
// first iteration, a parked one within LISTENER_TICK).
|
|
||||||
if shutdown.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if INJECT_LISTENER_PANICS
|
|
||||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_sub(1))
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
panic!("urus: injected listener panic (test hook)");
|
|
||||||
}
|
|
||||||
match accept_nonblocking(fd) {
|
|
||||||
Ok(client) => {
|
|
||||||
// Hand the fd off to a new connection actor. spawn() is
|
|
||||||
// cheap on smarm — it's a single Vec push under the
|
|
||||||
// shared lock.
|
|
||||||
let p = pipeline.clone();
|
|
||||||
let l = limits;
|
|
||||||
let r = registry.clone();
|
|
||||||
smarm::spawn(move || run_connection(client, p, l, r));
|
|
||||||
}
|
|
||||||
Err(e) if e.kind() == ErrorKind::WouldBlock => {
|
|
||||||
// No pending connection. Park until the listener is
|
|
||||||
// readable or the tick elapses; either way we come back
|
|
||||||
// around through the shutdown-flag check above.
|
|
||||||
match smarm::wait_readable_timeout(fd, LISTENER_TICK) {
|
|
||||||
Ok(_ready) => {} // ready or tick — loop re-checks, retries accept
|
|
||||||
Err(we) => {
|
|
||||||
// epoll registration failed. Under Transient
|
|
||||||
// supervision a normal return is terminal but a
|
|
||||||
// panic restarts us — and a failed wait IS
|
|
||||||
// abnormal, so panic: a transient failure (e.g.
|
|
||||||
// EMFILE on the epoll set) heals by restart
|
|
||||||
// instead of silently shrinking the pool. (smarm
|
|
||||||
// catches actor panics in the trampoline; this is
|
|
||||||
// a Signal::Panic to the supervisor, not process
|
|
||||||
// noise.)
|
|
||||||
panic!("urus: listener wait_readable failed: {we}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) if e.kind() == ErrorKind::Interrupted => {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// EMFILE / ENFILE / ECONNABORTED etc. Log and continue;
|
|
||||||
// the system may recover.
|
|
||||||
eprintln!("urus: accept error: {e}");
|
|
||||||
// Small backoff via smarm's sleep to avoid spinning if
|
|
||||||
// the error is sticky.
|
|
||||||
smarm::sleep(Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The Arc clone we were started with drops here (normal exit or
|
|
||||||
// unwind), but the ChildSpec factory holds another — the fd outlives
|
|
||||||
// any one incarnation of this listener.
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handle / ShutdownSignal — graceful shutdown plumbing.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// How often the root actor polls for a shutdown signal (see
|
|
||||||
/// `serve_with_shutdown` for why this is a poll); bounds shutdown latency.
|
|
||||||
const SHUTDOWN_POLL: Duration = Duration::from_millis(100);
|
|
||||||
|
|
||||||
/// Listener accept-waits are timed at this tick; the shutdown flag is
|
|
||||||
/// observed at the top of every accept-loop iteration, so this bounds how
|
|
||||||
/// long a fully idle listener takes to notice shutdown. It is the SOLE
|
|
||||||
/// listener-shutdown mechanism (no `request_stop` — see the shutdown
|
|
||||||
/// sequence notes). Idle cost: one timer wake per listener per tick.
|
|
||||||
const LISTENER_TICK: Duration = Duration::from_millis(250);
|
|
||||||
|
|
||||||
/// A clonable trigger for graceful shutdown. Safe to use from any OS
|
|
||||||
/// thread (the send only enqueues; the serving side polls), e.g. from a
|
|
||||||
/// signal-handling thread.
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Handle {
|
pub struct Handle {
|
||||||
tx: smarm::Sender<()>,
|
tx: smarm::Sender<()>,
|
||||||
@@ -223,8 +209,8 @@ pub struct Handle {
|
|||||||
impl Handle {
|
impl Handle {
|
||||||
/// Begin graceful shutdown: stop accepting, close idle keep-alive
|
/// Begin graceful shutdown: stop accepting, close idle keep-alive
|
||||||
/// connections, drain in-flight requests up to `Config.drain_timeout`,
|
/// connections, drain in-flight requests up to `Config.drain_timeout`,
|
||||||
/// then force-stop stragglers. `serve_with_shutdown` returns once the
|
/// then force-stop stragglers. `serve*` returns once the runtime has
|
||||||
/// runtime has wound down. Idempotent; extra calls are no-ops.
|
/// wound down. Idempotent; extra calls are no-ops.
|
||||||
pub fn shutdown(&self) {
|
pub fn shutdown(&self) {
|
||||||
let _ = self.tx.send(());
|
let _ = self.tx.send(());
|
||||||
}
|
}
|
||||||
@@ -242,183 +228,152 @@ pub fn shutdown_handle() -> (Handle, ShutdownSignal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// serve_with_shutdown — main entry. Boots smarm, supervises listeners,
|
// serve* — batteries-included entries for apps whose only job is serving.
|
||||||
// blocks until shutdown.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Boots an smarm runtime (one OS thread per CPU by default — see smarm's
|
// These own the smarm runtime and build a one-child tree around
|
||||||
// `Config::default()`). The root actor starts the connection registry and
|
// `endpoint()`. An app with its own actors should call `endpoint()`
|
||||||
// a one-for-one supervisor over the listener pool, then blocks on the
|
// directly and put it in its own supervision tree — that is the real API;
|
||||||
// shutdown signal. A panicking listener is restarted on the same
|
// everything here is a convenience wrapper over it.
|
||||||
// (still-open) fd instead of silently shrinking the accept pool.
|
|
||||||
// Connection actors stay unsupervised bare spawns — per spec, a connection
|
|
||||||
// is cheap and its failure is local: a 500 path, not a restart path.
|
|
||||||
// (`spawn` from a listener parents the conn actor under that listener,
|
|
||||||
// which never registers a supervisor channel, so conn deaths are invisible
|
|
||||||
// to the pool supervisor by construction.)
|
|
||||||
//
|
|
||||||
// Shutdown sequence (drain-then-stop, the v0.2 chunk 2 decision):
|
|
||||||
// 1. Set the shared shutdown flag. Every listener observes it at the
|
|
||||||
// top of its accept loop (parks are timed at LISTENER_TICK) and
|
|
||||||
// returns normally. Under `Restart::Transient` a normal exit is
|
|
||||||
// terminal, so no restart happens and the supervisor's active count
|
|
||||||
// drains to zero.
|
|
||||||
// 2. Join the supervisor: `sup.run()` returns on its own once every
|
|
||||||
// listener has exited. The join is therefore the barrier "no new
|
|
||||||
// connections can ever be accepted" — listener fds are closed (the
|
|
||||||
// last Arc clones drop with the supervisor's ChildSpecs), and the
|
|
||||||
// kernel refuses new connects.
|
|
||||||
//
|
|
||||||
// Why a flag and not `request_stop`: stopping pids that announce
|
|
||||||
// themselves races the spawn-to-registration gap (a fast shutdown
|
|
||||||
// CAN beat a fresh listener to the registry), and smarm's
|
|
||||||
// `request_stop` is lossy against a QUEUED actor that then parks
|
|
||||||
// without passing an observation point — found the hard way; see
|
|
||||||
// the chunk 2 commit message. The flag is wake-free and race-free.
|
|
||||||
// 3. BeginDrain: the registry stops idle conns now and each remaining
|
|
||||||
// conn the moment it finishes its in-flight request.
|
|
||||||
// 4. Poll until no conns remain or the drain deadline passes; past the
|
|
||||||
// deadline, ForceStopConns every tick until the set empties (a conn
|
|
||||||
// accepted just before listener death may register late).
|
|
||||||
// 5. Root returns. `rt.run` itself returns only when every actor has
|
|
||||||
// exited (force-stopped conns unwind through their fd waits safely —
|
|
||||||
// smarm's 06-10 io fix — and close their sockets via OwnedFd::drop).
|
|
||||||
|
|
||||||
|
/// Boot a runtime, serve until `signal` fires, then drain and return.
|
||||||
|
///
|
||||||
|
/// The tree is `root sup -> [..app_children, endpoint]` on
|
||||||
|
/// [`Strategy::RestForOne`], with the endpoint on [`Shutdown::Infinity`]
|
||||||
|
/// so its `drain_timeout` — not a supervisor deadline — bounds the drain.
|
||||||
|
///
|
||||||
|
/// `app_children` is your application's actors: a
|
||||||
|
/// [`PubSub::child`](crate::PubSub::child), a
|
||||||
|
/// [`ChannelHub::children`](crate::channels::ChannelHub::children), your
|
||||||
|
/// own state servers. They start **before** the endpoint and, because
|
||||||
|
/// shutdown is ordered in reverse, stop **after** it has drained — so a
|
||||||
|
/// request still in flight can still reach the bus. `RestForOne` in that
|
||||||
|
/// order also means one of them crashing restarts the endpoint behind it,
|
||||||
|
/// dropping connections whose subscriptions died with it, rather than
|
||||||
|
/// leaving live sockets addressing a table that no longer knows them.
|
||||||
|
///
|
||||||
|
/// The root actor parks on the signal channel; a `Handle::shutdown` from a
|
||||||
|
/// foreign OS thread wakes it, it shuts the supervisor down and `rt.run`
|
||||||
|
/// returns when the last actor is gone.
|
||||||
pub fn serve_with_shutdown(
|
pub fn serve_with_shutdown(
|
||||||
config: Config,
|
config: Config,
|
||||||
|
rt_config: smarm::Config,
|
||||||
pipeline: Pipeline,
|
pipeline: Pipeline,
|
||||||
|
app_children: Vec<ChildSpec>,
|
||||||
signal: ShutdownSignal,
|
signal: ShutdownSignal,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let listener = bind_and_listen(config.addr)?;
|
let addr = config.addr;
|
||||||
println!("urus: listening on {}", config.addr);
|
let endpoint = crate::endpoint::endpoint(config, pipeline)?;
|
||||||
|
println!("urus: listening on {addr}");
|
||||||
// One connection-actor-spawning loop per listener pool slot. Each gets
|
|
||||||
// its own dup'd fd so epoll registrations don't collide. Each fd is
|
|
||||||
// owned by its ChildSpec's factory closure (via `Arc`): a restarted
|
|
||||||
// listener re-enters `accept`/`wait_readable` on the same fd — no
|
|
||||||
// re-dup, no window where the slot has no fd.
|
|
||||||
let mut listener_fds = Vec::with_capacity(config.listener_pool);
|
|
||||||
listener_fds.push(Arc::new(listener)); // primary keeps the original
|
|
||||||
|
|
||||||
for _ in 1..config.listener_pool {
|
|
||||||
let dup = dup_fd(listener_fds[0].as_raw())?;
|
|
||||||
listener_fds.push(Arc::new(dup));
|
|
||||||
}
|
|
||||||
|
|
||||||
let limits = config.to_conn_limits();
|
|
||||||
let drain_timeout = config.drain_timeout;
|
|
||||||
|
|
||||||
let smarm_cfg = match config.scheduler_threads {
|
|
||||||
Some(n) => smarm::Config::exact(n),
|
|
||||||
None => smarm::Config::default(),
|
|
||||||
};
|
|
||||||
let rt = smarm::init(smarm_cfg);
|
|
||||||
// Listener self-termination flag — see the shutdown sequence below.
|
|
||||||
let shutdown_flag = Arc::new(AtomicBool::new(false));
|
|
||||||
|
|
||||||
|
let rt = smarm::init(rt_config);
|
||||||
rt.run(move || {
|
rt.run(move || {
|
||||||
// Registry first: listeners and conns cast into it from birth.
|
let sup = smarm::spawn(move || {
|
||||||
let registry = conn_registry::start();
|
let mut sup = OneForOne::new().strategy(Strategy::RestForOne);
|
||||||
|
for child in app_children {
|
||||||
let mut sup = OneForOne::new().strategy(Strategy::OneForOne);
|
sup = sup.child(child);
|
||||||
for (i, lfd) in listener_fds.into_iter().enumerate() {
|
|
||||||
let p = pipeline.clone();
|
|
||||||
let r = registry.clone();
|
|
||||||
let sf = shutdown_flag.clone();
|
|
||||||
sup = sup.child(ChildSpec::new(Restart::Transient, move || {
|
|
||||||
println!("urus: listener {} starting", i);
|
|
||||||
listener_loop(lfd.clone(), p.clone(), limits, r.clone(), sf.clone());
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
// Default intensity (3 per 5s) applies; a listener crash-looping
|
sup.child(ChildSpec::new(Restart::Permanent, endpoint).shutdown(Shutdown::Infinity))
|
||||||
// faster than that trips the cap and tears the pool down — loud
|
.run()
|
||||||
// failure over a zombie server.
|
});
|
||||||
let sup_h = smarm::spawn(move || sup.run());
|
|
||||||
// The old `urus.server` / `urus.listener.{i}` name registrations
|
|
||||||
// are gone with smarm's RFC 014 registry rework: `register` is now
|
|
||||||
// `(Name<M>, Sender<M>)`, self-only — a name is a typed messaging
|
|
||||||
// endpoint, not a pid tag. urus's bindings were introspection-only
|
|
||||||
// with no channel behind them, so they were dropped rather than
|
|
||||||
// faked with a unit channel. A real messageable `urus.server`
|
|
||||||
// name is in the icebox (ROADMAP.md).
|
|
||||||
|
|
||||||
// Block until told to shut down. We poll `try_recv` + `sleep`
|
// Park until told to shut down. If every Handle was dropped the
|
||||||
// rather than parking in `recv`: a smarm `Sender::send` from a
|
// channel closes and no shutdown can ever arrive: serve forever,
|
||||||
// foreign OS thread (no runtime in its TLS) enqueues fine but its
|
// exactly v1's semantics.
|
||||||
// unpark is a `try_with_runtime` no-op — a parked receiver would
|
match signal.rx.recv() {
|
||||||
// never wake. The timer wake comes from inside the runtime, so the
|
Ok(()) => smarm::request_shutdown(sup.pid()),
|
||||||
// poll sees the message within one interval. (A cross-thread-safe
|
Err(_) => {
|
||||||
// unpark is a smarm roadmap candidate; this poll dies with it.)
|
// Sender side gone. Park indefinitely; the process is
|
||||||
// If every Handle was dropped the channel closes and no shutdown
|
// expected to be killed externally.
|
||||||
// can ever arrive: serve forever, exactly v1's semantics.
|
|
||||||
loop {
|
loop {
|
||||||
match signal.rx.try_recv() {
|
smarm::sleep(Duration::from_secs(3600));
|
||||||
Ok(Some(())) => break,
|
|
||||||
Ok(None) => smarm::sleep(SHUTDOWN_POLL),
|
|
||||||
Err(_) => smarm::sleep(Duration::from_secs(3600)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Shutdown. -----
|
|
||||||
// 1 + 2. Flag the listeners down and join the supervisor; the
|
|
||||||
// join returns once every listener has exited normally
|
|
||||||
// (Transient: normal exit is terminal). After this point
|
|
||||||
// no connection can ever be accepted again.
|
|
||||||
shutdown_flag.store(true, Ordering::Relaxed);
|
|
||||||
let _ = sup_h.join();
|
|
||||||
|
|
||||||
// 3 + 4. Drain. Same sweep discipline as listeners on the force-
|
|
||||||
// stop path: a conn accepted just before its listener died may
|
|
||||||
// register after the deadline, so keep force-stopping until the
|
|
||||||
// set is empty (each pass kills everything registered; new
|
|
||||||
// registrants are a strictly shrinking population once listeners
|
|
||||||
// are gone).
|
|
||||||
let _ = registry.cast(Cast::BeginDrain);
|
|
||||||
let deadline = std::time::Instant::now() + drain_timeout;
|
|
||||||
let mut force = false;
|
|
||||||
loop {
|
|
||||||
match registry.call(Call::ConnCount) {
|
|
||||||
Ok(Reply::ConnCount(0)) => break,
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(_) => break, // registry gone; nothing left to track
|
|
||||||
}
|
}
|
||||||
let now = std::time::Instant::now();
|
let _ = sup.join();
|
||||||
if force || now >= deadline {
|
|
||||||
force = true;
|
|
||||||
let _ = registry.cast(Cast::ForceStopConns);
|
|
||||||
smarm::sleep(Duration::from_millis(10));
|
|
||||||
} else {
|
|
||||||
smarm::sleep(Duration::from_millis(50).min(deadline - now));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Our GenServerRef drops here. The registry's inbox closes once
|
|
||||||
// the last conn's clone drops with it, and the runtime winds
|
|
||||||
// down when the last actor exits.
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/// [`serve_with_shutdown`] without a shutdown handle: serves until the
|
||||||
// serve_with / serve — convenience entries without a shutdown handle.
|
/// process is killed.
|
||||||
// ---------------------------------------------------------------------------
|
pub fn serve_with(
|
||||||
|
config: Config,
|
||||||
pub fn serve_with(config: Config, pipeline: Pipeline) -> io::Result<()> {
|
rt_config: smarm::Config,
|
||||||
// The Handle is dropped immediately: shutdown can never be signalled
|
pipeline: Pipeline,
|
||||||
// and the server runs until externally killed (v1 semantics).
|
app_children: Vec<ChildSpec>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
// The Handle is dropped immediately: shutdown can never be signalled.
|
||||||
let (_handle, signal) = shutdown_handle();
|
let (_handle, signal) = shutdown_handle();
|
||||||
serve_with_shutdown(config, pipeline, signal)
|
serve_with_shutdown(config, rt_config, pipeline, app_children, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/// Defaults all round: default [`Config`], default smarm runtime (one
|
||||||
// serve — convenience over serve_with.
|
/// scheduler thread per CPU), no app children, serve until killed.
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub fn serve(addr: impl ToSocketAddrs, pipeline: Pipeline) -> io::Result<()> {
|
pub fn serve(addr: impl ToSocketAddrs, pipeline: Pipeline) -> io::Result<()> {
|
||||||
let addr = addr
|
let addr = addr
|
||||||
.to_socket_addrs()?
|
.to_socket_addrs()?
|
||||||
.next()
|
.next()
|
||||||
.ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "no addresses resolved"))?;
|
.ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "no addresses resolved"))?;
|
||||||
serve_with(Config::new(addr), pipeline)
|
serve_with(Config::new(addr), smarm::Config::default(), pipeline, Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "config-file"))]
|
||||||
|
mod config_file_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn base() -> Config {
|
||||||
|
Config::new("127.0.0.1:0".parse().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toml_empty_keeps_defaults() {
|
||||||
|
let d = base();
|
||||||
|
let c = base().with_toml_str("").unwrap();
|
||||||
|
assert_eq!(c.head_timeout, d.head_timeout);
|
||||||
|
assert_eq!(c.body_timeout, d.body_timeout);
|
||||||
|
assert_eq!(c.body_burst_bytes, d.body_burst_bytes);
|
||||||
|
assert_eq!(c.body_stall_timeout, d.body_stall_timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toml_partial_overrides_only_named() {
|
||||||
|
let d = base();
|
||||||
|
let c = base().with_toml_str("head_timeout_secs = 5").unwrap();
|
||||||
|
assert_eq!(c.head_timeout, Duration::from_secs(5)); // overridden
|
||||||
|
assert_eq!(c.body_timeout, d.body_timeout); // default kept
|
||||||
|
assert_eq!(c.body_burst_bytes, d.body_burst_bytes); // default kept
|
||||||
|
assert_eq!(c.body_stall_timeout, d.body_stall_timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toml_full_overrides_all() {
|
||||||
|
let c = base()
|
||||||
|
.with_toml_str(
|
||||||
|
"head_timeout_secs = 10\n\
|
||||||
|
body_timeout_secs = 120\n\
|
||||||
|
body_burst_bytes = 8192\n\
|
||||||
|
body_stall_timeout_secs = 15\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(c.head_timeout, Duration::from_secs(10));
|
||||||
|
assert_eq!(c.body_timeout, Duration::from_secs(120));
|
||||||
|
assert_eq!(c.body_burst_bytes, 8192);
|
||||||
|
assert_eq!(c.body_stall_timeout, Duration::from_secs(15));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toml_unknown_key_errors() {
|
||||||
|
// A mistyped/unknown key is a hard error, not a silent no-op.
|
||||||
|
let e = base().with_toml_str("body_timeout_sec = 120"); // typo: missing 's'
|
||||||
|
assert!(e.is_err(), "unknown key should error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toml_malformed_errors() {
|
||||||
|
let e = base().with_toml_str("this is not = valid = toml");
|
||||||
|
assert!(e.is_err(), "malformed TOML should error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -33,7 +33,8 @@
|
|||||||
//! write — event or heartbeat — stalls past `write_timeout`; the conn
|
//! write — event or heartbeat — stalls past `write_timeout`; the conn
|
||||||
//! actor then drops the stream and the producer's next [`EventSender`]
|
//! actor then drops the stream and the producer's next [`EventSender`]
|
||||||
//! call returns `Err(SseClosed)`. There is no request clock on an SSE
|
//! call returns `Err(SseClosed)`. There is no request clock on an SSE
|
||||||
//! response: `request_timeout` covers only the read phase, by design.
|
//! response: the head/body read budgets cover only the read phase, by
|
||||||
|
//! design.
|
||||||
|
|
||||||
use crate::conn::{Conn, RespBody, StreamBody};
|
use crate::conn::{Conn, RespBody, StreamBody};
|
||||||
|
|
||||||
|
|||||||
+366
-72
@@ -26,15 +26,19 @@ fn free_port() -> u16 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_server(pipeline: Pipeline) -> u16 {
|
fn spawn_server(pipeline: Pipeline) -> u16 {
|
||||||
|
spawn_server_children(pipeline, Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`spawn_server`] with app children started ahead of the endpoint.
|
||||||
|
fn spawn_server_children(pipeline: Pipeline, app_children: Vec<smarm::ChildSpec>) -> u16 {
|
||||||
let port = free_port();
|
let port = free_port();
|
||||||
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
listener_pool: 2,
|
listener_pool: 2,
|
||||||
scheduler_threads: Some(2),
|
|
||||||
..Config::new(addr)
|
..Config::new(addr)
|
||||||
};
|
};
|
||||||
serve_with(cfg, pipeline).unwrap();
|
serve_with(cfg, smarm::Config::exact(2), pipeline, app_children).unwrap();
|
||||||
});
|
});
|
||||||
// Wait for the server to actually be listening.
|
// Wait for the server to actually be listening.
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
@@ -252,10 +256,9 @@ fn panicking_listener_restarts() {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
listener_pool: 1,
|
listener_pool: 1,
|
||||||
scheduler_threads: Some(2),
|
|
||||||
..Config::new(addr)
|
..Config::new(addr)
|
||||||
};
|
};
|
||||||
serve_with(cfg, pipe).unwrap();
|
serve_with(cfg, smarm::Config::exact(2), pipe, Vec::new()).unwrap();
|
||||||
});
|
});
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
if TcpStream::connect(addr).is_ok() {
|
if TcpStream::connect(addr).is_ok() {
|
||||||
@@ -271,13 +274,13 @@ fn panicking_listener_restarts() {
|
|||||||
// Arm the fault: the listener's next accept-loop iteration panics
|
// Arm the fault: the listener's next accept-loop iteration panics
|
||||||
// *before* accepting, so our connection waits in the kernel backlog
|
// *before* accepting, so our connection waits in the kernel backlog
|
||||||
// until the restarted listener picks it up.
|
// 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");
|
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_status(&resp), 200, "request pending across the panic was not served");
|
||||||
assert_eq!(http_body(&resp), b"pong");
|
assert_eq!(http_body(&resp), b"pong");
|
||||||
assert_eq!(
|
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,
|
0,
|
||||||
"fault was never consumed — listener didn't wake for the connection"
|
"fault was never consumed — listener didn't wake for the connection"
|
||||||
);
|
);
|
||||||
@@ -298,6 +301,16 @@ fn panicking_listener_restarts() {
|
|||||||
fn spawn_server_with_handle(
|
fn spawn_server_with_handle(
|
||||||
pipeline: Pipeline,
|
pipeline: Pipeline,
|
||||||
drain: Duration,
|
drain: Duration,
|
||||||
|
) -> (u16, urus::Handle, std::sync::mpsc::Receiver<()>) {
|
||||||
|
spawn_server_with_children(pipeline, drain, Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`spawn_server_with_handle`] with app children started ahead of the
|
||||||
|
/// endpoint — the pubsub table, a channel hub's actors, and so on.
|
||||||
|
fn spawn_server_with_children(
|
||||||
|
pipeline: Pipeline,
|
||||||
|
drain: Duration,
|
||||||
|
app_children: Vec<smarm::ChildSpec>,
|
||||||
) -> (u16, urus::Handle, std::sync::mpsc::Receiver<()>) {
|
) -> (u16, urus::Handle, std::sync::mpsc::Receiver<()>) {
|
||||||
let port = free_port();
|
let port = free_port();
|
||||||
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||||
@@ -306,11 +319,11 @@ fn spawn_server_with_handle(
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
listener_pool: 2,
|
listener_pool: 2,
|
||||||
scheduler_threads: Some(2),
|
|
||||||
drain_timeout: drain,
|
drain_timeout: drain,
|
||||||
..Config::new(addr)
|
..Config::new(addr)
|
||||||
};
|
};
|
||||||
urus::serve_with_shutdown(cfg, pipeline, signal).unwrap();
|
urus::serve_with_shutdown(cfg, smarm::Config::exact(2), pipeline, app_children, signal)
|
||||||
|
.unwrap();
|
||||||
let _ = done_tx.send(());
|
let _ = done_tx.send(());
|
||||||
});
|
});
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
@@ -437,19 +450,52 @@ fn shutdown_force_stops_at_drain_deadline() {
|
|||||||
fn spawn_server_with_timeouts(
|
fn spawn_server_with_timeouts(
|
||||||
pipeline: Pipeline,
|
pipeline: Pipeline,
|
||||||
keep_alive: Duration,
|
keep_alive: Duration,
|
||||||
request: Duration,
|
head: Duration,
|
||||||
|
body: Duration,
|
||||||
) -> u16 {
|
) -> u16 {
|
||||||
let port = free_port();
|
let port = free_port();
|
||||||
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
listener_pool: 2,
|
listener_pool: 2,
|
||||||
scheduler_threads: Some(2),
|
|
||||||
keep_alive_timeout: keep_alive,
|
keep_alive_timeout: keep_alive,
|
||||||
request_timeout: request,
|
head_timeout: head,
|
||||||
|
body_timeout: body,
|
||||||
..Config::new(addr)
|
..Config::new(addr)
|
||||||
};
|
};
|
||||||
serve_with(cfg, pipeline).unwrap();
|
serve_with(cfg, smarm::Config::exact(2), pipeline, Vec::new()).unwrap();
|
||||||
|
});
|
||||||
|
for _ in 0..50 {
|
||||||
|
if TcpStream::connect(addr).is_ok() {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
panic!("server didn't come up on {addr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a server with the body stall-gate knobs under test; keep-alive
|
||||||
|
/// and head budgets are set out of the way so only the body path matters.
|
||||||
|
fn spawn_server_with_body_gate(
|
||||||
|
pipeline: Pipeline,
|
||||||
|
head: Duration,
|
||||||
|
body: Duration,
|
||||||
|
burst_bytes: usize,
|
||||||
|
stall: Duration,
|
||||||
|
) -> u16 {
|
||||||
|
let port = free_port();
|
||||||
|
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let cfg = Config {
|
||||||
|
listener_pool: 2,
|
||||||
|
keep_alive_timeout: Duration::from_secs(30),
|
||||||
|
head_timeout: head,
|
||||||
|
body_timeout: body,
|
||||||
|
body_burst_bytes: burst_bytes,
|
||||||
|
body_stall_timeout: stall,
|
||||||
|
..Config::new(addr)
|
||||||
|
};
|
||||||
|
serve_with(cfg, smarm::Config::exact(2), pipeline, Vec::new()).unwrap();
|
||||||
});
|
});
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
if TcpStream::connect(addr).is_ok() {
|
if TcpStream::connect(addr).is_ok() {
|
||||||
@@ -486,7 +532,8 @@ fn idle_keepalive_reaped_at_keep_alive_timeout() {
|
|||||||
let port = spawn_server_with_timeouts(
|
let port = spawn_server_with_timeouts(
|
||||||
pipe,
|
pipe,
|
||||||
Duration::from_millis(300), // keep_alive_timeout under test
|
Duration::from_millis(300), // keep_alive_timeout under test
|
||||||
Duration::from_secs(10), // request_timeout out of the way
|
Duration::from_secs(10), // head_timeout out of the way
|
||||||
|
Duration::from_secs(10), // body_timeout out of the way
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||||
@@ -511,17 +558,18 @@ fn idle_keepalive_reaped_at_keep_alive_timeout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A slowloris client that sends a partial head and then stalls is killed
|
/// A slowloris client that sends a partial head and then stalls is killed
|
||||||
/// at request_timeout with a best-effort 408, even though the (large)
|
/// at head_timeout with a best-effort 408, even though the (large)
|
||||||
/// keep-alive budget hasn't expired.
|
/// keep-alive and body budgets haven't expired.
|
||||||
#[test]
|
#[test]
|
||||||
fn slowloris_partial_head_killed_at_request_timeout() {
|
fn slowloris_partial_head_killed_at_head_timeout() {
|
||||||
let pipe = Pipeline::new().plug(
|
let pipe = Pipeline::new().plug(
|
||||||
Router::new().get("/", |c: Conn, _n: Next| c.put_status(200))
|
Router::new().get("/", |c: Conn, _n: Next| c.put_status(200))
|
||||||
);
|
);
|
||||||
let port = spawn_server_with_timeouts(
|
let port = spawn_server_with_timeouts(
|
||||||
pipe,
|
pipe,
|
||||||
Duration::from_secs(10), // keep_alive_timeout out of the way
|
Duration::from_secs(10), // keep_alive_timeout out of the way
|
||||||
Duration::from_millis(300), // request_timeout under test
|
Duration::from_millis(300), // head_timeout under test
|
||||||
|
Duration::from_secs(10), // body_timeout out of the way
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||||
@@ -776,11 +824,10 @@ fn stalled_reader_killed_at_write_timeout() {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
listener_pool: 2,
|
listener_pool: 2,
|
||||||
scheduler_threads: Some(2),
|
|
||||||
write_timeout: Duration::from_millis(300),
|
write_timeout: Duration::from_millis(300),
|
||||||
..Config::new(addr)
|
..Config::new(addr)
|
||||||
};
|
};
|
||||||
serve_with(cfg, pipe).unwrap();
|
serve_with(cfg, smarm::Config::exact(2), pipe, Vec::new()).unwrap();
|
||||||
});
|
});
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
if TcpStream::connect(addr).is_ok() {
|
if TcpStream::connect(addr).is_ok() {
|
||||||
@@ -866,11 +913,10 @@ fn chunked_request_over_limit_413() {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
listener_pool: 2,
|
listener_pool: 2,
|
||||||
scheduler_threads: Some(2),
|
|
||||||
max_body_bytes: 8, // tiny
|
max_body_bytes: 8, // tiny
|
||||||
..Config::new(addr)
|
..Config::new(addr)
|
||||||
};
|
};
|
||||||
serve_with(cfg, pipe).unwrap();
|
serve_with(cfg, smarm::Config::exact(2), pipe, Vec::new()).unwrap();
|
||||||
});
|
});
|
||||||
for _ in 0..50 {
|
for _ in 0..50 {
|
||||||
if TcpStream::connect(addr).is_ok() { break; }
|
if TcpStream::connect(addr).is_ok() { break; }
|
||||||
@@ -913,14 +959,16 @@ fn chunked_plus_content_length_400() {
|
|||||||
assert_eq!(http_status(&resp), 400);
|
assert_eq!(http_status(&resp), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A chunked body that stalls mid-stream is killed by the request
|
/// A chunked body that stalls mid-stream is killed by the BODY deadline
|
||||||
/// deadline: the connection just closes (no response owed mid-body).
|
/// (head budget generous): the connection just closes (no response owed
|
||||||
|
/// mid-body).
|
||||||
#[test]
|
#[test]
|
||||||
fn chunked_request_stall_killed_at_request_timeout() {
|
fn chunked_body_stall_killed_at_body_timeout() {
|
||||||
let port = spawn_server_with_timeouts(
|
let port = spawn_server_with_timeouts(
|
||||||
echo_pipeline(),
|
echo_pipeline(),
|
||||||
Duration::from_secs(30),
|
Duration::from_secs(30), // keep_alive_timeout out of the way
|
||||||
Duration::from_millis(400), // request_timeout
|
Duration::from_secs(30), // head_timeout out of the way
|
||||||
|
Duration::from_millis(400), // body_timeout under test
|
||||||
);
|
);
|
||||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||||
@@ -936,6 +984,139 @@ fn chunked_request_stall_killed_at_request_timeout() {
|
|||||||
assert!(start.elapsed() < Duration::from_secs(3), "close took too long");
|
assert!(start.elapsed() < Duration::from_secs(3), "close took too long");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The core of the head/body split: a client that sends a COMPLETE head
|
||||||
|
/// promptly and then trickles its (small) body over a span LONGER than
|
||||||
|
/// head_timeout still succeeds, because the body runs on its own, larger
|
||||||
|
/// budget. Under the old shared request clock this would have been killed
|
||||||
|
/// mid-body at head_timeout. This is the slow-but-legit IoT upload we must
|
||||||
|
/// not punish.
|
||||||
|
#[test]
|
||||||
|
fn slow_body_outlives_head_timeout() {
|
||||||
|
let port = spawn_server_with_timeouts(
|
||||||
|
echo_pipeline(),
|
||||||
|
Duration::from_secs(30), // keep_alive_timeout out of the way
|
||||||
|
Duration::from_millis(500), // head_timeout: SHORT
|
||||||
|
Duration::from_secs(8), // body_timeout: generous
|
||||||
|
);
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||||
|
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||||
|
// Full head at once (parses well within head_timeout), Connection:
|
||||||
|
// close so the server closes after responding and read_to_end lands
|
||||||
|
// the whole response.
|
||||||
|
s.write_all(
|
||||||
|
b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 4\r\nConnection: close\r\n\r\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Trickle the 4-byte body at 250ms/byte => ~1s total, well past the
|
||||||
|
// 500ms head_timeout but inside the 8s body_timeout.
|
||||||
|
for b in b"test" {
|
||||||
|
std::thread::sleep(Duration::from_millis(250));
|
||||||
|
s.write_all(&[*b]).unwrap();
|
||||||
|
}
|
||||||
|
let mut resp = Vec::new();
|
||||||
|
s.read_to_end(&mut resp).expect("expected full response");
|
||||||
|
assert_eq!(http_status(&resp), 200, "resp: {:?}", String::from_utf8_lossy(&resp));
|
||||||
|
assert!(
|
||||||
|
resp.ends_with(b"test"),
|
||||||
|
"expected echoed body 'test', got: {:?}", String::from_utf8_lossy(&resp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fixed-Content-Length body that stalls before completing is killed by
|
||||||
|
/// the BODY deadline (head budget generous): silent close, nothing owed
|
||||||
|
/// mid-body. The fixed-path twin of chunked_body_stall_killed_at_body_timeout.
|
||||||
|
#[test]
|
||||||
|
fn fixed_body_stall_killed_at_body_timeout() {
|
||||||
|
let port = spawn_server_with_timeouts(
|
||||||
|
echo_pipeline(),
|
||||||
|
Duration::from_secs(30), // keep_alive_timeout out of the way
|
||||||
|
Duration::from_secs(30), // head_timeout out of the way
|
||||||
|
Duration::from_millis(400), // body_timeout under test
|
||||||
|
);
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||||
|
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||||
|
// Promises 100 bytes, sends a few, then stalls forever.
|
||||||
|
s.write_all(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\npartial")
|
||||||
|
.unwrap();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let mut resp = Vec::new();
|
||||||
|
s.read_to_end(&mut resp).unwrap(); // server closes; EOF
|
||||||
|
assert!(resp.is_empty(), "expected silent close, got: {:?}", String::from_utf8_lossy(&resp));
|
||||||
|
assert!(start.elapsed() < Duration::from_secs(3), "close took too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Burst gate, NEGATIVE (chunked path): a client that ACTIVELY but SMOOTHLY
|
||||||
|
/// trickles sub-burst bytes is evicted at ~body_stall_timeout — even though
|
||||||
|
/// the absolute body_timeout is far away and the client never goes fully
|
||||||
|
/// silent. This is the slowloris-body case the gate exists to catch, and
|
||||||
|
/// exercises the fill_to gate in read_chunked_body.
|
||||||
|
#[test]
|
||||||
|
fn body_smooth_trickle_evicted_at_stall_timeout() {
|
||||||
|
let port = spawn_server_with_body_gate(
|
||||||
|
echo_pipeline(),
|
||||||
|
Duration::from_secs(30), // head_timeout out of the way
|
||||||
|
Duration::from_secs(30), // body_timeout out of the way (prove it's the STALL gate)
|
||||||
|
4096, // body_burst_bytes
|
||||||
|
Duration::from_millis(800), // body_stall_timeout under test
|
||||||
|
);
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||||
|
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||||
|
// Head + a chunk-size line announcing a 4096-byte chunk, then trickle
|
||||||
|
// its payload one byte at a time: never a full burst, so the stall mark
|
||||||
|
// never advances.
|
||||||
|
s.write_all(b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n1000\r\n")
|
||||||
|
.unwrap();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let mut evicted = false;
|
||||||
|
for _ in 0..200 { // up to ~20s; eviction expected at ~800ms
|
||||||
|
if s.write_all(b"x").is_err() {
|
||||||
|
evicted = true; // server closed on us -> write failed
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
assert!(evicted, "server never evicted the smooth sub-burst trickle");
|
||||||
|
assert!(
|
||||||
|
start.elapsed() < Duration::from_secs(3),
|
||||||
|
"eviction took {:?}, expected ~800ms (stall gate, not the 30s cap)", start.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Burst gate, POSITIVE (fixed-CL path): a slow-but-legit client that
|
||||||
|
/// delivers real bursts with gaps SHORTER than body_stall_timeout keeps
|
||||||
|
/// resetting the stall mark and completes intact. This is the slow IoT
|
||||||
|
/// upload the gate must NOT punish; exercises the read_body gate.
|
||||||
|
#[test]
|
||||||
|
fn bursty_slow_body_survives_stall_gate() {
|
||||||
|
let port = spawn_server_with_body_gate(
|
||||||
|
echo_pipeline(),
|
||||||
|
Duration::from_secs(30), // head_timeout out of the way
|
||||||
|
Duration::from_secs(30), // body_timeout out of the way
|
||||||
|
4096, // body_burst_bytes
|
||||||
|
Duration::from_secs(2), // body_stall_timeout: gaps stay under this
|
||||||
|
);
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||||
|
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||||
|
// Promise 3 * 4096 bytes, Connection: close so read_to_end lands the
|
||||||
|
// full echo.
|
||||||
|
let burst = vec![b'x'; 4096];
|
||||||
|
s.write_all(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 12288\r\nConnection: close\r\n\r\n")
|
||||||
|
.unwrap();
|
||||||
|
for i in 0..3 {
|
||||||
|
s.write_all(&burst).unwrap();
|
||||||
|
if i < 2 {
|
||||||
|
std::thread::sleep(Duration::from_millis(500)); // < 2s stall window
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut resp = Vec::new();
|
||||||
|
s.read_to_end(&mut resp).expect("expected full response");
|
||||||
|
assert_eq!(http_status(&resp), 200, "resp head: {:?}", String::from_utf8_lossy(&resp[..resp.len().min(120)]));
|
||||||
|
let body_at = resp.windows(4).position(|w| w == b"\r\n\r\n").expect("no head terminator") + 4;
|
||||||
|
let body = &resp[body_at..];
|
||||||
|
assert_eq!(body.len(), 12288, "echoed body truncated: {} bytes", body.len());
|
||||||
|
assert!(body.iter().all(|&b| b == b'x'), "echoed body corrupted");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SSE (v0.3 chunk 3)
|
// SSE (v0.3 chunk 3)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1273,15 +1454,14 @@ fn shutdown_force_stops_open_ws() {
|
|||||||
/// Minimal chat handler mirroring the example: on_open subscribes (conn
|
/// Minimal chat handler mirroring the example: on_open subscribes (conn
|
||||||
/// actor pid) + spawns a relay holding ONLY the Receiver and a WsSender
|
/// actor pid) + spawns a relay holding ONLY the Receiver and a WsSender
|
||||||
/// clone; on_message broadcast_from's, skipping the sender's own relay.
|
/// clone; on_message broadcast_from's, skipping the sender's own relay.
|
||||||
struct Chat {
|
const CHAT_BUS: urus::PubSub<String> = urus::PubSub::new("chat-test");
|
||||||
bus: std::sync::Arc<std::sync::OnceLock<urus::PubSub<String>>>,
|
|
||||||
}
|
struct Chat;
|
||||||
|
|
||||||
impl urus::WsHandler for Chat {
|
impl urus::WsHandler for Chat {
|
||||||
fn on_open(&mut self, sender: &urus::WsSender) {
|
fn on_open(&mut self, sender: &urus::WsSender) {
|
||||||
let bus = self.bus.get_or_init(urus::PubSub::new);
|
let rx = CHAT_BUS.subscribe("room").unwrap();
|
||||||
let rx = bus.subscribe("room").unwrap();
|
let _ = CHAT_BUS.broadcast_from(smarm::self_pid(), "room", "joined".into());
|
||||||
let _ = bus.broadcast_from(smarm::self_pid(), "room", "joined".into());
|
|
||||||
let out = sender.clone();
|
let out = sender.clone();
|
||||||
smarm::spawn(move || {
|
smarm::spawn(move || {
|
||||||
while let Ok(msg) = rx.recv() {
|
while let Ok(msg) = rx.recv() {
|
||||||
@@ -1303,19 +1483,14 @@ impl urus::WsHandler for Chat {
|
|||||||
let _ = sender.send(urus::Message::Text("synced".into()));
|
let _ = sender.send(urus::Message::Text("synced".into()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let bus = self.bus.get_or_init(urus::PubSub::new);
|
let _ = CHAT_BUS.broadcast_from(smarm::self_pid(), "room", t);
|
||||||
let _ = bus.broadcast_from(smarm::self_pid(), "room", t);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chat_pipeline() -> Pipeline {
|
fn chat_pipeline() -> Pipeline {
|
||||||
let bus: std::sync::Arc<std::sync::OnceLock<urus::PubSub<String>>> =
|
Pipeline::new()
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new());
|
.plug(Router::new().get("/ws", |c: Conn, _n: Next| c.upgrade(Chat)))
|
||||||
Pipeline::new().plug(Router::new().get("/ws", move |c: Conn, _n: Next| {
|
|
||||||
let bus = bus.clone();
|
|
||||||
c.upgrade(Chat { bus })
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two clients in a room: a broadcast reaches the other client and (via
|
/// Two clients in a room: a broadcast reaches the other client and (via
|
||||||
@@ -1324,7 +1499,7 @@ fn chat_pipeline() -> Pipeline {
|
|||||||
/// having sent a single frame when A's first message arrives.
|
/// having sent a single frame when A's first message arrives.
|
||||||
#[test]
|
#[test]
|
||||||
fn ws_chat_broadcast_reaches_other_client_not_sender() {
|
fn ws_chat_broadcast_reaches_other_client_not_sender() {
|
||||||
let port = spawn_server(chat_pipeline());
|
let port = spawn_server_children(chat_pipeline(), vec![CHAT_BUS.child()]);
|
||||||
|
|
||||||
let mut a = ws_connect(port);
|
let mut a = ws_connect(port);
|
||||||
let mut a_buf = Vec::new();
|
let mut a_buf = Vec::new();
|
||||||
@@ -1367,7 +1542,11 @@ fn ws_chat_broadcast_reaches_other_client_not_sender() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn shutdown_with_open_chat_terminates() {
|
fn shutdown_with_open_chat_terminates() {
|
||||||
let (port, handle, done_rx) =
|
let (port, handle, done_rx) =
|
||||||
spawn_server_with_handle(chat_pipeline(), Duration::from_millis(300));
|
spawn_server_with_children(
|
||||||
|
chat_pipeline(),
|
||||||
|
Duration::from_millis(300),
|
||||||
|
vec![CHAT_BUS.child()],
|
||||||
|
);
|
||||||
|
|
||||||
let mut a = ws_connect(port);
|
let mut a = ws_connect(port);
|
||||||
let mut a_buf = Vec::new();
|
let mut a_buf = Vec::new();
|
||||||
@@ -1427,21 +1606,19 @@ mod channels_wire {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn channels_pipeline() -> Pipeline {
|
/// The hub is a description: no actors, so it is built once here and
|
||||||
let hub: std::sync::Arc<std::sync::OnceLock<ChannelHub<P>>> =
|
/// its `children()` handed to the supervisor separately.
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new());
|
fn channels_hub() -> (ChannelHub<P>, Vec<smarm::ChildSpec>) {
|
||||||
Pipeline::new().plug(Router::new().get("/socket", move |c: Conn, _n: Next| {
|
ChannelHub::new(
|
||||||
// Hub construction is in-runtime only (it spawns the pubsub
|
"chan-test",
|
||||||
// table); the NON-static OnceLock is what lets shutdown
|
PrefixRouter::new()
|
||||||
// drain the table (the v0.5 pattern).
|
.channel("room:*", |_: &str| Box::new(Lobby) as Box<dyn Channel<P>>),
|
||||||
let hub = hub.clone();
|
)
|
||||||
let hub = hub.get_or_init(|| {
|
}
|
||||||
ChannelHub::new(PrefixRouter::new().channel("room:*", |_: &str| {
|
|
||||||
Box::new(Lobby) as Box<dyn Channel<P>>
|
fn channels_pipeline(hub: ChannelHub<P>) -> Pipeline {
|
||||||
}))
|
Pipeline::new()
|
||||||
});
|
.plug(Router::new().get("/socket", move |c: Conn, _n: Next| hub.upgrade(c)))
|
||||||
hub.upgrade(c)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHAN_HANDSHAKE: &[u8] =
|
const CHAN_HANDSHAKE: &[u8] =
|
||||||
@@ -1469,7 +1646,8 @@ mod channels_wire {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn channels_join_heartbeat_event_broadcast_leave() {
|
fn channels_join_heartbeat_event_broadcast_leave() {
|
||||||
let port = spawn_server(channels_pipeline());
|
let (hub, children) = channels_hub();
|
||||||
|
let port = spawn_server_children(channels_pipeline(hub), children);
|
||||||
|
|
||||||
let mut a = chan_connect(port);
|
let mut a = chan_connect(port);
|
||||||
let mut a_buf = Vec::new();
|
let mut a_buf = Vec::new();
|
||||||
@@ -1555,7 +1733,8 @@ mod channels_wire {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn channels_codec_garbage_closes_1002() {
|
fn channels_codec_garbage_closes_1002() {
|
||||||
let port = spawn_server(channels_pipeline());
|
let (hub, children) = channels_hub();
|
||||||
|
let port = spawn_server_children(channels_pipeline(hub), children);
|
||||||
let mut s = chan_connect(port);
|
let mut s = chan_connect(port);
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
ws_send(&mut s, &Frame::new(Opcode::Text, "not a v2 frame"));
|
ws_send(&mut s, &Frame::new(Opcode::Text, "not a v2 frame"));
|
||||||
@@ -1572,7 +1751,14 @@ mod channels_wire {
|
|||||||
// is monitor-pruned -> the non-static hub's last handle drops
|
// is monitor-pruned -> the non-static hub's last handle drops
|
||||||
// with the drained pipeline -> pubsub table exits -> AllDone.
|
// with the drained pipeline -> pubsub table exits -> AllDone.
|
||||||
let (port, handle, done_rx) =
|
let (port, handle, done_rx) =
|
||||||
spawn_server_with_handle(channels_pipeline(), Duration::from_millis(300));
|
{
|
||||||
|
let (hub, children) = channels_hub();
|
||||||
|
spawn_server_with_children(
|
||||||
|
channels_pipeline(hub),
|
||||||
|
Duration::from_millis(300),
|
||||||
|
children,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let mut a = chan_connect(port);
|
let mut a = chan_connect(port);
|
||||||
let mut a_buf = Vec::new();
|
let mut a_buf = Vec::new();
|
||||||
@@ -1600,19 +1786,20 @@ mod channels_wire {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_pipeline() -> Pipeline {
|
fn session_hub() -> (ChannelHub<P>, Vec<smarm::ChildSpec>) {
|
||||||
let hub: std::sync::Arc<std::sync::OnceLock<ChannelHub<P>>> =
|
ChannelHub::new(
|
||||||
std::sync::Arc::new(std::sync::OnceLock::new());
|
"sess-test-bus",
|
||||||
Pipeline::new().plug(Router::new().get("/socket", move |c: Conn, _n: Next| {
|
PrefixRouter::new().channel_session::<LobbySession>(
|
||||||
let hub = hub.clone();
|
|
||||||
let hub = hub.get_or_init(|| {
|
|
||||||
ChannelHub::new(PrefixRouter::new().channel_session::<LobbySession>(
|
|
||||||
"room:*",
|
"room:*",
|
||||||
|
"sess-test-registry",
|
||||||
|_: &str| Box::new(Lobby) as Box<dyn Channel<P>>,
|
|_: &str| Box::new(Lobby) as Box<dyn Channel<P>>,
|
||||||
))
|
),
|
||||||
});
|
)
|
||||||
hub.upgrade(c)
|
}
|
||||||
}))
|
|
||||||
|
fn session_pipeline(hub: ChannelHub<P>) -> Pipeline {
|
||||||
|
Pipeline::new()
|
||||||
|
.plug(Router::new().get("/socket", move |c: Conn, _n: Next| hub.upgrade(c)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1625,7 +1812,14 @@ mod channels_wire {
|
|||||||
// terminates, and exits -> AllDone. No links anywhere in that
|
// terminates, and exits -> AllDone. No links anywhere in that
|
||||||
// chain; this test is the proof it composes.
|
// chain; this test is the proof it composes.
|
||||||
let (port, handle, done_rx) =
|
let (port, handle, done_rx) =
|
||||||
spawn_server_with_handle(session_pipeline(), Duration::from_millis(300));
|
{
|
||||||
|
let (hub, children) = session_hub();
|
||||||
|
spawn_server_with_children(
|
||||||
|
session_pipeline(hub),
|
||||||
|
Duration::from_millis(300),
|
||||||
|
children,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let mut a = chan_connect(port);
|
let mut a = chan_connect(port);
|
||||||
let mut a_buf = Vec::new();
|
let mut a_buf = Vec::new();
|
||||||
@@ -1644,3 +1838,103 @@ mod channels_wire {
|
|||||||
.expect("detached session outlived the drain: the registry-drop chain is broken");
|
.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