Browse / github.com/storytold/lightcraft / patch
Issue #3066: each ui-egui test app's caches are sized for its test's share of memory
Each ui-egui test session takes cache budgets sized for the whole machine Part of #3057. Builds on #3065. ## Summary Each ui-egui test that builds a demo session gets cache budgets sized for a whole machine running one app. Several tests run at once in one process, so those budgets add up. The ui-egui lib tests peaked at 7.8 GB with 4 tests at once, and ran out of a 12 GB cap with 16 (#3057). The process-wide budget is the wrong knob. `LIGHTCRAFT_MEMORY_MB=384`, set together with `RAYON_NUM_THREADS=4`, cut the peak by only 21 % and made the run 3× slower. It also shrinks the process-wide scene cache (budget / 2) below the size of one full demo scene. My guess is
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- THEtheChad (imported from pull request #3100, open)
- Base
c9860ea7a3fa367451acd9d8cf23538c345192c6- Size
- +475 −13 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
b66105f892f3bad644d9a972
How it went
No other operator has reported using it yet.
Diff
raw · README.md crates/testkit/src/lib.rs crates/testkit/src/memory.rs crates/ui-egui/src/app.rs crates/ui-egui/src/headless/tests.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/render.rs crates/ui-egui/src/test_budget.rs crates/ui-egui/src/tests/preview_limit.rs docs/architecture.md
@@ -419,7 +419,8 @@ turn. `LIGHTCRAFT_CI_SLOTS=2` allows two; `LIGHTCRAFT_CI_LOCK=off` skips the loc CI defaults to line-table debug information and one build job per 1.5 GB of RAM available when it starts (at most one per CPU; 4 if memory can't be read), so an 8 GB machine with 6 GB free builds with 4, and a busy machine with little free-builds with fewer. Test threads follow, capped at 4.+builds with fewer. Test threads follow, capped at 4, and each `ui-egui` test app sizes its caches+for its test's share of the memory available, not the whole machine's. Full-debuginfo linkers, one per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged. Explicit `CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS`, `RUST_TEST_THREADS` and `NEXTEST_TEST_THREADS` settings override these defaults. The tests run under@@ -20,6 +20,9 @@ //! `cargo xtask ci` lists the skips by need and reason in its summary and in //! `target/ci/report.json`. A test that runs part of its checks (e.g. only the CPU half) records //! the part it could not run the same way.+//!+//! **Memory per test.** [`memory::per_test_bytes`] is a test's share of the memory available+//! when libtest runs several tests at once, for harnesses that size caches per test. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -27,6 +30,8 @@ use std::fmt::Write as _; use std::io::Write as _; use std::path::Path; +pub mod memory;+ /// The environment variable naming the file skips are appended to (JSON lines). pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG"; new file mode 100644@@ -0,0 +1,192 @@+//! **Memory per test.** libtest runs several tests at once in one process, and a test that sizes+//! its caches for the whole machine, as an app does, takes that much once per test running. A+//! test harness sizes them for its share instead: [`per_test_bytes`] is the memory available now+//! divided by the number of tests libtest runs at once, clamped to [`MIN_PER_TEST`] ..=+//! [`MAX_PER_TEST`] (issue #3066).+//!+//! The memory available is `MemAvailable` from `/proc/meminfo` on Linux; elsewhere, or when it+//! can't be read, [`FALLBACK_AVAILABLE`]. It doesn't yet honour a cgroup memory limit (a container+//! or CI runner): #3072 adds that to `lightcraft-sysmem`. The tests at once are counted as libtest+//! counts them: the test binary's `--test-threads` argument, else `RUST_TEST_THREADS` (as `cargo+//! xtask ci` sets it), else the machine's parallelism.++/// The least memory one test is given, however little is available.+pub const MIN_PER_TEST: u64 = 1 << 30;++/// The most memory one test is given, however much is available.+pub const MAX_PER_TEST: u64 = 16 << 30;++/// The memory assumed available where the platform doesn't say (bytes).+pub const FALLBACK_AVAILABLE: u64 = 8 << 30;++/// Bytes of memory one test may use: the memory available now ÷ the tests libtest runs at once,+/// clamped to [`MIN_PER_TEST`] ..= [`MAX_PER_TEST`]. Reads `/proc/meminfo` on every call: a+/// caller that wants one value for the whole run keeps the first.+pub fn per_test_bytes() -> u64 {+ per_test(available_bytes(), test_threads())+}++/// [`per_test_bytes`] for `available` bytes (`None`: unknown, [`FALLBACK_AVAILABLE`]) shared by+/// `threads` tests at once (0 counts as 1).+pub fn per_test(available: Option<u64>, threads: usize) -> u64 {+ let threads = u64::try_from(threads.max(1)).unwrap_or(u64::MAX);+ (available.unwrap_or(FALLBACK_AVAILABLE) / threads).clamp(MIN_PER_TEST, MAX_PER_TEST)+}++/// How many tests libtest runs at once in this process, in libtest's order: the binary's+/// `--test-threads` argument, else `RUST_TEST_THREADS`, else the machine's parallelism, else 1.+pub fn test_threads() -> usize {+ // args_os: `std::env::args` panics on an argument that isn't Unicode+ let args: Vec<String> = std::env::args_os().skip(1).filter_map(|a| a.into_string().ok()).collect();+ let env = std::env::var("RUST_TEST_THREADS").ok();+ threads(test_threads_arg(&args), env.as_deref(), std::thread::available_parallelism().ok().map(|n| n.get()))+}++/// The value of `--test-threads` in a test binary's arguments (without the program name):+/// `--test-threads=N` or `--test-threads N`, before any `--` (after it, libtest reads filters).+pub fn test_threads_arg(args: &[String]) -> Option<&str> {+ let mut args = args.iter().map(String::as_str).take_while(|a| *a != "--");+ while let Some(arg) = args.next() {+ if let Some(value) = arg.strip_prefix("--test-threads=") {+ return Some(value);+ }+ if arg == "--test-threads" {+ return args.next();+ }+ }+ None+}++/// [`test_threads`] from the `--test-threads` argument, the variable's value and the machine's+/// parallelism, the first that is set winning. A value that isn't a positive whole number is+/// ignored (libtest refuses it too).+pub fn threads(arg: Option<&str>, rust_test_threads: Option<&str>, parallelism: Option<usize>) -> usize {+ let count = |v: &str| v.trim().parse::<usize>().ok().filter(|n| *n > 0);+ arg.and_then(count).or(rust_test_threads.and_then(count)).or(parallelism).unwrap_or(1).max(1)+}++/// The memory available to start new work without swapping (bytes), when the platform says.+pub fn available_bytes() -> Option<u64> {+ #[cfg(target_os = "linux")]+ {+ std::fs::read_to_string("/proc/meminfo").ok().as_deref().and_then(mem_available)+ }+ #[cfg(not(target_os = "linux"))]+ {+ None+ }+}++/// `MemAvailable` from the text of `/proc/meminfo`, in bytes. `None` when the line is missing+/// (kernels before 3.14), malformed, in a unit other than kB, or too large.+pub fn mem_available(meminfo: &str) -> Option<u64> {+ let line = meminfo.lines().find_map(|l| l.strip_prefix("MemAvailable:"))?;+ let mut fields = line.split_whitespace();+ let kb = fields.next()?.parse::<u64>().ok()?;+ match fields.next() {+ Some("kB") => kb.checked_mul(1024),+ _ => None,+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ const GIB: u64 = 1 << 30;++ #[test]+ fn the_memory_available_is_shared_by_the_tests_at_once() {+ assert_eq!(per_test(Some(16 * GIB), 4), 4 * GIB);+ assert_eq!(per_test(Some(16 * GIB), 8), 2 * GIB);+ assert_eq!(per_test(Some(12 * GIB), 3), 4 * GIB);+ }++ #[test]+ fn the_share_stays_in_its_range() {+ assert_eq!(per_test(Some(4 * GIB), 16), MIN_PER_TEST, "little available");+ assert_eq!(per_test(Some(0), 4), MIN_PER_TEST, "nothing available");+ assert_eq!(per_test(Some(512 * GIB), 1), MAX_PER_TEST, "a big machine");+ assert_eq!(per_test(Some(u64::MAX), 0), MAX_PER_TEST, "no threads counts as one");+ assert_eq!(per_test(Some(8 * GIB), usize::MAX), MIN_PER_TEST);+ }++ #[test]+ fn unknown_memory_assumes_the_fallback() {+ assert_eq!(per_test(None, 4), FALLBACK_AVAILABLE / 4);+ assert_eq!(per_test(None, 64), MIN_PER_TEST);+ }++ #[test]+ fn the_tests_at_once_come_from_rust_test_threads_else_the_machine() {+ assert_eq!(threads(None, Some("4"), Some(16)), 4);+ assert_eq!(threads(None, Some(" 2\n"), Some(16)), 2);+ assert_eq!(threads(None, None, Some(16)), 16);+ assert_eq!(threads(None, Some("0"), Some(16)), 16, "libtest refuses 0");+ assert_eq!(threads(None, Some("four"), Some(8)), 8);+ assert_eq!(threads(None, Some("-1"), None), 1);+ assert_eq!(threads(None, Some("99999999999999999999999"), Some(6)), 6, "too large to count");+ assert_eq!(threads(None, None, None), 1);+ assert_eq!(threads(None, Some(""), Some(0)), 1);+ }++ #[test]+ fn the_test_threads_argument_wins_over_the_variable_and_the_machine() {+ assert_eq!(threads(Some("32"), Some("4"), Some(8)), 32);+ assert_eq!(threads(Some("32"), None, Some(8)), 32);+ assert_eq!(threads(Some("1"), Some("4"), Some(8)), 1);+ assert_eq!(threads(Some("0"), Some("4"), Some(8)), 4, "libtest refuses 0");+ assert_eq!(threads(Some("many"), None, Some(8)), 8);+ }++ #[test]+ fn the_test_threads_argument_is_read_as_libtest_reads_it() {+ let args = |a: &[&str]| a.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();+ assert_eq!(test_threads_arg(&args(&["--test-threads=32"])), Some("32"));+ assert_eq!(test_threads_arg(&args(&["tests_masking", "--test-threads", "6", "--nocapture"])), Some("6"));+ assert_eq!(test_threads_arg(&args(&["--nocapture", "--test-threads="])), Some(""));+ assert_eq!(test_threads_arg(&args(&["--test-threads"])), None, "no value");+ assert_eq!(test_threads_arg(&args(&["--", "--test-threads=32"])), None, "a filter after --");+ assert_eq!(test_threads_arg(&args(&["--test-threadsX=3", "test-threads=3"])), None);+ assert_eq!(test_threads_arg(&args(&[])), None);+ assert_eq!(test_threads_arg(&args(&["--test-threads=2", "--test-threads=9"])), Some("2"));+ }++ #[test]+ fn mem_available_is_read_from_meminfo() {+ let meminfo = "MemTotal: 22544024 kB\nMemFree: 2072936 kB\nMemAvailable: 16888300 kB\nBuffers: 123 kB\n";+ assert_eq!(mem_available(meminfo), Some(16_888_300 * 1024));+ assert_eq!(mem_available("MemAvailable: 1 kB"), Some(1024));+ }++ #[test]+ fn hostile_meminfo_is_unknown_not_a_panic() {+ for text in [+ "",+ "MemTotal: 1024 kB\n",+ "MemAvailable:\n",+ "MemAvailable: kB\n",+ "MemAvailable: -5 kB\n",+ "MemAvailable: 12.5 kB\n",+ "MemAvailable: 1024 MB\n",+ "MemAvailable: 1024\n",+ "MemAvailable: 18446744073709551615 kB\n",+ "MemAvailable: 99999999999999999999999999 kB\n",+ "MemAvailableX 1024 kB\n",+ "\u{0}\u{ffff}MemAvailable: 1 kB",+ ] {+ assert_eq!(mem_available(text), None, "{text:?}");+ }+ }++ #[test]+ fn this_machine_gives_a_share_in_range() {+ let share = per_test_bytes();+ assert!((MIN_PER_TEST..=MAX_PER_TEST).contains(&share), "{share}");+ assert!(test_threads() >= 1);+ // /proc can be missing or masked (a sandbox, an old kernel): the share is then the fallback's+ if available_bytes().is_none() {+ assert_eq!(share, per_test(None, test_threads()));+ }+ }+}@@ -20,7 +20,7 @@ impl LightcraftApp { pub fn new(mut session: Session, services: Services) -> Self { // AI mask requests run on the model's worker; frames apply their results (never wait) session.segmenter.background = true;- Self {+ let app = Self { session, ui: UiState::default(), services,@@ -122,7 +122,11 @@ impl LightcraftApp { recent_checks: Default::default(), activity_expanded: false, model_setup: Default::default(),- }+ };+ // a test app's own caches are sized for its test's share of the machine (issue #3066)+ #[cfg(test)]+ let app = test_budget::cap_instance(app);+ app } /// Supply the browser's separately downloaded Simplified Chinese font before the first frame.@@ -4268,13 +4268,26 @@ fn settings_dialog_by_keyboard_and_clicks() { assert_eq!(h.app.session.cache_mb, 512); h.request("ui.clickWidget", json!({"id": "button:settingsPreview-3"}), t); assert_eq!(h.app.ui.settings.preview_limit, 3840);- h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t);- assert_eq!(h.app.ui.settings.memory_mb, 1024);- h.step();- assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget");- h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t);- h.step();- assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic");+ {+ // the memory budget is process-wide: this test's turn to change it, and it goes back+ // to what it was (not the default) when the guard drops, also on a failed assertion+ // (issue #3065). The change lasts these few frames.+ use lightcraft_engine::memory::{budget, cache_share, default_budget};+ let _budget = crate::test_budget::BudgetGuard::hold();+ // the session's own share, set apart from the 1024 MB choice's: only the command+ // changes it, whatever another test does to the process-wide budget+ h.app.session.media.set_budget(cache_share(1024 << 20) + (1 << 20));+ h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t);+ assert_eq!(h.app.ui.settings.memory_mb, 1024);+ h.step();+ assert_eq!(h.app.session.media.budget(), cache_share(1024 << 20), "the session's caches follow");+ assert_eq!(budget(), 1024 << 20, "applied through app.memoryBudget");+ h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t);+ h.step();+ let automatic = (default_budget() >> 20) << 20;+ assert_eq!(h.app.session.media.budget(), cache_share(automatic), "back to automatic");+ assert_eq!(budget(), automatic, "back to automatic");+ } // Import: per-camera defaults h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t); h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t);@@ -39,6 +39,8 @@ pub mod theme; pub mod widgets; pub mod window_place; +#[cfg(test)]+mod test_budget; #[cfg(test)] mod tests; #[cfg(test)]@@ -176,7 +176,7 @@ pub(crate) fn stage_trim_order(sizes: &[(Slot, usize)], budget: usize) -> Vec<Sl /// before / hover renders') may hold together before the idle ones are cleared (see /// [`stage_trim_order`] for the open photo's own): they are the decoded-image sized buffers that the /// engine's own caches do not count.-const STAGE_BUDGET_SHARE: usize = 4;+pub(crate) const STAGE_BUDGET_SHARE: usize = 4; /// Variant thumbnails kept as textures (LRU), when nothing asks for more. pub const VARIANT_TEXTURES: usize = 96;new file mode 100644@@ -0,0 +1,234 @@+//! Memory budgets in tests: each test app's own cache budget, and the tests that change the+//! process-wide budget ([`lightcraft_engine::memory::budget`]).+//!+//! **Each test app's caches** (the session's [`MediaCache`](lightcraft_engine::media::MediaCache)+//! and the renderer's stage caches) are sized for its test's share of the machine, not for the+//! whole machine as an app's are: libtest runs several tests at once in one process, and each+//! would take the full share. [`LightcraftApp::new`] calls [`cap_instance`]: the app takes what it+//! would on a machine with [`lightcraft_testkit::memory::per_test_bytes`] of RAM, never more than+//! [`memory::default_budget`] (issue #3066). Both are read once per test process, at the first+//! app, so a test that changes the process-wide budget meanwhile doesn't change the caps of the+//! apps other tests build. The process-wide budget, the decode, export and GPU gates and the demo+//! scene cache stay as they are.+//!+//! What it bounds is the most those two caches may hold per test app, so a run with many tests at+//! once (or little memory free) can't hold a whole machine's share per test. It doesn't bound a+//! test's working memory. At 4 tests at once it changed neither the peak nor the time of the+//! `ui-egui` lib tests on a 16-core, 21.5 GiB machine: the caches rarely fill (median 40 MB held+//! of 526 MiB), and the peaks are short spikes of working memory. The share depends on the memory+//! free when the run starts; every test passes at the floor (256 MiB).+//!+//! **Changing the process-wide budget.** It is one number for the whole test process: the decode,+//! export and GPU gates, the caches of sessions made outside a test app and the demo scene cache+//! size from it, and other tests run at the same time. A test that changes it (through+//! `app.memoryBudget` or Settings ▸ Performance) holds a [`BudgetGuard`] for as long as the change+//! lasts. Tests that change the budget then run one at a time, so one can't change it under+//! another's assertion. The budget goes back to exactly what it was before, also when the test+//! fails: putting back the value found, not `default_budget()`, keeps a budget set from outside+//! (`LIGHTCRAFT_MEMORY_MB`, a test harness) (issue #3065).+//!+//! Tests that only read the budget don't take the lock; keep the change short.+//! The lock is per process, so per test binary, which is all the budget is shared by.++use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};++use lightcraft_engine::memory;++use crate::LightcraftApp;++/// The least budget a test app gets, as [`memory::default_budget`] gives the smallest machine.+const MIN_INSTANCE_BUDGET: usize = 256 << 20;+/// The most budget a test app gets, as [`memory::default_budget`] gives the largest machine.+const MAX_INSTANCE_BUDGET: usize = 3 << 29;++/// Size `app`'s own caches for its test's share of the machine (issue #3066): the session's cache+/// share and the renderer's stage budget come from [`instance_budget`] instead of the process-wide+/// budget. A test that sets either itself afterwards still can.+pub(crate) fn cap_instance(mut app: LightcraftApp) -> LightcraftApp {+ let budget = instance_budget();+ app.session.media.set_budget(memory::cache_share(budget));+ app.renderer.stage_budget_override = Some(budget / crate::render::STAGE_BUDGET_SHARE);+ app+}++thread_local! {+ /// A budget the apps built on this thread take instead of [`baseline`] ([`InstanceBudget`]).+ static INSTANCE_OVERRIDE: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };+}++/// The memory budget a test app sizes its own caches from: the [`baseline`], unless a test on+/// this thread set its own with [`InstanceBudget`].+pub(crate) fn instance_budget() -> usize {+ INSTANCE_OVERRIDE.with(std::cell::Cell::get).unwrap_or_else(baseline)+}++/// [`budget_for_share`] of this test's share of the machine and [`memory::default_budget`] (which+/// honours `LIGHTCRAFT_MEMORY_MB`), taken once per test process: not the process-wide budget at+/// the time, which a test changing it may have set to anything for a few frames.+fn baseline() -> usize {+ static BASELINE: OnceLock<usize> = OnceLock::new();+ *BASELINE.get_or_init(|| budget_for_share(lightcraft_testkit::memory::per_test_bytes(), memory::default_budget()))+}++/// What [`memory::default_budget`] gives a machine with `per_test` bytes of RAM (a quarter, from+/// 256 MiB to 1.5 GiB), and never more than `process`, so a lower `LIGHTCRAFT_MEMORY_MB` still+/// wins.+fn budget_for_share(per_test: u64, process: usize) -> usize {+ let quarter = usize::try_from(per_test / 4).unwrap_or(usize::MAX);+ quarter.clamp(MIN_INSTANCE_BUDGET, MAX_INSTANCE_BUDGET).min(process)+}++/// While held, the apps built on this thread size their caches from the given budget instead of+/// the [`baseline`]: a test that needs a known cap, whatever the machine. Dropping it goes back.+/// Not `Send`: dropped on another thread, it would put back that thread's override, not this one's.+pub(crate) struct InstanceBudget(Option<usize>, std::marker::PhantomData<std::rc::Rc<()>>);++impl InstanceBudget {+ pub(crate) fn set(bytes: usize) -> InstanceBudget {+ InstanceBudget(INSTANCE_OVERRIDE.with(|o| o.replace(Some(bytes))), std::marker::PhantomData)+ }+}++impl Drop for InstanceBudget {+ fn drop(&mut self) {+ INSTANCE_OVERRIDE.with(|o| o.set(self.0));+ }+}++/// Taken by every test that changes the process-wide budget.+static CHANGING_BUDGET: Mutex<()> = Mutex::new(());++/// Held while a test changes the process-wide memory budget: the other tests that change it wait,+/// and the budget is put back as it was found when the guard drops.+pub(crate) struct BudgetGuard {+ // fields drop in order: the budget is put back before the next test may take its turn+ _restore: Restore,+ _turn: MutexGuard<'static, ()>,+}++impl BudgetGuard {+ /// Wait for the other tests that change the budget, then remember the budget as it is now.+ pub(crate) fn hold() -> BudgetGuard {+ // poisoned: a test failed holding it, and its guard put the budget back on unwind+ let turn = CHANGING_BUDGET.lock().unwrap_or_else(PoisonError::into_inner);+ BudgetGuard { _restore: Restore::new(), _turn: turn }+ }+}++/// Puts the process-wide budget back to what it was when made.+struct Restore(usize);++impl Restore {+ fn new() -> Restore {+ Restore(memory::budget())+ }+}++impl Drop for Restore {+ fn drop(&mut self) {+ // doesn't panic; the value came from `budget()`, so it's within what `set_budget` keeps+ memory::set_budget(self.0);+ }+}++mod tests {+ use super::*;++ const GIB: u64 = 1 << 30;++ // A test app takes what an app would on a machine with its test's share of memory: a quarter,+ // in the app's range, never more than the process's own budget (issue #3066)+ #[test]+ fn a_test_apps_budget_is_a_quarter_of_its_share() {+ let process = 3 << 29;+ // 16 GiB available, 4 tests at once: 4 GiB each, 1 GiB of budget+ assert_eq!(budget_for_share(4 * GIB, process), 1 << 30);+ assert_eq!(budget_for_share(2 * GIB, process), 512 << 20);+ assert_eq!(budget_for_share(lightcraft_testkit::memory::MIN_PER_TEST, process), MIN_INSTANCE_BUDGET);+ assert_eq!(budget_for_share(0, process), MIN_INSTANCE_BUDGET, "never below the app's least");+ assert_eq!(budget_for_share(u64::MAX, process), MAX_INSTANCE_BUDGET, "never above the app's most");+ assert_eq!(budget_for_share(4 * GIB, 384 << 20), 384 << 20, "a lower LIGHTCRAFT_MEMORY_MB wins");+ // (usize: 2 GiB still fits on 32-bit targets)+ assert_eq!(budget_for_share(4 * GIB, 2usize << 30), 1 << 30, "a higher one doesn't");+ assert_eq!(budget_for_share(4 * GIB, usize::MAX), 1 << 30);+ }++ fn app() -> crate::headless::Headless {+ crate::headless::Headless::new(LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()), [800.0, 600.0], 1.0)+ }++ /// The caps `h`'s app was built with: its session's cache budget and its renderer's stage budget.+ fn caps(h: &crate::headless::Headless) -> (usize, usize) {+ (h.app.session.media.budget(), h.app.renderer.stage_budget())+ }++ /// The caps an app built with `budget` has.+ fn caps_of(budget: usize) -> (usize, usize) {+ (memory::cache_share(budget), budget / crate::render::STAGE_BUDGET_SHARE)+ }++ // Given a test's share below the process-wide budget, a test app's session caches and its+ // renderer's stage caches take that share, not the whole machine's (issue #3066)+ #[test]+ fn a_test_app_is_sized_for_its_tests_share() {+ // a share no machine gives and no budget derives: 1 GiB less 4 MiB+ let share = (1 << 30) - (4 << 20);+ let _share = InstanceBudget::set(share);+ let h = app();+ assert_eq!(caps(&h), caps_of(share));+ assert_eq!(h.app.renderer.memory()["stageCaches"]["budgetBytes"], share / crate::render::STAGE_BUDGET_SHARE, "and reports it");+ }++ // Given a test that has the process-wide budget at an odd value for a few frames, an app+ // another test builds meanwhile still takes the baseline, and keeps it after (issue #3066)+ #[test]+ fn an_app_built_while_the_budget_is_changed_takes_the_baseline() {+ // never the baseline, whatever LIGHTCRAFT_MEMORY_MB says (and above set_budget's 64 MiB least)+ let odd = baseline() + (7 << 20);+ let h = {+ let _turn = BudgetGuard::hold();+ assert_eq!(memory::set_budget(odd), odd);+ app()+ };+ assert_eq!(caps(&h), caps_of(baseline()));+ assert_ne!(caps(&h), caps_of(odd));+ assert!(baseline() >= MIN_INSTANCE_BUDGET.min(memory::default_budget()));+ }++ // The budget is put back as it was found, not to the default, also when the test fails+ // (issue #3065)+ #[test]+ fn the_budget_is_put_back_as_it_was_found() {+ let _turn = BudgetGuard::hold();+ // a budget set from outside the test, unlike the default+ let outside = if memory::budget() == 700 << 20 { 600 << 20 } else { 700 << 20 };+ memory::set_budget(outside);++ {+ let _r = Restore::new();+ memory::set_budget(1024 << 20);+ assert_eq!(memory::budget(), 1024 << 20);+ }+ assert_eq!(memory::budget(), outside, "the budget found, not the default");++ let failed = std::panic::catch_unwind(|| {+ let _r = Restore::new();+ memory::set_budget(96 << 20);+ panic!("an assertion fails while the budget is changed");+ });+ assert!(failed.is_err());+ assert_eq!(memory::budget(), outside, "put back on unwind too");+ }++ // A test that fails holding the lock doesn't make the next one fail+ #[test]+ fn a_failed_test_does_not_block_the_next() {+ let failed = std::thread::spawn(|| {+ let _g = BudgetGuard::hold();+ panic!("an assertion fails while holding the turn");+ })+ .join();+ assert!(failed.is_err());+ let _g = BudgetGuard::hold();+ }+}@@ -921,12 +921,15 @@ mod in_the_loupe { assert!(before > 3, "the views hold something ({before} bytes)"); // a third of it is the budget: over it, but inside the runaway limit (four times) h.app.renderer.stage_budget_override = Some(before / 3);+ // counted from here: a test app's own stage budget (its test's share of memory, issue+ // #3066) may already have trimmed an idle view while the photo opened+ let trimmed = h.app.renderer.memory()["stageCaches"]["trimmed"].as_u64().unwrap(); h.app.session.begin_interaction("Exposure").unwrap(); for tick in 0..8 { h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": tick as f64 * 0.1}}), T); h.settle(SETTLE); }- assert_eq!(h.app.renderer.memory()["stageCaches"]["trimmed"], 0);+ assert_eq!(h.app.renderer.memory()["stageCaches"]["trimmed"].as_u64().unwrap(), trimmed, "nothing is trimmed during the drag"); assert!(held(&h) > before / 3, "and they are still there"); } @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (`cargo xtask ci`: one integration-test binary per crate, doctests apart, one WASM check) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (test apps size their caches for their test's share of memory) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft is built today. The longer design document (`plan/architecture.md`) is local-only (`plan/` is gitignored); this page is the committed, current summary. Sizes are Rust lines counted@@ -73,6 +73,13 @@ module per file (`cargo test -p <crate> --test it <module>::`); a test that chan whole process (a global allocator, closing the GPU) keeps a binary of its own in `tests/`. The WASM build is one `cargo check --target wasm32-unknown-unknown --keep-going` over the wasm-safe crates. +libtest runs several tests at once in one process, so each `ui-egui` lib test's app sizes its own+caches (the session's cache share and the renderer's stage caches) for its test's share of the+machine, not the whole machine: `lightcraft_testkit::memory::per_test_bytes` (`MemAvailable` ÷+libtest's tests at once: `--test-threads`, else `RUST_TEST_THREADS`, else the CPU count; 1–16 GiB)+taken as the app's RAM. The process-wide budget, the decode, export and GPU gates and the demo scene+cache keep their size.+ `ci` also probes the machine: OS, CPUs, RAM, the GPU adapters of each backend (from the ignored `gpu_probe` test of `lightcraft-gpu`, run after the workspace tests so it reuses their build; xtask has no wgpu), the corpus and `CRAFT_FONTS_DIR`. Tests@@ -87,6 +94,7 @@ the `lightcraft-gpu` tests once per backend with a hardware adapter (`LIGHTCRAFT | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Quality gates: `ui-egui` test apps size their caches for their test's share of memory | | 2026-10-23 | minor | Quality gates: one integration-test binary per crate, doctests apart, nextest on request, one WASM check | | 2026-10-11 | minor | Quality gates: the CI machine probe, visible skips (`testkit`) and `target/ci/report.json` | | 2026-10-10 | major | Created from the code (crate sizes, layers, data model, rendering, control, gates) |