Files
vectorcraft/crates/engine/src/tests_cancel.rs
T
vangel76andClaude Opus 5.5 9122ed0a7e MCP: cancel long exports, answer while one runs, report progress (#2165, #2171, #2172)
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>
2026-10-17 13:49:48 +07:00

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", &params));
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", &params).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", &params));
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());
}