From 9122ed0a7e3a88d09906d275061aa88604802d82 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 22:41:56 +0200 Subject: [PATCH] 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 --- apps/vectorcraft-cli/src/main.rs | 4 +- crates/doc/src/cancel.rs | 142 ++++++++++ crates/doc/src/lib.rs | 1 + crates/effects/src/bake.rs | 3 +- crates/engine/src/cmd/fileio/encode.rs | 7 +- crates/engine/src/cmd/fileio/mod.rs | 3 + crates/engine/src/cmd/fileio/pdf.rs | 5 + crates/engine/src/cmd/flatten.rs | 5 + crates/engine/src/cmd/rasterfx.rs | 6 + crates/engine/src/lib.rs | 12 + crates/engine/src/tests_cancel.rs | 90 ++++++ crates/mcp/src/backend.rs | 37 +++ crates/mcp/src/headless.rs | 2 + crates/mcp/src/intake.rs | 309 +++++++++++++++++++++ crates/mcp/src/lib.rs | 3 + crates/mcp/src/server.rs | 281 ++++++++++++++----- crates/mcp/src/tests_concurrency.rs | 368 +++++++++++++++++++++++++ crates/mcp/tests/protocol_props.rs | 61 ++++ crates/pdf/src/export.rs | 10 +- crates/pdf/src/lib.rs | 3 + crates/render/src/lib.rs | 4 + docs/gaps.md | 4 +- docs/mcp.md | 57 ++-- docs/target-app-parity.md | 5 +- 24 files changed, 1328 insertions(+), 94 deletions(-) create mode 100644 crates/doc/src/cancel.rs create mode 100644 crates/engine/src/tests_cancel.rs create mode 100644 crates/mcp/src/intake.rs create mode 100644 crates/mcp/src/tests_concurrency.rs diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index bb3879b47..b843af67f 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -213,8 +213,8 @@ fn mcp(args: &[String]) -> Result<(), String> { // uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel. vectorcraft_mcp::logging::install(); let stdin = std::io::stdin(); - let stdout = std::io::stdout(); - Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) + // The server reads and writes from threads of its own, so it takes the handles, not locks held here. + Server::new(backend).serve(std::io::BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string()) } fn commands() -> Result<(), String> { diff --git a/crates/doc/src/cancel.rs b/crates/doc/src/cancel.rs new file mode 100644 index 000000000..bff510707 --- /dev/null +++ b/crates/doc/src/cancel.rs @@ -0,0 +1,142 @@ +//! Cooperative cancellation and progress for long-running work (exports, flattening, baking). +//! +//! A host that wants to be able to stop a call (the MCP server, on `notifications/cancelled`) +//! runs it inside [`scope`] with a [`CancelToken`]. Code deep below, which has no handle on the +//! host, calls [`check`] (or [`is_cancelled`] where it can't return an error) at points where +//! stopping is safe, and returns [`Cancelled`] up through the same `Result`s it already has. The +//! token is tied to the thread that entered the scope, so concurrent sessions on other threads +//! (and tests running in parallel) never see each other's cancellation, and code outside any +//! scope is never cancelled. +//! +//! Work that stops early leaves nothing behind: callers drop whatever was half built and report +//! the error, so a cancelled command changes nothing. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// A flag one thread raises and another polls. Cloning shares the flag. +#[derive(Clone, Debug, Default)] +pub struct CancelToken(Arc); + +impl CancelToken { + pub fn new() -> Self { + Self::default() + } + + /// Ask for the work holding this token to stop. + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// The error of work that stopped because it was cancelled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cancelled; + +impl std::fmt::Display for Cancelled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("cancelled") + } +} + +impl std::error::Error for Cancelled {} + +/// Receives progress: `(done, total, what is being done)`. `total` is 0 when it isn't known. +pub type ProgressSink = Rc; + +struct Scope { + token: CancelToken, + sink: Option, +} + +thread_local! { + static CURRENT: RefCell> = const { RefCell::new(None) }; +} + +/// Puts the previous scope back, also when the work inside unwinds. +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + // `try_with`: during thread teardown the slot may be gone; nothing to restore then. + let _ = CURRENT.try_with(|c| *c.borrow_mut() = previous); + } +} + +/// Run `f` with `token` (and an optional progress `sink`) in effect on this thread. +pub fn scope(token: &CancelToken, sink: Option, f: impl FnOnce() -> T) -> T { + let previous = CURRENT.with(|c| c.borrow_mut().replace(Scope { token: token.clone(), sink })); + let _restore = Restore(previous); + f() +} + +/// Has the work running on this thread been cancelled? Always false outside a [`scope`]. +pub fn is_cancelled() -> bool { + CURRENT.with(|c| c.borrow().as_ref().is_some_and(|s| s.token.is_cancelled())) +} + +/// Is this thread running inside a [`scope`], so that a cancel can reach it? +pub fn is_cancellable() -> bool { + CURRENT.with(|c| c.borrow().is_some()) +} + +/// `Err(Cancelled)` when the work running on this thread has been cancelled. +pub fn check() -> Result<(), Cancelled> { + if is_cancelled() { Err(Cancelled) } else { Ok(()) } +} + +/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`] +/// that has a sink. +pub fn progress(done: u64, total: u64, what: &str) { + // The sink runs outside the borrow, so it may itself call back into this module. + let sink = CURRENT.with(|c| c.borrow().as_ref().and_then(|s| s.sink.clone())); + if let Some(sink) = sink { + sink(done, total, what); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_work_inside_the_scope_is_cancelled() { + let t = CancelToken::new(); + assert_eq!(check(), Ok(())); + scope(&t, None, || { + assert_eq!(check(), Ok(())); + t.cancel(); + assert_eq!(check(), Err(Cancelled)); + // A nested scope with its own token shadows, then restores. + scope(&CancelToken::new(), None, || assert_eq!(check(), Ok(()))); + assert!(is_cancelled()); + }); + assert!(!is_cancelled(), "the scope ended"); + } + + #[test] + fn progress_reaches_the_sink_and_the_scope_survives_a_panic() { + let seen = Rc::new(RefCell::new(vec![])); + let sink: ProgressSink = { + let seen = seen.clone(); + Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) + }; + let t = CancelToken::new(); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + scope(&t, Some(sink), || { + progress(1, 3, "page"); + panic!("boom"); + }) + })); + assert!(r.is_err()); + progress(2, 3, "after"); + assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a11f04725..3b80bf692 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ pub mod appearance; pub mod assets; pub mod blend; +pub mod cancel; pub mod clipnest; pub mod cmyk; pub mod corners; diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 0f5b89d1c..8c1b18d5e 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -89,7 +89,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind { /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { - if !needs_bake(n) { + // A cancelled export leaves the rest of the document as it is: the caller drops the result. + if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index f37521efa..d28254f88 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -413,6 +413,8 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result { let f = super::writable(C, Some(format), None)?; let failed = vectorcraft_render::decode_failures(); let mut enc = encode_files(doc, f, p)?; + // Baking, flattening and drawing stop early when cancelled and leave a truncated result. + vectorcraft_doc::cancel::check()?; // An image the renderer couldn't decode draws nothing: say so rather than write a picture // that silently lacks it. if f.raster && vectorcraft_render::decode_failures() > failed { @@ -502,7 +504,10 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { }; let mut enc = Encoded::default(); let mut renderer = vectorcraft_render::Renderer::new(); - for b in chosen { + let total = chosen.len() as u64; + for (done, b) in chosen.into_iter().enumerate() { + vectorcraft_doc::cancel::check()?; + vectorcraft_doc::cancel::progress(done as u64, total, &format!("Rendering artboard {} of {total}", done + 1)); let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect; check_format_size(f, region.width() * scale, region.height() * scale)?; vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index b20e0db25..abdadb361 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -861,6 +861,8 @@ pub(crate) fn read_file_head(path: &str, max: usize) -> Result> { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + // Work that stops early for a cancel can leave a truncated result: none of it is written. + vectorcraft_doc::cancel::check()?; may_write(path)?; // Never a half-written file: see [`write_atomic`]. write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) @@ -869,6 +871,7 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { /// [`write_file`] with what `fill` writes into the file (a save streamed into it). #[cfg(not(target_arch = "wasm32"))] pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + vectorcraft_doc::cancel::check()?; may_write(path)?; vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 1703bece0..4af802a0a 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -169,6 +169,7 @@ pub(crate) fn merge(base: &mut Value, over: &Value) { pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError { match e { PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()), + PdfError::Cancelled => EngineError::Cancelled, e => EngineError::Other(e.to_string()), } } @@ -273,7 +274,11 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl } } let set = &opts.settings; + if set.pdf13() { + vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency"); + } let flat = flatten_for(cmd, doc, set, p)?; + vectorcraft_doc::cancel::check()?; if flat.is_some() { let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 104af70d2..842293892 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -818,7 +818,10 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R let see_through: Vec = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect(); let cmyk = src.color_mode == ColorMode::Cmyk; let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; + // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). + vectorcraft_doc::cancel::check()?; for g in overlapping(&reaches) { + vectorcraft_doc::cancel::check()?; let ids: Vec = g.iter().map(|&i| roots[i]).collect(); let group = g.iter().any(|&i| see_through[i]).then(|| { let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); @@ -950,6 +953,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio ); let mut r = vectorcraft_render::Renderer::new(); let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha); + // The drawing stops early when cancelled: this image is not whole. + vectorcraft_doc::cancel::check()?; if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha { // Without transparency the clip alone shapes the image: it is opaque, over white (its // edge pixels too). diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 3bab565d0..554afb881 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -440,6 +440,10 @@ fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) { /// has none. An object whose effects all paint below it keeps its vector art over an image of /// them; one whose effects change it (blur, feather, inner glow) becomes the image. fn walk(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option { + // A cancelled export leaves the rest of the document as it is: the caller drops the result. + if vectorcraft_doc::cancel::is_cancelled() { + return None; + } let checkpoint = report.checkpoint(); let result = walk_inner(out, n, report); report.attribute_since(checkpoint, n); @@ -558,6 +562,8 @@ pub fn export_pdf_with_report( ) -> std::result::Result { let mut report = FlattenReport::new(Which::All, doc); let flat = flatten(doc, &mut report); + // Flattening stops early when cancelled, leaving a half-flattened copy that must not be written. + vectorcraft_doc::cancel::check().map_err(|_| vectorcraft_pdf::PdfError::Cancelled)?; let mut exported = vectorcraft_pdf::export_with_report(flat.as_ref().unwrap_or(doc), opts)?; exported.warnings.extend(report.warnings()); Ok(exported) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 395a83910..30ffda9f4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,6 +34,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use journal::Journal; pub use tooling::{UiRequest, ViewInfo}; pub use vectorcraft_doc as doc; +pub use vectorcraft_doc::cancel; pub use vectorcraft_tools as tools; #[derive(Debug, thiserror::Error)] @@ -50,11 +51,20 @@ pub enum EngineError { NoNode(NodeId), #[error("{0}")] Other(String), + /// The caller asked for the command to stop ([`cancel`]). It changed nothing. + #[error("cancelled; nothing was changed")] + Cancelled, /// A bug: the command panicked. The document was rolled back to its state before the command. #[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")] Internal { cmd: String, msg: String }, } +impl From for EngineError { + fn from(_: cancel::Cancelled) -> Self { + EngineError::Cancelled + } +} + impl From for EngineError { fn from(e: vectorcraft_doc::DocError) -> Self { EngineError::Other(e.to_string()) @@ -1892,6 +1902,8 @@ mod tests_brushsym; #[cfg(test)] mod tests_build; #[cfg(test)] +mod tests_cancel; +#[cfg(test)] mod tests_charstroke; #[cfg(test)] mod tests_clip; diff --git a/crates/engine/src/tests_cancel.rs b/crates/engine/src/tests_cancel.rs new file mode 100644 index 000000000..8237707a1 --- /dev/null +++ b/crates/engine/src/tests_cancel.rs @@ -0,0 +1,90 @@ +//! 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()); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index b3a00604a..97c990b88 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -85,6 +85,42 @@ impl Remote { Ok(()) } + /// Wait until the app starts to answer, giving up when the call is cancelled + /// ([`vectorcraft_engine::cancel`]): the app has no way to stop what it runs, but this server + /// stops waiting for it (the connection is dropped, so a late answer can't be mistaken for + /// the next call's). Without a cancellable call this is just the read the caller does next. + fn wait_for_reply(stream: &TcpStream) -> std::io::Result<()> { + const POLL: Duration = Duration::from_millis(100); + const LIMIT: Duration = Duration::from_secs(90); + if !vectorcraft_engine::cancel::is_cancellable() { + return Ok(()); + } + let start = std::time::Instant::now(); + let kept = stream.read_timeout()?; + stream.set_read_timeout(Some(POLL))?; + let outcome = loop { + match stream.peek(&mut [0]) { + // Bytes (or the end of the stream) to read now. + Ok(_) => break Ok(()), + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + if vectorcraft_engine::cancel::is_cancelled() { + break Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled; the app may still finish what it was asked, which can't be stopped from here", + )); + } + if start.elapsed() >= LIMIT { + break Err(e); + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => break Err(e), + } + }; + stream.set_read_timeout(kept)?; + outcome + } + fn roundtrip(&mut self, line: &str) -> std::io::Result { let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected to the app")); @@ -92,6 +128,7 @@ impl Remote { writer.write_all(line.as_bytes())?; writer.write_all(b"\n")?; writer.flush()?; + Self::wait_for_reply(writer)?; let mut buf = Vec::new(); if !crate::server::read_line_capped(reader, &mut buf, MAX_REPLY_BYTES)? { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index d11e5eeb0..9442339e5 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -253,6 +253,8 @@ impl Headless { let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); vectorcraft_render::raster_size(r, scale)?; let img = self.renderer.render_region(&doc, r, scale, true); + // A cancelled render stops drawing: the picture is not whole and is not written. + vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?; let png = img.to_png()?; let mut out = json!({"width": img.width, "height": img.height}); match s(p, "path") { diff --git a/crates/mcp/src/intake.rs b/crates/mcp/src/intake.rs new file mode 100644 index 000000000..186bdbfd8 --- /dev/null +++ b/crates/mcp/src/intake.rs @@ -0,0 +1,309 @@ +//! What lets the server hear a client while a call runs: a reader thread that takes lines off the +//! transport, a queue the engine thread works through, and a writer thread that owns the output. +//! +//! The engine runs one command at a time, on the thread that called [`crate::Server::serve`]. The +//! reader never touches the engine. While a call runs it can still +//! +//! - act on [`notifications/cancelled`](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation): +//! a queued request is dropped, a running one has its [`CancelToken`] raised, and neither gets +//! a reply; +//! - answer the requests that need no engine (`ping`, the `…/list` methods, `prompts/get`), and +//! - refuse, with a "busy" error that names the running call, a request that would have to wait +//! behind a call that has already run for [`Server::with_busy_after`](crate::Server::with_busy_after). +//! +//! Requests that arrive sooner than that queue up and run in order, so a client that pipelines a +//! few quick calls sees them answered in order, as before. The queue is bounded: past +//! [`MAX_QUEUED`] lines the reader waits for the engine rather than buffering without bound. + +use std::collections::VecDeque; +use std::io::BufRead; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::SyncSender; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_engine::cancel::CancelToken; + +use crate::server::{INVALID_REQUEST, MAX_LINE_BYTES, PARSE_ERROR, SERVER_BUSY, error, error_with, read_line_capped}; + +/// Most lines waiting for the engine. Past this the reader waits (it still reads nothing else). +pub(crate) const MAX_QUEUED: usize = 64; + +/// A line the engine thread has still to run. +pub(crate) enum Job { + /// A reply that is already known (a parse error, an overlong line), kept in order with the rest. + Canned(String), + /// A parsed message: a request, a notification or a batch. + Message(Message), +} + +pub(crate) struct Message { + pub(crate) value: Value, + /// The request id as JSON text, for a request; what `notifications/cancelled` names. + pub(crate) key: Option, + pub(crate) method: String, + /// Raised by `notifications/cancelled`, or when the transport closes under the call. + pub(crate) token: CancelToken, + /// What the call is, for the "busy" answer: `tools/call command_run document.export`. + pub(crate) label: String, +} + +/// The call the engine thread is running. +struct Running { + key: Option, + method: String, + token: CancelToken, + label: String, + since: Instant, +} + +#[derive(Default)] +struct State { + queue: VecDeque, + running: Option, + /// The transport has no more lines. + eof: bool, + /// The output failed: nobody reads replies any more. + closed: bool, +} + +/// What the reader and the engine thread share. +pub(crate) struct Shared { + state: Mutex, + wake: Condvar, + /// Whether the client speaks the `2026-07-28` revision, for results answered by the reader. + pub(crate) modern: Arc, + busy_after: Duration, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + // A thread that panicked while holding the lock left a queue that is still well formed. + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl Shared { + pub(crate) fn new(modern: Arc, busy_after: Duration) -> Self { + Self { state: Mutex::new(State::default()), wake: Condvar::new(), modern, busy_after } + } + + /// The next job, or `None` once the transport is done and the queue empty (or the output + /// failed). The job counts as running until [`Shared::finished`]. + pub(crate) fn next(&self) -> Option { + let mut st = lock(&self.state); + loop { + if st.closed { + return None; + } + if let Some(job) = st.queue.pop_front() { + if let Job::Message(m) = &job { + st.running = Some(Running { + key: m.key.clone(), + method: m.method.clone(), + token: m.token.clone(), + label: m.label.clone(), + since: Instant::now(), + }); + } + // The reader may be waiting for room. + self.wake.notify_all(); + return Some(job); + } + if st.eof { + return None; + } + st = self.wake.wait(st).unwrap_or_else(PoisonError::into_inner); + } + } + + pub(crate) fn finished(&self) { + lock(&self.state).running = None; + self.wake.notify_all(); + } + + /// The output failed: stop the running call and everything queued behind it. + pub(crate) fn close(&self) { + let mut st = lock(&self.state); + st.closed = true; + if let Some(r) = &st.running { + r.token.cancel(); + } + st.queue.clear(); + self.wake.notify_all(); + } + + fn end_of_input(&self) { + lock(&self.state).eof = true; + self.wake.notify_all(); + } + + fn is_closed(&self) -> bool { + lock(&self.state).closed + } + + /// The running call, once it has run for `busy_after` (so a quick call never makes a + /// pipelined neighbour fail). + fn long_running(&self, st: &State) -> Option { + let r = st.running.as_ref()?; + let secs = r.since.elapsed(); + (secs >= self.busy_after).then(|| json!({"running": r.label, "requestId": r.key, "seconds": secs.as_secs()})) + } +} + +/// The id of a request message: `Some` when `msg` is a JSON-RPC 2.0 request with a usable id. +fn request_id(msg: &Value) -> Option<&Value> { + let o = msg.as_object()?; + if o.get("jsonrpc").and_then(Value::as_str) != Some("2.0") || !o.get("method")?.is_string() { + return None; + } + o.get("id").filter(|id| id.is_string() || id.is_number()) +} + +/// A short name for the call, as the "busy" answer shows it. +fn label_of(method: &str, params: &Value) -> String { + if method != "tools/call" { + return method.to_string(); + } + let name = params.get("name").and_then(Value::as_str).unwrap_or("?"); + let args = params.get("arguments"); + let which = ["id", "command", "path"].iter().find_map(|k| args?.get(k)?.as_str()); + match which { + Some(w) => format!("{method} {name} {w}"), + None => format!("{method} {name}"), + } +} + +/// Read lines from `reader` until the end, acting on each as described in the module docs. +pub(crate) fn read_loop(shared: &Shared, out: &SyncSender, mut reader: impl BufRead) -> std::io::Result<()> { + let mut buf = Vec::new(); + let result = loop { + match read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES) { + Ok(true) => {} + Ok(false) => break Ok(()), + Err(e) => break Err(e), + } + if shared.is_closed() { + break Ok(()); + } + if buf.len() > MAX_LINE_BYTES { + // Longer than anything a client can mean: answer the framing violation, skip the + // rest of the line in bounded chunks, and keep serving what follows. + let reply = error(Value::Null, INVALID_REQUEST, format!("request line longer than {MAX_LINE_BYTES} bytes")).to_string(); + submit_canned(shared, out, reply); + let ended = loop { + match read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES) { + Ok(true) if buf.len() > MAX_LINE_BYTES => {} + Ok(true) => break Ok(false), + Ok(false) => break Ok(true), + Err(e) => break Err(e), + } + }; + match ended { + Ok(false) => continue, + Ok(true) => break Ok(()), + Err(e) => break Err(e), + } + } + let line = match std::str::from_utf8(&buf) { + Ok(l) => l, + Err(e) => break Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)), + }; + intake(shared, out, line); + }; + shared.end_of_input(); + result +} + +/// A reply that is already known: in order behind queued work, or at once when the engine is +/// tied up for long (then nobody can expect order). +fn submit_canned(shared: &Shared, out: &SyncSender, reply: String) { + let mut st = lock(&shared.state); + if shared.long_running(&st).is_some() { + drop(st); + let _ = out.send(reply); + return; + } + st.queue.push_back(Job::Canned(reply)); + shared.wake.notify_all(); +} + +fn intake(shared: &Shared, out: &SyncSender, line: &str) { + let line = line.trim(); + if line.is_empty() { + return; + } + let value = match serde_json::from_str::(line) { + Ok(v) => v, + Err(e) => return submit_canned(shared, out, error(Value::Null, PARSE_ERROR, format!("parse error: {e}")).to_string()), + }; + let method = value.get("method").and_then(Value::as_str).unwrap_or("").to_string(); + let enveloped = value.get("jsonrpc").and_then(Value::as_str) == Some("2.0"); + if enveloped && method == "notifications/cancelled" && value.get("id").is_none() { + return cancel(shared, value.get("params").unwrap_or(&Value::Null)); + } + let params = value.get("params").cloned().unwrap_or(Value::Null); + let id = request_id(&value).cloned(); + let is_batch = value.is_array(); + let token = CancelToken::new(); + let msg = Message { key: id.as_ref().map(Value::to_string), label: label_of(&method, ¶ms), method, token, value }; + let mut st = lock(&shared.state); + loop { + if st.closed { + return; + } + let busy = shared.long_running(&st); + if let Some(busy) = busy { + if let Some(id) = &id { + // Engine-free requests are answered here; others are told to retry. + let reply = match crate::server::stateless_reply(&msg.value, shared.modern.load(Ordering::Relaxed)) { + Some(r) => r, + None => error_with(id.clone(), SERVER_BUSY, busy_message(&busy), json!({"busy": busy})), + }; + drop(st); + let _ = out.send(reply.to_string()); + return; + } + if is_batch { + drop(st); + let reply = error_with(Value::Null, SERVER_BUSY, busy_message(&busy), json!({"busy": busy})); + let _ = out.send(reply.to_string()); + return; + } + } + if st.queue.len() < MAX_QUEUED { + break; + } + // Wake now and then: the call that fills the queue may be the one that turns it away. + st = shared.wake.wait_timeout(st, Duration::from_millis(50)).unwrap_or_else(PoisonError::into_inner).0; + } + st.queue.push_back(Job::Message(msg)); + shared.wake.notify_all(); +} + +fn busy_message(busy: &Value) -> String { + format!( + "the engine is busy: `{}` has been running for {} s and this request was not run. Retry when it has finished, or cancel it with notifications/cancelled (requestId {})", + busy["running"].as_str().unwrap_or("a call"), + busy["seconds"].as_u64().unwrap_or(0), + busy["requestId"], + ) +} + +/// `notifications/cancelled {requestId}`: stop that request if it is still queued or running. +/// An unknown or finished id is ignored, as is a request that can't be cancelled (`initialize`). +fn cancel(shared: &Shared, params: &Value) { + let Some(id) = params.get("requestId").filter(|id| id.is_string() || id.is_number()) else { return }; + let key = id.to_string(); + let mut st = lock(&shared.state); + if let Some(r) = st.running.as_ref().filter(|r| r.key.as_deref() == Some(&key) && r.method != "initialize") { + log::debug!("cancelling running request {key}: {}", r.label); + r.token.cancel(); + return; + } + let at = st.queue.iter().position(|j| matches!(j, Job::Message(m) if m.key.as_deref() == Some(&key) && m.method != "initialize")); + if let Some(at) = at { + log::debug!("dropping queued request {key}"); + st.queue.remove(at); + shared.wake.notify_all(); + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 73a37272d..1c7b7020c 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -16,6 +16,7 @@ mod backend; mod headless; +mod intake; pub mod logging; mod prompts; mod resources; @@ -37,6 +38,8 @@ mod tests; #[cfg(test)] mod tests_batchdraw; #[cfg(test)] +mod tests_concurrency; +#[cfg(test)] mod tests_conventions; #[cfg(test)] mod tests_distortkeys; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index ba5b23c13..8fef0ee46 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -1,10 +1,18 @@ //! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods. +use std::cell::Cell; use std::io::{BufRead, Write}; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{SyncSender, sync_channel}; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; +use vectorcraft_engine::cancel; use crate::backend::Backend; +use crate::intake::{Job, Message, Shared, read_loop}; use crate::prompts; use crate::resources; use crate::tools::{call_tool, tool_definitions}; @@ -14,12 +22,18 @@ pub const PROTOCOL_VERSION: &str = "2025-06-18"; const MODERN_VERSION: &str = "2026-07-28"; const SUPPORTED_VERSIONS: &[&str] = &[MODERN_VERSION, PROTOCOL_VERSION, "2025-03-26", "2024-11-05"]; -const PARSE_ERROR: i64 = -32700; -const INVALID_REQUEST: i64 = -32600; +pub(crate) const PARSE_ERROR: i64 = -32700; +pub(crate) const INVALID_REQUEST: i64 = -32600; const METHOD_NOT_FOUND: i64 = -32601; const INVALID_PARAMS: i64 = -32602; const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; +/// The engine is running a long call and this request was not run (implementation-defined range). +pub(crate) const SERVER_BUSY: i64 = -32003; + +/// How long a call runs before a request that would have to wait behind it is refused as busy +/// instead ([`Server::with_busy_after`]). +const DEFAULT_BUSY_AFTER: Duration = Duration::from_secs(2); /// Longest accepted request line (bytes, without the newline). Requests are small JSON objects; /// anything longer is answered with an error and skipped, so one huge line can't make the server @@ -29,6 +43,40 @@ pub(crate) const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; /// older clients can make the server parse and answer at once (#1727). pub(crate) const MAX_BATCH_MESSAGES: usize = 64; +/// Lines waiting to be written. A full queue holds up the thread that wants to write (the +/// engine thread, the reader) rather than growing; progress is dropped instead (it is advisory). +const OUTBOX: usize = 256; +/// Fewest time between two `notifications/progress` of one request, the last one excepted. +const PROGRESS_EVERY: Duration = Duration::from_millis(200); + +/// The sink that turns the engine's progress into `notifications/progress` for the request +/// `msg`, when the client asked for it with `_meta.progressToken`. +/// +fn progress_sink(out: SyncSender, msg: &Value) -> Option { + let token = msg.get("params")?.get("_meta")?.get("progressToken").filter(|t| t.is_string() || t.is_number())?.clone(); + let last = Cell::new(None::); + let at = Cell::new(None::); + Some(Rc::new(move |done, total, what| { + // `progress` must increase with each notification. + if last.get().is_some_and(|l| done <= l) { + return; + } + let finished = total > 0 && done >= total; + if !finished && at.get().is_some_and(|t| t.elapsed() < PROGRESS_EVERY) { + return; + } + last.set(Some(done)); + at.set(Some(Instant::now())); + let mut params = json!({"progressToken": token, "progress": done, "message": what}); + if total > 0 { + params["total"] = json!(total); + } + let line = json!({"jsonrpc": "2.0", "method": "notifications/progress", "params": params}).to_string(); + // Advisory: a client that doesn't read fast enough misses an update, not the reply. + let _ = out.try_send(line); + })) +} + const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are \ points in document space (y down, origin at the first artboard's top-left; a new document is 612×792). \ Draw with draw_shape / draw_path, change colours with set_paint, look with render_preview and doc_inspect. \ @@ -42,20 +90,88 @@ vectorcraft://swatch/{name} read one thing at a time instead of the whole docume pub struct Server { backend: Box, initialized: bool, - modern: bool, + /// Whether the client speaks the `2026-07-28` revision. Shared with the reader thread, which + /// answers some requests itself while the engine is busy. + modern: Arc, /// Whether this server's client asked for log records (`logging/setLevel`). The queue is /// process-wide, so a server whose client didn't ask leaves it to the one that did. logging: bool, + busy_after: Duration, } -fn response(id: Value, result: Value) -> Value { +pub(crate) fn response(id: Value, result: Value) -> Value { json!({"jsonrpc": "2.0", "id": id, "result": result}) } -fn error(id: Value, code: i64, message: impl Into) -> Value { +pub(crate) fn error(id: Value, code: i64, message: impl Into) -> Value { json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) } +pub(crate) fn error_with(id: Value, code: i64, message: impl Into, data: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into(), "data": data}}) +} + +/// The requests that need neither the engine nor the server's state beyond the protocol revision, +/// so the reader can answer them while the engine is busy. +pub(crate) fn stateless_request(method: &str, params: &Value) -> Option> { + Some(match method { + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": tool_definitions()})), + "resources/list" => Ok(resources::list()), + "resources/templates/list" => Ok(resources::templates()), + "prompts/list" => Ok(prompts::list()), + "prompts/get" => { + let name = params.get("name").and_then(Value::as_str); + let args = params.get("arguments").cloned().unwrap_or(Value::Null); + match name { + Some(name) => prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e)), + None => Err((INVALID_PARAMS, "missing prompt `name`".to_string())), + } + } + _ => return None, + }) +} + +/// The reply to `msg` when it is a request [`stateless_request`] answers. +pub(crate) fn stateless_reply(msg: &Value, modern: bool) -> Option { + let o = msg.as_object()?; + let method = o.get("method")?.as_str()?; + let id = o.get("id").filter(|id| id.is_string() || id.is_number())?.clone(); + if o.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return None; + } + let params = o.get("params").cloned().unwrap_or(Value::Null); + // A panic here is a bug: leave the request to the engine thread, which reports it. + let r = vectorcraft_engine::guard::catch_panic(|| stateless_request(method, ¶ms)).ok()??; + Some(match r { + Ok(r) => response(id, complete_result(method, ¶ms, modern, r)), + Err((code, m)) => error(id, code, m), + }) +} + +/// The cache hints the `2026-07-28` revision adds to list and read results. `modern` is whether the +/// session negotiated that revision; a request can also name it in its `_meta`. +pub(crate) fn complete_result(method: &str, params: &Value, modern: bool, mut r: Value) -> Value { + let modern = params + .get("_meta") + .and_then(|m| m.get("io.modelcontextprotocol/protocolVersion")) + .and_then(Value::as_str) + .map_or(modern, |v| v == MODERN_VERSION); + if modern && let Some(result) = r.as_object_mut() { + let ttl = match method { + "tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000), + "resources/read" | "prompts/get" => Some(0), + _ => None, + }; + if let Some(ttl) = ttl { + result.insert("resultType".into(), json!("complete")); + result.insert("ttlMs".into(), json!(ttl)); + result.insert("cacheScope".into(), json!("private")); + } + } + r +} + /// Read one `\n`-terminated line into `buf` (newline stripped). `Ok(false)` at end of stream. /// `buf` holding more than `max` bytes means the line is overlong: no newline arrived within the /// bound, so the bytes so far are only its start. Only `max + 1` bytes are ever buffered for one @@ -101,7 +217,15 @@ fn write_reply(output: &mut impl Write, line: &str) -> std::io::Result<()> { impl Server { pub fn new(backend: Box) -> Self { - Self { backend, initialized: false, modern: false, logging: false } + Self { backend, initialized: false, modern: Arc::new(AtomicBool::new(false)), logging: false, busy_after: DEFAULT_BUSY_AFTER } + } + + /// How long a call runs before [`Server::serve`] turns away a request that would have to wait + /// behind it, with an error naming the call (default two seconds). Shorter waits queue, so a + /// client pipelining quick calls is answered in order. + pub fn with_busy_after(mut self, after: Duration) -> Self { + self.busy_after = after; + self } pub fn backend(&mut self) -> &mut dyn Backend { @@ -130,36 +254,76 @@ impl Server { /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is /// the protocol stream). - pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - let mut reader = input; - let mut buf = Vec::new(); - loop { - if !read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES)? { - return Ok(()); - } - if buf.len() > MAX_LINE_BYTES { - // Longer than anything a client can mean: answer the framing violation, skip the - // rest of the line in bounded chunks, and keep serving what follows. - write_reply( - &mut output, - &error(Value::Null, INVALID_REQUEST, format!("request line longer than {MAX_LINE_BYTES} bytes")).to_string(), - )?; - loop { - if !read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES)? { - return Ok(()); - } - if buf.len() <= MAX_LINE_BYTES { - break; + /// + /// A reader thread takes lines off `input` while the calling thread runs them, so the server + /// hears a client during a long call: `notifications/cancelled` stops a queued or running + /// request (which then gets no reply), and `ping` and the other engine-free requests are + /// answered at once. A request that would have to wait behind a call that has run for + /// [`Server::with_busy_after`] is refused with a "busy" error. Calls still run one at a time, + /// in arrival order, and replies of quick calls keep that order. See the module docs of + /// `intake` and `docs/mcp.md`. + /// + /// Returns when `input` ends and everything read has been answered, or when `output` fails + /// (the running call is then cancelled and the queue dropped; the reader thread ends with its + /// next line, which a client that closed our output closes as well). + pub fn serve(&mut self, input: R, mut output: W) -> std::io::Result<()> { + let shared = Shared::new(self.modern.clone(), self.busy_after); + let shared = &shared; + let (tx, rx) = sync_channel::(OUTBOX); + let reader_tx = tx.clone(); + std::thread::scope(|scope| { + // The only thread that writes: replies, progress and log records stay whole lines. + let writer = scope.spawn(move || { + let mut failed = None; + for line in rx { + if failed.is_none() + && let Err(e) = write_reply(&mut output, &line) + { + failed = Some(e); + shared.close(); } } - continue; + failed + }); + let reader = scope.spawn(move || read_loop(shared, &reader_tx, input)); + self.work(shared, &tx); + drop(tx); + let read = reader.join().unwrap_or_else(|_| Err(std::io::Error::other("the MCP reader thread panicked"))); + let written = writer.join().unwrap_or_else(|_| Some(std::io::Error::other("the MCP writer thread panicked"))); + match written { + Some(e) => Err(e), + None => read, } - let line = std::str::from_utf8(&buf).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - let reply = self.handle_line(line); - let notes = self.take_notifications(); - for line in reply.iter().chain(notes.iter()) { - write_reply(&mut output, line)?; + }) + } + + /// Run the queued jobs, one at a time, until the reader is done and the queue empty. + fn work(&mut self, shared: &Shared, out: &SyncSender) { + while let Some(job) = shared.next() { + match job { + Job::Canned(line) => { + let _ = out.send(line); + } + Job::Message(m) => self.run(m, out), } + for note in self.take_notifications() { + let _ = out.send(note); + } + shared.finished(); + } + } + + fn run(&mut self, m: Message, out: &SyncSender) { + let Message { value, key, token, .. } = m; + let sink = key.is_some().then(|| progress_sink(out.clone(), &value)).flatten(); + let reply = cancel::scope(&token, sink, || self.handle_parsed(value)); + // A cancelled request gets no reply (MCP: the receiver should not answer it). A call that + // finished before it noticed is dropped the same way: the client has stopped waiting. + if key.is_some() && token.is_cancelled() { + return; + } + if let Some(reply) = reply { + let _ = out.send(reply.to_string()); } } @@ -170,7 +334,16 @@ impl Server { return None; } let reply = match serde_json::from_str::(line) { - Ok(Value::Array(batch)) => { + Ok(value) => self.handle_parsed(value), + Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))), + }; + reply.map(|r| r.to_string()) + } + + /// Handle one parsed line: a message, or a batch of them. + fn handle_parsed(&mut self, value: Value) -> Option { + match value { + Value::Array(batch) => { // Batches were removed in 2025-06-18; still answer older clients sensibly. if batch.is_empty() { Some(error(Value::Null, INVALID_REQUEST, "empty batch")) @@ -181,10 +354,8 @@ impl Server { (!replies.is_empty()).then_some(Value::Array(replies)) } } - Ok(msg) => self.handle(msg), - Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))), - }; - reply.map(|r| r.to_string()) + msg => self.handle(msg), + } } /// Handle one JSON-RPC message; `None` for notifications and responses. @@ -216,26 +387,7 @@ impl Server { let r = vectorcraft_engine::guard::catch_panic(|| self.request(method, ¶ms)) .unwrap_or_else(|msg| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {msg} (please report this bug)")))); Some(match r { - Ok(mut r) => { - let modern = params - .get("_meta") - .and_then(|m| m.get("io.modelcontextprotocol/protocolVersion")) - .and_then(Value::as_str) - .map_or(self.modern, |v| v == MODERN_VERSION); - if modern && let Some(result) = r.as_object_mut() { - let ttl = match method { - "tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000), - "resources/read" | "prompts/get" => Some(0), - _ => None, - }; - if let Some(ttl) = ttl { - result.insert("resultType".into(), json!("complete")); - result.insert("ttlMs".into(), json!(ttl)); - result.insert("cacheScope".into(), json!("private")); - } - } - response(id, r) - } + Ok(r) => response(id, complete_result(method, ¶ms, self.modern.load(Ordering::Relaxed), r)), Err((code, m)) => error(id, code, m), }) } @@ -249,11 +401,14 @@ impl Server { } fn request(&mut self, method: &str, params: &Value) -> Result { + if let Some(r) = stateless_request(method, params) { + return r; + } match method { "initialize" => { let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION); let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION }; - self.modern = version == MODERN_VERSION; + self.modern.store(version == MODERN_VERSION, Ordering::Relaxed); Ok(json!({ "protocolVersion": version, "capabilities": { @@ -267,8 +422,6 @@ impl Server { "instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()), })) } - "ping" => Ok(json!({})), - "tools/list" => Ok(json!({"tools": tool_definitions()})), "tools/call" => { let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; let args = params.get("arguments").cloned().unwrap_or(Value::Null); @@ -277,8 +430,6 @@ impl Server { } Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) } - "resources/list" => Ok(resources::list()), - "resources/templates/list" => Ok(resources::templates()), "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e { @@ -288,12 +439,6 @@ impl Server { })?; Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]})) } - "prompts/list" => Ok(prompts::list()), - "prompts/get" => { - let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing prompt `name`".to_string()))?; - let args = params.get("arguments").cloned().unwrap_or(Value::Null); - prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e)) - } "completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)), "logging/setLevel" => { // `level` is required. A null level is our own way of turning logging back off, diff --git a/crates/mcp/src/tests_concurrency.rs b/crates/mcp/src/tests_concurrency.rs new file mode 100644 index 000000000..c2e23a77a --- /dev/null +++ b/crates/mcp/src/tests_concurrency.rs @@ -0,0 +1,368 @@ +//! Reading while a call runs: `ping`, cancellation and busy answers reach the client while a long +//! command holds the engine (#2165, #2171). Driven through [`Server::serve`] on a pipe, the way a +//! client on stdio drives it, with a backend that works until it is told to stop. + +use std::io::{BufReader, Write}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_engine::cancel; + +use crate::{Backend, Server}; + +/// A backend whose `engine.execute` runs until cancelled (or for `limit`), like a long export. +struct Slow { + limit: Duration, + /// Commands that ran to the end, and the ones that were cancelled. + log: Arc>>, + /// Told when a command starts. + started: Sender, +} + +impl Backend for Slow { + fn call(&mut self, method: &str, params: Value) -> Result { + if method != "engine.execute" { + return Ok(json!({"method": method})); + } + let id = params.get("command").and_then(Value::as_str).unwrap_or("?").to_string(); + self.started.send(id.clone()).ok(); + if id == "progress.demo" { + for page in 1..=3 { + // Enough apart to pass the sender's throttle. + std::thread::sleep(Duration::from_millis(230)); + cancel::progress(page, 3, &format!("page {page}")); + } + return Ok(json!({"ran": id})); + } + let limit = if id.starts_with("quick") { Duration::ZERO } else { self.limit }; + let start = Instant::now(); + while start.elapsed() < limit { + if let Err(e) = cancel::check() { + self.log.lock().unwrap().push(format!("cancelled {id}")); + return Err(e.to_string()); + } + std::thread::sleep(Duration::from_millis(5)); + } + self.log.lock().unwrap().push(format!("done {id}")); + Ok(json!({"ran": id})) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "slow".into() + } +} + +/// Collects the lines the server writes. +struct Lines(Sender, Vec); + +impl Write for Lines { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + for &b in buf { + if b == b'\n' { + self.0.send(String::from_utf8_lossy(&std::mem::take(&mut self.1)).into_owned()).ok(); + } else { + self.1.push(b); + } + } + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// A running server with a pipe in and a channel out. +struct Rig { + to_server: std::io::PipeWriter, + replies: Receiver, + started: Receiver, + log: Arc>>, + thread: Option>, +} + +impl Rig { + fn new(limit: Duration, busy_after: Duration) -> Self { + let (read, to_server) = std::io::pipe().unwrap(); + let (tx, replies) = mpsc::channel(); + let (started_tx, started) = mpsc::channel(); + let log = Arc::new(Mutex::new(vec![])); + let backend = Slow { limit, log: log.clone(), started: started_tx }; + let thread = std::thread::spawn(move || { + let mut server = Server::new(Box::new(backend)).with_busy_after(busy_after); + server.serve(BufReader::new(read), Lines(tx, vec![])).unwrap(); + }); + Self { to_server, replies, started, log, thread: Some(thread) } + } + + fn send(&mut self, msg: Value) { + writeln!(self.to_server, "{msg}").unwrap(); + } + + fn request(&mut self, id: u64, method: &str, params: Value) { + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + } + + fn run(&mut self, id: u64, command: &str) { + self.request(id, "tools/call", json!({"name": "command_run", "arguments": {"id": command}})); + } + + fn notify(&mut self, method: &str, params: Value) { + self.send(json!({"jsonrpc": "2.0", "method": method, "params": params})); + } + + fn next(&self, within: Duration) -> Option { + match self.replies.recv_timeout(within) { + Ok(l) => serde_json::from_str(&l).ok(), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => None, + } + } + + /// The next reply that answers request `id`, skipping notifications. + fn reply(&self, id: u64, within: Duration) -> Option { + let end = Instant::now() + within; + while let Some(left) = end.checked_duration_since(Instant::now()) { + let v = self.next(left)?; + if v.get("id") == Some(&json!(id)) { + return Some(v); + } + } + None + } + + fn finish(mut self) { + let thread = self.thread.take().unwrap(); + drop(self.to_server); + thread.join().unwrap(); + } +} + +const LONG: Duration = Duration::from_secs(20); +const QUICK: Duration = Duration::from_secs(3); + +#[test] +fn ping_is_answered_while_a_command_runs() { + let mut rig = Rig::new(LONG, Duration::from_millis(50)); + rig.run(1, "document.export"); + assert_eq!(rig.started.recv_timeout(QUICK).unwrap(), "document.export"); + // Past `busy_after` the call counts as long-running. + std::thread::sleep(Duration::from_millis(120)); + rig.request(2, "ping", json!({})); + let pong = rig.reply(2, QUICK).expect("ping must not wait for the running command"); + assert_eq!(pong["result"], json!({})); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + rig.finish(); +} + +#[test] +fn cancelling_stops_the_running_command_and_sends_no_reply() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.run(1, "document.export"); + rig.started.recv_timeout(QUICK).unwrap(); + rig.notify("notifications/cancelled", json!({"requestId": 1, "reason": "user"})); + // The engine is free again: the next command runs and answers. + rig.run(2, "pdf.preset.list"); + let end = Instant::now() + QUICK; + loop { + if rig.log.lock().unwrap().iter().any(|l| l == "cancelled document.export") { + break; + } + assert!(Instant::now() < end, "the export kept running: {:?}", rig.log.lock().unwrap()); + std::thread::sleep(Duration::from_millis(10)); + } + rig.notify("notifications/cancelled", json!({"requestId": 2})); + // Request 1 was cancelled: nothing answers it (MCP: the receiver should not reply). + assert!(rig.reply(1, Duration::from_millis(300)).is_none()); + rig.finish(); +} + +#[test] +fn a_request_that_would_wait_behind_a_long_call_is_refused_as_busy() { + let mut rig = Rig::new(LONG, Duration::from_millis(50)); + rig.run(1, "document.export"); + rig.started.recv_timeout(QUICK).unwrap(); + std::thread::sleep(Duration::from_millis(120)); + rig.run(2, "pdf.preset.list"); + let busy = rig.reply(2, QUICK).expect("a busy answer, not a wait"); + assert_eq!(busy["error"]["code"], -32003, "{busy}"); + assert!(busy["error"]["message"].as_str().unwrap().contains("document.export"), "{busy}"); + assert_eq!(busy["error"]["data"]["busy"]["requestId"], "1", "{busy}"); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + // The refused request never ran, and the engine is free once the export stops. + rig.run(3, "pdf.preset.list"); + rig.notify("notifications/cancelled", json!({"requestId": 3})); + rig.finish(); +} + +#[test] +fn quick_calls_pipelined_together_queue_and_answer_in_order() { + let mut rig = Rig::new(Duration::from_millis(30), Duration::from_secs(60)); + for id in 1..=4 { + rig.run(id, &format!("cmd{id}")); + } + for id in 1..=4 { + let r = rig.reply(id, QUICK).unwrap_or_else(|| panic!("no reply to {id}")); + assert!(r.get("error").is_none(), "{r}"); + } + assert_eq!(*rig.log.lock().unwrap(), ["done cmd1", "done cmd2", "done cmd3", "done cmd4"]); + rig.finish(); +} + +#[test] +fn a_queued_request_that_is_cancelled_never_runs() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.run(1, "document.export"); + rig.started.recv_timeout(QUICK).unwrap(); + rig.run(2, "never.runs"); + rig.run(3, "quick.last"); + // Request 2 may not have been read yet when the cancel is: it is the order of lines that counts. + rig.notify("notifications/cancelled", json!({"requestId": 2})); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + let r = rig.reply(3, QUICK).expect("the engine goes on to the next request"); + assert!(r.get("error").is_none(), "{r}"); + rig.notify("notifications/cancelled", json!({"requestId": 3})); + let log = rig.log.lock().unwrap().clone(); + assert!(log.iter().all(|l| !l.contains("never.runs")), "{log:?}"); + assert!(log.iter().any(|l| l == "cancelled document.export"), "{log:?}"); + drop(log); + rig.finish(); +} + +#[test] +fn cancelling_an_unknown_or_finished_request_changes_nothing() { + let mut rig = Rig::new(Duration::from_millis(10), Duration::from_secs(60)); + rig.run(1, "quick"); + assert!(rig.reply(1, QUICK).unwrap().get("result").is_some()); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + rig.notify("notifications/cancelled", json!({"requestId": 99})); + rig.notify("notifications/cancelled", json!({"requestId": [1], "reason": "junk"})); + rig.notify("notifications/cancelled", json!("junk")); + rig.run(2, "quick"); + assert!(rig.reply(2, QUICK).unwrap().get("result").is_some()); + rig.finish(); +} + +#[test] +fn initialize_cannot_be_cancelled() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.request(1, "initialize", json!({})); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + assert!(rig.reply(1, QUICK).unwrap().get("result").is_some()); + rig.finish(); +} + +#[test] +fn progress_reaches_a_client_that_gave_a_token_before_the_reply() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.request(1, "tools/call", json!({"name": "command_run", "arguments": {"id": "progress.demo"}, "_meta": {"progressToken": "tok"}})); + let mut seen = vec![]; + let reply = loop { + let v = rig.next(QUICK).expect("a reply"); + if v["method"] == "notifications/progress" { + seen.push(v["params"].clone()); + } else { + break v; + } + }; + assert!(reply.get("result").is_some(), "{reply}"); + assert_eq!(seen.len(), 3, "{seen:?}"); + let values: Vec = seen.iter().map(|p| p["progress"].as_u64().unwrap()).collect(); + assert_eq!(values, [1, 2, 3]); + assert!(seen.iter().all(|p| p["progressToken"] == "tok" && p["total"] == 3), "{seen:?}"); + assert_eq!(seen[1]["message"], "page 2"); + // Without a token nothing is sent. + rig.run(2, "progress.demo"); + let next = rig.next(QUICK).unwrap(); + assert_eq!(next["id"], 2, "{next}"); + rig.finish(); +} + +/// The real engine behind the server: waits (like a slow render would) until the call is +/// cancelled, then lets the export run into the cancelled token. +struct Gated { + inner: crate::Headless, + started: Sender<()>, +} + +impl Backend for Gated { + fn call(&mut self, method: &str, params: Value) -> Result { + if method == "engine.execute" && params["command"] == "document.export" { + self.started.send(()).ok(); + let end = Instant::now() + LONG; + while !cancel::is_cancelled() && Instant::now() < end { + std::thread::sleep(Duration::from_millis(5)); + } + } + self.inner.call(method, params) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "gated".into() + } +} + +#[test] +fn a_cancelled_export_through_the_real_engine_writes_nothing_and_leaves_the_session_usable() { + let path = vectorcraft_testkit::temp_dir("mcp-cancel").join("out.pdf"); + std::fs::remove_file(&path).ok(); + let (read, mut to_server) = std::io::pipe().unwrap(); + let (tx, replies) = mpsc::channel(); + let (started_tx, started) = mpsc::channel(); + let thread = std::thread::spawn(move || { + let backend = Gated { inner: crate::Headless::with_document(), started: started_tx }; + Server::new(Box::new(backend)).serve(BufReader::new(read), Lines(tx, vec![])).unwrap(); + }); + let send = |w: &mut std::io::PipeWriter, v: Value| writeln!(w, "{v}").unwrap(); + let params = json!({"name": "command_run", "arguments": {"id": "document.export", "params": {"path": path.to_str().unwrap()}}}); + send(&mut to_server, json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params})); + started.recv_timeout(QUICK).unwrap(); + send(&mut to_server, json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}})); + // The next request is answered: the cancelled export neither blocks nor breaks the engine. + send(&mut to_server, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "doc_inspect", "arguments": {}}})); + let reply: Value = serde_json::from_str(&replies.recv_timeout(QUICK).unwrap()).unwrap(); + assert_eq!(reply["id"], 2, "request 1 is not answered: {reply}"); + assert_eq!(reply["result"]["isError"], false, "{reply}"); + assert!(!path.exists(), "a cancelled export writes no file"); + drop(to_server); + thread.join().unwrap(); +} + +#[test] +fn a_remote_call_stops_waiting_for_the_app_when_cancelled() { + use std::io::BufRead; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + // An app that takes the request and never answers. + let app = std::thread::spawn(move || { + let (conn, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap(); + // Hold the connection open until the test is done with it. + std::thread::sleep(Duration::from_secs(3)); + drop(conn); + }); + let mut remote = crate::Remote::connect(&addr).unwrap(); + let token = cancel::CancelToken::new(); + let raise = token.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + raise.cancel(); + }); + let start = Instant::now(); + let r = cancel::scope(&token, None, || remote.call("engine.execute", json!({"command": "document.export"}))); + let why = r.unwrap_err(); + assert!(why.contains("cancelled"), "{why}"); + assert!(start.elapsed() < Duration::from_secs(2), "returned when cancelled, not when the app answered"); + app.join().unwrap(); +} diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index 7aad0fb9d..9605a0ca7 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -617,3 +617,64 @@ fn tools_never_leave_interactions_open() { } assert!(failures.is_empty(), "{}", failures.join("\n")); } + +proptest! { + #![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })] + + /// Requests mixed with `notifications/cancelled` (for ids that are queued, running, finished, + /// unknown or junk) read from one stream: the server ends, every request nobody cancelled is + /// answered exactly once, a cancelled one at most once, nothing is answered that wasn't asked, + /// replies keep the order of the requests, and the document stays valid. + #[test] + fn cancellation_never_loses_or_duplicates_an_answer( + steps in prop::collection::vec((0u8..6, 0u64..10), 1..40), + ) { + let mut input = String::new(); + let mut asked: Vec = vec![]; + let mut cancelled: Vec = vec![]; + for (i, &(kind, n)) in steps.iter().enumerate() { + let id = i as u64 + 1; + let line = match kind { + 0 => json!({"jsonrpc": "2.0", "id": id, "method": "ping"}), + 1 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/list"}), + 2 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": "draw_shape", "arguments": {"shape": "rectangle", "x": 1, "y": 2, "width": 30, "height": 20}}}), + 3 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": "command_run", "arguments": {"id": "select.all"}}, "_meta": {"progressToken": id}}), + // A cancel for an id that may be queued, finished or never asked. + 4 => { + let target = n.min(id.saturating_sub(1)).max(1); + cancelled.push(target); + input.push_str(&format!("{}\n", json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": target}}))); + continue; + } + // Junk cancels never take anything with them. + _ => { + let junk = [json!(null), json!([1]), json!({"a": 1}), json!("nope"), json!(1.5), json!(-1)]; + input.push_str(&format!("{}\n", json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": junk[n as usize % junk.len()]}}))); + continue; + } + }; + asked.push(id); + input.push_str(&format!("{line}\n")); + } + let mut s = server(); + let mut out = Vec::new(); + s.serve(input.as_bytes(), &mut out).unwrap(); + let ids: Vec = String::from_utf8(out) + .unwrap() + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .filter(|v| v.get("method").is_none()) + .map(|v| v["id"].as_u64().unwrap()) + .collect(); + prop_assert!(ids.windows(2).all(|w| w[0] < w[1]), "answered once each, in request order: {:?}", ids); + for id in &asked { + if !cancelled.contains(id) { + prop_assert!(ids.contains(id), "request {} was never answered: {:?}", id, ids); + } + } + for id in &ids { + prop_assert!(asked.contains(id), "answer to a request nobody made: {}", id); + } + check_document(&document(&mut s)).unwrap(); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 1da3b3710..7d729846e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -44,8 +44,11 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result { let set = &opts.settings; set.check()?; + vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; // Live geometry effects export as their result; raster effects are reported below. let baked = vectorcraft_effects::bake_document(doc); + // Baking stops early when cancelled, leaving a half-baked copy that must not be written. + vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let doc = baked.as_ref().unwrap_or(doc); let pages = pages(doc, opts)?; @@ -77,7 +80,11 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result Result **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -283,6 +283,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, graph variable kinds. Actions, Variables (text, visibility and image), dataset CSV import/export and full agent control over kinds, dataset import/export. Actions, Variables (text, visibility and image) and full agent control over +- **Done (2026-10-16):** the MCP server hears the client during a long call: `notifications/cancelled` stops a queued or running export (PDF pages, flattening, baking, drawing, artboards) without writing anything or changing the document, `ping` and the list methods answer at once, any other request that would wait behind a call running for two seconds or more is refused as busy (`-32003`, naming the call), and `_meta.progressToken` gets page progress (#2165, #2171, #2172). Reading the document during an export is not served (refused as busy); estimate unchanged. - **Exposed in MCP:** `add_text {vertical: true}` uses the existing vertical point, area and path type commands. Horizontal writing remains the default; the broader automation gap and estimate are unchanged. - **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, image and graph variable kinds, dataset import/export. Actions, Variables (text and visibility) and full agent control over @@ -335,6 +336,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G6: MCP cancellation, progress notifications and a busy answer during long exports (#2165, #2171, #2172); no entry closed, estimates unchanged | | 2026-10-16 | minor | G11: PDF export of a CMYK document reports its RGB colours and non-CMYK images, with a count, in the export warnings (#2162); no entry closed, estimates unchanged | | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to G12: quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button in the Control bar or Properties opens the controls (). Wording only; no entry opened or closed, estimates unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 5d8f0c204..e0f2fb9e5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -225,36 +225,57 @@ crate that installed one would override whatever logger an embedder had already `vectorcraft-mcp` directly means calling `vectorcraft_mcp::logging::install()` yourself if you want log records to reach your client. -### Synchronous calls +### Long calls: cancellation, progress and a busy engine -Exports complete synchronously. A `_meta.progressToken` is harmlessly ignored, as are -`notifications/cancelled` (including unknown request ids). There are no background export jobs -or progress notifications; cancellation cannot interrupt a running call. +A call runs to the end before its reply, and the engine runs one call at a time, in the order the +lines arrived. A thread of its own reads the stream meanwhile, so the server still hears the client +while an export runs (#2165, #2171, #2172): + +- **Cancellation.** `notifications/cancelled {requestId}` + ([spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation)) + takes a request out of the queue, or stops the one that is running, and **that request gets no + reply**. A running export, flatten, bake or render notices at its next checkpoint (between pages + and artboards, between flattened groups, while drawing objects), returns, and writes nothing: the + document is exactly as it was and the engine is free for the next call. An unknown, finished or + malformed `requestId` is ignored, and so is a cancel of `initialize`. A call without checkpoints + (every ordinary edit is over in milliseconds) runs to its end, but its reply is dropped all the + same. Against a running app (`--connect`) the server stops waiting and drops the connection, but + the app cannot be told to stop what it started. +- **Progress.** A request with `_meta.progressToken` gets `notifications/progress` + ([spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress)) before + its reply: the PDF writer reports "Writing page 2 of 5" with `progress` 2 and `total` 5, raster + export reports each artboard, and the PDF flattening stage reports a message without a total. `progress` only grows, updates come at most every 200 ms (the last page always), + and none follows the reply. A client that does not read fast enough misses updates, never the reply. +- **A busy engine.** `ping`, `tools/list`, `resources/list`, `resources/templates/list`, + `prompts/list` and `prompts/get` need no engine and are answered at once, whatever is running. + Anything else that arrives while a call has already run for two seconds is not queued behind it: + it is answered with error `-32003` (`data.busy` names the running call, its request id and how + long it has run) and was **not run**, so the client can retry later or cancel the running call. + Requests that arrive sooner than that queue and are answered in order, as before, so a client + that sends a few quick calls together sees no difference. At most 64 lines wait; past that the + server stops reading until the engine catches up. + +`vectorcraft_mcp::Server::serve` takes the input and the output on separate threads (`Send`), and +`Server::with_busy_after` changes the two seconds. Calling `handle_line` directly is as before: +one line in, one reply out, no threads. ### Not implemented -Everything below needs the same thing first: the server reads one line at a time and answers it with -one reply, so **while a `tools/call` is running it neither reads an incoming line nor writes an -outgoing one.** Progress and cancellation are therefore both meaningless as they stand, and a -subscription could only ever be checked between calls — which is not what a client asking to be told -about a change expects. - - **`resources/subscribe` / `notifications/resources/updated`.** It would also have to be honest about *whose* edits it sees: comparing the undo history's length misses edits made in the app window, which is the main reason to run against a live document. `document.inspect`'s `revision` is the right signal — it is monotonic and already bumped when a document is replaced — but noticing an edit still means someone asking. -- **`notifications/progress`.** It would have to be written *before* the reply it belongs to, since a - progress token stops meaning anything once the request has completed. -- **`notifications/cancelled`.** It would have to be read *during* the call it cancels. -- **`sampling/createMessage` and `elicitation/create`.** Both are server→client requests, blocked by - the same one-line-one-reply loop. Supporting them means being able to suspend a tool call - mid-flight, which is what the `2026-07-28` revision's input-requests mechanism exists for. +- **Reading the document during a long call.** Requests that need the engine (`doc_inspect`, + `resources/read`, `command_list`) are refused as busy while an export runs, not answered from a + snapshot. +- **`sampling/createMessage` and `elicitation/create`.** Both are server→client requests. Supporting + them means being able to suspend a tool call mid-flight, which is what the `2026-07-28` revision's + input-requests mechanism exists for. - **Streamable HTTP / SSE.** stdio only. `completion/complete` covers the "what could I pass here" half of the same problem, which is why it -is here and subscriptions are not. The fix for the rest is a reader thread (or an event loop) around -the transport — a design change rather than a feature, so it is not in this crate yet. +is here and subscriptions are not. ## Tools diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b35db0fb8..f8f68e48a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -418,7 +418,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - JSON-lines control channel with real egui pointer and keyboard injection. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. - - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). + - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785). - Actions panel that records and plays back commands. @@ -690,6 +690,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Agent automation: MCP cancellation, progress notifications and busy answers during long exports (#2165, #2171, #2172); scores and hours unchanged | | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Type: localized legacy family names remain available when typographic names are English-only (#2208); loaded and catalog paths tested. Scores and estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) |