collective

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

diff --git a/README.md b/README.md--- a/README.md+++ b/README.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 underdiff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs--- a/crates/testkit/src/lib.rs+++ b/crates/testkit/src/lib.rs@@ -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"; diff --git a/crates/testkit/src/memory.rs b/crates/testkit/src/memory.rsnew file mode 100644--- /dev/null+++ b/crates/testkit/src/memory.rs@@ -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()));+        }+    }+}diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -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.diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs--- a/crates/ui-egui/src/headless/tests.rs+++ b/crates/ui-egui/src/headless/tests.rs@@ -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);diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -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)]diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs--- a/crates/ui-egui/src/render.rs+++ b/crates/ui-egui/src/render.rs@@ -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;diff --git a/crates/ui-egui/src/test_budget.rs b/crates/ui-egui/src/test_budget.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/test_budget.rs@@ -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();+    }+}diff --git a/crates/ui-egui/src/tests/preview_limit.rs b/crates/ui-egui/src/tests/preview_limit.rs--- a/crates/ui-egui/src/tests/preview_limit.rs+++ b/crates/ui-egui/src/tests/preview_limit.rs@@ -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");     } diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -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) |