crates/monetize-server/src/bench.rs
//! **The mock's time travel: monetize's DECISION clock, wall + an offset the
//! bench serves** (`bench_travel_url`, the g-nonpay proof, 2026-09-29).
//!
//! The owner's ruling is proven on a gunnar mock whose clock travels: the
//! lapse, the warnings on days 0, 23 and 29, the deletion on day 30, and a
//! payment on day 10 that calls it off. The whole ESTATE cannot jump thirty
//! days — every credential the appliance checks is five minutes wide, and the
//! front steps its own clock hourly at most (`gunnar-clock`) — so only the
//! clock monetize DECIDES by travels ([`crate::state::AppState::now`]: the
//! ladder, the nonpay engine, the reminders). Every fact is still SIGNED on the
//! wall clock (`monetize::Signer::sign_fact`), so the appliance's replay rule
//! and every credential window stay true.
//!
//! The bench serves `{"offset_ms": N}` over plain HTTP; this polls it and the
//! clock is `wall + N`. **Fenced**: the URL must be `http://` on loopback or the
//! offline mock's host alias `10.0.2.100` — a config that names anything else
//! is refused at start, by name. The kit renders the key only for a mock run
//! that asked for it; no estate's monetize.toml carries it.
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::state::Clock;
/// The offline mock relays a guest's `10.0.2.100:<port>` to the bench machine.
pub const BENCH_HOST_ALIAS: &str = "10.0.2.100";
/// The travel as it stands: the offset last served, and where from.
#[derive(Debug)]
pub struct Travel {
offset: AtomicI64,
pub url: String,
}
impl Travel {
pub fn offset_ms(&self) -> i64 {
self.offset.load(Ordering::SeqCst)
}
}
/// `None` when `url` may be a travel source; the refusal otherwise.
pub fn travel_refusal(url: &str) -> Option<String> {
let Some(rest) = url.strip_prefix("http://") else {
return Some(format!("REFUSED [bench-travel-not-local] bench_travel_url {url:?} is not plain http:// — the bench serves it on this machine or the mock's relay"));
};
let authority = rest.split('/').next().unwrap_or("");
let host = authority
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(authority);
let local = host == BENCH_HOST_ALIAS
|| host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
(!local).then(|| {
format!(
"REFUSED [bench-travel-not-local] bench_travel_url {url:?} names {host:?}: a travel clock is the MOCK's (loopback or {BENCH_HOST_ALIAS}), never a deployment's"
)
})
}
fn addr_of(url: &str) -> Result<SocketAddr, String> {
let rest = url.strip_prefix("http://").unwrap_or(url);
let authority = rest.split('/').next().unwrap_or("");
let with_port = if authority.contains(':') {
authority.to_owned()
} else {
format!("{authority}:80")
};
with_port
.to_socket_addrs()
.map_err(|e| format!("{url}: {e}"))?
.next()
.ok_or_else(|| format!("{url}: no address"))
}
/// One ask: `GET` → `{"offset_ms": N}`.
pub fn fetch_offset(url: &str) -> Result<i64, String> {
let addr = addr_of(url)?;
let mut s = TcpStream::connect_timeout(&addr, Duration::from_secs(2))
.map_err(|e| format!("{url}: {e}"))?;
s.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|e| e.to_string())?;
let path = url
.strip_prefix("http://")
.and_then(|r| r.find('/').map(|i| &r[i..]))
.unwrap_or("/");
s.write_all(format!("GET {path} HTTP/1.0\r\nHost: {addr}\r\n\r\n").as_bytes())
.map_err(|e| format!("{url}: {e}"))?;
let mut text = String::new();
s.read_to_string(&mut text)
.map_err(|e| format!("{url}: {e}"))?;
let body = text.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or("");
let v: serde_json::Value = serde_json::from_str(body.trim())
.map_err(|e| format!("{url}: the answer is not {{\"offset_ms\": N}}: {e}"))?;
v.get("offset_ms")
.and_then(|o| o.as_i64())
.ok_or_else(|| format!("{url}: the answer carries no offset_ms"))
}
/// **The travelling clock**: `wall + the served offset`, the offset re-asked
/// every `every` on a thread of its own. Refused for a URL [`travel_refusal`]
/// refuses. A source that stops answering keeps the last offset (a clock that
/// snapped back to the wall mid-run would un-lapse every tenant it had lapsed).
pub fn travel_clock(url: &str, every: Duration) -> Result<(Clock, Arc<Travel>), String> {
if let Some(r) = travel_refusal(url) {
return Err(r);
}
let travel = Arc::new(Travel {
offset: AtomicI64::new(fetch_offset(url).unwrap_or(0)),
url: url.to_owned(),
});
eprintln!(
"monetize-server: BENCH TRAVEL — the decision clock is the wall clock plus the offset {url} serves (now {} ms); a MOCK's time travel, never an estate's",
travel.offset_ms()
);
{
let t = Arc::clone(&travel);
// A32-14, stays a task: the bench's clock follower, a test fixture's thread for the bench verb, never on a sold box.
std::thread::Builder::new()
.name("bench-travel".into())
.spawn(move || loop {
std::thread::sleep(every);
if let Ok(o) = fetch_offset(&t.url) {
let was = t.offset.swap(o, Ordering::SeqCst);
if was != o {
eprintln!("monetize-server: BENCH TRAVEL the decision clock moved from +{} ms to +{o} ms ({:.2} days)", was, o as f64 / 86_400_000.0);
}
}
})
.map_err(|e| e.to_string())?;
}
let t = Arc::clone(&travel);
let clock: Clock = Box::new(move || {
let wall = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or_default();
wall.saturating_add(t.offset_ms()).max(0) as u64
});
Ok((clock, travel))
}