forked from 1b-agent/fieldcraft-upstream
- A solve or a mesher runs as a job with one progress mechanism: the overlay shows the step, stage, count and elapsed time with a bar when bounded, OK waits while it runs, and the status sums it up (elements, smallest angle, time) (#322, #357). - Over the control channel such a command is a job too: answered when it ends however long it runs, or at once with wait: false, then job.status, job.wait, job.cancel; ui.inspect gives job; MCP waits for it (#367). - A command that cannot run yet lists what it misses in the context panel, each with a button to the command that provides it (#323). - A cancelled or refused solve stays in its workspace (#324, test). - A mesher form starts from the parameters of the mesh it would replace (#343). - Seed Edges and edge/vertex/face datum points switch the selection filter while open and restore it (#341). - Datum forms draw the datum live, the Partition form shows its datum, and new datums and partitions get the next free name (#347). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
167 lines
6.4 KiB
Rust
167 lines
6.4 KiB
Rust
//! 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<ControlRequest> {
|
|
let (tx, rx) = channel::<ControlRequest>();
|
|
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<Option<Vec<u8>>> {
|
|
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<ControlRequest>, 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::<Value>(&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());
|
|
}
|
|
}
|