fix(service): run the exchange on a poll loop

This commit is contained in:
2026-10-19 19:22:43 -07:00
parent 24b5a78f22
commit d98895b9d6
4 changed files with 402 additions and 76 deletions
+10
View File
@@ -213,6 +213,16 @@ impl<S: Store, T: Transport> Node<S, T> {
&self.store &self.store
} }
/// Mutable access to the backing store.
///
/// Lets a host call backend-specific methods that aren't on the [`Store`]
/// trait — e.g. `tines_store::LogStore::flush`, the durability boundary a
/// host reaches on its flush schedule. [`Self::trim`] takes `&mut self`;
/// this exposes the concrete backend the same way.
pub fn store_mut(&mut self) -> &mut S {
&mut self.store
}
/// Access the transport (e.g. to `connect`/`disconnect` a peer in tests). /// Access the transport (e.g. to `connect`/`disconnect` a peer in tests).
pub fn transport_mut(&mut self) -> &mut T { pub fn transport_mut(&mut self) -> &mut T {
&mut self.transport &mut self.transport
+368 -70
View File
@@ -33,17 +33,23 @@
//! clock's `now` (0 at startup), so the service and the store agree on "now". //! clock's `now` (0 at startup), so the service and the store agree on "now".
use std::fmt::Write as _; use std::fmt::Write as _;
use std::io::{Read, Write}; use std::io::{self, Read, Write};
use std::os::unix::net::UnixListener; use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf}; use std::path::Path;
use std::sync::{Arc, Mutex}; use std::time::Duration;
use tines_core::{Node, Store, Transport}; use tines_core::{Node, Store, Transport, TrimPolicy};
use tines_store::LogStore;
use crate::HostClock; use crate::HostClock;
const MAX_FRAME: usize = 1 << 20; // 1 MiB cap on a single JSON frame const MAX_FRAME: usize = 1 << 20; // 1 MiB cap on a single JSON frame
/// Cap on the number of `poll` rounds the exchange runs per iteration. Without
/// it, a busy peer that keeps the exchange full could starve the IPC listener
/// of its accept loop.
const POLL_CAP: u64 = 64;
/// A confidence event handed out by the IPC `events` request. /// A confidence event handed out by the IPC `events` request.
#[derive(serde::Serialize, serde::Deserialize, Debug)] #[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct EventOut { pub struct EventOut {
@@ -184,70 +190,148 @@ where
} }
} }
/// Serve IPC on `socket_path` until the listener dies or a response write // Drive the exchange until no new observations arrive or the per-iteration cap
/// fails. Blocks; each accepted connection is served request-by-request. // (`POLL_CAP`) is reached, then — on schedule — trim and flush.
/// //
/// `now` for each request comes from `clock`, the host's single monotonic // The node isn't `Send` (the real `BtTransport` carries a `dyn Radio` with no
/// millisecond [`HostClock`] (an `Instant` at process start), so the tick the // `Send` bound), so this can't run on its own thread; it runs inline in
/// exchange advances on is in real milliseconds. Open the store with the same // [`serve`], fed by the socket read timeouts.
/// clock's `now` (0 at startup) so the service and store agree on "now". fn poll_exchange<T>(node: &mut Node<LogStore, T>, clock: &HostClock, rounds: &mut u64)
///
/// `Node<S, T>` may be any concrete store/transport, so the real binary passes
/// `Node<LogStore, BtTransport>` and tests pass a mock.
pub fn serve<S, T>(
node: Node<S, T>,
socket_path: impl AsRef<Path>,
clock: HostClock,
) -> std::io::Result<()>
where where
S: Store + 'static,
T: Transport + 'static, T: Transport + 'static,
{ {
let node = Arc::new(Mutex::new(node)); while node.poll(clock.now()) > 0 && *rounds < POLL_CAP {
let path: PathBuf = socket_path.as_ref().to_path_buf(); *rounds += 1;
}
// On a schedule: trim stale observations and flush the store. Flush is
// `LogStore::flush` (not on the `Store` trait), so it's called on the
// concrete store through `Node::store_mut` — tines-core keeps no flush hook.
if *rounds >= POLL_CAP {
*rounds = 0;
node.trim(&TrimPolicy::default(), clock.now());
match node.store_mut().flush() {
Ok(()) => {}
Err(e) => eprintln!("tines: store flush failed: {e:?}"),
}
}
}
/// Serve this process's IPC socket and run its exchange.
///
/// `serve` answers IPC and drives the exchange inline on a poll loop fed by the
/// socket read timeouts (see `poll_exchange`). Because the node isn't `Send`
/// (the real `BtTransport` carries a `dyn Radio` with no `Send` bound), the
/// exchange can't move to a spawned thread — it runs inline here, between
/// connections and while a connection is idle.
///
/// `serve` is generic over the transport (so tests can drive it over an
/// in-memory link) but concrete on the on-disk [`LogStore`], whose `flush` is
/// the trim/flush hook. The real binary passes `Node<LogStore, BtTransport>`
/// and the tests pass `Node<LogStore, mock>`.
///
/// `poll_interval_ms` is the read-timeout cadence that feeds the poll loop; the
/// default (20 ms) keeps the exchange responsive without busy-polling.
pub fn serve<T>(
mut node: Node<LogStore, T>,
socket_path: impl AsRef<Path>,
clock: HostClock,
poll_interval_ms: u64,
) -> std::io::Result<()>
where
T: Transport + 'static,
{
let socket_path = socket_path.as_ref().to_path_buf();
// Clear a stale socket file left by a previous run so bind() can't fail. // Clear a stale socket file left by a previous run so bind() can't fail.
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&socket_path);
let listener = UnixListener::bind(&path)?; let listener = UnixListener::bind(&socket_path)?;
for stream in listener.incoming() { // `accept` blocks, so run it in a dedicated thread that just forwards
let mut stream = stream?; // accepted streams over a channel. That lets the main loop yield to the
// Serve this connection request-by-request until the peer cleanly // exchange poll loop (via `recv_timeout`) instead of blocking forever. The
// disconnects, then move on to the next connection. // accept thread never touches the node, so the transport need not be
loop { // `Send` here — only the main loop borrows it.
match read_request(&mut stream) { let (tx, rx) = std::sync::mpsc::channel::<UnixStream>();
Ok(req) => { std::thread::spawn(move || {
let now = clock.now(); for stream in listener.incoming() {
let resp = { if stream
// Recover from a poisoned lock so one panicking .and_then(|s| {
// handler can't take down the daemon. tx.send(s)
let mut guard = .map_err(|_| std::io::Error::other("receiver dropped"))
node.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); })
handle(&mut guard, req, now) .is_err()
}; {
if write_response(&mut stream, &resp).is_err() { break;
// A client that vanished mid-connection shouldn't }
// take down the whole service; just drop this socket. }
break; });
}
let mut rounds = 0u64;
loop {
match rx.recv_timeout(Duration::from_millis(poll_interval_ms.max(1))) {
Ok(mut stream) => {
// A read timeout on the connection lets an idle client yield to
// the poll loop instead of stalling the exchange forever.
stream
.set_read_timeout(Some(Duration::from_millis(poll_interval_ms.max(1))))
.expect("connection read timeout");
serve_stream(&mut node, &mut stream, &clock, &mut rounds)?;
}
// Timeout: run the exchange poll loop instead of blocking.
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
poll_exchange(&mut node, &clock, &mut rounds);
}
// The accept thread is gone; stop serving.
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return Ok(());
}
}
}
}
/// Serve a single accepted connection until the peer disconnects, driving the
/// exchange on every idle read.
fn serve_stream<T>(
node: &mut Node<LogStore, T>,
stream: &mut UnixStream,
clock: &HostClock,
rounds: &mut u64,
) -> io::Result<()>
where
T: Transport + 'static,
{
loop {
match read_request(stream) {
Ok(request) => {
let response = handle(node, request, clock.now());
if write_response(stream, &response).is_err() {
// A client that vanished mid-connection shouldn't take
// down the whole service; just drop this socket.
break;
} }
// A clean disconnect (peer closed the socket) ends the loop. // Drive the exchange after every request so a busy client that
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, // connects back-to-back can't starve it (the WouldBlock path
// A malformed frame isn't a disconnect: tell the client and // below only fires once the client goes idle).
// keep serving this connection rather than dropping it. The poll_exchange(node, clock, rounds);
// frame can't be identified, so the error is generic. }
Err(_) => { // Peer closed the connection cleanly.
if write_response(&mut stream, &error("malformed request")).is_err() { Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
break; // Idle read: drive the exchange rather than blocking forever.
} Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
poll_exchange(node, clock, rounds);
}
// A malformed frame isn't a disconnect: tell the client and keep
// serving this connection. The frame can't be identified, so the
// error is generic.
Err(_) => {
if write_response(stream, &error("malformed request")).is_err() {
break;
} }
} }
} }
} }
Ok(()) Ok(())
} }
/// Read one length-prefixed JSON frame from a stream.
fn read_request<S: Read>(stream: &mut S) -> std::io::Result<Request> { fn read_request<S: Read>(stream: &mut S) -> std::io::Result<Request> {
let mut len_buf = [0u8; 4]; let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)?; stream.read_exact(&mut len_buf)?;
@@ -320,23 +404,237 @@ fn hex_val(c: u8) -> Result<u8, String> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod f2_tests {
use super::*;
use std::collections::{HashMap, VecDeque};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration; use std::time::Duration;
// Regression for F17: the tines `Tick` is milliseconds. A wall-clock use tines_core::{consts, Event, FrameBuf, Node, NodeId, SendError, Transport};
// *seconds* clock (the old `now_ms`) advances by at most ~1 over a use tines_store::LogStore;
// short interval, so an observation's `lifetime_ms` would expire ~1000×
// too slowly. This asserts the service clock advances in milliseconds. fn topic() -> Vec<u8> {
b"tines/f2".to_vec()
}
fn payload() -> Vec<u8> {
b"hello".to_vec()
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unique_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tines-f2-{}-{}", tag, process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn connect(path: &Path) -> UnixStream {
for _ in 0..500 {
if let Ok(s) = UnixStream::connect(path) {
return s;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("failed to connect to {}", path.display());
}
fn rpc<S: Read + Write>(stream: &mut S, req: &Request) {
let body = serde_json::to_vec(req).expect("serialize request");
let len = (body.len() as u32).to_le_bytes();
stream.write_all(&len).unwrap();
stream.write_all(&body).unwrap();
stream.flush().unwrap();
}
fn read_response<S: Read>(stream: &mut S) -> Response {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf).unwrap();
let len = u32::from_le_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
stream.read_exact(&mut buf).unwrap();
serde_json::from_slice(&buf).expect("valid response")
}
/// A minimal shared in-memory transport for the F2 propagation test: two
/// [`Node`]s bound to the same world share mailbox pairs, so a frame sent by
/// one is delivered to the other's [`Transport::poll`] — with no real BLE
/// radio. This is the only thing the spec means by "an in-memory transport"
/// when none exists in `tines`.
struct SharedInMemory {
me: NodeId,
their: NodeId,
world: Arc<Mutex<SharedInMemoryWorld>>,
/// Set to `true` once this endpoint's first `poll` has reported contact,
/// so the `Contact` event fires exactly once, per endpoint.
contacted: bool,
}
struct SharedInMemoryWorld {
/// Per-node mailboxes: `send` to `to` lands in that node's inbox and
/// `poll` drains the local node's inbox. A single shared mailbox would
/// let either endpoint consume a frame destined for the other.
inboxes: HashMap<NodeId, VecDeque<Event<NodeId>>>,
}
impl SharedInMemoryWorld {
fn new() -> Self {
Self {
inboxes: HashMap::new(),
}
}
}
impl Transport for SharedInMemory {
type Addr = NodeId;
fn poll(&mut self) -> Option<Event<NodeId>> {
if !self.contacted {
self.contacted = true;
return Some(Event::Contact(self.their));
}
self.world
.lock()
.unwrap()
.inboxes
.get_mut(&self.me)?
.pop_front()
}
fn send(&mut self, to: &NodeId, frame: &[u8]) -> Result<(), SendError> {
// Deliver only to our peer; a frame to an unknown Addr is dropped.
if *to != self.their {
return Ok(());
}
let payload = FrameBuf::from_slice(frame).map_err(|_| SendError::TooLarge)?;
self.world
.lock()
.unwrap()
.inboxes
.entry(*to)
.or_default()
.push_back(Event::Frame(self.me, payload));
Ok(())
}
fn mtu(&self) -> usize {
consts::MAX_FRAME
}
}
/// Build two [`Node`]s over a shared [`SharedInMemory`] world, each with its
/// own store, bound to the given ids.
fn build_pair(
dir: &Path,
) -> (
Node<LogStore, SharedInMemory>,
Node<LogStore, SharedInMemory>,
) {
let world = Arc::new(Mutex::new(SharedInMemoryWorld::new()));
let (a_id, b_id) = ([1; 16], [2; 16]);
let a_store = LogStore::open(&dir.join("a.store.log"), 0).expect("a store open");
let b_store = LogStore::open(&dir.join("b.store.log"), 0).expect("b store open");
let ta = SharedInMemory {
me: a_id,
their: b_id,
world: world.clone(),
contacted: false,
};
let tb = SharedInMemory {
me: b_id,
their: a_id,
world,
contacted: false,
};
(
Node::new(a_id, a_store.first_seq(), a_store, ta),
Node::new(b_id, b_store.first_seq(), b_store, tb),
)
}
#[test] #[test]
fn tick_advances_in_milliseconds() { fn f2_direct_propagation() {
let clock = crate::HostClock::start(); let dir = unique_dir("probe");
let a = clock.now(); let (mut node_a, mut node_b) = build_pair(&dir);
std::thread::sleep(Duration::from_millis(150)); // Observe FIRST so the broadcast is already in A's store when the
let b = clock.now(); // summary exchange rebuilds the send queue (mirrors pair.rs).
let delta = b.saturating_sub(a); let observed = node_a.observe(&topic(), &payload(), None, 0).unwrap();
assert!( let mut t = 0u64;
delta >= 120, let mut ok = false;
"tines tick advances in milliseconds: expected >=120 ms over a 150 ms sleep, got {delta} ms", for _ in 0..1000 {
let _ = node_a.poll(t);
let _ = node_b.poll(t);
t += 10;
if node_b.store().contains(&observed) {
ok = true;
break;
}
}
assert!(ok, "direct propagation failed after 1000 rounds");
}
/// F2 regression: two service instances over an in-memory transport — an
/// observation made through one instance's IPC becomes a belief readable
/// through the other's. Both instances drive their exchange inline in
/// [`serve`]'s poll loop (the node isn't `Send`, so there's no separate
/// exchange thread); the broadcast reaches the other instance over the
/// shared transport and is stored as a belief.
#[test]
fn f2_propagation_between_instances() {
let dir = unique_dir("propagate");
let (node_a, node_b) = build_pair(&dir);
let socket_a = dir.join("a.sock");
let socket_b = dir.join("b.sock");
let (clock_a, clock_b) = (HostClock::start(), HostClock::start());
let socket_a_thread = socket_a.clone();
let _a = thread::spawn(move || serve(node_a, socket_a_thread, clock_a, 20).unwrap());
let socket_b_thread = socket_b.clone();
let _b = thread::spawn(move || serve(node_b, socket_b_thread, clock_b, 20).unwrap());
// A observes on its socket.
let mut stream_a = connect(&socket_a);
rpc(
&mut stream_a,
&Request::Observe {
topic: hex_encode(&topic()),
payload: hex_encode(&payload()),
},
); );
let obs = match read_response(&mut stream_a) {
Response::Observe { id } => id,
other => panic!("expected Observe, got {other:?}"),
};
// B queries the belief; retry until its inline exchange has polled A's
// broadcast off the shared transport and stored it.
for _ in 0..1000 {
let maybe = {
let mut stream_b = connect(&socket_b);
rpc(
&mut stream_b,
&Request::Belief {
topic: hex_encode(&topic()),
},
);
read_response(&mut stream_b)
};
if let Response::Belief { obs: Some(b) } = maybe {
assert_eq!(b.id, obs);
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("observation through A never became a belief through B");
} }
} }
+21 -3
View File
@@ -27,6 +27,14 @@ struct Config {
log_path: String, log_path: String,
/// Unix-domain socket path the IPC listens on. /// Unix-domain socket path the IPC listens on.
socket_path: String, socket_path: String,
/// Exchange poll interval in milliseconds. The real default belongs to the
/// builder; 20 ms keeps the link responsive without burning idle cycles.
#[serde(default = "default_poll_interval_ms")]
poll_interval_ms: u64,
}
fn default_poll_interval_ms() -> u64 {
20
} }
fn main() -> Result<(), Box<dyn Error + Send + Sync>> { fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
@@ -44,14 +52,24 @@ fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
.map_err(|e| format!("failed to open log store: {e:?}"))?; .map_err(|e| format!("failed to open log store: {e:?}"))?;
let radio = BluerRadio::new(me, peer); let radio = BluerRadio::new(me, peer);
let transport = BtTransport::new(me, peer, radio); let transport = BtTransport::new(me, peer, radio);
// `serve` runs the exchange inline (a poll loop fed by the socket read
// timeouts) on this same thread, so observations and beliefs flow over the
// transport, not just over IPC. The node isn't `Send` (the BLE transport
// carries a `dyn Radio` with no `Send` bound), so it never crosses a thread.
let node = Node::new(me, store.first_seq(), store, transport); let node = Node::new(me, store.first_seq(), store, transport);
println!( println!(
"tines: listening on {} (node {})", "tines: listening on {} (node {}, exchange poll {} ms)",
config.socket_path, config.socket_path,
node_hex(me) node_hex(me),
config.poll_interval_ms,
); );
serve(node, Path::new(&config.socket_path), clock)?; serve(
node,
Path::new(&config.socket_path),
clock,
config.poll_interval_ms,
)?;
Ok(()) Ok(())
} }
+3 -3
View File
@@ -222,7 +222,7 @@ fn observe_and_belief_over_socket() {
let socket = dir.join("ipc.sock"); let socket = dir.join("ipc.sock");
let node = build_node([9; 16], &dir); let node = build_node([9; 16], &dir);
let serve_path = socket.clone(); let serve_path = socket.clone();
let _handle = thread::spawn(move || serve(node, serve_path, HostClock::start()).unwrap()); let _handle = thread::spawn(move || serve(node, serve_path, HostClock::start(), 20).unwrap());
let mut stream = connect(&socket); let mut stream = connect(&socket);
@@ -270,7 +270,7 @@ fn events_over_socket() {
node.poll(0); // ack processed -> confidence event fires node.poll(0); // ack processed -> confidence event fires
let serve_path = socket.clone(); let serve_path = socket.clone();
let _handle = thread::spawn(move || serve(node, serve_path, HostClock::start()).unwrap()); let _handle = thread::spawn(move || serve(node, serve_path, HostClock::start(), 20).unwrap());
let mut stream = connect(&socket); let mut stream = connect(&socket);
rpc(&mut stream, &Request::Events); rpc(&mut stream, &Request::Events);
@@ -324,7 +324,7 @@ fn malformed_frame_gets_error_and_connection_survives() {
let socket = dir.join("ipc.sock"); let socket = dir.join("ipc.sock");
let node = build_node([3; 16], &dir); let node = build_node([3; 16], &dir);
let serve_path = socket.clone(); let serve_path = socket.clone();
let _handle = thread::spawn(move || serve(node, serve_path, HostClock::start()).unwrap()); let _handle = thread::spawn(move || serve(node, serve_path, HostClock::start(), 20).unwrap());
let mut stream = connect(&socket); let mut stream = connect(&socket);
// A length-prefixed frame whose body is not valid JSON. // A length-prefixed frame whose body is not valid JSON.