fix(service): run the exchange on a poll loop
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user