//! Loopback JSON-lines control server: one request per line, one reply per line. Agents and //! tests drive the app through it; the UI thread answers each request between frames //! (`fieldcraft_ui_egui::control`). //! //! Adapted from SolidCraft's `apps/solidcraft/src/control_server.rs` (MIT OR Apache-2.0), see //! ATTRIBUTION.md. use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; use fieldcraft_ui_egui::ControlRequest; use serde_json::{Value, json}; /// Longest request line (bytes). const MAX_LINE: usize = 16 * 1024 * 1024; /// Connections served at once. The channel has no authentication beyond being bound to /// loopback, so a flood must not be able to spawn threads without end. const MAX_CONNECTIONS: usize = 16; /// Lines that are not JSON before the connection is closed, so garbage cannot loop forever. const MAX_BAD_LINES: usize = 8; /// How long a request waits for the UI thread. const REPLY_TIMEOUT: Duration = Duration::from_secs(60); static OPEN: AtomicUsize = AtomicUsize::new(0); pub fn start(port: u16, ctx: egui::Context) -> Receiver { let (tx, rx) = channel::(); let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { eprintln!("fieldcraft: control server failed to bind 127.0.0.1:{port}: {e}"); return rx; } }; eprintln!("fieldcraft: control server listening on 127.0.0.1:{port}"); std::thread::spawn(move || { for stream in listener.incoming().flatten() { if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS { OPEN.fetch_sub(1, Ordering::Relaxed); let mut refused = stream; let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"})); continue; } let tx = tx.clone(); let ctx = ctx.clone(); std::thread::spawn(move || { serve(stream, tx, ctx); OPEN.fetch_sub(1, Ordering::Relaxed); }); } }); rx } /// One line without its newline, or `None` at the end of the stream. A client that sends /// gigabytes with no newline would otherwise make this thread allocate until the process is /// killed, so the line is refused as soon as it passes `max`. fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result>> { let mut line = Vec::new(); loop { let (done, used) = { let available = r.fill_buf()?; if available.is_empty() { return Ok((!line.is_empty()).then_some(line)); } match available.iter().position(|b| *b == b'\n') { Some(i) => { line.extend_from_slice(available.get(..i).unwrap_or(&[])); (true, i + 1) } None => { line.extend_from_slice(available); (false, available.len()) } } }; r.consume(used); if line.len() > max { return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large")); } if done { return Ok(Some(line)); } } } fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; let mut reader = BufReader::new(read); let mut bad = 0usize; loop { let line = match read_line(&mut reader, MAX_LINE) { Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(), Ok(None) => break, Err(e) => { let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()})); break; } }; if line.trim().is_empty() { continue; } let reply = match serde_json::from_str::(&line) { Ok(msg) => { let id = msg.get("id").cloned().unwrap_or(Value::Null); let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = msg.get("params").cloned().unwrap_or(json!({})); let (req, rrx) = ControlRequest::new(method, params); if tx.send(req).is_err() { break; } ctx.request_repaint(); let mut r = rrx.recv_timeout(REPLY_TIMEOUT).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); // A request waiting for a job (a solve, a mesher): its reply comes when the job // ends, however long it runs; the job always answers, even when cancelled. if r.get(fieldcraft_ui_egui::control::PENDING).is_some() { r = rrx.recv().unwrap_or_else(|_| json!({"ok": false, "error": "the job ended without a reply"})); } if let Some(o) = r.as_object_mut() { o.insert("id".into(), id); } r } Err(e) => { bad += 1; json!({"ok": false, "error": format!("bad JSON: {e}")}) } }; if writeln!(out, "{reply}").is_err() || bad >= MAX_BAD_LINES { break; } } } #[cfg(test)] mod tests { use super::*; /// A request longer than the cap is refused instead of allocated. #[test] fn lines_past_the_cap_are_refused() { let flood = vec![b'A'; MAX_LINE + 2]; let mut r = BufReader::new(flood.as_slice()); let e = read_line(&mut r, MAX_LINE).unwrap_err(); assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); } /// Lines split on newlines, blank lines kept, no trailing newline needed; a line spanning /// several reads arrives whole. #[test] fn lines_split_on_newlines() { let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec(); let mut r = BufReader::with_capacity(3, text.as_slice()); let mut got = Vec::new(); while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() { got.push(String::from_utf8(line).unwrap()); } assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]); let mut r = BufReader::with_capacity(4, b"0123456789\n".as_slice()); assert!(read_line(&mut r, 5).is_err()); } }