Files
figcraft/apps/figcraft/src/control_server_tests.rs
T
236f3185d6 Migrate FigCraft to egui 0.37 (#1312)
* E21: require release font coverage and stabilize capture deadline tests

* E21: separate capture, IME and field-save regression phases

* M0: correct the migration qualification reference

* M-egui037: document input lifecycle and UI regression contracts

* M0: correct egui 0.37 status docs and stabilize join-code regression

---------

Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 15:04:39 -04:00

345 lines
15 KiB
Rust

#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::io::Read;
use std::net::Shutdown;
use std::sync::mpsc;
use std::time::Instant;
fn pair() -> (TcpStream, TcpStream) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
client.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
client.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
(client, listener.accept().unwrap().0)
}
fn queue() -> Queue {
Arc::new(Mutex::new(Vec::new()))
}
fn context() -> CtxSlot {
Arc::new(Mutex::new(None))
}
fn reply(reader: &mut BufReader<TcpStream>) -> Value {
let mut line = String::new();
assert!(reader.read_line(&mut line).unwrap() > 0);
serde_json::from_str(&line).unwrap()
}
/// Build a `ui.inspect` control request with a throwaway reply channel.
fn request() -> ControlRequest {
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() }
}
#[test]
fn rejects_an_oversized_unterminated_frame_without_dispatch() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
// Generate bytes as they are written; no giant input string and no terminating newline.
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE + 1) as u64), &mut client).unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "message too large"}));
assert_eq!(reader.read(&mut [0]).unwrap(), 0);
worker.join().unwrap().unwrap();
assert!(pending.lock().unwrap().is_empty());
}
#[test]
fn valid_frames_keep_their_order_ids_and_parameters() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
let mut reader = BufReader::new(client);
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
break r;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
assert_eq!(r.id, "test");
assert_eq!(r.method, "engine.execute");
assert_eq!(r.params, json!({"command": "shape.rectangle"}));
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"id": 42})) }).unwrap();
assert_eq!(reply(&mut reader), json!({"id": "test", "ok": true, "result": {"id": 42}}));
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() {
// What a browser sends when a web page posts to the port: the JSON line in its body never runs.
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n";
let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len());
client.write_all(http.as_bytes()).unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader)["ok"], false);
assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed");
worker.join().unwrap().unwrap();
assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched");
}
#[test]
fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
let (mut client, server) = pair();
let pending = queue();
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| request()));
let mut ui_queue = (0..MAX_QUEUED_REQUESTS - 1).map(|_| request()).collect();
drain_queue(&pending, &mut ui_queue);
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS - 1);
pending.lock().unwrap().push(request());
drain_queue(&pending, &mut ui_queue);
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
client.write_all(b"{\"id\":1,\"method\":\"engine.execute\"}\n").unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader), json!({"id": 1, "ok": false, "error": "control request queue is full"}));
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
ui_queue.clear();
drain_queue(&pending, &mut ui_queue);
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
assert!(pending.lock().unwrap().is_empty());
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's
/// timeout branch consumes that reply and leaves the state `Replied`.
#[test]
fn a_committed_reply_wins_over_the_transport_timeout() {
let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id()));
let path = dir.join("shot.png");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel();
// The UI's final commit gets the gate first: the file and reply are both committed before the
// transport's timeout branch can look at the gate.
let committed = gate.commit(|| {
std::fs::write(&path, b"png").unwrap();
tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap();
});
assert!(committed.is_some());
assert!(path.is_file());
let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed");
assert_eq!(reply.id, json!(7));
assert!(reply.result.is_ok());
assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit");
assert!(path.is_file(), "a winning commit keeps its file");
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate.
#[test]
fn a_timeout_cancels_an_open_request() {
let gate = RequestGate::new();
let (_tx, rx) = mpsc::channel::<ControlReply>();
assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout)));
assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout");
}
/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not
/// cancelled.
#[test]
fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel::<ControlReply>();
drop(tx);
assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected)));
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
let (mut client, server) = pair();
let (done, finished) = mpsc::channel();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(50))));
client.write_all(bytes).unwrap();
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
assert!(pending.lock().unwrap().is_empty());
worker.join().unwrap().unwrap();
}
}
/// A failed accept is followed by a wait before the next one, so a listener that keeps failing
/// does not spin, and the connections around the failures still come through.
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
#[test]
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let pending = queue();
let ctx = context();
let mut workers = Vec::new();
let mut clients = Vec::new();
for _ in 0..MAX_CONNECTIONS {
let (client, server) = pair();
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
clients.push(client);
}
assert_eq!(workers.len(), MAX_CONNECTIONS);
let (mut extra, server) = pair();
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
assert_eq!(workers.len(), MAX_CONNECTIONS);
for client in clients {
client.shutdown(Shutdown::Both).unwrap();
}
let start = Instant::now();
while workers.iter().any(|worker| !worker.is_finished()) {
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
}
let (mut client, server) = pair();
spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
assert_eq!(workers.len(), 1);
client.write_all(b"invalid\n").unwrap();
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
// The server closes this connection after answering an invalid request, and shutting down
// a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux
// returns Ok. Closing the client is what the worker is waiting for, so the result is moot:
// the other shutdowns in this file run while the peer is still open and stay checked.
let _ = client.shutdown(Shutdown::Both);
workers.pop().unwrap().join().unwrap();
}
#[test]
fn a_client_that_stops_reading_cannot_hold_a_worker() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let (done, finished) = mpsc::channel();
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(100))));
client.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
break r;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!("x".repeat(8 * 1024 * 1024))) }).unwrap();
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
worker.join().unwrap().unwrap();
}
#[test]
fn a_limit_sized_frame_is_accepted_at_eof() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
let message = b"{\"id\":1,\"method\":\"ui.inspect\"}";
client.write_all(message).unwrap();
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE - message.len()) as u64), &mut client).unwrap();
client.shutdown(Shutdown::Write).unwrap();
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
break r;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
assert_eq!(r.id, 1);
assert_eq!(r.method, "ui.inspect");
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({})) }).unwrap();
assert_eq!(reply(&mut BufReader::new(client)), json!({"id": 1, "ok": true, "result": {}}));
worker.join().unwrap().unwrap();
}
/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted
/// and answered, and a window that finally draws discards its expired command before
/// dispatch.
#[test]
fn timed_out_requests_release_a_full_deferred_ui_queue() {
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
let mut app = FigApp::new(figcraft_engine::Session::new(), Box::new(NoServices));
let window = windows::open(&mut app, &ctx, Some(0), None, None).unwrap();
let pending = queue();
let mut clients = Vec::new();
let mut workers = Vec::new();
for id in 0..MAX_QUEUED_REQUESTS {
let (mut client, server) = pair();
let q = pending.clone();
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(1))));
writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}})).unwrap();
clients.push(BufReader::new(client));
}
let start = Instant::now();
while pending.lock().unwrap().len() < MAX_QUEUED_REQUESTS {
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
}
drain_queue(&pending, &mut app.control_queue);
control::drain(&mut app, &ctx);
assert_eq!(app.control_queue.len(), MAX_QUEUED_REQUESTS, "the other window has not drawn");
for client in &mut clients {
assert!(reply(client)["error"].as_str().unwrap().contains("timed out"));
client.get_ref().shutdown(Shutdown::Write).unwrap();
}
assert!(app.control_queue.iter().all(ControlRequest::is_cancelled));
let expired = app.control_queue.pop().unwrap();
// Include expired requests in the socket queue, too: admission must reclaim their slots.
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
let r = request();
r.state.cancel_if_open();
r
}));
let (mut client, server) = pair();
let q = pending.clone();
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))));
client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0}}\n").unwrap();
let start = Instant::now();
while !pending.lock().unwrap().iter().any(|r| r.id == 100) {
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
}
drain_queue(&pending, &mut app.control_queue);
assert_eq!(app.control_queue.len(), 1, "expired UI requests no longer occupy the cap");
control::drain(&mut app, &ctx);
let mut client = BufReader::new(client);
assert_eq!(reply(&mut client)["ok"], true);
client.get_ref().shutdown(Shutdown::Write).unwrap();
assert!(app.control_queue.is_empty());
// A window that finally draws must discard its expired command before dispatch, too.
let documents = app.session.docs.len();
app.windows.current = window;
app.control_queue.push(expired);
control::drain(&mut app, &ctx);
assert!(app.control_queue.is_empty());
assert_eq!(app.session.docs.len(), documents);
for worker in workers {
worker.join().unwrap().unwrap();
}
}