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

8.0 KiB

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 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:

[dependencies]
libc = "0.2"

[dependencies.fips]
package = "fips-mesh"
git = "https://github.com/jmcorgan/fips"
//! 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