mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 14:26:49 -06:00
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.
218 lines
10 KiB
Rust
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, ¶ms) {
|
|
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;
|