diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ca93f29b..3313543a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -373,6 +373,8 @@ pub struct Session { /// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown /// (headless sessions don't query it, see [`Session::memory_tick`]). pub sys_memory: Option, + /// Reads the system's memory in the background for [`Session::memory_tick`]. + pub memory_watch: sysinfo::MemoryWatch, /// 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 @@ -465,6 +467,7 @@ impl Default for Session { plugin_loader: None, script_ui: scriptui::ScriptUi::default(), sys_memory: None, + memory_watch: Default::default(), applied_nested_switches: None, offload: None, offloaded: vec![], diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index e19d0701..4afd9691 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -75,11 +75,13 @@ impl Session { self.footage.set_cache_budget(b.media); } - /// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply - /// the cache budgets when the reading changes them. Returns the effective budgets. + /// 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. pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets { let before = self.prefs.cache_budgets(self.sys_memory); - self.sys_memory = crate::sysinfo::memory().or(self.sys_memory); + self.sys_memory = self.memory_watch.latest().or(self.sys_memory); + self.memory_watch.poll(); let after = self.prefs.cache_budgets(self.sys_memory); if after != before { self.apply_cache_budgets(); diff --git a/crates/engine/src/sysinfo.rs b/crates/engine/src/sysinfo.rs index 0c79c2a1..8e26e70a 100644 --- a/crates/engine/src/sysinfo.rs +++ b/crates/engine/src/sysinfo.rs @@ -4,7 +4,11 @@ //! 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. +//! 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. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use serde::Serialize; @@ -23,10 +27,51 @@ 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>>, + 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) { + 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() { + *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() { + self.busy.store(false, Ordering::Release); + } + #[cfg(target_arch = "wasm32")] + read(); + } + + /// The last reading (`None` until one finished). + pub fn latest(&self) -> Option { + *self.latest.lock().unwrap_or_else(PoisonError::into_inner) + } +} + #[cfg(not(target_arch = "wasm32"))] #[allow(dead_code)] fn run(cmd: &str, args: &[&str]) -> Option { - let out = std::process::Command::new(cmd).args(args).output().ok()?; + let mut c = std::process::Command::new(cmd); + c.args(args); + // 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 + let out = c.output().ok()?; out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string()) } diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 50fe186d..e1820578 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -464,3 +464,25 @@ fn open_recent_increment_and_revert() { assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj"); let _ = std::fs::remove_dir_all(d); } + +#[test] +fn memory_tick_never_waits_for_the_system() { + let mut s = Session::default(); + let t0 = std::time::Instant::now(); + s.memory_tick(); + s.memory_tick(); + // Reading the system's memory starts PowerShell on Windows (about a second): the UI thread + // only takes the last reading. + assert!(t0.elapsed() < std::time::Duration::from_millis(200), "{:?}", t0.elapsed()); + if crate::sysinfo::memory().is_some() { + let mut got = None; + for _ in 0..200 { + got = s.memory_watch.latest(); + if got.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(got.is_some_and(|m| m.total > 0), "the reading arrives in the background"); + } +}