use std::io::{self, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use crate::telemetry::{TickTrace, VISUALIZER_HTML};
const READ_TIMEOUT: Duration = Duration::from_secs(5);
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
const PEEK_BUF_BYTES: usize = 1024;
struct Client {
ws: tungstenite::WebSocket<TcpStream>,
}
pub fn spawn_server(
listener: TcpListener,
tree_definition_json: String,
rx: Receiver<TickTrace>,
) -> io::Result<(JoinHandle<()>, Arc<AtomicBool>, SocketAddr)> {
let bound_addr = listener.local_addr()?;
let clients: Arc<Mutex<Vec<Client>>> = Arc::new(Mutex::new(Vec::new()));
let shutdown = Arc::new(AtomicBool::new(false));
let clients_acceptor = Arc::clone(&clients);
let shutdown_acceptor = Arc::clone(&shutdown);
let acceptor_handle = std::thread::Builder::new()
.name("bonsai-viz-acceptor".into())
.spawn(move || {
for stream in listener.incoming() {
let stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
if shutdown_acceptor.load(Ordering::Relaxed) {
break; }
stream.set_nodelay(true).ok();
stream.set_read_timeout(Some(READ_TIMEOUT)).ok();
stream.set_write_timeout(Some(WRITE_TIMEOUT)).ok();
handle_connection(stream, &clients_acceptor, &tree_definition_json);
}
})?;
let clients_broadcaster = Arc::clone(&clients);
let shutdown_broadcaster = Arc::clone(&shutdown);
std::thread::Builder::new()
.name("bonsai-viz-broadcaster".into())
.spawn(move || {
while let Ok(trace) = rx.recv() {
let clients = &clients_broadcaster;
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Ok(json) = serde_json::to_string(&trace) else {
return;
};
let mut guard = clients.lock().expect("clients mutex poisoned");
let mut i = 0;
while i < guard.len() {
match guard[i].ws.send(tungstenite::Message::Text(json.clone())) {
Ok(()) => i += 1,
Err(_) => {
let _ = guard.swap_remove(i);
}
}
}
}))
.is_err()
{
eprintln!("bonsai-viz broadcaster: panic while broadcasting tick trace; continuing");
}
}
shutdown_broadcaster.store(true, Ordering::Relaxed);
})?;
Ok((acceptor_handle, shutdown, bound_addr))
}
fn handle_connection(stream: TcpStream, clients: &Mutex<Vec<Client>>, tree_json: &str) {
let mut peek = [0u8; PEEK_BUF_BYTES];
let n = match stream.peek(&mut peek) {
Ok(n) => n,
Err(_) => return,
};
let head = std::str::from_utf8(&peek[..n]).unwrap_or("");
let is_ws = head.lines().any(|l| {
let lower = l.to_ascii_lowercase();
lower.starts_with("upgrade:") && lower.contains("websocket")
});
if is_ws {
if let Ok(mut ws) = tungstenite::accept(stream) {
if ws.send(tungstenite::Message::Text(tree_json.to_owned())).is_err() {
return;
}
let mut guard = clients.lock().expect("clients mutex poisoned");
guard.push(Client { ws });
}
} else {
serve_http(stream, head);
}
}
fn serve_http(mut stream: TcpStream, head: &str) {
let path = head.split_whitespace().nth(1).unwrap_or("/");
let is_root = path == "/" || path.starts_with("/?");
let (status, body): (&str, &[u8]) = if is_root {
("200 OK", VISUALIZER_HTML.as_bytes())
} else {
("404 Not Found", b"not found")
};
let header = format!(
"HTTP/1.1 {status}\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\n\
Cache-Control: no-store\r\n\
Connection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(body);
}