From 16972e6589ee85c01886c605874a4417cab5d5d4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 19:56:33 -0700 Subject: [PATCH] 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.