use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const STEP_EVERY: Duration = Duration::from_secs(15);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const CALL_TIMEOUT: Duration = Duration::from_secs(10);
pub fn agent() -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_global(Some(CALL_TIMEOUT))
.build()
.new_agent()
}
const RETRY_EVERY: Duration = Duration::from_secs(5);
#[derive(Clone, Debug, Default)]
pub struct NetState {
pub name: String,
pub url: String,
pub height: u64,
pub root: String,
pub last_step: String,
pub ok: bool,
pub last_sync: Option<Instant>,
pub last_advance: Option<Instant>,
pub rx: u64,
pub tx: u64,
}
#[derive(Clone, Default)]
pub struct NetHub {
pub states: Arc<Mutex<Vec<NetState>>>,
generation: Arc<std::sync::atomic::AtomicU64>,
}
impl NetHub {
pub fn snapshot(&self) -> Vec<NetState> {
self.states.lock().map(|v| v.clone()).unwrap_or_default()
}
pub fn beacon(&self) -> Option<(String, u64, String)> {
self.states.lock().ok()?.iter().find(|n| n.height > 0).map(|n| {
(n.name.clone(), n.height, n.root.clone())
})
}
pub fn note_block(&self, name: &str, height: u64, root: &str) {
if let Ok(mut v) = self.states.lock() {
if let Some(n) = v.iter_mut().find(|n| n.name == name) {
if height > n.height {
n.last_advance = Some(Instant::now());
n.height = height;
n.root = root.to_string();
n.ok = true;
n.last_step = format!("ok h={height} (relay)");
persist_state(&v);
}
}
}
}
pub fn start() -> Self {
let hub = NetHub::default();
hub.reload();
hub
}
fn spawn_sync(&self, name: String, url: String, born: u64) {
let states = self.states.clone();
let generation = self.generation.clone();
std::thread::Builder::new()
.name(format!("net-{name}"))
.spawn(move || {
let agent = agent();
loop {
if generation.load(std::sync::atomic::Ordering::Relaxed) != born {
return; }
let step = step_status_with(&agent, &url);
if let Ok(mut v) = states.lock() {
if let Some(n) = v.iter_mut().find(|n| n.name == name) {
n.tx += (url.len() + 16) as u64; match &step {
Ok((height, root, bytes)) => {
n.rx += *bytes as u64;
if *height != n.height {
n.last_advance = Some(Instant::now());
}
n.height = *height;
n.root = root.clone();
n.ok = true;
n.last_step = format!("ok h={height}");
n.last_sync = Some(Instant::now());
persist_state(&v);
}
Err(e) => {
n.ok = false;
n.last_step = e.clone();
}
}
}
}
let nap = if step.is_ok() { STEP_EVERY } else { RETRY_EVERY };
let slept = Instant::now();
while slept.elapsed() < nap {
if generation.load(std::sync::atomic::Ordering::Relaxed) != born {
return;
}
std::thread::sleep(Duration::from_millis(250));
}
}
})
.expect("spawn net sync thread");
}
pub fn reload(&self) {
let configured = load_config();
let born = self
.generation
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ 1;
if let Ok(mut v) = self.states.lock() {
let old = std::mem::take(&mut *v);
for (name, url) in &configured {
let mut st = old
.iter()
.find(|n| &n.name == name)
.cloned()
.unwrap_or_default();
st.name = name.clone();
if &st.url != url {
st = NetState { name: st.name.clone(), ..Default::default() };
}
st.url = url.clone();
v.push(st);
}
}
for (name, url) in configured {
self.spawn_sync(name, url, born);
}
}
}
fn config_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
std::path::Path::new(&home).join("cyb").join("networks.toml")
}
fn state_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
std::path::Path::new(&home).join("cyb").join("netstate")
}
fn parse_config(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let (mut name, mut url): (Option<String>, Option<String>) = (None, None);
let mut flush = |name: &mut Option<String>, url: &mut Option<String>, out: &mut Vec<_>| {
if let (Some(n), Some(u)) = (name.take(), url.take()) {
out.push((n, u));
}
};
for line in text.lines() {
let line = line.trim();
if line.starts_with("span>
[[network\n\
name = \"pussy\"\n\
url = \"https://cyb.ai/spacepussy-test\"\n";
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&path, default);
parse_config(default)
}
}
}
fn save_config(nets: &[(String, String)]) -> Result<(), String> {
let mut text = String::from(
"# Networks this body talks to. Edited by hand or by the\n\
# commander: net add <name> <url> / net set / net rm\n",
);
for (name, url) in nets {
text.push_str(&format!("\nnetwork\nname = \"{name}\"\nurl = \"{url}\"\n"));
}
std::fs::write(config_path(), text).map_err(|e| e.to_string())
}
fn persist_state(states: &[NetState]) {
let mut text = String::new();
for n in states {
if n.height > 0 {
text.push_str(&format!("{} {} {}\n", n.name, n.height, n.root));
}
}
let _ = std::fs::write(state_path(), text);
}
fn step_status_with(agent: &ureq::Agent, url: &str) -> Result<(u64, String, usize), String> {
let full = format!("{url}/status");
let mut res = agent
.get(&full)
.call()
.map_err(|e| short_err(&e.to_string()))?;
let body = res
.body_mut()
.read_to_string()
.map_err(|e| short_err(&e.to_string()))?;
let field = |key: &str| -> Option<String> {
body.lines()
.find(|l| l.trim_start().starts_with(key))
.and_then(|l| l.split_once(':'))
.map(|(_, v)| v.trim().to_string())
};
let height: u64 = field("height")
.and_then(|h| h.parse().ok())
.ok_or("no height in status")?;
let root = field("bbg-root").unwrap_or_default();
Ok((height, root, body.len()))
}
fn short_err(e: &str) -> String {
let mut s: String = e.chars().take(48).collect();
if s.len() < e.len() {
s.push_str("...");
}
s
}
pub fn handle_command(rest: &str, hub: &NetHub) -> String {
let rest = rest.trim();
let mut nets = load_config();
if rest.is_empty() || rest == "list" {
if nets.is_empty() {
return "net: none configured - net add <name> <url>".into();
}
let states = hub.snapshot();
return nets
.iter()
.map(|(n, u)| {
let st = states.iter().find(|s| &s.name == n);
match st {
Some(s) if s.height > 0 => {
format!("{n} {u} - h={} {}", s.height, short_root(&s.root))
}
_ => format!("{n} {u} - not reached yet"),
}
})
.collect::<Vec<_>>()
.join(" | ");
}
if let Some(spec) = rest.strip_prefix("add ") {
let Some((name, url)) = split_name_url(spec) else {
return "net add <name> <url>".into();
};
if nets.iter().any(|(n, _)| *n == name) {
return format!("net: {name} exists - net set {name} <url> to change it");
}
nets.push((name.clone(), url));
return apply(&nets, hub, format!("net: {name} added"));
}
if let Some(spec) = rest.strip_prefix("set ") {
let Some((name, url)) = split_name_url(spec) else {
return "net set <name> <url>".into();
};
match nets.iter_mut().find(|(n, _)| *n == name) {
Some(entry) => {
entry.1 = url;
apply(&nets, hub, format!("net: {name} repointed"))
}
None => format!("net: no network named {name}"),
}
} else if let Some(name) = rest.strip_prefix("rm ") {
let name = name.trim();
let before = nets.len();
nets.retain(|(n, _)| n != name);
if nets.len() == before {
return format!("net: no network named {name}");
}
apply(&nets, hub, format!("net: {name} gone"))
} else {
"net [list] | net add <name> <url> | net set <name> <url> | net rm <name>".into()
}
}
fn split_name_url(spec: &str) -> Option<(String, String)> {
let mut it = spec.split_whitespace();
let name = it.next()?.to_string();
let url = it.next()?.trim_end_matches('/').to_string();
(!url.is_empty()).then_some((name, url))
}
fn apply(nets: &[(String, String)], hub: &NetHub, ok: String) -> String {
match save_config(nets) {
Ok(()) => {
hub.reload();
ok
}
Err(e) => format!("net: {e}"),
}
}
pub fn short_root(root: &str) -> String {
if root.len() <= 8 {
root.to_string()
} else {
format!("{}..", &root[..8])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_round_trips() {
let text = "\nnetwork\nname = \"pussy\"\nurl = \"https://cyb.ai/spacepussy-test\"\n\
\nnetwork\nname = \"local\"\nurl = \"http://127.0.0.1:9911/\"\n";
let nets = parse_config(text);
assert_eq!(
nets,
vec![
("pussy".into(), "https://cyb.ai/spacepussy-test".into()),
("local".into(), "http://127.0.0.1:9911".into()),
]
);
}
#[test]
fn status_fields_parse_from_soft3_form() {
let body = "particle: status\nchain: spacepussy-test\nheight: 3\n\
bbg-root: 3acf50598b5f855224a4d253e5a1395c01d2ee17\n";
let field = |key: &str| -> Option<String> {
body.lines()
.find(|l| l.trim_start().starts_with(key))
.and_then(|l| l.split_once(':'))
.map(|(_, v)| v.trim().to_string())
};
assert_eq!(field("height").unwrap(), "3");
assert!(field("bbg-root").unwrap().starts_with("3acf5059"));
}
#[test]
fn blackhole_times_out_instead_of_hanging() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
std::thread::spawn(move || {
for stream in listener.incoming() {
let s = stream;
std::thread::sleep(Duration::from_secs(120));
drop(s);
}
});
let t0 = Instant::now();
let r = step_status_with(&agent(), &format!("http://{addr}"));
assert!(r.is_err(), "a silent socket must not look like a chain");
assert!(
t0.elapsed() < Duration::from_secs(15),
"timeout too slow: {:?}",
t0.elapsed()
);
}
#[test]
#[ignore]
fn net_step_live() {
let (h, root, bytes) =
step_status_with(&agent(), "https://cyb.ai/spacepussy-test").expect("reachable");
eprintln!("pussy: h={h} root={root} ({bytes} bytes)");
assert!(h >= 1);
assert!(!root.is_empty());
}
}