Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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))
}