Files
fips/docs/how-to/serve-many-peers-on-one-thread.md
Johnathan Corgan c7fa576275 Merge maint into master: v0.5.3 release content
Bring v0.5.3 up from maint: the rustls 0.23.46 update, the action pin
refresh, the TreeAnnounce resend on a failed link send, the start-up stop
test timing, the package rename to fips-mesh with its include list, and the
release notes and documentation corrections.

What stays master's: the package version stays 0.6.0-dev; README.md keeps
master's status badge and status section, which name v0.5.2 as the current
release; the native API how-to snippets take the fips-mesh package form
without the v0.5.3 tag, since they describe master's API. CHANGELOG.md
gains the [0.5.3] section below master's [Unreleased], and, as with v0.5.2,
master's Unreleased keeps only entries not released in 0.5.3.

The rename carries up with master's workspace block, and the
interface-binding CI step now builds and selects the fips-mesh package.
Cargo.lock is master's, with the rename and rustls 0.23.46 applied.
2026-10-16 15:28:14 +00:00

201 lines
8.0 KiB
Markdown

# Serve Many Peers on One Thread
**Goal:** handle every native datagram API flow from a single `poll` loop,
instead of dedicating a thread to each peer.
The straightforward listening program spawns a thread per flow. That is fine for
a handful of peers and wrong at the node's ceiling of 256, where it costs 256
threads mostly parked in `recv`.
Every object on this surface is a descriptor, so there is nothing to integrate:
both types implement `AsFd` and `AsRawFd` and go straight into a `poll`,
`select` or `epoll` set. **A listener is readable exactly when `accept` would not
block**, which is the property the whole shape rests on, and the crate asserts it
as a test rather than claiming it.
Read [use-the-native-datagram-api.md](use-the-native-datagram-api.md) first if
you have not opened a flow before.
## The whole program
One dependency beyond the crate, `libc`, for `poll` itself. The crate's package
is `fips-mesh` and its library is `fips`, so the dependency key stays `fips`:
```toml
[dependencies]
libc = "0.2"
[dependencies.fips]
package = "fips-mesh"
git = "https://github.com/jmcorgan/fips"
```
```rust
//! Serve many flows from one poll loop, with no thread per peer.
//!
//! ```text
//! eventloop /run/fips/api.sock 4242
//! ```
use fips::native::client::{FipsListener, FipsStream};
use std::env;
use std::error::Error;
use std::os::fd::{AsRawFd, RawFd};
use std::path::Path;
use std::process::ExitCode;
use std::time::{Duration, Instant};
/// How long a flow may go without a datagram before this program closes it.
///
/// Nothing else will end one. The wire carries no far-end close, so a peer that
/// has stopped sending is indistinguishable from one that is thinking, and a
/// reactor with no deadline holds every flow it ever accepted until it exits.
const IDLE: Duration = Duration::from_secs(30);
/// Hold the port named on the command line and serve every flow from one loop.
fn run() -> Result<(), Box<dyn Error>> {
let mut args = env::args().skip(1);
let (Some(socket), Some(port)) = (args.next(), args.next()) else {
return Err("usage: eventloop <socket-path> <local-port>".into());
};
let port: u16 = port.parse()?;
let listener = FipsListener::bind_at(Path::new(&socket), port)?;
println!("holding {}", listener.local_addr());
// Each flow with the time its last datagram arrived, which is what the
// deadline is measured against.
let mut flows: Vec<(FipsStream, Instant)> = Vec::new();
loop {
let mut fds = vec![watch(listener.as_raw_fd())];
fds.extend(flows.iter().map(|(flow, _)| watch(flow.as_raw_fd())));
let count = fds.len() as libc::nfds_t;
// The timeout is what makes the deadline reachable: with no events at
// all the loop must still wake to notice a flow that has gone quiet.
let timeout = IDLE.as_millis() as libc::c_int;
// SAFETY: `fds` is a live slice of `pollfd` for the whole call, and
// `count` is its length.
if unsafe { libc::poll(fds.as_mut_ptr(), count, timeout) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
// The established flows first, and backwards: removing a closed one
// must not renumber one not yet examined, and accepting below would
// otherwise push a flow this pass has no `revents` for. The listener
// occupies slot 0, hence the offset.
let now = Instant::now();
for index in (0..flows.len()).rev() {
if ready(&fds[index + 1]) {
if echo(&flows[index].0) {
flows[index].1 = now;
} else {
flows.remove(index); // dropping it releases the flow
}
} else if now.duration_since(flows[index].1) >= IDLE {
println!("closing an idle flow from {}", flows[index].0.peer_addr());
flows.remove(index);
}
}
// Once per readiness rather than in a loop: the descriptor is blocking,
// so a second `accept` with nothing queued would stall the whole loop.
if ready(&fds[0]) {
let (flow, peer) = listener.accept()?;
println!("flow from {peer}");
flows.push((flow, now));
}
}
}
/// A `pollfd` asking for readability on `fd`.
///
/// `POLLHUP` needs no asking for: it is reported in `revents` whether or not
/// it was requested, which is what lets one mask serve both cases.
fn watch(fd: RawFd) -> libc::pollfd {
libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
}
}
/// Whether this descriptor has something to read or has hung up.
fn ready(poll: &libc::pollfd) -> bool {
poll.revents & (libc::POLLIN | libc::POLLHUP) != 0
}
/// Return one datagram, reporting whether the flow is still usable.
fn echo(flow: &FipsStream) -> bool {
let mut buf = vec![0u8; flow.max_payload()];
match flow.recv(&mut buf) {
// `Ok(0)` is an empty datagram and not a close, so it is echoed like
// any other. `EPIPE` is the daemon gone: no peer can close a flow.
Ok(len) => flow.send(&buf[..len]).is_ok(),
Err(_) => false,
}
}
/// Report a failure on stderr and exit non-zero.
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("eventloop: {error}");
ExitCode::FAILURE
}
}
}
```
## The four rules
**Register the listener for readability only.** There is nothing else to ask it
for, and `POLLHUP` arrives in `revents` whether or not it was requested.
**Accept once per readiness, not in a loop.** The descriptor is blocking, so a
second `accept` with nothing queued stalls the whole loop. Looping until
`WouldBlock` is correct only after `listener.set_nonblocking(true)`, which is
also what an edge-triggered `epoll` requires.
**Give every flow a deadline, and the poll a timeout that makes the deadline
reachable.** This is the most important of the four. Nothing will tell a reactor
that a peer is finished, so a flow that goes quiet stays in the poll set for ever
unless the program removes it — and with no events at all the loop must still
wake in order to notice. **A reactor with a deadline but no poll timeout has a
deadline it can never reach.**
**A flow from `accept` arrives blocking, whatever the listener was set to.**
They are separate sockets and the daemon hands over a fresh one. The program
above deliberately leaves them blocking and makes exactly one `recv` per
readiness, which is safe on a blocking descriptor and is why it needs no flags at
all. If you want them otherwise, call `set_nonblocking` on the flow.
## Where this reaches past the client module
This program needs `libc` and an `unsafe` block, and it is the only one of the
API's example programs that needs anything.
That is a narrower gap than it once was. Readiness and a bounded wait were both
missing from the surface; `set_nonblocking` closed the first and
`set_read_timeout` the second, and a program wanting an option on a flow now has
a method for it. What is left is a different kind of thing: **an option on a flow
is something a surface can supply, and a reactor's own polling mechanism is
not.** No surface that stops at the descriptor can supply `poll`.
`AsRawFd` rather than `AsFd` here is deliberate: `libc::poll` takes a raw
descriptor. Prefer `AsFd` anywhere you **hold** a registration, because its
borrow cannot outlive the stream; this loop rebuilds its `pollfd` set from live
references on every pass, so it holds nothing across an iteration.
## See also
- [use-the-native-datagram-api.md](use-the-native-datagram-api.md) — opening
flows and receiving them, and the traps that apply to any program here
- [write-a-native-api-client.md](write-a-native-api-client.md) — the same loop
in a language with no client library
- [../reference/native-api.md](../reference/native-api.md#fipslistener) — what
`accept`, `incoming` and `set_nonblocking` guarantee
- [../reference/native-api.md](../reference/native-api.md#what-a-daemon-restart-costs)
— what a reactor sees when the daemon goes away