Files
figcraft/apps/figcraft/src/control_server.rs
T
BFlat 05f811105c M1: start on other graphics when the window's first ones fail (#965, #1438) (#1557)
Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-call
validation shaders, the two causes of "Parent device is lost" at start.
When the graphics still fail before the first frame, the app starts again
with the next of a fixed list of attempts (Windows: Vulkan, OpenGL; Linux
and FreeBSD: OpenGL; then lower requirements on the power-saving GPU), and
the last failure's message names what was tried.
2026-10-30 03:04:11 +07:00

218 lines
10 KiB
Rust

//! Localhost JSON-lines control server: each line `{"id", "method", "params"}` is queued for the UI
//! thread; the reply line `{"id", "ok", "result" | "error"}` is written when the frame handled it.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use figcraft_engine::events::{self, EventLog};
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
use serde_json::{Value, json};
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
type CtxSlot = Arc<Mutex<Option<egui::Context>>>;
const MAX_LINE: usize = 32 * 1024 * 1024;
const MAX_CONNECTIONS: usize = 8;
const MAX_QUEUED_REQUESTS: usize = 8;
const TIMEOUT: Duration = Duration::from_secs(60);
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// Why a request's transport wait ended without a control reply.
#[derive(Debug, PartialEq, Eq)]
enum WaitFailure {
Timeout,
/// The UI side dropped its sender before replying (the app is closing).
Disconnected,
}
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
/// A commit that got the gate first has already queued its reply: consume that reply instead of
/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing.
fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate, timeout: Duration) -> Result<ControlReply, WaitFailure> {
match rx.recv_timeout(timeout) {
Ok(reply) => Ok(reply),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected),
}
}
/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically
/// against a gate whose commit already happened.
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
match gate.cancel_if_open() {
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
}
}
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.retain(|request| !request.is_cancelled());
let mut queue = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
queue.retain(|request| !request.is_cancelled());
let count = queue.len().min(MAX_QUEUED_REQUESTS.saturating_sub(target.len()));
target.extend(queue.drain(..count));
}
/// Bind `port`, waiting up to `busy` for it while another process holds it: an app started again on
/// other graphics ([`crate::graphics`]) starts beside the one ending, which still holds it.
pub fn bind(port: u16, busy: Duration) -> std::io::Result<TcpListener> {
let since = std::time::Instant::now();
loop {
match TcpListener::bind(("127.0.0.1", port)) {
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && since.elapsed() < busy => std::thread::sleep(Duration::from_millis(50)),
bound => return bound,
}
}
}
pub fn start(port: u16, busy: Duration, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>) {
let listener = match bind(port, busy) {
Ok(l) => l,
Err(e) => {
log::error!("control server: can't bind 127.0.0.1:{port}: {e} (another app may own it; pick another port)");
eprintln!("FigCraft control server failed to bind port {port}: {e}");
return;
}
};
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
let mut workers = Vec::new();
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
spawn_connection(stream, queue.clone(), ctx.clone(), events.clone(), &mut workers, TIMEOUT);
}
});
if let Err(e) = spawned {
log::error!("control server thread: {e}");
}
}
/// The connections `incoming` accepts, calling `wait` after each failed accept.
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
fn spawn_connection(
stream: TcpStream,
queue: Queue,
ctx: CtxSlot,
events: Arc<EventLog>,
workers: &mut Vec<std::thread::JoinHandle<()>>,
timeout: Duration,
) {
workers.retain(|worker| !worker.is_finished());
if workers.len() >= MAX_CONNECTIONS {
return;
}
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
let _ = serve_with_events(stream, queue, ctx, events, timeout);
}) {
Ok(worker) => workers.push(worker),
Err(e) => log::error!("control connection thread: {e}"),
}
}
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
/// Serve a connection with a private event log (the tests; the app shares its session's log).
#[cfg(test)]
fn serve(out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
serve_with_events(out, queue, ctx, Arc::new(EventLog::default()), timeout)
}
fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
let mut reader = BufReader::new(out.try_clone()?);
let mut line = Vec::new();
loop {
line.clear();
if (&mut reader).take((MAX_LINE + 1) as u64).read_until(b'\n', &mut line)? == 0 {
break;
}
if line.last() == Some(&b'\n') {
line.pop();
}
if line.len() > MAX_LINE {
writeln!(out, "{}", json!({"id": null, "ok": false, "error": "message too large"}))?;
break;
}
let Ok(line) = std::str::from_utf8(&line) else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(line) {
Ok(v) => {
let id = v.get("id").cloned().unwrap_or(Value::Null);
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
// A poll waits on the shared log, not on a frame: it needs no UI thread and no
// request slot, and it works while the window is minimised.
if method == "events.poll" {
let reply = match event_poll(&events, &params) {
Ok(result) => json!({"id": id, "ok": true, "result": result}),
Err(error) => json!({"id": id, "ok": false, "error": error}),
};
writeln!(out, "{reply}")?;
continue;
}
let (tx, rx) = std::sync::mpsc::channel();
let gate = RequestGate::new();
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pending.retain(|request| !request.is_cancelled());
if pending.len() >= MAX_QUEUED_REQUESTS {
drop(pending);
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
continue;
}
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
drop(pending);
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
match wait_for_reply(&rx, &gate, timeout) {
Ok(r) => match r.result {
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
},
Err(WaitFailure::Timeout) => {
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
}
Err(WaitFailure::Disconnected) => {
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
}
}
}
Err(e) => {
// A line that isn't JSON ends the connection: a web page can make the browser send
// an HTTP request here, and its header lines must not let the JSON lines of its body
// through as commands.
writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?;
break;
}
};
writeln!(out, "{reply}")?;
}
Ok(())
}
/// `events.poll {after?, timeoutMs?, topics?, limit?}`: read the shared log, waiting up to 25 s.
fn event_poll(log: &EventLog, params: &Value) -> Result<Value, String> {
let after = params.get("after").and_then(Value::as_u64).unwrap_or(0);
let timeout = params.get("timeoutMs").and_then(Value::as_u64).unwrap_or(20_000).min(25_000);
let limit = params.get("limit").and_then(Value::as_u64).unwrap_or(100).clamp(1, 1000) as usize;
let topics = events::topics(params.get("topics"))?;
let result = log.poll(after, &topics, limit, Duration::from_millis(timeout));
serde_json::to_value(result).map_err(|e| format!("serialize events: {e}"))
}
#[cfg(test)]
#[path = "control_server_tests.rs"]
mod tests;