From f14f4ad55cf726e07bce4340ae9b5947cf87bca7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 21:52:56 -0700 Subject: [PATCH 1/6] Render: the decoded-image cache can grow to a quarter of installed memory on every desktop OS The renderers share decoded images within a budget that was 1 GiB on every desktop OS: the installed memory was read only on Linux, and there the budget was an eighth of it, at most 1 GiB. Images whose decoded pixels add up to more than that were decoded again each time the view moved from one image to another: 0.9 to 1.1 s for a 12000 x 9000 JPEG and 2.0 to 2.2 s for a 16384 x 16384 one on a Ryzen 7 9800X3D. The default budget is now a quarter of the installed memory, with no fixed ceiling: GlobalMemoryStatusEx on Windows (through winsafe, which vectorcraft-cli already uses), sysctl hw.memsize on macOS, hw.physmem on FreeBSD and /proc/meminfo on Linux. It stays 1 GiB where the memory can't be read and 256 MiB in the web build, and a 32-bit build keeps at most a quarter of its address space. Docs: docs/target-app-parity.md. --- Cargo.lock | 1 + crates/render/Cargo.toml | 5 ++++ crates/render/src/images.rs | 59 +++++++++++++++++++++++++++++++++---- docs/target-app-parity.md | 1 + 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5abe6b35d..734ccc628 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6230,6 +6230,7 @@ dependencies = [ "vello_cpu", "wat", "weezl 0.2.1", + "winsafe", ] [[package]] diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index ad8696e1c..d8d56da1d 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -29,6 +29,11 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] vello_cpu = { workspace = true, features = ["multithreading"] } +# The shared image store's budget (src/images.rs) follows the installed memory, which it reads with GlobalMemoryStatusEx +# through winsafe's safe wrappers over Win32 (MIT), which vectorcraft-cli's src/perf.rs also uses. +[target.'cfg(windows)'.dependencies] +winsafe = { version = "0.0.29", default-features = false, features = ["kernel"] } + [dev-dependencies] vectorcraft-testkit = { workspace = true } # A variable font built in code (no font file is committed). diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 63577ab89..f71188e43 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -19,8 +19,9 @@ use vello_cpu::color::PremulRgba8; use crate::ink::Ink; -/// Largest default budget of the shared image store, in bytes; less on machines with under 8 GiB -/// of memory (an eighth of it). [`set_image_cache_budget`] changes it. +/// Default budget of the shared image store, in bytes, where the installed memory can't be read. +/// Elsewhere the default is a quarter of the installed memory, and on the web it is 256 MiB. +/// [`set_image_cache_budget`] changes it. pub const IMAGE_CACHE_MAX: usize = 1 << 30; /// The default budget on the web (a tab's memory is tight). @@ -31,10 +32,17 @@ fn default_budget() -> usize { #[cfg(not(target_arch = "wasm32"))] fn default_budget() -> usize { - physical_memory().map_or(IMAGE_CACHE_MAX, |m| usize::try_from(m / 8).unwrap_or(usize::MAX).min(IMAGE_CACHE_MAX)) + physical_memory().filter(|&m| m > 0).map_or(IMAGE_CACHE_MAX, budget_for) } -/// Installed memory in bytes, where the system says without a native call (Linux). +/// The default budget on a machine with `memory` bytes installed: a quarter of it, and at most a +/// quarter of the address space (on 32-bit builds). +#[cfg(not(target_arch = "wasm32"))] +fn budget_for(memory: u64) -> usize { + usize::try_from(memory / 4).unwrap_or(usize::MAX).min(usize::MAX / 4) +} + +/// Installed memory in bytes (`MemTotal` in `/proc/meminfo`). #[cfg(target_os = "linux")] fn physical_memory() -> Option { let info = std::fs::read_to_string("/proc/meminfo").ok()?; @@ -43,7 +51,22 @@ fn physical_memory() -> Option { kib.checked_mul(1024) } -#[cfg(not(any(target_os = "linux", target_arch = "wasm32")))] +/// Installed memory in bytes (`GlobalMemoryStatusEx`). +#[cfg(windows)] +fn physical_memory() -> Option { + winsafe::GlobalMemoryStatusEx().ok().map(|m| m.ullTotalPhys) +} + +/// Installed memory in bytes, as `sysctl` prints it (`hw.memsize` on macOS, `hw.physmem` on +/// FreeBSD). +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +fn physical_memory() -> Option { + let (sysctl, name) = if cfg!(target_os = "macos") { ("/usr/sbin/sysctl", "hw.memsize") } else { ("/sbin/sysctl", "hw.physmem") }; + let out = std::process::Command::new(sysctl).args(["-n", name]).output().ok().filter(|o| o.status.success())?; + String::from_utf8(out.stdout).ok()?.trim().parse().ok() +} + +#[cfg(not(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd", target_arch = "wasm32")))] fn physical_memory() -> Option { None } @@ -290,6 +313,32 @@ mod tests { assert_eq!(s.bytes(), 400); } + #[test] + #[cfg(target_pointer_width = "64")] + fn default_budget_is_a_quarter_of_installed_memory() { + assert_eq!(budget_for(16 << 30), 4 << 30); + assert_eq!(budget_for(64 << 30), 16 << 30, "no fixed ceiling"); + assert_eq!(budget_for(2 << 30), 512 << 20); + } + + #[test] + #[cfg(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd"))] + fn installed_memory_is_read() { + assert!(physical_memory().is_some_and(|m| m >= 256 << 20), "{:?}", physical_memory()); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn two_images_of_a_tenth_of_memory_each_stay_cached() { + // 16 MiB installed (16 GiB scaled down): two images of 1,600 KiB (640 × 640 pixels) take + // about a fifth of it. + let mut s = ImageStore::new(budget_for(16 << 20)); + drop(s.insert(key("a"), Arc::new(Pixmap::new(640, 640)))); + drop(s.insert(key("b"), Arc::new(Pixmap::new(640, 640)))); + assert!(s.peek(&key("a")).is_some() && s.peek(&key("b")).is_some(), "neither image is decoded again"); + assert_eq!(s.bytes(), 2 * 640 * 640 * 4); + } + #[test] fn half_averages_and_rounds_up_odd_sizes() { let px = |v: u8| PremulRgba8 { r: v, g: v, b: v, a: 255 }; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6a77b40d2..f559fe7af 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -979,6 +979,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the tool cursor is painted only while the window has the focus. Over a window behind another app, or under the File › Open… sheet, the system pointer shows alone instead of next to the painted cursor (#2020). In a rotated view the resize and rotate cursors turn with the view, to the nearest 45°. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502). + The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD, within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. From ac16d957fb6b188134d27d99180c5d352de546e0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 05:35:07 -0700 Subject: [PATCH 2/6] Undo: the history keeps steps within a quarter of installed memory on every desktop OS Each document's undo history drops its oldest steps once they keep more memory than a budget. The engine read the installed memory only on Linux, so the budget was 1 GiB on Windows, macOS and FreeBSD, and an eighth of the memory, at most 1 GiB, on Linux. The default budget is now a quarter of the installed memory, with no fixed ceiling and at least 64 MiB, read with the image store's reader, which vectorcraft-render now exports as physical_memory. It stays 1 GiB where the memory can't be read and 256 MiB in the web build, and a 32-bit build keeps at most a quarter of its address space. Docs: docs/target-app-parity.md. --- crates/engine/src/lib.rs | 20 ++++++++------------ crates/engine/src/tests_history_mem.rs | 18 ++++++++++++++++++ crates/render/src/images.rs | 11 ++++++----- crates/render/src/lib.rs | 1 + docs/target-app-parity.md | 1 + 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8515e07cc..03912ca7d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -225,25 +225,21 @@ impl History { } } -/// The default [`History::byte_budget`]: an eighth of the machine's memory where it is known -/// (Linux), at most 1 GiB (the figure elsewhere); 256 MiB on the web, whose memory is small. +/// The default [`History::byte_budget`]: a quarter of the installed memory +/// ([`vectorcraft_render::physical_memory`], see [`history_budget_for`]); 1 GiB where the installed +/// memory can't be read, and 256 MiB on the web. pub fn default_history_budget() -> usize { - const MAX: usize = 1 << 30; if cfg!(target_arch = "wasm32") { return 256 << 20; } static BUDGET: std::sync::OnceLock = std::sync::OnceLock::new(); - *BUDGET.get_or_init(|| physical_memory().map_or(MAX, |m| (m / 8).clamp(64 << 20, MAX))) + *BUDGET.get_or_init(|| vectorcraft_render::physical_memory().filter(|&m| m > 0).map_or(1 << 30, history_budget_for)) } -/// The machine's memory, where cheap to learn without platform calls. -fn physical_memory() -> Option { - if !cfg!(target_os = "linux") { - return None; - } - let info = std::fs::read_to_string("/proc/meminfo").ok()?; - let kb: usize = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; - kb.checked_mul(1024) +/// The default [`History::byte_budget`] on a machine with `memory` bytes installed: a quarter of it, +/// at least 64 MiB, and at most a quarter of the address space (on 32-bit builds). +fn history_budget_for(memory: u64) -> usize { + usize::try_from(memory / 4).unwrap_or(usize::MAX).clamp(64 << 20, usize::MAX / 4) } /// An in-progress drag: the snapshot the previews are applied to. diff --git a/crates/engine/src/tests_history_mem.rs b/crates/engine/src/tests_history_mem.rs index 7728672cc..b8f863877 100644 --- a/crates/engine/src/tests_history_mem.rs +++ b/crates/engine/src/tests_history_mem.rs @@ -75,6 +75,24 @@ fn within_the_budget_the_step_limit_still_counts() { assert!(st.history.bytes() < st.history.byte_budget); } +#[test] +#[cfg(target_pointer_width = "64")] +fn the_default_budget_is_a_quarter_of_installed_memory() { + assert_eq!(history_budget_for(16 << 30), 4 << 30); + assert_eq!(history_budget_for(64 << 30), 16 << 30, "no fixed ceiling"); + assert_eq!(history_budget_for(2 << 30), 512 << 20); + assert_eq!(history_budget_for(128 << 20), 64 << 20, "at least 64 MiB"); +} + +#[test] +#[cfg(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd"))] +fn the_default_budget_follows_the_installed_memory_read() { + let memory = vectorcraft_render::physical_memory(); + assert!(memory.is_some_and(|m| m >= 256 << 20), "{memory:?}"); + assert_eq!(default_history_budget(), memory.map_or(0, history_budget_for)); + assert_eq!(History::default().byte_budget, default_history_budget()); +} + #[test] fn undo_steps_keep_no_lookup_tables() { let mut s = session_with_rects(200); diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index f71188e43..951cb21c7 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -44,7 +44,7 @@ fn budget_for(memory: u64) -> usize { /// Installed memory in bytes (`MemTotal` in `/proc/meminfo`). #[cfg(target_os = "linux")] -fn physical_memory() -> Option { +pub fn physical_memory() -> Option { let info = std::fs::read_to_string("/proc/meminfo").ok()?; let line = info.lines().find(|l| l.starts_with("MemTotal:"))?; let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?; @@ -53,21 +53,22 @@ fn physical_memory() -> Option { /// Installed memory in bytes (`GlobalMemoryStatusEx`). #[cfg(windows)] -fn physical_memory() -> Option { +pub fn physical_memory() -> Option { winsafe::GlobalMemoryStatusEx().ok().map(|m| m.ullTotalPhys) } /// Installed memory in bytes, as `sysctl` prints it (`hw.memsize` on macOS, `hw.physmem` on /// FreeBSD). #[cfg(any(target_os = "macos", target_os = "freebsd"))] -fn physical_memory() -> Option { +pub fn physical_memory() -> Option { let (sysctl, name) = if cfg!(target_os = "macos") { ("/usr/sbin/sysctl", "hw.memsize") } else { ("/sbin/sysctl", "hw.physmem") }; let out = std::process::Command::new(sysctl).args(["-n", name]).output().ok().filter(|o| o.status.success())?; String::from_utf8(out.stdout).ok()?.trim().parse().ok() } -#[cfg(not(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd", target_arch = "wasm32")))] -fn physical_memory() -> Option { +/// Installed memory in bytes: not read on other targets, the web among them. +#[cfg(not(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd")))] +pub fn physical_memory() -> Option { None } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 49e6e23ff..f26f703e8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -14,6 +14,7 @@ mod freeform; mod fx; mod group; mod images; +pub use images::physical_memory; mod ink; mod live; mod lru; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f559fe7af..31f74c7a5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -980,6 +980,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a rotated view the resize and rotate cursors turn with the view, to the nearest 45°. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502). The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD, within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes. + Each document's undo history keeps its steps within a quarter of the installed memory on the same systems (at least 64 MiB), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; past that budget the oldest steps are dropped, but the last eight undo steps and the next eight redo steps stay. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. From f3104b154cd629112ea364e394278575dd24b34b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 20:47:51 -0700 Subject: [PATCH 3/6] Undo: the budget test allows unreadable memory; docs name the 32-bit cap the_default_budget_follows_the_installed_memory_read compares the default budget with the 1 GiB fallback when the installed memory can't be read, as default_history_budget does, instead of failing. The reader itself is still checked by vectorcraft-render's installed_memory_is_read. The undo history line in docs/target-app-parity.md says the budget is at most a quarter of the address space in a 32-bit build. --- crates/engine/src/tests/history_mem.rs | 6 +++--- docs/target-app-parity.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests/history_mem.rs b/crates/engine/src/tests/history_mem.rs index d13782331..ac1a29e17 100644 --- a/crates/engine/src/tests/history_mem.rs +++ b/crates/engine/src/tests/history_mem.rs @@ -87,9 +87,9 @@ fn the_default_budget_is_a_quarter_of_installed_memory() { #[test] #[cfg(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd"))] fn the_default_budget_follows_the_installed_memory_read() { - let memory = vectorcraft_render::physical_memory(); - assert!(memory.is_some_and(|m| m >= 256 << 20), "{memory:?}"); - assert_eq!(default_history_budget(), memory.map_or(0, history_budget_for)); + // Where the memory can't be read (a sandbox that hides it), the budget is the 1 GiB fallback. + let memory = vectorcraft_render::physical_memory().filter(|&m| m > 0); + assert_eq!(default_history_budget(), memory.map_or(1 << 30, history_budget_for), "{memory:?}"); assert_eq!(History::default().byte_budget, default_history_budget()); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d0a07c10e..cbed5e860 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -994,7 +994,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An image whose pixels can't be decoded, such as a PNG cut short or an image over the 2 GB decoding allowance as RGBA that can't be drawn at reduced size, is tried once: the renderer notes it and does not try to decode it again on later frames. An image refused only because the images being drawn leave no room for it is tried again on the next render. On the canvas such an image draws as a light gray box crossed from corner to corner, and the status bar says once for each image that it couldn't be decoded; exports leave it out and report it as before. A PNG, JPEG, GIF or WebP image that takes more than 2 GiB as RGBA (512 MiB in the web app) draws in the canvas and in raster exports at the largest size within that allowance, instead of as that crossed box on the canvas and not at all in exports. Each pixel of the result is the mean of the image's pixels it covers, and the document keeps the whole file. A PNG that isn't interlaced is read a row at a time, and other images are read whole when their decoded samples fit in the allowance. A 16-bit PNG that isn't interlaced, whose decoded samples take more than the allowance and whose RGBA fits in it, draws at its own size, read a row at a time. An image of more pixels than the allowance has bytes (on the desktop, 46,341 × 46,341 is more) still draws as the crossed box, and so does one whose decoded samples take more than the allowance, other than a PNG that isn't interlaced. Open, Place and the Links panel say "an image of W × H pixels is shown at reduced size", and say "too large to draw" of an image that is not drawn at reduced size. The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD, within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes. - Each document's undo history keeps its steps within a quarter of the installed memory on the same systems (at least 64 MiB), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; past that budget the oldest steps are dropped, but the last eight undo steps and the next eight redo steps stay. + Each document's undo history keeps its steps within a quarter of the installed memory on the same systems (at least 64 MiB, and at most a quarter of the address space in a 32-bit build), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; past that budget the oldest steps are dropped, but the last eight undo steps and the next eight redo steps stay. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. From 16972e6589ee85c01886c605874a4417cab5d5d4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 19:56:33 -0700 Subject: [PATCH 4/6] Undo: on Linux the memory budgets follow the process's cgroup memory limit The installed memory that sizes the image cache and the undo history is now the lower of MemTotal and the memory limit of the process's cgroups and their ancestors (memory.max under cgroup v2, memory.limit_in_bytes under v1), looked up under the cgroup mounts in /proc/self/mountinfo. A file that holds max or can't be read sets no limit. --- crates/engine/src/history.rs | 6 +- crates/render/src/images.rs | 183 ++++++++++++++++++++++++++++++++++- docs/target-app-parity.md | 2 +- 3 files changed, 184 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 7b02b4278..3f981f531 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -146,9 +146,9 @@ impl History { } } -/// The default [`History::byte_budget`]: a quarter of the installed memory -/// ([`vectorcraft_render::physical_memory`], see [`history_budget_for`]); 1 GiB where the installed -/// memory can't be read, and 256 MiB on the web. +/// The default [`History::byte_budget`]: a quarter of the installed memory, or on Linux of the +/// process's cgroup memory limit where that is lower ([`vectorcraft_render::physical_memory`], see +/// [`history_budget_for`]); 1 GiB where the installed memory can't be read, and 256 MiB on the web. pub fn default_history_budget() -> usize { if cfg!(target_arch = "wasm32") { return 256 << 20; diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index b79d56279..c80bc427b 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -52,13 +52,116 @@ pub fn installed_memory() -> Option { physical_memory() } -/// Installed memory in bytes (`MemTotal` in `/proc/meminfo`). +/// Installed memory in bytes (`MemTotal` in `/proc/meminfo`), or the memory limit of the process's +/// cgroups where that is lower. #[cfg(target_os = "linux")] pub fn physical_memory() -> Option { let info = std::fs::read_to_string("/proc/meminfo").ok()?; let line = info.lines().find(|l| l.starts_with("MemTotal:"))?; let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?; - kib.checked_mul(1024) + let total = kib.checked_mul(1024)?; + let cgroups = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default(); + let mounts = std::fs::read_to_string("/proc/self/mountinfo").unwrap_or_default(); + Some(cgroup_limit(&cgroups, &mounts, |path| std::fs::read_to_string(path).ok()).map_or(total, |limit| limit.min(total))) +} + +/// The lowest memory limit of the cgroups listed in `/proc/self/cgroup` (`cgroups`) and of their +/// ancestors within the mounted cgroup hierarchy: `memory.max` under cgroup v2 and +/// `memory.limit_in_bytes` under cgroup v1. Each cgroup is looked up under the mount in +/// `/proc/self/mountinfo` (`mounts`) whose root holds it, or under `/sys/fs/cgroup` (v2) or +/// `/sys/fs/cgroup/memory` (v1) when `mounts` lists no mount of its hierarchy; a cgroup that no +/// listed mount holds sets no limit. A mount whose root lies above the cgroup namespace's root +/// (`/..`) gives the limit of its mount point alone. `read` reads a file; a file that can't be read, or that holds +/// `max`, sets no limit. +#[cfg(any(target_os = "linux", test))] +fn cgroup_limit(cgroups: &str, mounts: &str, read: impl Fn(&str) -> Option) -> Option { + let mut lowest: Option = None; + for line in cgroups.lines() { + let mut fields = line.splitn(3, ':'); + let (Some(_), Some(controllers), Some(path)) = (fields.next(), fields.next(), fields.next()) else { + continue; + }; + let v2 = controllers.is_empty(); + if !v2 && !controllers.split(',').any(|c| c == "memory") { + continue; + } + let file = if v2 { "memory.max" } else { "memory.limit_in_bytes" }; + let mounted = cgroup_mounts(mounts, v2); + // Every mount that holds the cgroup shows the same hierarchy; each may show more ancestors. + let holding: Vec<(String, String)> = if mounted.is_empty() { + vec![(path.to_owned(), (if v2 { "/sys/fs/cgroup" } else { "/sys/fs/cgroup/memory" }).to_owned())] + } else { + mounted + .into_iter() + .filter_map(|(root, point)| { + let root = root.trim_end_matches('/'); + // A root of `..` steps is an ancestor of the cgroup namespace's root, so of every + // cgroup listed: only the mount point's own limit can be read. + if !root.is_empty() && root.split('/').skip(1).all(|step| step == "..") { + return Some((String::new(), point)); + } + let rest = path.strip_prefix(root).filter(|rest| rest.is_empty() || rest.starts_with('/'))?; + Some((rest.to_owned(), point)) + }) + .collect() + }; + for (relative, mount_point) in &holding { + let root = mount_point.trim_end_matches('/'); + let mut dir = relative.trim_end_matches('/'); + loop { + if let Some(limit) = read(&format!("{root}{dir}/{file}")).and_then(|s| s.trim().parse::().ok()) { + lowest = Some(lowest.map_or(limit, |l| l.min(limit))); + } + match dir.rsplit_once('/') { + Some((parent, _)) => dir = parent, + None => break, + } + } + } + } + lowest +} + +/// The root and the mount point of each cgroup v2 mount (`v2`) or cgroup v1 mount with the +/// `memory` controller in `/proc/self/mountinfo` (`mounts`), with their octal escapes decoded. +#[cfg(any(target_os = "linux", test))] +fn cgroup_mounts(mounts: &str, v2: bool) -> Vec<(String, String)> { + mounts + .lines() + .filter_map(|line| { + let (before, after) = line.split_once(" - ")?; + let mut fields = before.split_whitespace().skip(3); + let (root, point) = (fields.next()?, fields.next()?); + let mut after = after.split_whitespace(); + let (fs, _, options) = (after.next()?, after.next()?, after.next().unwrap_or("")); + let found = if v2 { fs == "cgroup2" } else { fs == "cgroup" && options.split(',').any(|o| o == "memory") }; + found.then(|| (mountinfo_unescape(root), mountinfo_unescape(point))) + }) + .collect() +} + +/// A path field of `/proc/self/mountinfo` with its octal escapes (`\040` for a space) decoded. +#[cfg(any(target_os = "linux", test))] +fn mountinfo_unescape(field: &str) -> String { + let mut bytes = Vec::with_capacity(field.len()); + let mut rest = field.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let code = match tail { + [a @ b'0'..=b'3', c @ b'0'..=b'7', d @ b'0'..=b'7', ..] if b == b'\\' => Some(((a - b'0') << 6) | ((c - b'0') << 3) | (d - b'0')), + _ => None, + }; + match code { + Some(code) => { + bytes.push(code); + rest = tail.get(3..).unwrap_or_default(); + } + None => { + bytes.push(b); + rest = tail; + } + } + } + String::from_utf8_lossy(&bytes).into_owned() } /// Installed memory in bytes (`GlobalMemoryStatusEx`). @@ -484,7 +587,8 @@ mod tests { #[test] #[cfg(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd"))] fn installed_memory_is_read() { - assert!(physical_memory().is_some_and(|m| m >= 256 << 20), "{:?}", physical_memory()); + // A cgroup's limit can be far below the installed memory. + assert!(physical_memory().is_some_and(|m| m > 0), "{:?}", physical_memory()); } #[test] @@ -499,6 +603,79 @@ mod tests { assert_eq!(s.bytes(), 2 * 640 * 640 * 4); } + #[test] + fn cgroup_limit_is_the_lowest_limit_of_the_cgroups_and_their_ancestors() { + let files = |list: &'static [(&'static str, &'static str)]| { + move |path: &str| list.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()) + }; + let v2 = files(&[("/sys/fs/cgroup/user.slice/memory.max", "2147483648\n"), ("/sys/fs/cgroup/user.slice/app.scope/memory.max", "max\n")]); + assert_eq!(cgroup_limit("0::/user.slice/app.scope\n", "", v2), Some(2 << 30), "the parent's limit applies"); + let v1 = files(&[("/sys/fs/cgroup/memory/memory.limit_in_bytes", "1073741824\n")]); + let v1_mounts = "30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu,cpuacct\n\ + 31 25 0:27 /docker/1 /sys/fs/cgroup/memory rw,nosuid master:9 - cgroup cgroup rw,memory\n"; + let cgroups = "4:cpu,cpuacct:/docker/1\n5:memory:/docker/1\n"; + assert_eq!(cgroup_limit(cgroups, v1_mounts, v1), Some(1 << 30), "a container's own mount"); + assert_eq!(cgroup_limit("0::/\n", "", files(&[("/sys/fs/cgroup/memory.max", "max\n")])), None, "no limit"); + assert_eq!(cgroup_limit("", "", files(&[])), None); + } + + #[test] + fn cgroup_limit_reads_a_subtree_mount_relative_to_its_root() { + let mounts = "25 1 0:22 / /proc rw - proc proc rw\n\ + 40 25 0:30 /docker/id /sys/fs/cgroup rw,nosuid - cgroup2 cgroup2 rw,nsdelegate\n"; + let files = [("/sys/fs/cgroup/worker/memory.max", "536870912\n"), ("/sys/fs/cgroup/memory.max", "4294967296\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/docker/id/worker\n", mounts, read), Some(512 << 20), "the worker's own limit"); + assert_eq!(cgroup_limit("0::/elsewhere\n", mounts, read), None, "no mount holds the cgroup"); + } + + #[test] + fn cgroup_limit_reads_the_mount_that_holds_the_cgroup() { + let mounts = "40 25 0:30 /other /mnt/other rw - cgroup2 cgroup2 rw\n\ + 41 25 0:30 /docker/id /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n"; + let files = [("/mnt/other/memory.max", "1048576\n"), ("/sys/fs/cgroup/memory.max", "268435456\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/docker/id\n", mounts, read), Some(256 << 20), "the unrelated subtree's limit is not read"); + let both = "40 25 0:30 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n\ + 41 25 0:30 /parent/child /mnt/child rw - cgroup2 cgroup2 rw\n"; + let files = [("/mnt/child/memory.max", "max\n"), ("/sys/fs/cgroup/parent/memory.max", "1073741824\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/parent/child\n", both, read), Some(1 << 30), "the ancestor that only the full mount shows"); + } + + #[test] + fn cgroup_limit_reads_a_mount_above_the_namespace_root() { + let mounts = "40 25 0:30 /.. /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n"; + let files = [("/sys/fs/cgroup/memory.max", "1073741824\n"), ("/sys/fs/cgroup/worker/memory.max", "4096\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/\n", mounts, read), Some(1 << 30), "the namespace root's ancestor"); + assert_eq!(cgroup_limit("0::/worker\n", mounts, read), Some(1 << 30), "the cgroup's directory is hidden"); + let v1 = "31 25 0:27 /../.. /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n"; + let files = [("/sys/fs/cgroup/memory/memory.limit_in_bytes", "536870912\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("5:memory:/\n", v1, read), Some(512 << 20)); + let sibling = "40 25 0:30 /../other /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n"; + let files = [("/sys/fs/cgroup/memory.max", "1048576\n")]; + let read = |path: &str| files.iter().find(|(p, _)| *p == path).map(|(_, text)| (*text).to_owned()); + assert_eq!(cgroup_limit("0::/\n", sibling, read), None, "a sibling of the namespace root is not an ancestor"); + } + + #[test] + fn cgroup_mounts_lists_the_hierarchy_of_the_memory_limit() { + let mounts = "30 25 0:26 / /sys/fs/cgroup/cpu rw - cgroup cgroup rw,cpu\n\ + 31 25 0:27 /a\\040b /sys/fs/cgroup/memory rw - cgroup cgroup rw,memory\n\ + 32 25 0:28 / /sys/fs/cgroup/unified rw - cgroup2 cgroup2 rw\n"; + assert_eq!(cgroup_mounts(mounts, false), [("/a b".to_owned(), "/sys/fs/cgroup/memory".to_owned())]); + assert_eq!(cgroup_mounts(mounts, true), [("/".to_owned(), "/sys/fs/cgroup/unified".to_owned())]); + assert!(cgroup_mounts("", true).is_empty()); + } + + #[test] + fn mountinfo_paths_have_their_escapes_decoded() { + assert_eq!(mountinfo_unescape(r"/a\040b\011c\012d\134e"), "/a b\tc\nd\\e"); + assert_eq!(mountinfo_unescape(r"/plain\9x\04"), r"/plain\9x\04", "not an escape"); + } + #[test] fn sysctl_prints_the_memory_size_in_bytes() { assert_eq!(sysctl_bytes(b"17179869184\n"), Some(16 << 30)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e4e4cc1cf..a5650a180 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1103,7 +1103,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Hard-edge rendering, including the internal hard-edge frame of Art Optimized supersampled raster exports, draws a knockout element with one opaque solid fill once instead of erasing and adding it through separate layers. Analytical antialiasing keeps the existing compositor and rounding. Synthetic path/compound-hole tests compare exact pixels against the general knockout path across precision, supersampling, translucent parent groups and backdrop blending; translucent fills retain their knockout behavior. An image whose pixels can't be decoded, such as a PNG cut short or an image over the 2 GB decoding allowance as RGBA that can't be drawn at reduced size, is tried once: the renderer notes it and does not try to decode it again on later frames. An image refused only because the images being drawn leave no room for it is tried again on the next render. On the canvas such an image draws as a light gray box crossed from corner to corner, and the status bar says once for each image that it couldn't be decoded; exports leave it out and report it as before. A PNG, JPEG, GIF or WebP image that takes more than 2 GiB as RGBA (512 MiB in the web app) draws in the canvas and in raster exports at the largest size within that allowance, instead of as that crossed box on the canvas and not at all in exports. Each pixel of the result is the mean of the image's pixels it covers, and the document keeps the whole file. A PNG that isn't interlaced is read a row at a time, and other images are read whole when their decoded samples fit in the allowance. A 16-bit PNG that isn't interlaced, whose decoded samples take more than the allowance and whose RGBA fits in it, draws at its own size, read a row at a time. An image of more pixels than the allowance has bytes (on the desktop, 46,341 × 46,341 is more) still draws as the crossed box, and so does one whose decoded samples take more than the allowance, other than a PNG that isn't interlaced. Open, Place and the Links panel say "an image of W × H pixels is shown at reduced size", and say "too large to draw" of an image that is not drawn at reduced size. - The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD, within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes. + The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD (on Linux, of the process's cgroup memory limit where that is lower), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes. Each document's undo history keeps its steps within a quarter of the installed memory on the same systems (at least 64 MiB, and at most a quarter of the address space in a 32-bit build), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; past that budget the oldest steps are dropped, but the last eight undo steps and the next eight redo steps stay. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. From 52061b102904a323ca536ec5ee32b277afb5c8bc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:52:08 -0800 Subject: [PATCH 5/6] Undo: tests for the default history budget with no memory reading, 0, small and large memory history_budget_for takes the memory reading itself, so the 1 GiB fallback for no reading or 0 is tested with the quarter, the 64 MiB floor and a reading of 2 GiB, as a cgroup limit lower than the installed memory gives. The budget is unchanged. --- crates/engine/src/history.rs | 11 ++++++----- crates/engine/src/tests/history_mem.rs | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 3f981f531..ea07ec8cc 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -154,11 +154,12 @@ pub fn default_history_budget() -> usize { return 256 << 20; } static BUDGET: std::sync::OnceLock = std::sync::OnceLock::new(); - *BUDGET.get_or_init(|| vectorcraft_render::physical_memory().filter(|&m| m > 0).map_or(1 << 30, history_budget_for)) + *BUDGET.get_or_init(|| history_budget_for(vectorcraft_render::physical_memory())) } -/// The default [`History::byte_budget`] on a machine with `memory` bytes installed: a quarter of it, -/// at least 64 MiB, and at most a quarter of the address space (on 32-bit builds). -pub(crate) fn history_budget_for(memory: u64) -> usize { - usize::try_from(memory / 4).unwrap_or(usize::MAX).clamp(64 << 20, usize::MAX / 4) +/// The default [`History::byte_budget`] for a reading of `memory` bytes installed: a quarter of it, +/// at least 64 MiB, and at most a quarter of the address space (on 32-bit builds); 1 GiB when the +/// reading is `None` or 0. +pub(crate) fn history_budget_for(memory: Option) -> usize { + memory.filter(|&m| m > 0).map_or(1 << 30, |m| usize::try_from(m / 4).unwrap_or(usize::MAX).clamp(64 << 20, usize::MAX / 4)) } diff --git a/crates/engine/src/tests/history_mem.rs b/crates/engine/src/tests/history_mem.rs index ac1a29e17..80e005929 100644 --- a/crates/engine/src/tests/history_mem.rs +++ b/crates/engine/src/tests/history_mem.rs @@ -78,18 +78,24 @@ fn within_the_budget_the_step_limit_still_counts() { #[test] #[cfg(target_pointer_width = "64")] fn the_default_budget_is_a_quarter_of_installed_memory() { - assert_eq!(history_budget_for(16 << 30), 4 << 30); - assert_eq!(history_budget_for(64 << 30), 16 << 30, "no fixed ceiling"); - assert_eq!(history_budget_for(2 << 30), 512 << 20); - assert_eq!(history_budget_for(128 << 20), 64 << 20, "at least 64 MiB"); + assert_eq!(history_budget_for(Some(16 << 30)), 4 << 30); + assert_eq!(history_budget_for(Some(64 << 30)), 16 << 30, "no fixed ceiling"); + // On Linux `physical_memory` returns a cgroup memory limit lower than the installed memory in + // its place (the `cgroup_limit_*` tests in vectorcraft-render), so a 2 GiB limit on a 64 GiB + // machine gives the budget of 2 GiB installed. + assert_eq!(history_budget_for(Some(2 << 30)), 512 << 20, "2 GiB, or a 2 GiB cgroup limit"); + assert_eq!(history_budget_for(Some(128 << 20)), 64 << 20, "at least 64 MiB"); + assert_eq!(history_budget_for(Some(1)), 64 << 20, "at least 64 MiB"); + assert_eq!(history_budget_for(Some(0)), 1 << 30, "a reading of 0 is the 1 GiB fallback"); + assert_eq!(history_budget_for(None), 1 << 30, "no reading is the 1 GiB fallback"); } #[test] #[cfg(any(target_os = "linux", windows, target_os = "macos", target_os = "freebsd"))] fn the_default_budget_follows_the_installed_memory_read() { // Where the memory can't be read (a sandbox that hides it), the budget is the 1 GiB fallback. - let memory = vectorcraft_render::physical_memory().filter(|&m| m > 0); - assert_eq!(default_history_budget(), memory.map_or(1 << 30, history_budget_for), "{memory:?}"); + let memory = vectorcraft_render::physical_memory(); + assert_eq!(default_history_budget(), history_budget_for(memory), "{memory:?}"); assert_eq!(History::default().byte_budget, default_history_budget()); } From c8d0cdf6a1ba6e2785913ce74bb1ccc9fb7d60e2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 11:26:55 -0800 Subject: [PATCH 6/6] Undo: the open documents' histories share one budget of a quarter of installed memory `Session::history_budget` (default `default_history_budget()`) caps the memory the undo steps of all open documents keep together. After each command, tool edit, interaction commit, direct edit, framed script run and closed undo group, `Session::trim_histories` drops the oldest steps of any document first, undo steps before redo steps. Each document keeps its last eight undo steps and next eight redo steps, and a held history keeps all of its steps. While a script runs or an undo group is open, no steps are dropped, so a canceled group leaves the other documents' steps in place. Closing a document frees its share. The steps are not counted again while neither the budget nor any history changed. Each document's own `byte_budget` stays as its ceiling. --- crates/engine/src/history.rs | 78 ++++++++++- crates/engine/src/lib.rs | 7 + crates/engine/src/script/mod.rs | 6 +- crates/engine/src/session/documents.rs | 2 + crates/engine/src/session/edit.rs | 10 +- crates/engine/src/session/execute.rs | 1 + crates/engine/src/session/undo.rs | 4 + crates/engine/src/tests/history_mem.rs | 180 ++++++++++++++++++++++++- crates/engine/src/tooling.rs | 3 + docs/target-app-parity.md | 2 +- 10 files changed, 287 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 3c5b0a5db..cc545b179 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -171,7 +171,8 @@ pub struct History { /// Maximum undo depth (Illustrator has no hard limit; snapshots are cheap here). pub limit: usize, /// About the most memory the steps may keep ([`HistoryEntry::bytes`]): past it the oldest - /// steps go, but never the last [`History::MIN_STEPS`] undo (and redo) steps. + /// steps go, but never the last [`History::MIN_STEPS`] undo (and redo) steps. The histories of a + /// session's open documents also share [`Session::history_budget`](crate::Session::history_budget). pub byte_budget: usize, /// Counts the changes made in place to steps below the newest ([`History::put_back`]). pub(crate) rewrites: u64, @@ -339,7 +340,80 @@ impl History { } } -/// The default [`History::byte_budget`]: a quarter of the installed memory, or on Linux of the +/// Let the oldest steps of `histories` go while together they keep more than `budget` +/// ([`History::bytes`]): undo steps first, the oldest of all the histories first, then redo steps, +/// the furthest ahead first. Each history keeps its last [`History::MIN_STEPS`] undo (and redo) +/// steps, and a held one ([`History::mark`]) keeps all of its steps; their memory still counts. +pub(crate) fn trim_together<'a>(histories: impl IntoIterator, budget: usize) { + let all: Vec<&mut History> = histories.into_iter().collect(); + let mut total = all.iter().fold(0usize, |t, h| t.saturating_add(h.bytes())); + if total <= budget { + return; + } + fn steps(h: &History, redo: bool) -> &[HistoryEntry] { + if redo { &h.redo } else { &h.undo } + } + let mut free: Vec<&mut History> = all.into_iter().filter(|h| !h.held).collect(); + for redo in [false, true] { + // How many of each history's oldest steps go. + let mut gone = vec![0usize; free.len()]; + while total > budget { + // The oldest step any history may still let go. + let next = free + .iter() + .zip(&gone) + .enumerate() + .filter(|(_, (h, n))| steps(h, redo).len().saturating_sub(**n) > History::MIN_STEPS) + .filter_map(|(i, (h, n))| steps(h, redo).get(*n).map(|e| (e.serial, i, e.bytes))) + .min(); + let Some((_, i, bytes)) = next else { break }; + total = total.saturating_sub(bytes); + if let Some(n) = gone.get_mut(i) { + *n += 1; + } + } + for (h, n) in free.iter_mut().zip(gone) { + let steps = if redo { &mut h.redo } else { &mut h.undo }; + steps.drain(..n.min(steps.len())); + } + } +} + +/// What [`Session::trim_histories`](crate::Session::trim_histories) last left: the budget, and for +/// each open document its uid and what a new, undone, redone, merged, recosted, removed or put-back +/// step changes in its history. +pub(crate) type HistoriesSeen = (usize, Vec<(u64, (u64, u64), usize, usize, usize, u64, bool)>); + +impl crate::Session { + /// Let the oldest undo and redo steps of the open documents go while together they keep more + /// than [`Session::history_budget`](crate::Session::history_budget) ([`trim_together`]). While a + /// script runs, its commands leave the histories as they are: the run's end trims them. While an + /// undo group is open in any document, the histories stay as they are too, so a canceled group + /// leaves the other documents' steps in place: closing the groups trims them. When + /// neither the budget nor any history changed since the last call, the steps are not counted + /// again. + pub(crate) fn trim_histories(&mut self) { + if self.script_in_run || self.docs.iter().any(|d| d.undo_group.is_some()) { + return; + } + if self.histories_seen.as_ref() == Some(&self.histories_now()) { + return; + } + trim_together(self.docs.iter_mut().map(|d| &mut d.history), self.history_budget); + self.histories_seen = Some(self.histories_now()); + } + + fn histories_now(&self) -> HistoriesSeen { + let doc = |d: &crate::DocState| { + let h = &d.history; + (d.uid, h.step(), h.undo.len(), h.redo.len(), h.undo.last().map_or(0, |e| e.bytes), h.rewrites, h.held) + }; + (self.history_budget, self.docs.iter().map(doc).collect()) + } +} + +/// The default [`History::byte_budget`] and [`Session::history_budget`](crate::Session::history_budget), +/// which the open documents' histories share: a quarter of the installed memory, or on Linux of the /// process's cgroup memory limit where that is lower ([`vectorcraft_render::physical_memory`], see /// [`history_budget_for`]); 1 GiB where the installed memory can't be read, and 256 MiB on the web. pub fn default_history_budget() -> usize { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d7a2bd032..c1f438e80 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -59,6 +59,13 @@ pub struct Session { pub clipboard: Clipboard, /// Executed commands (for actions and debugging), the most recent ones kept ([`Journal`]). pub journal: Journal, + /// About the most memory the undo steps of all the open documents may keep together + /// ([`History::bytes`]): past it the oldest steps of any of them go, but each keeps its last + /// [`History::MIN_STEPS`] undo (and redo) steps. [`default_history_budget`] by default. + pub history_budget: usize, + /// The budget and histories [`Session::trim_histories`] last left, so it does not count the + /// steps again while none of them changed. + pub(crate) histories_seen: Option, pub(crate) tool: Box, /// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes /// back as it was at the release ([`Session::pointer`]). diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 35e93c1ad..796f1b72b 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -1021,7 +1021,11 @@ impl Session { self.script_key = Some(key); None } - Ok(Ok(Progress::Done(_))) => Some(self.script_commit(job)), + Ok(Ok(Progress::Done(_))) => { + let r = self.script_commit(job); + self.trim_histories(); + Some(r) + } } } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 8a1ef8f69..7a187b197 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -44,6 +44,8 @@ impl Session { fill_active: true, clipboard: Clipboard::default(), journal: Journal::default(), + history_budget: crate::default_history_budget(), + histories_seen: None, tool: vectorcraft_tools::create("selection"), lender: None, held_input: Default::default(), diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 1020e11a5..1762c9676 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -112,7 +112,7 @@ impl Session { }, Err(e) => Err(e), }; - match result { + let r = match result { Ok(v) => { st.selection.prune(&st.doc); // An edit that selects what it unlocks, shows or pastes leaves locked guides out, @@ -153,7 +153,12 @@ impl Session { cmd::globaledit::follow_history(st); Err(e) } + }; + // An edit a frontend makes itself, outside a command, keeps to the shared budget too. + if r.is_ok() && self.depth == 0 && !self.in_tool_actions { + self.trim_histories(); } + r } /// [`Session::edit`] with the step named `template`, its `{name}` placeholder filled with `name` @@ -394,6 +399,9 @@ impl Session { let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs()); self.journal.push(preview); self.journal_in_undo_groups(doc, &grouped, journal); + if self.depth == 0 { + self.trim_histories(); + } Ok(()) } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 7dd5d8ae6..34c4eed72 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -200,6 +200,7 @@ impl Session { } if self.depth == 0 { self.journal_in_undo_groups(doc, &grouped, journal); + self.trim_histories(); self.after_command(Some(id)); } else if matches!(id, "edit.undo" | "edit.redo") { // A step of `command.batch`: the tool hears of it as it does of the command alone. diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index 58fd90579..d06934926 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -160,6 +160,10 @@ impl Session { *n -= below(*n); } } + // The shared budget, which waits while an undo group is open, applies again once none is. + if self.depth == 0 { + self.trim_histories(); + } } /// When journal entry `n` is a `command.batch` with `steps` noted in canceled undo groups and diff --git a/crates/engine/src/tests/history_mem.rs b/crates/engine/src/tests/history_mem.rs index e9d499607..279020432 100644 --- a/crates/engine/src/tests/history_mem.rs +++ b/crates/engine/src/tests/history_mem.rs @@ -10,6 +10,12 @@ use crate::*; /// A session with one document holding `n` rectangles, all selected. fn session_with_rects(n: usize) -> Session { let mut s = Session::new(); + open_with_rects(&mut s, n); + s +} + +/// Open a new document in `s` holding `n` rectangles, all selected. +fn open_with_rects(s: &mut Session, n: usize) { s.execute("file.new", &json!({"width": 2000, "height": 2000})).unwrap(); s.edit("Add", |d, _| { let layer = d.layers[0].id; @@ -22,7 +28,6 @@ fn session_with_rects(n: usize) -> Session { }) .unwrap(); s.execute("select.all", &json!({})).unwrap(); - s } fn first_x(s: &Session) -> f64 { @@ -75,6 +80,178 @@ fn within_the_budget_the_step_limit_still_counts() { assert!(st.history.bytes() < st.history.byte_budget); } +fn moves(s: &mut Session, n: usize) { + for _ in 0..n { + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + } +} + +fn undo_lens(s: &Session) -> Vec { + s.docs.iter().map(|d| d.history.undo.len()).collect() +} + +fn history_bytes(s: &Session) -> usize { + s.docs.iter().map(|d| d.history.bytes()).sum() +} + +#[test] +fn one_document_keeps_its_steps_within_the_session_budget() { + let mut s = session_with_rects(500); + moves(&mut s, 1); + let step = s.doc().unwrap().history.undo.last().unwrap().bytes; + s.history_budget = 3 * step; + moves(&mut s, 20); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), History::MIN_STEPS, "only the last few steps are kept"); + assert_eq!(st.history.byte_budget, default_history_budget(), "the document's own budget is unchanged"); + // A lower budget applies at the next command, even one that adds no step. + s.history_budget = usize::MAX; + moves(&mut s, 4); + assert_eq!(s.doc().unwrap().history.undo.len(), History::MIN_STEPS + 4); + s.history_budget = 3 * step; + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), History::MIN_STEPS); +} + +#[test] +fn open_documents_share_one_budget_and_the_oldest_steps_go_first() { + let n = 300; + let mut s = session_with_rects(n); + moves(&mut s, 1); + let step = s.doc().unwrap().history.undo.last().unwrap().bytes; + // Room for about 22 moves in all the open documents together. + s.history_budget = 22 * step; + moves(&mut s, 11); + assert_eq!(undo_lens(&s), [13], "Add and 12 moves fit in the budget"); + open_with_rects(&mut s, n); + moves(&mut s, 12); + let lens = undo_lens(&s); + assert_eq!(lens[1], 13, "the newer document keeps all of its steps: {lens:?}"); + assert!((History::MIN_STEPS..13).contains(&lens[0]), "the older document's oldest steps went: {lens:?}"); + assert!(history_bytes(&s) <= s.history_budget, "{} bytes kept, budget {}", history_bytes(&s), s.history_budget); + // Closing the older document frees its share: the other keeps more steps than before. + assert!(s.close_document(0)); + assert!(s.set_active(0)); + moves(&mut s, 6); + assert_eq!(undo_lens(&s), [19], "Add and 18 moves fit in the budget alone"); +} + +#[test] +fn an_undo_group_lets_no_other_documents_steps_go_until_it_ends() { + let mut s = session_with_rects(50); + moves(&mut s, 12); + open_with_rects(&mut s, 50); + // The steps kept now fill the budget exactly. + s.history_budget = history_bytes(&s); + s.execute("select.all", &json!({})).unwrap(); + assert_eq!(undo_lens(&s), [13, 1]); + // A scrubbed field's edits in the second document, then canceled. + s.begin_undo_group(); + moves(&mut s, 3); + assert_eq!(undo_lens(&s), [13, 2], "the group's step waits for the group to end"); + s.end_undo_group(true); + assert_eq!(undo_lens(&s), [13, 1], "a canceled group leaves the other document's steps"); + // The same edits kept: the other document's oldest steps go. + s.begin_undo_group(); + moves(&mut s, 3); + s.end_undo_group(false); + let lens = undo_lens(&s); + assert_eq!(lens[1], 2); + assert!((History::MIN_STEPS..13).contains(&lens[0]), "the older document's oldest steps went: {lens:?}"); + assert!(history_bytes(&s) <= s.history_budget); + // A scrub through an undo scope: its step counts once the scope ends. + s.history_budget = usize::MAX; + assert!(s.set_active(0)); + moves(&mut s, 6); + assert!(s.set_active(1)); + s.history_budget = history_bytes(&s); + let before = undo_lens(&s)[0]; + let scope = s.begin_undo_scope().unwrap(); + moves(&mut s, 3); + assert_eq!(undo_lens(&s), [before, 3], "the scope's step waits for the scope to end"); + s.end_undo_scope(scope, false); + let lens = undo_lens(&s); + assert!(lens[0] < before, "the older document's oldest steps went once the scope ended: {lens:?}"); + assert!(history_bytes(&s) <= s.history_budget); +} + +#[test] +fn steps_kept_outside_a_command_keep_to_the_shared_budget() { + let mut s = session_with_rects(50); + moves(&mut s, 12); + open_with_rects(&mut s, 50); + assert_eq!(undo_lens(&s), [13, 1]); + // A drag the frontend commits itself. + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap(); + s.history_budget = 1; + assert_eq!(undo_lens(&s), [13, 1]); + s.commit_interaction().unwrap(); + assert_eq!(undo_lens(&s), [History::MIN_STEPS, 2], "the drag's step counts against the other document's"); + // A script run across frames, whose step is kept after `script.run` returns. + s.history_budget = usize::MAX; + assert!(s.set_active(0)); + moves(&mut s, 4); + assert!(s.set_active(1)); + s.script_frames = true; + let source = "for (var i = 0; i < 3000000; i++) {} vc.run('object.move', {dx: 1, dy: 0});"; + assert_eq!(s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap()["running"], true); + s.history_budget = 1; + let mut slices = 0; + let r = loop { + slices += 1; + assert!(slices < 100_000, "the script ends"); + if let Some(r) = s.script_pump() { + break r; + } + }; + assert_eq!(r.unwrap()["ok"], true); + assert_eq!(undo_lens(&s), [History::MIN_STEPS, 3], "the run's step counts against the other document's"); + // An edit a frontend makes itself, outside a command. + s.history_budget = usize::MAX; + assert!(s.set_active(0)); + moves(&mut s, 4); + assert!(s.set_active(1)); + s.history_budget = 1; + s.edit("Add", |d, _| { + let id = d.alloc_id(); + let r = shapes::rectangle(kurbo::Rect::new(0.0, 20.0, 10.0, 30.0)); + d.insert(Some(d.layers[0].id), usize::MAX, Node::path(id, r, Appearance::default_art()))?; + Ok(()) + }) + .unwrap(); + assert_eq!(undo_lens(&s), [History::MIN_STEPS, 4], "the edit's step counts against the other document's"); +} + +#[test] +fn each_document_keeps_its_last_steps_however_small_the_budget() { + let mut s = Session::new(); + // Smaller than any one step. + s.history_budget = 1; + for _ in 0..4 { + open_with_rects(&mut s, 50); + moves(&mut s, 12); + } + assert_eq!(undo_lens(&s), [History::MIN_STEPS; 4]); + // The kept steps undo and redo, and the document now is as it was. + let x = first_x(&s); + assert_eq!(x, 12.0); + for _ in 0..History::MIN_STEPS { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(first_x(&s), x - History::MIN_STEPS as f64); + assert!(s.execute("edit.undo", &json!({})).is_err(), "the older steps went"); + assert_eq!(s.doc().unwrap().history.redo.len(), History::MIN_STEPS); + // A held history keeps all of its steps until it is released. + assert!(s.set_active(0)); + s.docs[0].history.held = true; + moves(&mut s, 3); + assert_eq!(undo_lens(&s)[0], History::MIN_STEPS + 3, "a held history keeps its steps"); + s.docs[0].history.held = false; + s.trim_histories(); + assert_eq!(undo_lens(&s), [History::MIN_STEPS, History::MIN_STEPS, History::MIN_STEPS, 0]); +} + #[test] #[cfg(target_pointer_width = "64")] fn the_default_budget_is_a_quarter_of_installed_memory() { @@ -97,6 +274,7 @@ fn the_default_budget_follows_the_installed_memory_read() { let memory = vectorcraft_render::physical_memory(); assert_eq!(default_history_budget(), history_budget_for(memory), "{memory:?}"); assert_eq!(History::default().byte_budget, default_history_budget()); + assert_eq!(Session::new().history_budget, default_history_budget(), "the open documents share it"); } #[test] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 13fb87ccd..3f364c2ce 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1298,6 +1298,9 @@ impl Session { let outer = std::mem::replace(&mut self.in_tool_actions, true); let r = self.apply_tool_actions(acts); self.in_tool_actions = outer; + if !outer && self.depth == 0 { + self.trim_histories(); + } r } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1cdf860bd..ab1894c5d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1486,7 +1486,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An image whose pixels can't be decoded, such as a PNG cut short or an image over the 2 GB decoding allowance as RGBA that can't be drawn at reduced size, is tried once: the renderer notes it and does not try to decode it again on later frames. An image refused only because the images being drawn leave no room for it is tried again on the next render. On the canvas such an image draws as a light gray box crossed from corner to corner, and the status bar says once for each image that it couldn't be decoded; exports leave it out and report it as before. A PNG, JPEG, GIF or WebP image that takes more than 2 GiB as RGBA (512 MiB in the web app) draws in the canvas and in raster exports at the largest size within that allowance, instead of as that crossed box on the canvas and not at all in exports. Each pixel of the result is the mean of the image's pixels it covers, and the document keeps the whole file. A PNG that isn't interlaced is read a row at a time, and other images are read whole when their decoded samples fit in the allowance. A 16-bit PNG that isn't interlaced, whose decoded samples take more than the allowance and whose RGBA fits in it, draws at its own size, read a row at a time. An image of more pixels than the allowance has bytes (on the desktop, 46,341 × 46,341 is more) still draws as the crossed box, and so does one whose decoded samples take more than the allowance, other than a PNG that isn't interlaced. Open, Place and the Links panel say "an image of W × H pixels is shown at reduced size", and say "too large to draw" of an image that is not drawn at reduced size. The renderers keep the decoded images they share within a quarter of the installed memory on Windows, macOS, Linux and FreeBSD (on Linux, of the process's cgroup memory limit where that is lower, and in a 32-bit build at most a quarter of the address space), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; while the decoded images add up to less than that budget, they are not decoded again when the view changes. - Each document's undo history keeps its steps within a quarter of the installed memory on the same systems (at least 64 MiB, and at most a quarter of the address space in a 32-bit build), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; past that budget the oldest steps are dropped, but the last eight undo steps and the next eight redo steps stay. + The undo histories of all the open documents together keep their steps within a quarter of the installed memory on the same systems (at least 64 MiB, and at most a quarter of the address space in a 32-bit build), within 1 GiB where the installed memory can't be read, and within 256 MiB in the web build; past that budget the oldest steps of any open document are dropped first, but each document keeps its last eight undo steps and its next eight redo steps. Closing a document frees its share for the others. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them.