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 under
diff --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.rs
new 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.rs
new 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) |
