mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 11:46:51 -06:00
270 lines
12 KiB
Rust
270 lines
12 KiB
Rust
//! Where MCP tool calls end up: a control-channel method call.
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::{TcpStream, ToSocketAddrs};
|
|
use std::time::Duration;
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
/// Something that answers control-channel methods (`engine.execute`, `document.inspect`,
|
|
/// `ui.pointer`, `ui.render`, …). See `designcraft_ui_egui::control` for the full list.
|
|
pub trait Backend {
|
|
/// Call one method. `Ok` carries the `result`, `Err` the error message.
|
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
|
|
/// True when a real UI is attached (`ui.screenshot`, `ui.click`, dialogs… work).
|
|
fn has_ui(&self) -> bool;
|
|
/// Short human description ("headless", "connected to 127.0.0.1:7979").
|
|
fn describe(&self) -> String;
|
|
}
|
|
|
|
/// A running DesignCraft app, reached through its loopback control port.
|
|
pub struct Remote {
|
|
addr: String,
|
|
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
|
next_id: u64,
|
|
}
|
|
|
|
impl Remote {
|
|
/// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening.
|
|
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
|
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
|
|
r.reconnect()?;
|
|
Ok(r)
|
|
}
|
|
|
|
pub fn addr(&self) -> &str {
|
|
&self.addr
|
|
}
|
|
|
|
fn reconnect(&mut self) -> std::io::Result<()> {
|
|
self.conn = None;
|
|
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
|
|
for sa in self.addr.to_socket_addrs()? {
|
|
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
|
|
Ok(s) => {
|
|
s.set_nodelay(true).ok();
|
|
// The app answers within 60 s (its own timeout); leave headroom.
|
|
s.set_read_timeout(Some(Duration::from_secs(90))).ok();
|
|
let read = s.try_clone()?;
|
|
self.conn = Some((BufReader::new(read), s));
|
|
return Ok(());
|
|
}
|
|
Err(e) => last = e,
|
|
}
|
|
}
|
|
Err(last)
|
|
}
|
|
|
|
fn prepare_connection(&mut self) -> std::io::Result<()> {
|
|
if let Some((reader, writer)) = self.conn.take() {
|
|
writer.set_nonblocking(true)?;
|
|
let probe = writer.peek(&mut [0]);
|
|
writer.set_nonblocking(false)?;
|
|
let live = match probe {
|
|
Ok(0) => false,
|
|
Ok(_) => true,
|
|
Err(e) => e.kind() == std::io::ErrorKind::WouldBlock,
|
|
};
|
|
if live {
|
|
self.conn = Some((reader, writer));
|
|
}
|
|
}
|
|
if self.conn.is_none() {
|
|
self.reconnect()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
|
let Some((reader, writer)) = self.conn.as_mut() else {
|
|
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
|
|
};
|
|
writer.write_all(line.as_bytes())?;
|
|
writer.write_all(b"\n")?;
|
|
writer.flush()?;
|
|
let mut reply = String::new();
|
|
if reader.read_line(&mut reply)? == 0 {
|
|
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
|
|
}
|
|
Ok(reply)
|
|
}
|
|
}
|
|
|
|
impl Backend for Remote {
|
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
|
if let Err(e) = self.prepare_connection() {
|
|
self.conn = None;
|
|
return Err(format!("DesignCraft app at {} is not reachable: {e}; nothing was sent", self.addr));
|
|
}
|
|
let reply = match self.roundtrip(&line) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
self.conn = None;
|
|
return Err(format!("DesignCraft app at {}: {e}; operation may have completed; inspect state before retrying", self.addr));
|
|
}
|
|
};
|
|
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
|
|
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
|
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
|
} else {
|
|
Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string())
|
|
}
|
|
}
|
|
|
|
fn has_ui(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn describe(&self) -> String {
|
|
format!("connected to the DesignCraft app at {}", self.addr)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::net::TcpListener;
|
|
|
|
fn closed_remote() -> (Remote, String) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let mut remote = Remote::connect(&addr).unwrap();
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
|
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
|
|
let server = std::thread::spawn(move || {
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut line = String::new();
|
|
assert!(reader.read_line(&mut line).unwrap() > 0);
|
|
let request: Value = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(request["method"], "document.inspect");
|
|
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": null})).unwrap();
|
|
stream.shutdown(std::net::Shutdown::Both).unwrap();
|
|
drop(listener);
|
|
});
|
|
assert_eq!(remote.call("document.inspect", json!({})), Ok(Value::Null));
|
|
server.join().unwrap();
|
|
let stream = &remote.conn.as_ref().unwrap().1;
|
|
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
|
assert_eq!(stream.peek(&mut [0]).unwrap(), 0, "FIN must be observable before the next call");
|
|
(remote, addr)
|
|
}
|
|
|
|
#[test]
|
|
fn remote_executes_once_when_app_restarts_between_calls() {
|
|
let (mut remote, addr) = closed_remote();
|
|
let listener = TcpListener::bind(&addr).unwrap();
|
|
listener.set_nonblocking(true).unwrap();
|
|
let server = std::thread::spawn(move || {
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
|
let mut stream = loop {
|
|
match listener.accept() {
|
|
Ok((stream, _)) => break stream,
|
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
if std::time::Instant::now() >= deadline {
|
|
return 0;
|
|
}
|
|
std::thread::yield_now();
|
|
}
|
|
Err(e) => panic!("accept failed: {e}"),
|
|
}
|
|
};
|
|
// Accepted sockets can inherit the listener's nonblocking mode. The server
|
|
// waits for both requests with timeouts, rather than reading before they arrive.
|
|
stream.set_nonblocking(false).unwrap();
|
|
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
|
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut applied = 0;
|
|
loop {
|
|
let mut line = String::new();
|
|
assert!(reader.read_line(&mut line).unwrap() > 0);
|
|
let request: Value = serde_json::from_str(&line).unwrap();
|
|
match request["method"].as_str().unwrap() {
|
|
"engine.execute" => {
|
|
assert_eq!(request["params"]["command"], "frame.create");
|
|
applied += 1;
|
|
}
|
|
"document.inspect" => {}
|
|
method => panic!("unexpected method: {method}"),
|
|
}
|
|
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap();
|
|
if request["method"] == "document.inspect" {
|
|
return applied;
|
|
}
|
|
}
|
|
});
|
|
|
|
let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}}));
|
|
let inspected = match &result {
|
|
Ok(_) => remote.call("document.inspect", json!({})),
|
|
Err(_) => Err("edit failed".to_string()),
|
|
};
|
|
let applied = server.join().unwrap();
|
|
|
|
assert_eq!(result, Ok(json!({"applied": 1})), "the first edit after restart must succeed");
|
|
assert_eq!(inspected, Ok(json!({"applied": 1})));
|
|
assert_eq!(applied, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_reports_nothing_sent_when_reconnect_has_no_listener() {
|
|
let (mut remote, _addr) = closed_remote();
|
|
|
|
let error = remote.call("engine.execute", json!({"command": "frame.create", "params": {}})).unwrap_err();
|
|
|
|
assert!(error.contains("not reachable"), "{error}");
|
|
assert!(error.contains("nothing was sent"), "{error}");
|
|
assert!(!error.contains("operation may have completed"), "{error}");
|
|
}
|
|
|
|
#[test]
|
|
fn remote_does_not_replay_when_reply_is_lost() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap().to_string();
|
|
let server = std::thread::spawn(move || {
|
|
let first = {
|
|
let (stream, _) = listener.accept().unwrap();
|
|
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
|
let mut reader = BufReader::new(stream);
|
|
let mut line = String::new();
|
|
assert!(reader.read_line(&mut line).unwrap() > 0);
|
|
let request: Value = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(request["method"], "engine.execute");
|
|
assert_eq!(request["params"]["command"], "frame.create");
|
|
request
|
|
};
|
|
let mut applied = 1;
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
|
stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
loop {
|
|
let mut line = String::new();
|
|
assert!(reader.read_line(&mut line).unwrap() > 0);
|
|
let request: Value = serde_json::from_str(&line).unwrap();
|
|
if request == first {
|
|
applied += 1;
|
|
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap();
|
|
} else {
|
|
assert_eq!(request, json!({"id": 2, "method": "document.inspect", "params": {}}));
|
|
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap();
|
|
return applied;
|
|
}
|
|
}
|
|
});
|
|
let mut remote = Remote::connect(&addr).unwrap();
|
|
|
|
let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}}));
|
|
let next = remote.call("document.inspect", json!({}));
|
|
let applied = server.join().unwrap();
|
|
|
|
assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}");
|
|
assert!(result.unwrap_err().contains("operation may have completed"), "lost reply must report an uncertain outcome");
|
|
assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully");
|
|
}
|
|
}
|