From 6efeae37931d28a7d8027f71d7cc477518b127e7 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 28 Oct 2026 18:27:31 +0530 Subject: [PATCH 1/6] Disk cache: 20 GB default, a free-space reserve and cheaper eviction (#2551) - Default disk cache size 20 GB (was 100 GB); settings version 3 migrates the stored old default and keeps a size the user chose. - The cache keeps max(2 GB, 5% of the disk) free: sysinfo::disk_space (df -Pk / PowerShell DriveInfo) is read in the background from memory_tick and caps the limit (DiskCache::set_disk_space); cache.diskStats reports limitBytes. - Eviction goes down to 90% of the limit, so the LRU sort runs once per tenth of the limit written instead of on every write at the limit. - Failed writes are counted (cache.diskStats failed) and the first is logged. Also fixes #2063. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 2 + crates/engine/src/lib.rs | 3 + crates/engine/src/settings/prefs.rs | 2 +- crates/engine/src/settings/prefs/document.rs | 8 ++ crates/engine/src/settings/prefs/sections.rs | 2 +- .../engine/src/settings/session_settings.rs | 6 + crates/engine/src/system/sysinfo.rs | 114 ++++++++++++--- crates/engine/src/tests/disk_cache.rs | 2 +- crates/engine/src/tests/settings.rs | 8 ++ crates/render/src/disk_cache.rs | 135 +++++++++++++++--- 10 files changed, 243 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d0dbabb5b..0130aff70 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -847,10 +847,12 @@ pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result { "layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(), "bytes": st.bytes, "maxBytes": st.max_bytes, + "limitBytes": st.limit_bytes, "hits": st.hits, "misses": st.misses, "writes": st.writes, "evictions": st.evictions, + "failed": st.failed, })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2174a87ca..4452ee067 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -202,6 +202,8 @@ pub struct Session { pub key_move: Option, /// Reads the system's memory in the background for [`Session::memory_tick`]. pub memory_watch: sysinfo::MemoryWatch, + /// Reads the disk cache's free disk space in the background for [`Session::memory_tick`]. + pub disk_watch: sysinfo::DiskWatch, /// Switches Affect Nested Comps as last applied (a change empties the layer cache). pub applied_nested_switches: Option, /// Runs renders and analyses off the UI thread where there are no threads (the web app's @@ -327,6 +329,7 @@ impl Default for Session { sys_memory: None, key_move: None, memory_watch: Default::default(), + disk_watch: Default::default(), applied_nested_switches: None, offload: None, offloaded: vec![], diff --git a/crates/engine/src/settings/prefs.rs b/crates/engine/src/settings/prefs.rs index 1ed7fea2a..922845fc3 100644 --- a/crates/engine/src/settings/prefs.rs +++ b/crates/engine/src/settings/prefs.rs @@ -35,7 +35,7 @@ pub use sections::{ use sections::{Extra, default_labels, lenient_comp_presets}; /// Current settings layout version. -pub const PREFS_VERSION: u32 = 2; +pub const PREFS_VERSION: u32 = 3; /// Settings file name in the config store. pub const PREFS_FILE: &str = "prefs.json"; diff --git a/crates/engine/src/settings/prefs/document.rs b/crates/engine/src/settings/prefs/document.rs index 9e991da46..b207e68c8 100644 --- a/crates/engine/src/settings/prefs/document.rs +++ b/crates/engine/src/settings/prefs/document.rs @@ -7,6 +7,8 @@ use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json /// /// - version 0/1 (early builds) kept a flat object: `undoLevels`, `autoSaveEnabled`, /// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level. +/// - version 2 defaulted the disk cache to 100 GB, more than most laptops have free; the stored +/// old default becomes the new one (a size the user chose is kept). pub fn migrate(mut v: Value) -> Value { let Some(obj) = v.as_object_mut() else { return json!({}) }; let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0); @@ -34,6 +36,12 @@ pub fn migrate(mut v: Value) -> Value { obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default()); } } + if version < 3 + && let Some(d) = obj.get_mut("disk").and_then(Value::as_object_mut) + && d.get("diskCacheMaxGb").and_then(Value::as_u64) == Some(100) + { + d.insert("diskCacheMaxGb".into(), json!(20)); + } // Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or // Light mode, instead of switching established users to Auto. if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut) diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index 6f2a1923c..e0bea8b85 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -179,7 +179,7 @@ page!(Audio { page!(Disk { disk_cache_enabled: bool = true, - disk_cache_max_gb: u32 = 100, + disk_cache_max_gb: u32 = 20, disk_cache_folder: String = String::new(), media_cache_folder: String = String::new(), conformed_media_folder: String = String::new(), diff --git a/crates/engine/src/settings/session_settings.rs b/crates/engine/src/settings/session_settings.rs index 7abdfb53d..98f34bd34 100644 --- a/crates/engine/src/settings/session_settings.rs +++ b/crates/engine/src/settings/session_settings.rs @@ -82,7 +82,13 @@ impl Session { /// Take the latest reading of the system's memory and start the next one in the background /// (desktop frontends call this every few seconds; it never waits for the system), then /// re-apply the cache budgets when the reading changes them. Returns the effective budgets. + /// The disk cache's free disk space is read the same way (it keeps a reserve free). pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets { + if let Some(dc) = &self.disk_cache { + dc.set_disk_space(self.disk_watch.latest()); + let folder = dc.folder().to_path_buf(); + self.disk_watch.poll_with("ec-disk-space", move || crate::sysinfo::disk_space(&folder)); + } let before = self.prefs.cache_budgets(self.sys_memory); self.sys_memory = self.memory_watch.latest().or(self.sys_memory); self.memory_watch.poll(); diff --git a/crates/engine/src/system/sysinfo.rs b/crates/engine/src/system/sysinfo.rs index de39974da..fe94df09e 100644 --- a/crates/engine/src/system/sysinfo.rs +++ b/crates/engine/src/system/sysinfo.rs @@ -1,11 +1,12 @@ //! Host information for Settings ▸ Memory & CPU (RAM reserved for other applications, reducing -//! caches when the system is low on memory) and Help ▸ System Compatibility Report. +//! caches when the system is low on memory), the disk cache's free-space reserve and Help ▸ +//! System Compatibility Report. //! //! Pure Rust and dependency-free: the values come from the operating system's own reporting -//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every -//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their -//! configured budgets. The periodic reading behind the cache budgets runs on a background -//! thread ([`MemoryWatch`]): on Windows it starts PowerShell, which takes about a second. +//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `df` on Unix, `wmic`/PowerShell on +//! Windows). Every query may fail (sandboxes, the web): callers treat `None` as "unknown" and +//! keep their configured budgets. The periodic readings behind the cache budgets run on a +//! background thread ([`Watch`]): on Windows they start PowerShell, which takes about a second. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; @@ -27,47 +28,74 @@ impl SysMemory { } } -/// The system's memory, read in the background: [`MemoryWatch::poll`] starts a reading (unless -/// one is running) and [`MemoryWatch::latest`] returns the last one without waiting. -#[derive(Clone, Default)] -pub struct MemoryWatch { - latest: Arc>>, +/// A system value read in the background: [`Watch::poll_with`] starts a reading (unless one is +/// running) and [`Watch::latest`] returns the last one without waiting. +#[derive(Clone)] +pub struct Watch { + latest: Arc>>, busy: Arc, } -impl MemoryWatch { - /// Start a new reading in the background (no threads on the web: read inline, it is - /// unknown there anyway). - pub fn poll(&self) { +impl Default for Watch { + fn default() -> Self { + Watch { latest: Arc::new(Mutex::new(None)), busy: Arc::new(AtomicBool::new(false)) } + } +} + +/// The system's memory ([`memory`]). +pub type MemoryWatch = Watch; +/// The disk cache's disk: `(total, available)` bytes ([`disk_space`]). +pub type DiskWatch = Watch<(u64, u64)>; + +impl Watch { + /// Start a new reading in the background on a thread called `name` (no threads on the web: + /// read inline, it is unknown there anyway). + pub fn poll_with(&self, name: &str, read: impl FnOnce() -> Option + Send + 'static) { if self.busy.swap(true, Ordering::AcqRel) { return; } let (latest, busy) = (self.latest.clone(), self.busy.clone()); let read = move || { - if let Some(m) = memory() { + if let Some(m) = read() { *latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(m); } busy.store(false, Ordering::Release); }; #[cfg(not(target_arch = "wasm32"))] - if std::thread::Builder::new().name("ec-memory".into()).spawn(read).is_err() { + if std::thread::Builder::new().name(name.into()).spawn(read).is_err() { self.busy.store(false, Ordering::Release); } #[cfg(target_arch = "wasm32")] - read(); + { + let _ = name; + read(); + } } /// The last reading (`None` until one finished). - pub fn latest(&self) -> Option { + pub fn latest(&self) -> Option { *self.latest.lock().unwrap_or_else(PoisonError::into_inner) } } +impl MemoryWatch { + /// Start a new reading of the system's memory in the background. + pub fn poll(&self) { + self.poll_with("ec-memory", memory); + } +} + #[cfg(not(target_arch = "wasm32"))] #[allow(dead_code)] fn run(cmd: &str, args: &[&str]) -> Option { + run_env(cmd, args, &[]) +} + +#[cfg(not(target_arch = "wasm32"))] +#[allow(dead_code)] +fn run_env(cmd: &str, args: &[&str], env: &[(&str, &std::ffi::OsStr)]) -> Option { let mut c = std::process::Command::new(cmd); - c.args(args); + c.args(args).envs(env.iter().copied()); // A console program started from the windowed app would flash a console window. #[cfg(target_os = "windows")] std::os::windows::process::CommandExt::creation_flags(&mut c, 0x0800_0000); // CREATE_NO_WINDOW @@ -139,6 +167,41 @@ pub fn memory() -> Option { None } +/// Parse `df -Pk` output: the disk's total and available bytes. +pub fn parse_df(text: &str) -> Option<(u64, u64)> { + let line = text.lines().skip(1).find(|l| !l.trim().is_empty())?; + let f: Vec<&str> = line.split_whitespace().collect(); + // Size, used, available and capacity follow the device name (which may contain spaces). + let i = (0..f.len().saturating_sub(3)) + .find(|&i| f.get(i + 3).is_some_and(|c| c.ends_with('%')) && f.get(i..i + 3).is_some_and(|n| n.iter().all(|v| v.parse::().is_ok())))?; + let kb = |j: usize| f.get(j)?.parse::().ok().map(|v| v.saturating_mul(1024)); + Some((kb(i)?, kb(i + 2)?)) +} + +/// Total and available bytes of the disk holding `path`, when the system reports them. +pub fn disk_space(path: &std::path::Path) -> Option<(u64, u64)> { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] + { + return parse_df(&run("df", &["-Pk", &path.to_string_lossy()])?); + } + #[cfg(target_os = "windows")] + { + // The path goes through the environment: no quoting. + let text = run_env( + "powershell", + &["-NoProfile", "-Command", "$d=[IO.DriveInfo]::new($env:EC_DISK_PATH); \"$($d.TotalSize) $($d.AvailableFreeSpace)\""], + &[("EC_DISK_PATH", path.as_os_str())], + )?; + let mut it = text.split_whitespace().filter_map(|v| v.parse::().ok()); + return Some((it.next()?, it.next()?)); + } + #[allow(unreachable_code)] + { + let _ = path; + None + } +} + /// CPU model name. pub fn cpu_name() -> Option { #[cfg(target_os = "linux")] @@ -225,6 +288,19 @@ mod tests { assert_eq!(m.available, 1000); } + #[test] + fn parses_df() { + let linux = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/nvme0n1p2 488281250 123456789 364824461 26% /\n"; + assert_eq!(parse_df(linux), Some((488_281_250 * 1024, 364_824_461 * 1024))); + // macOS: a device name with spaces and a mount point with spaces. + let mac = "Filesystem 1024-blocks Used Available Capacity Mounted on\nmap auto_home 0 0 0 100% /System/Volumes/Data/home\n"; + assert_eq!(parse_df(mac), Some((0, 0))); + let spaced = "Filesystem 1024-blocks Used Available Capacity Mounted on\n//nas/My Share 1000 400 600 40% /Volumes/My Share\n"; + assert_eq!(parse_df(spaced), Some((1000 * 1024, 600 * 1024))); + assert_eq!(parse_df("Filesystem 1024-blocks\n"), None); + assert_eq!(parse_df(""), None); + } + #[test] fn parses_proc_meminfo_and_vm_stat() { let m = parse_meminfo("MemTotal: 16000000 kB\nMemFree: 1000 kB\nMemAvailable: 8000000 kB\n").unwrap(); diff --git a/crates/engine/src/tests/disk_cache.rs b/crates/engine/src/tests/disk_cache.rs index 4204b8238..119699aa8 100644 --- a/crates/engine/src/tests/disk_cache.rs +++ b/crates/engine/src/tests/disk_cache.rs @@ -35,7 +35,7 @@ fn disk_cache_settings_persistence_and_invalidation() { let mut s = session(&dir); let st = s.execute_checked("cache.diskStats", json!({})).unwrap(); assert_eq!(st["enabled"], json!(true)); - assert_eq!(st["maxBytes"], json!(100u64 << 30)); + assert_eq!(st["maxBytes"], json!(20u64 << 30)); let cid = project(&mut s); let t = s.time(); let first = s.render(cid, t, RenderOpts::default()); diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 620bc3eeb..81bed3c43 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -71,6 +71,14 @@ fn prefs_migrate_old_layout_and_keep_unknown_keys() { assert_eq!(Prefs::from_json("not json"), Prefs::default()); } +#[test] +fn prefs_migrate_old_disk_cache_default() { + // The old 100 GB default becomes 20 GB; a size the user chose stays (#2551). + assert_eq!(Prefs::from_json(r#"{"version": 2, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 20); + assert_eq!(Prefs::from_json(r#"{"version": 2, "disk": {"diskCacheMaxGb": 50}}"#).disk.disk_cache_max_gb, 50); + assert_eq!(Prefs::from_json(r#"{"version": 3, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 100); +} + #[test] fn prefs_set_validates_keys_types_and_ranges() { let mut p = Prefs::default(); diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 4fa2e1cb3..7ca68a0cc 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -12,9 +12,11 @@ //! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are //! reused whenever the same frames, settings and prompt come back, also after a restart). Every file ends in a checksum; damaged files read as misses and //! are deleted. -//! * Size limit with least-recently-used eviction. The in-memory index is rebuilt from the -//! folder when the cache opens (file modification times order it), so the cache survives -//! restarts; reads refresh a file's time. +//! * Size limit with least-recently-used eviction, down to 90% of the limit so the eviction scan +//! runs once per tenth of the limit written, not on every write. The limit shrinks to keep +//! free space on the cache's disk ([`DiskCache::set_disk_space`]). The in-memory index is +//! rebuilt from the folder when the cache opens (file modification times order it), so the +//! cache survives restarts; reads refresh a file's time. //! * Safe concurrent access: writes go to a unique temporary file that is renamed into place //! (atomic on every desktop OS), so readers in this or another process never see a partial //! entry; a missing or damaged file is just a miss. Writes run on a background thread with a @@ -22,7 +24,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use effectcraft_effects::Buf; @@ -75,11 +77,15 @@ pub struct DiskStats { pub entries: usize, pub bytes: u64, pub max_bytes: u64, + /// The limit in effect: `max_bytes`, or less when the disk is short of free space. + pub limit_bytes: u64, pub hits: u64, pub misses: u64, pub writes: u64, pub evictions: u64, pub dropped: u64, + /// Writes that failed (a full disk, permissions). + pub failed: u64, } struct Meta { @@ -157,15 +163,17 @@ impl DiskIndex { v.into_iter().map(|e| e.1).collect() } - /// Remove least recently used entries until the total fits `max` bytes; returns them (the - /// caller deletes their files). + /// When the total exceeds `max` bytes, remove least recently used entries down to 90% of + /// it (so the sort runs once per tenth of `max` written, not on every write); returns them + /// (the caller deletes their files). pub fn evict(&mut self, max: u64) -> Vec<(Kind, u128)> { if self.total <= max { return vec![]; } + let target = max - max / 10; let mut out = vec![]; for k in self.lru_order() { - if self.total <= max { + if self.total <= target { break; } self.remove(k.0, k.1); @@ -225,11 +233,16 @@ pub struct DiskCache { dir: PathBuf, index: Mutex, max_bytes: AtomicU64, + /// The size free disk space allows (`u64::MAX` = unknown), see [`DiskCache::set_disk_space`]. + disk_cap: AtomicU64, + /// Free space is limiting the cache (warned once per episode). + space_limited: AtomicBool, hits: AtomicU64, misses: AtomicU64, writes: AtomicU64, evictions: AtomicU64, dropped: AtomicU64, + failed: AtomicU64, tmp_counter: AtomicU64, queue: Arc<(Mutex, Condvar)>, /// Layer buffers that took less than this to render are not written (ms). @@ -298,11 +311,14 @@ impl DiskCache { dir, index: Mutex::new(DiskIndex::default()), max_bytes: AtomicU64::new(max_bytes), + disk_cap: AtomicU64::new(u64::MAX), + space_limited: AtomicBool::new(false), hits: AtomicU64::new(0), misses: AtomicU64::new(0), writes: AtomicU64::new(0), evictions: AtomicU64::new(0), dropped: AtomicU64::new(0), + failed: AtomicU64::new(0), tmp_counter: AtomicU64::new(0), queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: false }), Condvar::new())), min_layer_ms: AtomicU64::new(30), @@ -371,6 +387,30 @@ impl DiskCache { self.evict(); } + /// The limit in effect: the setting, or less when the disk is short of free space. + pub fn limit_bytes(&self) -> u64 { + self.max_bytes().min(self.disk_cap.load(Ordering::Relaxed)) + } + + /// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves at least + /// 2 GB or 5% of the disk free, whichever is more (evicting when it must). + pub fn set_disk_space(&self, space: Option<(u64, u64)>) { + let cap = match space { + Some((total, available)) => { + let reserve = (2u64 << 30).max(total / 20); + let used = self.index.lock().map(|g| g.total()).unwrap_or_default(); + used.saturating_add(available.saturating_sub(reserve)) + } + None => u64::MAX, + }; + self.disk_cap.store(cap, Ordering::Relaxed); + let limited = cap < self.max_bytes(); + if self.space_limited.swap(limited, Ordering::Relaxed) != limited && limited { + log::warn!("disk cache at {}: little free disk space, limiting the cache to {} MB", self.folder().display(), cap >> 20); + } + self.evict(); + } + /// Layer buffers faster to render than this are not worth a disk write. pub fn set_min_layer_ms(&self, ms: u64) { self.min_layer_ms.store(ms, Ordering::Relaxed); @@ -395,11 +435,13 @@ impl DiskCache { entries, bytes, max_bytes: self.max_bytes(), + limit_bytes: self.limit_bytes(), hits: self.hits.load(Ordering::Relaxed), misses: self.misses.load(Ordering::Relaxed), writes: self.writes.load(Ordering::Relaxed), evictions: self.evictions.load(Ordering::Relaxed), dropped: self.dropped.load(Ordering::Relaxed), + failed: self.failed.load(Ordering::Relaxed), } } @@ -494,18 +536,16 @@ impl DiskCache { } let path = self.path(kind, key); let Some(parent) = path.parent() else { return }; - if std::fs::create_dir_all(parent).is_err() { + if let Err(e) = std::fs::create_dir_all(parent) { + self.write_failed(&e); return; } let data = seal(kind, payload); let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed); let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id())); - if std::fs::write(&tmp, &data).is_err() { - let _ = std::fs::remove_file(&tmp); - return; - } - if std::fs::rename(&tmp, &path).is_err() { + if let Err(e) = std::fs::write(&tmp, &data).and_then(|()| std::fs::rename(&tmp, &path)) { let _ = std::fs::remove_file(&tmp); + self.write_failed(&e); return; } self.writes.fetch_add(1, Ordering::Relaxed); @@ -515,9 +555,16 @@ impl DiskCache { self.evict(); } + /// Count a failed write; the first one is logged (a full disk shouldn't go unnoticed). + fn write_failed(&self, e: &std::io::Error) { + if self.failed.fetch_add(1, Ordering::Relaxed) == 0 { + log::warn!("disk cache at {}: a write failed: {e}", self.folder().display()); + } + } + /// Remove least recently used entries until the cache fits its limit. fn evict(&self) { - let max = self.max_bytes(); + let max = self.limit_bytes(); let victims = match self.index.lock() { Ok(mut g) => g.evict(max), Err(_) => return, @@ -1030,9 +1077,9 @@ mod tests { let per = dc.stats().bytes / 6; // Touch 0 so it is the most recently used. assert!(dc.get_layer(0).is_some()); - dc.set_max_bytes(per * 3 + per / 2); + dc.set_max_bytes(per * 3 + per * 3 / 4); let st = dc.stats(); - assert!(st.bytes <= per * 3 + per / 2, "{st:?}"); + assert!(st.bytes <= per * 3 + per * 3 / 4, "{st:?}"); assert_eq!(st.entries, 3); assert!(dc.contains(Kind::Layer, 0), "recently used survives"); assert!(!dc.contains(Kind::Layer, 1) && !dc.contains(Kind::Layer, 2) && !dc.contains(Kind::Layer, 3)); @@ -1042,7 +1089,61 @@ mod tests { assert_eq!(files, 3); // New writes keep evicting. dc.put_layer_now(99, &buf(9, 64, 64)); - assert!(dc.stats().bytes <= per * 3 + per / 2); + assert!(dc.stats().bytes <= per * 3 + per * 3 / 4); + } + + #[test] + fn eviction_goes_down_to_ninety_percent_in_lru_order() { + let mut ix = DiskIndex::default(); + for k in 0..10u128 { + ix.insert(Kind::Layer, k, 100); + } + assert!(ix.touch(Kind::Layer, 0)); + // Over 950: down to 855, least recently used first. + ix.insert(Kind::Layer, 10, 100); + assert_eq!(ix.evict(950), vec![(Kind::Layer, 1), (Kind::Layer, 2), (Kind::Layer, 3)]); + assert_eq!(ix.total(), 800); + // The slack absorbs the next write without another scan. + ix.insert(Kind::Layer, 11, 100); + assert!(ix.evict(950).is_empty()); + } + + #[test] + fn free_disk_space_caps_the_limit() { + let dir = tmpdir("space"); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + for k in 0..4u128 { + dc.put_layer_now(k, &buf(k as u32, 64, 64)); + } + let used = dc.stats().bytes; + // Unknown space: the setting alone. + dc.set_disk_space(None); + assert_eq!(dc.stats().limit_bytes, 1 << 30); + // 100 GB disk, 5 GB reserved, 1 GB free: the cache may not grow. + dc.set_disk_space(Some((100 << 30, 1 << 30))); + let st = dc.stats(); + assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used)); + dc.put_layer_now(9, &buf(9, 64, 64)); + let st = dc.stats(); + assert!(st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}"); + assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used"); + // Plenty of space again: back to the setting. + dc.set_disk_space(Some((100 << 30, 50 << 30))); + assert_eq!(dc.stats().limit_bytes, 1 << 30); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn failed_writes_are_counted() { + let dir = tmpdir("failed"); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + // A file where the layers folder should be: every layer write fails. + std::fs::write(dir.join(VERSION).join(Kind::Layer.dir()), b"x").unwrap(); + dc.put_layer_now(1, &buf(1, 8, 8)); + dc.put_layer_now(2, &buf(2, 8, 8)); + let st = dc.stats(); + assert_eq!((st.failed, st.writes, st.entries), (2, 0, 0)); + let _ = std::fs::remove_dir_all(&dir); } fn walk(d: &Path) -> usize { From 8af5f54c9232bba92b0b18cdbfce94aa14027206 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 28 Oct 2026 18:34:02 +0530 Subject: [PATCH 2/6] Disk cache: give space back below the free-space reserve, skip writes that can't fit (#2551) A disk reported as 0 bytes (an automount map) counts as unknown. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/disk_cache.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 7ca68a0cc..41be3c454 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -396,12 +396,14 @@ impl DiskCache { /// 2 GB or 5% of the disk free, whichever is more (evicting when it must). pub fn set_disk_space(&self, space: Option<(u64, u64)>) { let cap = match space { - Some((total, available)) => { + // (a disk reported as 0 bytes, such as an automount map, is unknown) + Some((total, available)) if total > 0 => { let reserve = (2u64 << 30).max(total / 20); let used = self.index.lock().map(|g| g.total()).unwrap_or_default(); - used.saturating_add(available.saturating_sub(reserve)) + // The cache's own files count as free: below the reserve it gives space back. + used.saturating_add(available).saturating_sub(reserve) } - None => u64::MAX, + _ => u64::MAX, }; self.disk_cap.store(cap, Ordering::Relaxed); let limited = cap < self.max_bytes(); @@ -541,6 +543,10 @@ impl DiskCache { return; } let data = seal(kind, payload); + // An entry that cannot fit would be evicted straight away. + if data.len() as u64 > self.limit_bytes() { + return; + } let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed); let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id())); if let Err(e) = std::fs::write(&tmp, &data).and_then(|()| std::fs::rename(&tmp, &path)) { @@ -1119,14 +1125,22 @@ mod tests { // Unknown space: the setting alone. dc.set_disk_space(None); assert_eq!(dc.stats().limit_bytes, 1 << 30); - // 100 GB disk, 5 GB reserved, 1 GB free: the cache may not grow. - dc.set_disk_space(Some((100 << 30, 1 << 30))); + // A disk reported as 0 bytes is unknown. + dc.set_disk_space(Some((0, 0))); + assert_eq!(dc.stats().limit_bytes, 1 << 30); + // 100 GB disk, 5 GB reserved, exactly 5 GB free: the cache may not grow. + dc.set_disk_space(Some((100 << 30, 5 << 30))); let st = dc.stats(); assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used)); dc.put_layer_now(9, &buf(9, 64, 64)); let st = dc.stats(); assert!(st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}"); assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used"); + // Below the reserve the cache gives its space back, and skips writes that can't fit. + dc.set_disk_space(Some((100 << 30, 4 << 30))); + dc.put_layer_now(10, &buf(10, 64, 64)); + let st = dc.stats(); + assert_eq!((st.limit_bytes, st.entries, st.bytes), (0, 0, 0), "{st:?}"); // Plenty of space again: back to the setting. dc.set_disk_space(Some((100 << 30, 50 << 30))); assert_eq!(dc.stats().limit_bytes, 1 << 30); From 52dda004a5786fbdb4e1aa5f68fe589b0faf728f Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 28 Oct 2026 21:55:33 +0530 Subject: [PATCH 3/6] Disk cache: evict before writing, reset the disk reading when the folder changes (#2551) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/settings/session_settings.rs | 9 ++++++++- crates/render/src/disk_cache.rs | 11 ++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/settings/session_settings.rs b/crates/engine/src/settings/session_settings.rs index 98f34bd34..a4e800da6 100644 --- a/crates/engine/src/settings/session_settings.rs +++ b/crates/engine/src/settings/session_settings.rs @@ -119,6 +119,7 @@ impl Session { // (no file system in the browser: the disk cache stays off there) let Some(folder) = folder.filter(|_| d.disk_cache_enabled && !cfg!(target_arch = "wasm32")) else { self.disk_cache = None; + self.disk_watch = Default::default(); self.layer_cache.set_disk(None); return; }; @@ -132,11 +133,17 @@ impl Session { c.set_max_bytes(max); c.clone() } + // A new folder: the free-space reading was for the old one (a reader still running + // holds the old watcher's state, so it can't write into the new one). _ => match DiskCache::open(&folder, max) { - Ok(c) => c, + Ok(c) => { + self.disk_watch = Default::default(); + c + } Err(e) => { log::warn!("disk cache at {}: {e}", folder.display()); self.disk_cache = None; + self.disk_watch = Default::default(); self.layer_cache.set_disk(None); return; } diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 41be3c454..44a53f8d9 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -547,6 +547,8 @@ impl DiskCache { if data.len() as u64 > self.limit_bytes() { return; } + // Make room first, so the cache never exceeds its limit (or the free-space reserve). + self.evict_to(self.limit_bytes().saturating_sub(data.len() as u64)); let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed); let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id())); if let Err(e) = std::fs::write(&tmp, &data).and_then(|()| std::fs::rename(&tmp, &path)) { @@ -558,7 +560,6 @@ impl DiskCache { if let Ok(mut g) = self.index.lock() { g.insert(kind, key, data.len() as u64); } - self.evict(); } /// Count a failed write; the first one is logged (a full disk shouldn't go unnoticed). @@ -570,7 +571,11 @@ impl DiskCache { /// Remove least recently used entries until the cache fits its limit. fn evict(&self) { - let max = self.limit_bytes(); + self.evict_to(self.limit_bytes()); + } + + /// Remove least recently used entries until the cache fits `max` bytes. + fn evict_to(&self, max: u64) { let victims = match self.index.lock() { Ok(mut g) => g.evict(max), Err(_) => return, @@ -1134,7 +1139,7 @@ mod tests { assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used)); dc.put_layer_now(9, &buf(9, 64, 64)); let st = dc.stats(); - assert!(st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}"); + assert!(st.bytes <= st.limit_bytes && st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}"); assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used"); // Below the reserve the cache gives its space back, and skips writes that can't fit. dc.set_disk_space(Some((100 << 30, 4 << 30))); From 74ea0be88fa0b9ffbc892aac1ac5a2b70c044d4f Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 28 Oct 2026 22:29:21 +0530 Subject: [PATCH 4/6] Disk cache: a failed free-space reading clears the last one (#2551) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/system/sysinfo.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/system/sysinfo.rs b/crates/engine/src/system/sysinfo.rs index fe94df09e..c02e68773 100644 --- a/crates/engine/src/system/sysinfo.rs +++ b/crates/engine/src/system/sysinfo.rs @@ -56,9 +56,8 @@ impl Watch { } let (latest, busy) = (self.latest.clone(), self.busy.clone()); let read = move || { - if let Some(m) = read() { - *latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(m); - } + // A failed reading clears the last one: a stale value is worse than none. + *latest.lock().unwrap_or_else(PoisonError::into_inner) = read(); busy.store(false, Ordering::Release); }; #[cfg(not(target_arch = "wasm32"))] @@ -72,7 +71,7 @@ impl Watch { } } - /// The last reading (`None` until one finished). + /// The last reading (`None` until one finished or when the last one failed). pub fn latest(&self) -> Option { *self.latest.lock().unwrap_or_else(PoisonError::into_inner) } @@ -288,6 +287,26 @@ mod tests { assert_eq!(m.available, 1000); } + #[test] + fn a_failed_reading_clears_the_last_one() { + let w = Watch::::default(); + let wait = || { + for _ in 0..500 { + if !w.busy.load(Ordering::Acquire) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("the reading never finished"); + }; + w.poll_with("ec-test", || Some(7)); + wait(); + assert_eq!(w.latest(), Some(7)); + w.poll_with("ec-test", || None); + wait(); + assert_eq!(w.latest(), None); + } + #[test] fn parses_df() { let linux = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/nvme0n1p2 488281250 123456789 364824461 26% /\n"; From 8ab492d8cd28ba46a9d4dfab77de47a48d772987 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 28 Oct 2026 22:45:48 +0530 Subject: [PATCH 5/6] Disk cache: apply the free-space reading where it's taken, evict off the UI thread at open (#2551) Co-Authored-By: Claude Opus 5.5 --- .../engine/src/settings/session_settings.rs | 21 ++++++++++++++----- crates/engine/src/system/sysinfo.rs | 5 +++-- crates/render/src/disk_cache.rs | 13 +++++++++++- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/settings/session_settings.rs b/crates/engine/src/settings/session_settings.rs index a4e800da6..609ad5987 100644 --- a/crates/engine/src/settings/session_settings.rs +++ b/crates/engine/src/settings/session_settings.rs @@ -84,11 +84,7 @@ impl Session { /// re-apply the cache budgets when the reading changes them. Returns the effective budgets. /// The disk cache's free disk space is read the same way (it keeps a reserve free). pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets { - if let Some(dc) = &self.disk_cache { - dc.set_disk_space(self.disk_watch.latest()); - let folder = dc.folder().to_path_buf(); - self.disk_watch.poll_with("ec-disk-space", move || crate::sysinfo::disk_space(&folder)); - } + self.poll_disk_space(); let before = self.prefs.cache_budgets(self.sys_memory); self.sys_memory = self.memory_watch.latest().or(self.sys_memory); self.memory_watch.poll(); @@ -104,6 +100,19 @@ impl Session { after } + /// Read the disk cache's free disk space in the background and apply it there (the reserve + /// it keeps free): the free space and the cache's size are taken at the same moment, and the + /// reader holds its own cache, so a reading never reaches another folder's. + fn poll_disk_space(&mut self) { + if let Some(dc) = &self.disk_cache { + let dc = dc.clone(); + self.disk_watch.poll_with("ec-disk-space", move || { + dc.set_disk_space(crate::sysinfo::disk_space(dc.folder())); + Some(()) + }); + } + } + /// Apply Settings ▸ Media & Disk Cache ▸ Disk Cache: open (or close) the persistent cache /// and attach it to the layer cache. Without a folder setting the platform cache folder is /// used, but only by frontends with a settings store (headless sessions stay off disk). @@ -150,6 +159,8 @@ impl Session { }, }; self.disk_cache = Some(dc.clone()); + // The reserve applies from the start (a new folder's watcher is idle). + self.poll_disk_space(); let salt = self.refresh_disk_salt(true).unwrap_or_default(); self.layer_cache.set_disk(Some((dc.clone(), salt))); // Roto Brush / Object Matte segmentations persist there too (held weakly: switching the diff --git a/crates/engine/src/system/sysinfo.rs b/crates/engine/src/system/sysinfo.rs index c02e68773..b0bf16231 100644 --- a/crates/engine/src/system/sysinfo.rs +++ b/crates/engine/src/system/sysinfo.rs @@ -44,8 +44,9 @@ impl Default for Watch { /// The system's memory ([`memory`]). pub type MemoryWatch = Watch; -/// The disk cache's disk: `(total, available)` bytes ([`disk_space`]). -pub type DiskWatch = Watch<(u64, u64)>; +/// The disk cache's free-space reading ([`disk_space`]): the reader applies it to its cache +/// itself, so only the busy guard is used. +pub type DiskWatch = Watch<()>; impl Watch { /// Start a new reading in the background on a thread called `name` (no threads on the web: diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 44a53f8d9..ca5660a67 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -320,10 +320,12 @@ impl DiskCache { dropped: AtomicU64::new(0), failed: AtomicU64::new(0), tmp_counter: AtomicU64::new(0), - queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: false }), Condvar::new())), + queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: cfg!(not(target_arch = "wasm32")) }), Condvar::new())), min_layer_ms: AtomicU64::new(30), }); dc.rescan(); + // Shrinking a large cache deletes many files: the writer thread does it. + #[cfg(target_arch = "wasm32")] dc.evict(); #[cfg(not(target_arch = "wasm32"))] { @@ -644,6 +646,15 @@ impl DiskCache { #[cfg(not(target_arch = "wasm32"))] fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { let (m, cv) = &*q; + // The eviction at open (a lower limit than the folder holds), off the opening thread; + // `busy` starts set, so `flush` waits for it. + if let Some(cache) = dc.upgrade() { + cache.evict(); + } + if let Ok(mut g) = m.lock() { + g.busy = false; + } + cv.notify_all(); loop { let job = { let Ok(mut g) = m.lock() else { return }; From 4171b6c0db49c8ee4b1ff93cc7cc91a5e25cbdaf Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 28 Oct 2026 22:53:53 +0530 Subject: [PATCH 6/6] Disk cache: negative free space from df counts as none (#2551) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/system/sysinfo.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/system/sysinfo.rs b/crates/engine/src/system/sysinfo.rs index b0bf16231..bfb471e92 100644 --- a/crates/engine/src/system/sysinfo.rs +++ b/crates/engine/src/system/sysinfo.rs @@ -171,10 +171,15 @@ pub fn memory() -> Option { pub fn parse_df(text: &str) -> Option<(u64, u64)> { let line = text.lines().skip(1).find(|l| !l.trim().is_empty())?; let f: Vec<&str> = line.split_whitespace().collect(); + // A full disk into its root-reserved blocks reports negative numbers (Linux): none free. + let num = |v: &str| match v.strip_prefix('-') { + Some(d) => d.parse::().ok().map(|_| 0), + None => v.parse::().ok(), + }; // Size, used, available and capacity follow the device name (which may contain spaces). let i = (0..f.len().saturating_sub(3)) - .find(|&i| f.get(i + 3).is_some_and(|c| c.ends_with('%')) && f.get(i..i + 3).is_some_and(|n| n.iter().all(|v| v.parse::().is_ok())))?; - let kb = |j: usize| f.get(j)?.parse::().ok().map(|v| v.saturating_mul(1024)); + .find(|&i| f.get(i + 3).is_some_and(|c| c.ends_with('%')) && f.get(i..i + 3).is_some_and(|n| n.iter().all(|v| num(v).is_some())))?; + let kb = |j: usize| f.get(j).and_then(|v| num(v)).map(|v| v.saturating_mul(1024)); Some((kb(i)?, kb(i + 2)?)) } @@ -317,6 +322,9 @@ mod tests { assert_eq!(parse_df(mac), Some((0, 0))); let spaced = "Filesystem 1024-blocks Used Available Capacity Mounted on\n//nas/My Share 1000 400 600 40% /Volumes/My Share\n"; assert_eq!(parse_df(spaced), Some((1000 * 1024, 600 * 1024))); + // A full disk into its reserved blocks: nothing available. + let full = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sda2 1000 1010 -10 101% /\n"; + assert_eq!(parse_df(full), Some((1000 * 1024, 0))); assert_eq!(parse_df("Filesystem 1024-blocks\n"), None); assert_eq!(parse_df(""), None); }