mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-10 12:29:34 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ad81f944f | ||
|
|
d021e2baed | ||
|
|
bb0048e78d |
@@ -811,10 +811,32 @@ fn clamp_selection(st: &mut DocState) {
|
|||||||
/// Default for [`History::byte_limit`].
|
/// Default for [`History::byte_limit`].
|
||||||
pub const UNDO_BYTE_BUDGET: usize = 256 << 20;
|
pub const UNDO_BYTE_BUDGET: usize = 256 << 20;
|
||||||
|
|
||||||
|
/// A rough size of a text body in memory: its paragraphs and runs, at their inline struct sizes.
|
||||||
|
fn text_cost(t: &deckcraft_model::text::TextBody) -> usize {
|
||||||
|
use deckcraft_model::text::{Paragraph, Run};
|
||||||
|
let (para, run) = (std::mem::size_of::<Paragraph>(), std::mem::size_of::<Run>());
|
||||||
|
t.paragraphs.iter().map(|p| para + p.runs.iter().map(|r| run + r.text.len()).sum::<usize>()).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rough size of comments in memory, replies included.
|
||||||
|
fn comments_cost(cs: &[deckcraft_model::Comment], depth: usize) -> usize {
|
||||||
|
let kids = |c: &deckcraft_model::Comment| if depth < 64 { comments_cost(&c.replies, depth + 1) } else { 0 };
|
||||||
|
cs.iter().map(|c| 256 + c.author.len() + c.initials.len() + c.text.len() + c.date.len() + kids(c)).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rough size of a slide's own data besides its shapes: notes, comments, tags and kept XML,
|
||||||
|
/// all copied with the slide when an edit changes it.
|
||||||
|
fn slide_extra_cost(s: &deckcraft_model::Slide) -> usize {
|
||||||
|
text_cost(&s.notes)
|
||||||
|
+ comments_cost(&s.comments, 0)
|
||||||
|
+ s.name.len()
|
||||||
|
+ s.tags.iter().map(|(k, v)| 48 + k.len() + v.len()).sum::<usize>()
|
||||||
|
+ s.raw_ext.as_ref().map_or(0, String::len)
|
||||||
|
}
|
||||||
|
|
||||||
/// A rough size of a shape in memory: the struct, its text and kept XML, its children.
|
/// A rough size of a shape in memory: the struct, its text and kept XML, its children.
|
||||||
fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize {
|
fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize {
|
||||||
let text: usize =
|
let text: usize = sh.text.as_ref().map_or(0, text_cost);
|
||||||
sh.text.as_ref().map_or(0, |t| t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::<usize>()).sum());
|
|
||||||
let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 };
|
let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 };
|
||||||
768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids
|
768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids
|
||||||
}
|
}
|
||||||
@@ -832,7 +854,7 @@ pub fn undo_cost(doc: &Presentation, newer: &Presentation) -> usize {
|
|||||||
let media: HashSet<*const Vec<u8>> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect();
|
let media: HashSet<*const Vec<u8>> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect();
|
||||||
let mut n = 64 * doc.slides.len();
|
let mut n = 64 * doc.slides.len();
|
||||||
for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) {
|
for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) {
|
||||||
n += 512 + shapes_cost(&s.shapes);
|
n += 512 + shapes_cost(&s.shapes) + slide_extra_cost(s);
|
||||||
}
|
}
|
||||||
for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) {
|
for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) {
|
||||||
n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::<usize>();
|
n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::<usize>();
|
||||||
|
|||||||
@@ -3338,6 +3338,25 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() {
|
|||||||
assert_eq!(s.doc().unwrap().history.undo.len(), 120);
|
assert_eq!(s.doc().unwrap().history.undo.len(), 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A slide's notes count toward the undo budget: small edits to a slide with large notes keep a
|
||||||
|
/// copy of those notes per step, and must not slip past the budget as if the slide were empty.
|
||||||
|
#[test]
|
||||||
|
fn undo_history_counts_slide_notes() {
|
||||||
|
let mut s = Session::with_new();
|
||||||
|
let notes = "n".repeat(1 << 20);
|
||||||
|
s.edit(|doc, _| {
|
||||||
|
std::sync::Arc::make_mut(&mut doc.slides[0]).notes = deckcraft_model::text::TextBody::from_text(¬es);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
s.doc_mut().unwrap().history = History { limit: 500, byte_limit: 8 << 20, ..Default::default() };
|
||||||
|
for i in 0..40 {
|
||||||
|
s.execute("slide.rename", &json!({"name": format!("v{i}"), "index": 0})).unwrap();
|
||||||
|
}
|
||||||
|
let n = s.doc().unwrap().history.undo.len();
|
||||||
|
assert!((1..=8).contains(&n), "{n} steps of 1 MB notes kept under an 8 MB budget");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn picture_styles_replace_each_other_in_one_step() {
|
fn picture_styles_replace_each_other_in_one_step() {
|
||||||
const PNG: &[u8] = &[
|
const PNG: &[u8] = &[
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ const INVALID_REQUEST: i64 = -32600;
|
|||||||
const METHOD_NOT_FOUND: i64 = -32601;
|
const METHOD_NOT_FOUND: i64 = -32601;
|
||||||
const INVALID_PARAMS: i64 = -32602;
|
const INVALID_PARAMS: i64 = -32602;
|
||||||
const INTERNAL_ERROR: i64 = -32603;
|
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;
|
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||||
/// Keep request frames in step with the connected backend's reply limit.
|
/// Keep request frames in step with the connected backend's reply limit.
|
||||||
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
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.
|
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
||||||
if batch.is_empty() {
|
if batch.is_empty() {
|
||||||
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
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 {
|
} else {
|
||||||
let mut used = 0usize;
|
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
(!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!({}))))
|
wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({}))))
|
||||||
}
|
}
|
||||||
"batch" => {
|
"batch" => {
|
||||||
use crate::server::{MAX_BATCH_BYTES, MAX_BATCH_ITEMS};
|
|
||||||
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
|
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 out = vec![];
|
||||||
let mut used = 0usize;
|
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
|
||||||
for c in commands {
|
|
||||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
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!({}))) {
|
match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
|
||||||
Ok(v) => (json!({"command": id, "ok": true, "result": v}), false),
|
Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
|
||||||
Err(e) => (json!({"command": id, "ok": false, "error": e}), true),
|
Err(e) => {
|
||||||
};
|
out.push(json!({"command": id, "ok": false, "error": e}));
|
||||||
// What the reply carries: the entry as text, escaped again inside the JSON-RPC reply.
|
if !keep {
|
||||||
// Failures count too: with keepGoing, a remote backend's large errors add up.
|
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolResult::json(&Value::Array(out))
|
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).
|
// 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)]
|
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
mod automation_roots;
|
mod automation_roots;
|
||||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
||||||
mod batch_budget;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user