mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
The MCP server read one line, ran it and answered it before reading the next, so notifications/cancelled was never seen during a call and every request (even ping) waited behind a running export. - vectorcraft_doc::cancel: a thread-local cancel token and progress sink that long work polls. Checkpoints: PDF pages, PDF flattening, geometry bake, raster-effect flattening, drawing objects, raster artboards, and before any file is written. A cancelled command returns EngineError::Cancelled and changes nothing. - Server::serve reads on its own thread: notifications/cancelled drops a queued request or stops the running one (no reply, as the MCP spec says), engine-free requests (ping, the list methods, prompts/get) are answered at once, a request that would wait behind a call running for 2 s is refused with -32003 naming that call, and _meta.progressToken gets notifications/progress (page n of m). Queue and output are bounded. - The remote backend stops waiting for the app when the call is cancelled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
91 lines
3.5 KiB
Rust
91 lines
3.5 KiB
Rust
//! Cancelling a long export (#2165): it stops at the next checkpoint with `EngineError::Cancelled`,
|
|
//! writes nothing, leaves the document as it was, and the session goes on working.
|
|
|
|
use std::cell::Cell;
|
|
use std::rc::Rc;
|
|
|
|
use serde_json::json;
|
|
|
|
use crate::cancel::{self, CancelToken, ProgressSink};
|
|
use crate::{EngineError, Session};
|
|
|
|
fn session(artboards: usize) -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": artboards})).unwrap();
|
|
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn revision(s: &Session) -> u64 {
|
|
s.doc().unwrap().revision
|
|
}
|
|
|
|
#[test]
|
|
fn a_pdf_export_cancelled_between_pages_stops_writes_nothing_and_changes_nothing() {
|
|
let path = vectorcraft_testkit::temp_dir("cancel-pdf").join("out.pdf");
|
|
std::fs::remove_file(&path).ok();
|
|
let mut s = session(5);
|
|
let before = (revision(&s), s.doc().unwrap().history.undo.len());
|
|
let token = CancelToken::new();
|
|
let pages = Rc::new(Cell::new(0));
|
|
// The sink runs inside the export, as the page loop reports each page: cancel on the third.
|
|
let sink: ProgressSink = {
|
|
let (token, pages) = (token.clone(), pages.clone());
|
|
Rc::new(move |done, total, _| {
|
|
// Stages before the pages report without a total.
|
|
if total != 5 {
|
|
return;
|
|
}
|
|
pages.set(done);
|
|
if done == 3 {
|
|
token.cancel();
|
|
}
|
|
})
|
|
};
|
|
let params = json!({"path": path.to_str().unwrap()});
|
|
let r = cancel::scope(&token, Some(sink), || s.execute("document.exportPdf", ¶ms));
|
|
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
|
|
assert_eq!(pages.get(), 3, "the export stopped at the checkpoint after the cancel, not at the end");
|
|
assert!(!path.exists(), "a cancelled export writes no file");
|
|
assert_eq!((revision(&s), s.doc().unwrap().history.undo.len()), before, "the document is as it was");
|
|
// The session is fine: the same export, not cancelled, completes.
|
|
s.execute("document.exportPdf", ¶ms).unwrap();
|
|
assert!(path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn a_cancelled_flattening_export_stops_before_the_pages() {
|
|
let mut s = session(2);
|
|
let token = CancelToken::new();
|
|
token.cancel();
|
|
// PDF/X-1a flattens transparency first; a token raised beforehand stops at the first check.
|
|
let r = cancel::scope(&token, None, || s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})));
|
|
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
|
|
assert!(s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn raster_exports_and_svg_stop_when_cancelled_too() {
|
|
let dir = vectorcraft_testkit::temp_dir("cancel-formats");
|
|
let mut s = session(3);
|
|
let token = CancelToken::new();
|
|
token.cancel();
|
|
for ext in ["png", "svg", "pdf"] {
|
|
let path = dir.join(format!("out.{ext}"));
|
|
std::fs::remove_file(&path).ok();
|
|
let params = json!({"path": path.to_str().unwrap()});
|
|
let r = cancel::scope(&token, None, || s.execute("document.export", ¶ms));
|
|
assert!(matches!(r, Err(EngineError::Cancelled)), "{ext}: {r:?}");
|
|
assert!(!path.exists(), "{ext}: nothing is written");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_is_cancelled_outside_a_scope() {
|
|
let token = CancelToken::new();
|
|
token.cancel();
|
|
// The token only counts for work run under it, on its thread.
|
|
let mut s = session(1);
|
|
assert!(s.execute("document.exportPdf", &json!({})).is_ok());
|
|
}
|