mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-09 20:09:38 -06:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5edf3f693e | ||
|
|
16ddef0744 | ||
|
|
00b8bf38d6 | ||
|
|
3fd1dfed36 |
@@ -16,12 +16,6 @@ const INVALID_REQUEST: i64 = -32600;
|
||||
const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
|
||||
/// Entries one batch (a JSON-RPC batch, or the `batch` tool's commands) may hold.
|
||||
pub(crate) const MAX_BATCH_ITEMS: usize = 1000;
|
||||
/// Bytes of results one batch may gather before the rest of it is refused: each result can copy
|
||||
/// much of the document (an inspection holds every note), so a batch holds at most this much.
|
||||
pub(crate) const MAX_BATCH_BYTES: usize = 64 << 20;
|
||||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
/// Keep request frames in step with the connected backend's reply limit.
|
||||
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
||||
@@ -99,28 +93,8 @@ impl Server {
|
||||
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
||||
if batch.is_empty() {
|
||||
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
||||
} else if batch.len() > MAX_BATCH_ITEMS {
|
||||
Some(error(Value::Null, INVALID_REQUEST, format!("a batch holds at most {MAX_BATCH_ITEMS} messages")))
|
||||
} else {
|
||||
let mut used = 0usize;
|
||||
let mut replies: Vec<Value> = Vec::new();
|
||||
for m in batch {
|
||||
if used > MAX_BATCH_BYTES {
|
||||
// Not run: answer each remaining request with a short error.
|
||||
if let Some(id) = m.get("id").filter(|id| id.is_string() || id.is_number()) {
|
||||
replies.push(error(id.clone(), INTERNAL_ERROR, "the batch's replies passed 64 MiB; not run"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = self.handle(m) {
|
||||
used = used.saturating_add(r.to_string().len());
|
||||
replies.push(if used > MAX_BATCH_BYTES {
|
||||
error(r.get("id").cloned().unwrap_or(Value::Null), INTERNAL_ERROR, "the batch's replies passed 64 MiB; reply dropped")
|
||||
} else {
|
||||
r
|
||||
});
|
||||
}
|
||||
}
|
||||
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||
}
|
||||
}
|
||||
|
||||
+9
-21
@@ -357,30 +357,18 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({}))))
|
||||
}
|
||||
"batch" => {
|
||||
use crate::server::{MAX_BATCH_BYTES, MAX_BATCH_ITEMS};
|
||||
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
|
||||
let commands = a.get("commands").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
if commands.len() > MAX_BATCH_ITEMS {
|
||||
return ToolResult::error(format!("a batch holds at most {MAX_BATCH_ITEMS} commands"));
|
||||
}
|
||||
let mut out = vec![];
|
||||
let mut used = 0usize;
|
||||
for c in commands {
|
||||
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
let (entry, failed) = match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
|
||||
Ok(v) => (json!({"command": id, "ok": true, "result": v}), false),
|
||||
Err(e) => (json!({"command": id, "ok": false, "error": e}), true),
|
||||
};
|
||||
// What the reply carries: the entry as text, escaped again inside the JSON-RPC reply.
|
||||
// Failures count too: with keepGoing, a remote backend's large errors add up.
|
||||
used = used.saturating_add(json!(entry.to_string()).to_string().len());
|
||||
if used > MAX_BATCH_BYTES {
|
||||
out.push(json!({"command": id, "ok": false, "error": "the batch's results passed 64 MiB; stopped here"}));
|
||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||
}
|
||||
out.push(entry);
|
||||
if failed && !keep {
|
||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||
match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
|
||||
Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
|
||||
Err(e) => {
|
||||
out.push(json!({"command": id, "ok": false, "error": e}));
|
||||
if !keep {
|
||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolResult::json(&Value::Array(out))
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//! A batch (JSON-RPC or the `batch` tool) is bounded in entries and in the bytes of results it
|
||||
//! gathers, so a short request cannot copy a large document into memory hundreds of times.
|
||||
|
||||
use deckcraft_mcp::{Headless, Server};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn server_with_notes(bytes: usize) -> Server {
|
||||
let mut server = Server::new(Box::new(Headless::with_document()));
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "n".repeat(bytes)}}}});
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
|
||||
assert_ne!(reply["result"]["isError"], true, "{reply}");
|
||||
server
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_rpc_batches_are_bounded_in_entries_and_reply_bytes() {
|
||||
let mut server = server_with_notes(1 << 20);
|
||||
let ping = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "ping"});
|
||||
let too_many: Vec<Value> = (0..1001).map(ping).collect();
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&Value::Array(too_many).to_string()).unwrap()).unwrap();
|
||||
assert_eq!(reply["error"]["code"], -32600, "{reply}");
|
||||
// 100 inspections of a slide with 1 MB of notes: about 64 are answered, the rest refused.
|
||||
let inspect = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "tools/call", "params": {"name": "inspect_slide", "arguments": {}}});
|
||||
let line = Value::Array((0..100).map(inspect).collect()).to_string();
|
||||
let reply = server.handle_line(&line).unwrap();
|
||||
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
|
||||
let replies: Vec<Value> = serde_json::from_str(&reply).unwrap();
|
||||
assert_eq!(replies.len(), 100, "every request is answered");
|
||||
assert!(replies.last().unwrap()["error"]["message"].as_str().unwrap().contains("64 MiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batch_tool_is_bounded_in_commands_and_result_bytes() {
|
||||
let mut server = server_with_notes(1 << 20);
|
||||
let call = |server: &mut Server, commands: Vec<Value>| {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
|
||||
(reply["result"]["isError"] == true, reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string())
|
||||
};
|
||||
let (err, text) = call(&mut server, vec![json!({"command": "document.inspect"}); 1001]);
|
||||
assert!(err && text.contains("at most 1000"), "{text}");
|
||||
let (err, text) = call(&mut server, vec![json!({"command": "slide.inspect"}); 100]);
|
||||
assert!(err && text.contains("64 MiB"), "{}", &text[text.len().saturating_sub(200)..]);
|
||||
assert!(text.len() < (64 << 20) + (4 << 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batch_tool_charges_the_escaped_reply() {
|
||||
// Quotes escape twice on the way out (`\\\"`), so 30 results of ~2 MiB serialize to ~120 MiB.
|
||||
let mut server = Server::new(Box::new(Headless::with_document()));
|
||||
let notes = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "\"".repeat(1 << 20)}}}});
|
||||
server.handle_line(¬es.to_string()).unwrap();
|
||||
let commands = vec![json!({"command": "slide.inspect"}); 30];
|
||||
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
|
||||
let reply = server.handle_line(&line.to_string()).unwrap();
|
||||
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
|
||||
assert!(reply.contains("64 MiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batch_tool_charges_failures_too() {
|
||||
// Each unknown command's error echoes its ~1 MiB id; with keepGoing none of them is a result.
|
||||
let mut server = Server::new(Box::new(Headless::with_document()));
|
||||
let id = "x".repeat(1 << 20);
|
||||
let commands = vec![json!({"command": id}); 70];
|
||||
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
|
||||
let reply = server.handle_line(&line.to_string()).unwrap();
|
||||
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
|
||||
assert!(reply.contains("64 MiB"));
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod automation_roots;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod batch_budget;
|
||||
|
||||
@@ -11,6 +11,10 @@ use crate::geom::{Op, P};
|
||||
/// Line segments per Bézier when a crossing shape is flattened.
|
||||
const CURVE_STEPS: usize = 16;
|
||||
|
||||
/// Point-against-edge tests one clipped item may cost (all its subpaths together); an item over
|
||||
/// it is dropped, as a single polygon over it already was.
|
||||
const MAX_CLIP_WORK: usize = 1_000_000;
|
||||
|
||||
/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included).
|
||||
pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> {
|
||||
let mut b: Option<[f64; 4]> = None;
|
||||
@@ -100,14 +104,20 @@ fn is_rect(clip: &[P]) -> bool {
|
||||
|
||||
/// Sutherland–Hodgman: `poly` cut by convex `clip`.
|
||||
pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
||||
if poly.len().saturating_mul(clip.len()) > 1_000_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
clip_polygon_within(poly, clip, &mut { MAX_CLIP_WORK }).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`clip_polygon`] charging each point visited against each clip edge to `budget`: the polygon
|
||||
/// can gain a vertex at every edge, so its cost isn't known from its input size alone. `None`
|
||||
/// once the budget is spent.
|
||||
fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) -> Option<Vec<P>> {
|
||||
let mut poly = poly.to_vec();
|
||||
for i in 0..clip.len() {
|
||||
if poly.len().saturating_mul(clip.len() - i) > 1_000_000 {
|
||||
return Vec::new();
|
||||
// The work actually done: this edge visits the polygon as it now stands.
|
||||
if poly.len() > *budget {
|
||||
return None;
|
||||
}
|
||||
*budget -= poly.len();
|
||||
let (a, b) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||
let keep = |p: P| cross(a, b, p) >= 0.0;
|
||||
let cut = |p: P, q: P| {
|
||||
@@ -133,7 +143,7 @@ pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
poly
|
||||
Some(poly)
|
||||
}
|
||||
|
||||
fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool {
|
||||
@@ -158,9 +168,12 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
|
||||
return Vec::new();
|
||||
}
|
||||
let subs = flatten(&ops);
|
||||
if too_much_work(&subs, clip, usize::from(fill.is_some()) + usize::from(stroke.is_some())) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if fill.is_some() {
|
||||
let f = clip_fill(&subs, clip);
|
||||
let Some(f) = clip_fill(&subs, clip) else { return Vec::new() };
|
||||
if !f.is_empty() {
|
||||
out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() });
|
||||
}
|
||||
@@ -181,7 +194,11 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
|
||||
if disjoint(b, cb) {
|
||||
return Vec::new();
|
||||
}
|
||||
let f = clip_fill(&flatten(&ops), clip);
|
||||
let subs = flatten(&ops);
|
||||
if too_much_work(&subs, clip, 1) {
|
||||
return Vec::new();
|
||||
}
|
||||
let f = clip_fill(&subs, clip).unwrap_or_default();
|
||||
if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] }
|
||||
}
|
||||
Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(),
|
||||
@@ -230,17 +247,28 @@ pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec<P>, bool)> {
|
||||
subs
|
||||
}
|
||||
|
||||
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
/// Whether cutting every subpath of one item by `clip` would exceed [`MAX_CLIP_WORK`]; the
|
||||
/// per-polygon check alone lets many small subpaths each spend the whole allowance. `passes` is
|
||||
/// how many clipping passes the item gets (fill and stroke each cut every subpath), so one item
|
||||
/// shares a single allowance between them.
|
||||
fn too_much_work(subs: &[(Vec<P>, bool)], clip: &[P], passes: usize) -> bool {
|
||||
subs.iter().map(|s| s.0.len() + 1).sum::<usize>().saturating_mul(clip.len()).saturating_mul(passes) > MAX_CLIP_WORK
|
||||
}
|
||||
|
||||
/// The clipped fill of every subpath, its clipping work shared by all of them: `None` (the whole
|
||||
/// item goes) once [`MAX_CLIP_WORK`] is spent.
|
||||
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Option<Vec<Op>> {
|
||||
let mut ops = Vec::new();
|
||||
let mut budget = MAX_CLIP_WORK;
|
||||
for (pts, _) in subs {
|
||||
let poly = clip_polygon(pts, clip);
|
||||
let poly = clip_polygon_within(pts, clip, &mut budget)?;
|
||||
if poly.len() >= 3 {
|
||||
ops.push(Op::M(poly[0]));
|
||||
ops.extend(poly[1..].iter().map(|p| Op::L(*p)));
|
||||
ops.push(Op::Z);
|
||||
}
|
||||
}
|
||||
ops
|
||||
Some(ops)
|
||||
}
|
||||
|
||||
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
|
||||
@@ -2074,3 +2074,63 @@ fn clip_polygon_work_bound() {
|
||||
let clipped = crate::cliprect::clip_polygon(&poly, &clip);
|
||||
assert!(clipped.is_empty(), "excessive polygon clip product rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_polygon_charges_actual_work_not_projected_growth() {
|
||||
// Two convex 1,000-gons: the product is exactly the budget, and a cut that adds a vertex must
|
||||
// not make a later step look over budget.
|
||||
let gon = |cx: f64| -> Vec<(f64, f64)> {
|
||||
(0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect()
|
||||
};
|
||||
let clipped = crate::cliprect::clip_polygon(&gon(0.0), &gon(30.0));
|
||||
assert!(clipped.len() > 100, "the intersection is kept: {}", clipped.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_work_is_shared_by_fill_and_stroke() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
// One zigzag subpath crossing a 512-gon's edge: about 510k point-edge tests per pass, so a
|
||||
// fill alone fits the allowance but a fill plus a stroke would spend it twice.
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
let mut ops = vec![Op::M((0.0, 95.0))];
|
||||
ops.extend((1..1000).map(|i| Op::L((i as f64 * 0.19, if i % 2 == 0 { 95.0 } else { 300.0 }))));
|
||||
ops.push(Op::Z);
|
||||
let raw = |fill, stroke| Raw::Path { ops: ops.clone(), fill, stroke, even_odd: false, region: None };
|
||||
let black = [0, 0, 0, 255];
|
||||
assert!(!crate::cliprect::clip_raw(raw(Some(black), None), &clip).is_empty(), "a fill alone is within the allowance");
|
||||
assert!(crate::cliprect::clip_raw(raw(Some(black), Some((black, 1.0))), &clip).is_empty(), "fill and stroke share one allowance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_work_is_bounded_across_subpaths() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
// 512-gon clip; 4,000 small triangles crossing its edge: each under the per-polygon cap, together
|
||||
// far over it, for both the fill and the stroke.
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
let tri = |x: f64| [Op::M((x, 95.0)), Op::L((x + 1.0, 95.0)), Op::L((x, 300.0)), Op::Z];
|
||||
let many: Vec<Op> = (0..4000).flat_map(|i| tri(i as f64 % 200.0)).collect();
|
||||
let raw = |ops| Raw::Path { ops, fill: Some([0, 0, 0, 255]), stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false, region: None };
|
||||
assert!(crate::cliprect::clip_raw(raw(many), &clip).is_empty(), "the whole item's clipping work is bounded");
|
||||
assert_eq!(crate::cliprect::clip_raw(raw(tri(100.0).to_vec()), &clip).len(), 2, "a small crossing shape is still clipped");
|
||||
}
|
||||
|
||||
/// Clipping a fill can add a vertex at every clip edge, so a polygon's work isn't its input size:
|
||||
/// shapes that pass the up-front estimate but grow while clipped are charged as they grow, against
|
||||
/// one budget for the whole item.
|
||||
#[test]
|
||||
fn growing_fill_clips_share_one_budget() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
// A triangle around the whole circle: clipped, it becomes the 512-gon, about 130,000 steps.
|
||||
let tri = [Op::M((-1000.0, -1000.0)), Op::L((1000.0, -1000.0)), Op::L((100.0, 1000.0)), Op::Z];
|
||||
let raw = |n: usize| Raw::Path { ops: tri.repeat(n), fill: Some([0, 0, 0, 255]), stroke: None, even_odd: false, region: None };
|
||||
// 400 of them pass the estimate (400 x 4 x 512 is under a million) but cost about 52 million.
|
||||
assert!(crate::cliprect::clip_raw(raw(400), &clip).is_empty(), "the growing work is charged");
|
||||
assert_eq!(crate::cliprect::clip_raw(raw(2), &clip).len(), 1, "a couple of them are still clipped");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user