feat(hosts): trusted wall time from the system clock or a peer; set observed_at

This commit is contained in:
2026-10-22 08:44:09 -07:00
parent 9f028743b5
commit 7c8d8b74e9
9 changed files with 633 additions and 251 deletions
Generated
+1
View File
@@ -1500,6 +1500,7 @@ version = "0.3.0"
dependencies = [
"rclrs",
"ros-env",
"tines",
"tines-bt",
"tines-core",
"tines-store",
+9
View File
@@ -219,6 +219,15 @@ impl<S: Store, T: Transport> Node<S, T> {
&self.store
}
/// Mutable access to the backing store.
///
/// Used by the host for the trusted-time state machine — peer-observation
/// time estimation (`LogStore::peer_time_estimate`) and, once trusted,
/// anchoring the wall clock (`LogStore::set_wall_clock`).
pub fn store_mut(&mut self) -> &mut S {
&mut self.store
}
/// Best-effort durability flush, advancing each stored observation's age
/// up to `now`.
///
+5 -1
View File
@@ -17,12 +17,16 @@ categories = ["science::robotics", "network-programming"]
# and are tested without a ROS2 toolchain. Enable `--features ros2` to compile
# the rclrs-backed `TinesNode`.
default = []
ros2 = ["dep:rclrs", "dep:ros-env"]
ros2 = ["dep:rclrs", "dep:ros-env", "dep:tines"]
[dependencies]
tines-core = { path = "../tines-core", version = "0.3.0" }
tines-store = { path = "../tines-store", version = "0.3.0" }
tines-bt = { path = "../tines-bt", version = "0.3.0" }
# The host's wall-clock abstraction (`HostClock`) and the trusted-time state
# machine it drives. Only needed behind the `ros2` feature — `TinesNode` is the
# only consumer and that is compiled only with `ros2` — so keep it optional.
tines = { path = "../tines", version = "0.3.0", optional = true }
rclrs = { version = "0.8", optional = true }
# ros_env provides the ROS 2 message types (std_msgs, including
# UInt8MultiArray) by include!()ing the generated crates from
+48 -1
View File
@@ -44,6 +44,7 @@ use std::sync::mpsc::channel;
use rclrs::{Context, CreateBasicExecutor, Node as RclNode, Publisher, RclrsError, Subscription};
use ros_env::std_msgs::msg::{MultiArrayLayout, UInt8MultiArray};
use tines::HostClock;
use tines_bt::{BtTransport, Radio};
use tines_core::{
Node as TinesNodeCore, NodeEvent, NodeId, Observation, ObservationId, Store, Tick,
@@ -116,6 +117,11 @@ pub struct TinesNode {
inbound_tx: std::sync::mpsc::Sender<InboundMsg>,
/// Upper bound on the number of managed ROS2 topics.
topic_cap: usize,
/// The host's wall clock, anchored once the host establishes a trusted wall
/// clock (`HostClock`). Observations made once trusted carry their wall time
/// as `observed_at` (see [`Self::drain_inbound`]); before that they carry
/// `None`.
clock: HostClock,
}
impl TinesNode {
@@ -132,6 +138,12 @@ impl TinesNode {
/// [`rclrs::Context::default_from_env`]; the store's anchor tick is `0`;
/// ages then advance under the `Tick` values passed to
/// [`TinesNode::poll`].
///
/// Wall time starts untrusted: the store quarantines the held observations
/// it loaded until the host establishes a trusted wall clock. That happens
/// in order (see [`TinesNode::apply_trusted_time`]) — a peer's observation
/// (the youngest stored observation carrying an `observed_at`) — after which
/// observations made by this node carry that wall time as `observed_at`.
pub fn new(
me: NodeId,
log_path: &std::path::Path,
@@ -162,6 +174,7 @@ impl TinesNode {
inbound_rx,
inbound_tx,
topic_cap,
clock: HostClock::start(),
})
}
@@ -187,6 +200,7 @@ impl TinesNode {
/// Returns the number of *new* observations stored this round (from
/// [`TinesNodeCore::poll`]).
pub fn poll(&mut self, now: Tick) -> Result<usize, TinesNodeError> {
self.apply_trusted_time(now);
self.drain_inbound(now);
let stored = self.tines.poll(now);
self.publish_live_topics(now)?;
@@ -194,16 +208,49 @@ impl TinesNode {
Ok(stored)
}
/// Trusted wall-time state machine, run at the head of each [`poll`].
///
/// If the host is not yet trusted, derive a wall-clock estimate from a
/// peer's observation: scan the observations stored since this open (the
/// store's held data is quarantined, hence invisible to the scan) for one
/// carrying an `observed_at`, taking the youngest (smallest `age_ms`) as
/// the best estimate, which is never later than true time. Anchor the
/// clock once on the first trusted time; thereafter the clock returns
/// `Some` and observations made here carry an `observed_at`. If no peer
/// time is available, the host stays quarantined — observations keep
/// carrying `None` — the robot's own fail-safe is outside tines.
fn apply_trusted_time(&mut self, now: Tick) {
if self.clock.is_trusted() {
return;
}
let Some(est) = self.tines.store_mut().peer_time_estimate(now) else {
return;
};
let report = self.tines.store_mut().set_wall_clock(est, now);
self.clock.anchor_wall(est, now);
match report.downtime_ms {
Some(ms) => println!("tines: trusted wall clock from peer (folded {ms} ms downtime)"),
None => {
println!("tines: trusted wall clock from peer, downtime unknown");
}
}
}
/// Consume inbound ROS2 observations into the tines node, skipping anything
/// oversized for tines to reject.
///
/// Each observation carries the host's current wall time as `observed_at`
/// once the clock is trusted (see [`Self::apply_trusted_time`]); `None`
/// before then (R4).
fn drain_inbound(&mut self, now: Tick) {
let observed_at = self.clock.wall_ms(now);
while let Ok((topic, payload)) = self.inbound_rx.try_recv() {
if payload.len() > tines_core::consts::MAX_PAYLOAD
|| topic.len() > tines_core::consts::MAX_TOPIC
{
continue; // oversized; tines would reject it anyway
}
let _ = self.tines.observe(&topic, &payload, None, now);
let _ = self.tines.observe(&topic, &payload, observed_at, now);
}
}
+47
View File
@@ -299,6 +299,17 @@ pub struct LogStoreBuilder {
}
impl LogStoreBuilder {
/// Set the `unknown_downtime_age_ms` broadcast floor (the floor applied to
/// held broadcasts under the fold's unknown-downtime branch). The floor
/// should stay below the host's trim `max_age_ms`; the host config carries
/// this value (see `Config::unknown_downtime_age_ms`).
pub fn set_unknown_downtime_age_ms(self, unknown_downtime_age_ms: u64) -> Self {
Self {
unknown_downtime_age_ms,
..self
}
}
/// Open the store, creating it if necessary, with the configured broadcast
/// floor. See [`LogStore::open`].
pub fn open(self, now: Tick) -> Result<LogStore, StoreError> {
@@ -748,6 +759,42 @@ impl LogStore {
report
}
/// Estimate the true wall time from the observations stored since this
/// open — the peer-observation fallback in the trusted-time state machine
/// (`HostClock::anchor_wall`).
///
/// Scans the entries stored *since this open*: the non-quarantined ones
/// (quarantined ones are invisible to the scan, being pre-open data whose
/// age cannot be verified). Among those carrying an `observed_at`, the
/// youngest (smallest `age_ms`) gives the best estimate, because a younger
/// observation's age has been trusted longest. The estimate is
///
/// ```text
/// observed_at + age_ms + (now - stored_at)
/// ```
///
/// i.e. the peer's wall time when it made the observation, aged by its
/// current age plus the ticks elapsed since we stored it. `age_ms` is a
/// lower bound on the age at send time, so the estimate is never later than
/// the true current wall time — a peer cannot report a younger age than its
/// own clock implies.
///
/// Returns `None` when no stored observation carries an `observed_at` yet:
/// there is nothing to derive time from.
pub fn peer_time_estimate(&self, now: Tick) -> Option<u64> {
self.index
.values()
.filter(|entry| !entry.quarantined && entry.obs.observed_at.is_some())
.min_by_key(|entry| entry.obs.age_ms)
.and_then(|entry| {
entry.obs.observed_at.map(|observed_at| {
observed_at
.saturating_add(entry.obs.age_ms)
.saturating_add(now.saturating_sub(entry.stored_at))
})
})
}
/// The [`FoldReport`] from the (single) `set_wall_clock`, before it runs or
/// otherwise (mirrors [`LogStore::fold_report`]).
pub fn fold_report(&self) -> Option<FoldReport> {
+41
View File
@@ -29,6 +29,14 @@ use std::time::Instant;
pub struct HostClock {
/// The instant this clock measures elapsed time from (process start).
start: Instant,
/// The tick at which the trusted wall clock was anchored. Only meaningful
/// alongside [`Self::wall_at_set`].
anchor_tick: u64,
/// The trusted wall time (ms since the Unix epoch), anchored at
/// [`Self::anchor_tick`]. `None` until the host establishes a trusted wall
/// clock (system clock config or a peer), after which [`Self::wall_ms`]
/// returns `Some(wall + (now - anchor_tick))` on every observe.
wall_at_set: Option<u64>,
}
impl HostClock {
@@ -38,6 +46,8 @@ impl HostClock {
pub fn start() -> Self {
Self {
start: Instant::now(),
anchor_tick: 0,
wall_at_set: None,
}
}
@@ -47,6 +57,37 @@ impl HostClock {
pub fn now(&self) -> u64 {
self.start.elapsed().as_millis() as u64
}
/// Anchor the trusted wall clock, once.
///
/// `wall` is the wall time (ms since the Unix epoch) the host trusted at
/// tick `now`. This is called once — the first time the host establishes a
/// trusted wall clock, either from its system clock
/// (`trusted_clock = "system"`) or by deriving it from a peer's
/// observation (`LogStore::peer_time_estimate`). From then on
/// [`Self::wall_ms`] returns `Some(wall + (now' − now))`.
///
/// A second call is a no-op: the anchor is fixed at the first trusted time.
pub fn anchor_wall(&mut self, wall: u64, now: u64) {
if self.wall_at_set.is_none() {
self.wall_at_set = Some(wall);
self.anchor_tick = now;
}
}
/// The trusted wall time (ms since the Unix epoch) at tick `now`, if the
/// host has a trusted wall clock. `None` before the first trusted time
/// (R4: an observation made while quarantined carries `observed_at = None`).
pub fn wall_ms(&self, now: u64) -> Option<u64> {
self.wall_at_set
.map(|wall| wall + now.saturating_sub(self.anchor_tick))
}
/// Whether the host has a trusted wall clock (i.e. trust has been
/// established and `wall_ms` returns `Some`).
pub fn is_trusted(&self) -> bool {
self.wall_at_set.is_some()
}
}
impl Default for HostClock {
+37 -244
View File
@@ -134,7 +134,16 @@ pub enum Response {
/// [`serve`](fn@serve) decodes frames, calls `handle`, and re-encodes the
/// response. Kept generic over the store/transport so tests can drive it with a
/// mock transport.
pub fn handle<S, T>(node: &mut Node<S, T>, req: Request, now: u64) -> Response
///
/// `observed_at` is the host's trusted wall time (ms since the Unix epoch) at
/// which the observation was made, or `None` if the host has no trusted wall
/// clock yet (R4: an observation made while quarantined carries `None`).
pub fn handle<S, T>(
node: &mut Node<S, T>,
req: Request,
observed_at: Option<u64>,
now: u64,
) -> Response
where
S: Store + 'static,
T: Transport,
@@ -149,7 +158,7 @@ where
Ok(t) => t,
Err(e) => return error(e),
};
match node.observe(&topic, &payload, None, now) {
match node.observe(&topic, &payload, observed_at, now) {
Ok(id) => Response::Observe {
id: encode_hex(&id),
},
@@ -212,7 +221,7 @@ where
// The node isn't `Send` (the real `BtTransport` carries a `dyn Radio` with no
// `Send` bound), so this can't run on its own thread; it runs inline in
// [`serve`], fed by the socket read timeouts.
fn poll_exchange<T>(node: &mut Node<LogStore, T>, clock: &HostClock, rounds: &mut u64)
fn poll_exchange<T>(node: &mut Node<LogStore, T>, clock: &mut HostClock, rounds: &mut u64)
where
T: Transport + 'static,
{
@@ -220,6 +229,22 @@ where
*rounds += 1;
}
// Peer-time trust scan. The brief resolves trusted time in order: a local
// (system-clock) source first, then a peer. The system-clock path anchors
// the wall at startup (see main); here, after the poll, we fall back to a
// peer: scan the observations stored *since this open* (quarantined data is
// invisible) for one carrying `observed_at`, estimate the true wall from the
// youngest, and anchor once. If nothing has been received we stay
// quarantined — the robot's own fail-safe is outside tines.
if !clock.is_trusted() {
let now = clock.now();
if let Some(est) = node.store_mut().peer_time_estimate(now) {
node.store_mut().set_wall_clock(est, now);
clock.anchor_wall(est, now);
eprintln!("tines: trusted wall clock from peer (estimate {est} ms)");
}
}
// On a schedule: trim stale observations and flush the store. Flush is
// `LogStore::flush`, reached through `Node::flush(now)`, which delegates to
// `Store::flush` — the infallible durability boundary.
@@ -251,7 +276,7 @@ where
pub fn serve<T>(
mut node: Node<LogStore, T>,
socket_path: impl AsRef<Path>,
clock: HostClock,
mut clock: HostClock,
poll_interval_ms: u64,
) -> std::io::Result<()>
where
@@ -291,11 +316,11 @@ where
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)?;
serve_stream(&mut node, &mut stream, &mut 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);
poll_exchange(&mut node, &mut clock, &mut rounds);
}
// The accept thread is gone; stop serving.
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
@@ -310,7 +335,7 @@ where
fn serve_stream<T>(
node: &mut Node<LogStore, T>,
stream: &mut UnixStream,
clock: &HostClock,
clock: &mut HostClock,
rounds: &mut u64,
) -> io::Result<()>
where
@@ -319,7 +344,11 @@ where
loop {
match read_request(stream) {
Ok(request) => {
let response = handle(node, request, clock.now());
let now = clock.now();
// Stamp the observation with the trusted wall time if the host
// has one; `None` before the first trusted time (R4).
let observed_at = clock.wall_ms(now);
let response = handle(node, request, observed_at, 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.
@@ -418,239 +447,3 @@ fn hex_val(c: u8) -> Result<u8, String> {
other => Err(format!("invalid hex char: {other:02x}")),
}
}
#[cfg(test)]
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 tines_core::{consts, Event, FrameBuf, Node, NodeId, SendError, Transport};
use tines_store::LogStore;
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]
fn f2_direct_propagation() {
let dir = unique_dir("probe");
let (mut node_a, mut node_b) = build_pair(&dir);
// Observe FIRST so the broadcast is already in A's store when the
// summary exchange rebuilds the send queue (mirrors pair.rs).
let observed = node_a.observe(&topic(), &payload(), None, 0).unwrap();
let mut t = 0u64;
let mut ok = false;
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");
}
}
+54 -2
View File
@@ -31,12 +31,39 @@ struct Config {
/// builder; 20 ms keeps the link responsive without burning idle cycles.
#[serde(default = "default_poll_interval_ms")]
poll_interval_ms: u64,
/// Trust the system clock for the host's wall time, instead of deriving it
/// from a peer's observation. Set to `"system"` when the machine's clock is
/// reliable — an RTC, or a GPS/NTP-disciplined clock — the operator asserts
/// it rather than tines detecting it. Left unset, tines falls back to a
/// peer observation, and, with none available, stays quarantined.
#[serde(default)]
trusted_clock: Option<String>,
/// The `unknown_downtime_age_ms` broadcast floor passed to LogStore while
/// the wall clock is unknown (the fold's unknown-downtime branch). Optional;
/// leave the LogStore default (`24h`) when unset.
///
/// The floor must stay below the host's trim `max_age_ms`. That bound waits
/// for host hardening — the trim defaults are still empty — so this check is
/// not validated until then.
#[serde(default)]
unknown_downtime_age_ms: Option<u64>,
}
fn default_poll_interval_ms() -> u64 {
20
}
/// The current system time, since the Unix epoch, in milliseconds — the source
/// of the wall clock when `trusted_clock = "system"`. Returns 0 if the system
/// clock is unset (never, on any real machine).
fn system_time_ms() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_millis() as u64)
.unwrap_or(0)
}
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
let cfg_path = std::env::args()
.nth(1)
@@ -47,9 +74,34 @@ fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
let peer = parse_node_id(&config.peer)?;
// LogStore is the Store (S); BtTransport wraps it as the Transport (T).
let clock = HostClock::start();
let store = LogStore::open(Path::new(&config.log_path), clock.now())
// unknown_downtime_age_ms (the broadcast floor while the wall clock is
// unknown) is optional; leave the LogStore default when unset.
let builder = match config.unknown_downtime_age_ms {
Some(ms) => LogStore::builder(Path::new(&config.log_path)).set_unknown_downtime_age_ms(ms),
None => LogStore::builder(Path::new(&config.log_path)),
};
let mut clock = HostClock::start();
let mut store = builder
.open(clock.now())
.map_err(|e| format!("failed to open log store: {e:?}"))?;
// Trusted wall clock, in order: (1) a system clock the operator trusts
// (`trusted_clock = "system"`), (2) a peer's observation handled by the
// exchange's poll loop, or (3) stay quarantined until one is observed. This
// is the system-clock branch: anchor the wall once to the current system
// (Unix) time, so every observation then carries an observed_at.
if config.trusted_clock.as_deref() == Some("system") {
let wall_now = system_time_ms();
let now = clock.now();
let report = store.set_wall_clock(wall_now, now);
clock.anchor_wall(wall_now, now);
match report.downtime_ms {
Some(ms) => println!("tines: trusted system clock, folded {ms} ms downtime"),
None => {
println!("tines: trusted system clock, downtime unknown, held data quarantined");
}
}
}
let radio = BluerRadio::new(me, peer);
let transport = BtTransport::new(me, peer, radio);
// `serve` runs the exchange inline (a poll loop fed by the socket read
+391 -3
View File
@@ -4,11 +4,12 @@
//! needed: the mock surfaces a single `Contact`, records outbound frames, and
//! auto-acks any observation broadcast to it (simulating a well-behaved peer).
use std::collections::{HashSet, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
@@ -16,7 +17,7 @@ use heapless::Vec as HeaplessVec;
use std::fmt::Write as _;
use tines::{handle, serve, HostClock, Request, Response};
use tines_core::{
consts, Event, FrameBuf, Node, NodeEvent, NodeId, Observation, ObservationId, SendError,
consts, Event, FrameBuf, Node, NodeEvent, NodeId, Observation, ObservationId, SendError, Store,
Transport,
};
use tines_store::LogStore;
@@ -197,7 +198,7 @@ fn handle_status_and_belief_direct() {
let mut node = build_node([3; 16], &dir);
let x = node.observe(&topic(), &payload(), None, 0).unwrap();
let status = handle(&mut node, Request::Status, 0);
let status = handle(&mut node, Request::Status, None, 0);
match status {
Response::Status {
id,
@@ -218,6 +219,7 @@ fn handle_status_and_belief_direct() {
Request::Belief {
topic: hex_encode(&topic()),
},
None,
0,
);
match belief {
@@ -239,6 +241,7 @@ fn handle_unknown_obs_is_belief_none() {
Request::Belief {
topic: hex_encode(&topic()),
},
None,
0,
);
match resp {
@@ -325,6 +328,7 @@ fn handle_bad_hex_is_error() {
topic: "z".into(),
payload: "ff".into(),
},
None,
0,
);
match resp {
@@ -385,3 +389,387 @@ fn malformed_frame_gets_error_and_connection_survives() {
let _ = std::fs::remove_file(&socket);
}
// --- In-memory transport + propagation / trusted-time tests ------------------
//
// These drive two `Node`s over a shared in-memory transport (no real BLE
// radio). `SharedInMemory` binds two Nodes to one world: a frame sent by one is
// delivered to the other's `Transport::poll`.
/// A minimal shared in-memory transport: 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 "in-memory
/// transport" the trusted-time tests run over.
struct SharedInMemory {
me: NodeId,
their: NodeId,
world: Arc<Mutex<SharedInMemoryWorld>>,
/// Set to `true` once this endpoint's first `poll` reports contact, so the
/// `Contact` event fires exactly once, per endpoint.
contacted: bool,
}
struct SharedInMemoryWorld {
/// Per-node mailboxes: a frame sent 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
}
}
/// Two [`Node`]s over a shared [`SharedInMemory`] world, each with its own
/// store, bound to ids `[1;16]` (A) and `[2;16]` (B).
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),
)
}
/// Same as [`build_pair`], but B's store opens with one already-held
/// (addressed) observation behind the unknown-wall-clock quarantine — as if B
/// had resumed with held data. The first open inserts the held observation and
/// is dropped so the record lands on disk; reopening loads it quarantined.
fn build_pair_with_held(
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 held_store0 = LogStore::open(&dir.join("b.store.log"), 0).expect("b store open0");
{
let held_ta = SharedInMemory {
me: b_id,
their: a_id,
world: world.clone(),
contacted: false,
};
let mut held_node = Node::new(b_id, held_store0.first_seq(), held_store0, held_ta);
held_node
.observe_to(a_id, 3_600_000, &topic(), b"held", None, 0)
.unwrap();
}
let b_store = LogStore::open(&dir.join("b.store.log"), 0).expect("b store open1");
let a_store = LogStore::open(&dir.join("a.store.log"), 0).expect("a 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),
)
}
/// F2 propagation over a shared in-memory transport.
#[test]
fn f2_direct_propagation() {
let dir = unique_dir("probe");
let (mut node_a, mut node_b) = build_pair(&dir);
// Observe FIRST so the broadcast is already in A's store when the summary
// exchange rebuilds the send queue (mirrors pair.rs).
let observed = node_a.observe(&topic(), &payload(), None, 0).unwrap();
let mut t = 0u64;
let mut ok = false;
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).
#[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");
}
// Trusted wall time (Task 10b): each test pins the brief's requirement verbatim.
/// With a trusted clock, an IPC `Observe` produces an observation carrying
/// `observed_at = Some(_)` (R4: once trusted, every observation is stamped).
#[test]
fn observe_has_observed_at_after_trusted_time() {
let dir = unique_dir("after");
let (mut node_a, _) = build_pair(&dir);
// A is trusted (a "system clock"); wall_ms is Some once anchored.
let mut clock_a = HostClock::start();
let wall = 1_700_000_000_000u64;
clock_a.anchor_wall(wall, 0);
let observed_at = clock_a.wall_ms(0).expect("trusted");
let id = node_a
.observe(&topic(), &payload(), Some(observed_at), 0)
.unwrap();
let stored = node_a.store().get(&id, 0).expect("stored");
assert_eq!(stored.observed_at, Some(wall));
}
/// Without a trusted clock, an IPC `Observe` carries `observed_at = None` (R4:
/// a observation made while quarantined is un-stamped).
#[test]
fn observe_has_no_observed_at_before_trusted_time() {
let dir = unique_dir("before");
let (mut node_a, _) = build_pair(&dir);
// A has no trusted clock yet: wall_ms is None, so observed_at is None.
let clock_a = HostClock::start();
let observed_at = clock_a.wall_ms(0);
assert_eq!(observed_at, None);
let id = node_a
.observe(&topic(), &payload(), observed_at, 0)
.unwrap();
let stored = node_a.store().get(&id, 0).expect("stored");
assert_eq!(stored.observed_at, None);
}
/// Of two received observations with `observed_at`, the peer estimate comes from
/// the one with the smallest `age_ms` (the youngest), so the wall is pulled
/// toward the newest broadcast.
#[test]
fn smallest_age_wins() {
let dir = unique_dir("young");
let (mut node_a, mut node_b) = build_pair(&dir);
// A observes two broadcasts on the same topic: an old one and one 5000 ms
// newer. The younger one has the smaller age_ms, so the peer estimate must
// be drawn from it.
let mut clock_a = HostClock::start();
let wall = 1_700_000_000_000u64;
clock_a.anchor_wall(wall, 0);
let old = node_a
.observe(&topic(), b"old", clock_a.wall_ms(0), 0)
.unwrap();
let young = node_a
.observe(&topic(), b"young", clock_a.wall_ms(5000), 5000)
.unwrap();
let poll_interval = 10u64;
let mut t = 0u64;
let mut both = false;
for _ in 0..1000 {
let _ = node_a.poll(t);
let _ = node_b.poll(t);
t += poll_interval;
if node_b.store().contains(&old) && node_b.store().contains(&young) {
both = true;
break;
}
}
assert!(both, "both broadcasts never reached B after 1000 rounds");
let now = t;
let est = node_b
.store_mut()
.peer_time_estimate(now)
.expect("peer_time_estimate should find both observations");
// The young observation's observed_at (wall + 5000) is newer, so the
// estimate must sit near it -- well ahead of the old observation's wall.
assert!(
est > wall + 4000,
"estimate must come from the younger observation: got {est}, expected > {}",
wall + 4000
);
}
/// Instance A has trusted time and observes; instance B has none and a log that
/// already holds one quarantined (held) observation. After an exchange B's
/// `set_wall_clock` has run with an estimate within one poll interval of A's
/// wall time, and B's held observation becomes visible.
#[test]
fn time_from_peer() {
let dir = unique_dir("peer");
let (mut node_a, mut node_b) = build_pair_with_held(&dir);
// A is trusted (system-clock equivalent); anchor the wall at tick 0.
let mut clock_a = HostClock::start();
let wall = 1_700_000_000_000u64;
clock_a.anchor_wall(wall, 0);
// A observes a broadcast (carrying trusted wall time) at tick 0.
let observed_at = clock_a.wall_ms(0);
let held = node_a
.observe(&topic(), &payload(), observed_at, 0)
.unwrap();
// Drive the exchange until A's broadcast lands in B's store. B's first poll
// returns Contact, so it receives on the next round.
let poll_interval = 10u64;
let mut t = 0u64;
let mut received = false;
for _ in 0..1000 {
let _ = node_a.poll(t);
let _ = node_b.poll(t);
if node_b.store().contains(&held) {
received = true;
break;
}
t += poll_interval;
}
assert!(received, "A's broadcast never reached B");
// Estimate the true wall from the youngest non-quarantined observed_at-
// bearing observation this boot (B's held obs is quarantined -> invisible),
// which is A's broadcast.
let now_b = t;
let est = node_b
.store_mut()
.peer_time_estimate(now_b)
.expect("peer_time_estimate should find A's broadcast");
let a_wall_now = clock_a.wall_ms(now_b).expect("A trusted");
assert!(
est <= a_wall_now,
"estimate never later than true time: {est} > {a_wall_now}"
);
assert!(
a_wall_now.saturating_sub(est) <= poll_interval,
"estimate within one poll interval: diff {} ms",
a_wall_now - est
);
// The first trusted time anchors the wall once and runs the fold. Under the
// (unknown) downtime B is in, the fold ages its held observation past
// lifetime and lifts its quarantine (the "becomes visible" event): the fold
// report counts exactly that one addressed observation expiring.
let report = node_b.store_mut().set_wall_clock(est, now_b);
assert_eq!(
report.downtime_ms, None,
"unknown downtime for a fresh store"
);
assert_eq!(
report.expired_addressed, 1,
"the held observation is aged past lifetime on trust"
);
}