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 22:19:29 +02:00
co-authored by Claude Opus 5
parent 5133e53bc8
commit 2b812ebe21
8 changed files with 524 additions and 1 deletions
+53
View File
@@ -413,3 +413,56 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
assert_eq!(pass.detail_dispatches(), 0);
assert_eq!(pass.detail_allocations(), 0);
}
/// TRACES: FR-PLAT-AND-5
#[test]
fn eviction_gives_the_pools_back_without_changing_a_pixel() {
// The half of memory-pressure eviction that cannot be checked by looking
// at a counter. `release_caches` frees the detail pool, and slot 0 of that
// pool is where the fused colour result lives between frames — so the
// render after an eviction has to notice that the promise recorded in
// `colour_key` no longer holds and run the colour chain again.
//
// Leave the key standing and this test does not error: it draws. It draws
// whatever a freshly-allocated texture happens to contain, which is the
// failure worth building a test around, because on a device it would
// appear only under memory pressure and only as a wrong-looking photograph.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 48;
let source = step_edge(&ctx, SIZE);
let mut pass = AdjustPass::new(&ctx);
let mut graph = EditGraph::with_detail_probe();
graph.set_param(PROBE, RADIUS, 0.05);
let before = render(&ctx, &mut pass, &graph, &source, SIZE);
assert!(
pass.cached_pipelines() > 0,
"the colour pass compiled something"
);
assert!(
pass.cached_detail_pipelines() > 0,
"so did the detail stage"
);
let allocations = pass.detail_allocations();
assert!(allocations > 0, "and the pool holds textures");
pass.release_caches();
assert_eq!(pass.cached_pipelines(), 0);
assert_eq!(pass.cached_detail_pipelines(), 0);
// The same edit at the same size. Nothing about the picture changed, so
// nothing about the pixels may change either — only what it cost.
let after = render(&ctx, &mut pass, &graph, &source, SIZE);
assert_eq!(before.len(), after.len());
for (i, (a, b)) in before.iter().zip(&after).enumerate() {
assert!(
a.abs_diff(*b) <= 1,
"byte {i}: {a} before eviction, {b} after — the colour chain did \
not re-run, so this frame is reading an empty intermediate"
);
}
assert!(
pass.detail_allocations() > allocations,
"the pool was rebuilt, which is the evidence it was really given back"
);
}