Give memory back in the order the user will miss it least

FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".

A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.

Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.

The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 23:20:57 +02:00
co-authored by Claude Opus 5
parent 55cb9b5b30
commit 085ab766b3
8 changed files with 524 additions and 1 deletions
+43
View File
@@ -39,6 +39,7 @@ mod library_ui;
#[cfg(live_style)]
mod live_style;
mod masks_ui;
pub mod memory;
mod net_runtime;
mod peaking;
mod preset_store;
@@ -1025,6 +1026,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// each save over the other.
let settings = settings_ui::SettingsController::new();
// TRACES: FR-PLAT-AND-5
// The thumbnail tier. Registered here, beside the thing it frees, so that
// a controller which grows another cache is one line from offering it up.
//
// Weak, not strong: `run` returns when the window closes, and a registry
// holding the last reference to a controller would keep it — and every
// decoded portrait in it — alive past the interface it belonged to.
{
let identity = std::rc::Rc::downgrade(&identity);
memory::evict_at(memory::Tier::Thumbnails, move || {
if let Some(ctl) = identity.upgrade() {
ctl.clear_covers();
}
});
}
// Launch screen: shown when there is nothing to display — no local paths
// and no configured library. A user who has already signed in and chosen
// a folder goes straight to their images (FR-NC-1).
@@ -1438,6 +1455,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// The current develop session, if the file yielded sensor data.
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
// TRACES: FR-PLAT-AND-5
// The GPU tier — the first thing given back under memory pressure, and on
// Android the only thing given back merely for going into the background.
//
// `try_borrow_mut` rather than `borrow_mut`, and the miss is not an error
// worth reporting. A memory warning can land in the middle of a render, at
// which point the slot is already borrowed and freeing its textures under
// the code drawing with them is not something to do politely — skipping is
// correct, because the pass that is running will have finished by the time
// the platform asks again, and a warning that has not been acted on is
// always followed by another one.
{
let session = Rc::downgrade(&session);
memory::evict_at(memory::Tier::Gpu, move || {
let Some(session) = session.upgrade() else {
return;
};
let Ok(mut slot) = session.try_borrow_mut() else {
return;
};
if let Some(open) = slot.as_mut() {
open.release_gpu_caches();
}
});
}
// TRACES: FR-DEV-6 | FR-CAT-8
// The settings clipboard, and where the open image's edit is stored.
//