Compare commits

..
Author SHA1 Message Date
bflatastic bd807693e9 M0.9: a control line that isn't JSON ends the connection
The control server answered a line that wasn't JSON with an error and read
on. A web page can make the browser send an HTTP request to the loopback
port; its request and header lines were skipped that way and the JSON lines
of its body ran as commands. The first line that isn't JSON now closes the
connection before anything after it is read. Clients that send JSON lines
(MCP --attach, scripts) are unaffected.
2026-10-16 01:27:50 +07:00
5 changed files with 35 additions and 41 deletions

No files matched your search

+7 -1
View File
@@ -111,7 +111,13 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
}
}
}
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
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}")?;
}
+18 -2
View File
@@ -53,9 +53,8 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
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{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader)["ok"], false);
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
@@ -73,6 +72,23 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
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();
+1 -1
View File
@@ -20,7 +20,7 @@ use web_time::Instant;
/// A preview has this many times fewer pixels each way than the view it stands in for.
pub const PREVIEW_FACTOR: f64 = 4.0;
/// Full renders quicker than this (about a frame) show without a preview first.
pub const PREVIEW_AFTER_MS: f64 = 12.0;
const PREVIEW_AFTER_MS: f64 = 12.0;
#[derive(Clone)]
pub struct Job {
+7 -36
View File
@@ -13,7 +13,7 @@ use figcraft_render::{View, Work};
use serde_json::json;
use super::{CanvasState, shows};
use crate::render_worker::{Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, coarse_view, render_now};
use crate::render_worker::{Done, Inline, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now};
/// A page of ellipses with a rectangle on top; the session and the rectangle.
fn document() -> (Session, NodeId) {
@@ -27,35 +27,6 @@ fn document() -> (Session, NodeId) {
(s, rect)
}
/// A page whose renders from scratch of each of `views` take at least `ms`: the rectangle with glass
/// and a background blur, and more and more blurred rectangles over the views until they do (a
/// fixed scene would turn quick on a faster machine or renderer).
fn slow_scene(views: &[View], ms: f64) -> Session {
let (mut s, rect) = document();
s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap();
s.execute("effect.add", &json!({"ids": [rect], "type": "BACKGROUND_BLUR"})).unwrap();
let area = views.iter().map(View::page_rect).reduce(|a, b| a.union(b)).unwrap();
let mut more = 8;
for _ in 0..12 {
let ds = s.ds().unwrap();
let quickest = views.iter().map(|v| render_now(&job(&ds.doc, ds.page, ds.uid, v.clone())).unwrap().ms).fold(f64::INFINITY, f64::min);
if quickest >= ms {
return s;
}
let (w, h) = (area.width() / 4.0, area.height() / 4.0);
let ids: Vec<NodeId> = (0..more)
.map(|k| {
let (fx, fy) = (f64::from(k * 37 % 101) / 101.0, f64::from(k * 59 % 103) / 103.0);
let p = json!({"x": area.x0 + fx * (area.width() - w), "y": area.y0 + fy * (area.height() - h), "width": w, "height": h});
id_of(&s.execute("shape.rectangle", &p).unwrap()["id"]).unwrap()
})
.collect();
s.execute("effect.add", &json!({"ids": ids, "type": "BACKGROUND_BLUR"})).unwrap();
more *= 2;
}
panic!("no scene renders slower than {ms} ms");
}
/// A `w` × `h` device-pixel view at `scale` centred on page point `c`.
fn view_at(c: (f64, f64), scale: f64, w: u32, h: u32) -> View {
View { origin: (c.0 - f64::from(w) / scale / 2.0, c.1 - f64::from(h) / scale / 2.0), scale, width: w, height: h }
@@ -283,18 +254,18 @@ fn a_preview_replaces_a_refined_image_of_the_same_zoom_elsewhere_on_the_page() {
#[test]
fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_frame() {
// Renders from scratch at 200% and 100% (whose times decide the previews below) well over the
// threshold, wherever this runs.
let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS);
// Big enough that a render from scratch takes well over a frame: blurs and glass on 2400².
let (mut s, rect) = document();
s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap();
s.execute("effect.add", &json!({"ids": [rect], "type": "BACKGROUND_BLUR"})).unwrap();
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
let at = |scale| job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), scale, 2400, 2400));
// The production threshold, as the browser has it.
let mut w = Inline::new(egui::Context::default());
w.submit(at(2.0));
let first = w.poll().expect("the first image");
assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by");
assert!(first.ms >= PREVIEW_AFTER_MS, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms);
assert!(first.ms >= 12.0, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms);
// A new zoom: the preview this frame, the refined image the next.
w.submit(at(1.0));
let p = w.poll().expect("a preview");
+2 -1
View File
@@ -18,7 +18,8 @@ reply: {"id": 1, "ok": true, "result": ...}
Requests are queued and run on the UI thread at the start of the next frame, so replies reflect the
real UI state. A request that the UI doesn't answer within 60 s gets a timeout error. A line that is
not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}`. The implementation is in
not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}` and ends the connection (so an
HTTP request a web page makes the browser send here never runs). The implementation is in
`crates/ui-egui/src/control.rs` (methods) and `apps/figcraft/src/control_server.rs` (socket).
A request line is limited to **32 MiB before the newline**, including whitespace. The reader stops