Merge: answer Android's memory warnings, and stop reporting a lost root as an empty library

FR-PLAT-AND-5 in full, FR-PLAT-AND-2 in part -- the recovery is built and
live for Nextcloud roots, the SAF cause it names does not exist yet.

FR-PLAT-AND-4 and FR-PLAT-AND-6 are not here, both blocked behind the
same gap: assemble-apk.sh compiles no Java, so the APK cannot carry a
Service or a FileProvider. The container has JDK 17 and build-tools 36;
the build step is what is missing.

Verified: fmt, clippy --workspace --all-targets -D warnings, and 1043
tests across dr-catalog, dr-sync, dr-sync-folder, dr-sync-nextcloud,
dr-plat and dr-ui. The aarch64 target was checked before the branch was
finished but not after; no device was available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:19:46 +02:00
co-authored by Claude Opus 5
16 changed files with 1002 additions and 39 deletions
+38
View File
@@ -1026,6 +1026,44 @@ impl AdjustPass {
h
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
/// Give back every allocation this pass is holding only to be fast again.
///
/// What goes, and why each is safe to lose:
///
/// - **The compiled pipelines**, here and in the detail stage. A pure
/// lookup keyed by structure hash with a compile-on-miss behind it, and
/// unbounded until now — nothing ever removed an entry, so a session
/// that visited enough distinct edit structures accumulated shader
/// objects for the life of the process.
/// - **The detail intermediates**, which are viewport-sized `Rgba16Float`
/// and, as `detail.rs` says of them, grow but never shrink.
/// - **The two output textures.** Dropping these does not take the picture
/// off the screen: whatever was handed to the compositor holds its own
/// reference to the `wgpu::Texture`, so releasing ours only means the
/// *next* render allocates rather than reuses. `ensure_target` already
/// treats an empty slot as "allocate", because that is the state it
/// starts in.
///
/// **`colour_key` must be cleared with them, and this is the part that
/// would bite.** The key is the promise that slot 0 of the detail pool
/// still holds the fused colour result, and it is what lets a sharpening
/// slider skip the colour chain (FR-DEV-3d). Freeing the pool while the
/// promise stood would make the next detail-only render sample a
/// just-allocated texture with nothing in it — a silently wrong frame, not
/// a failure, and one that would only appear on a device under memory
/// pressure.
///
/// What deliberately stays: the demosaiced source is not this pass's to
/// drop, the film tables are set once by a caller that will not be asked
/// again, and the bind group layouts are bytes rather than megabytes.
pub fn release_caches(&mut self) {
self.cache.clear();
self.detail.release_caches();
self.targets = [None, None];
self.colour_key = None;
}
/// How many distinct pipelines are compiled. Exposed for tests asserting
/// that slider movement does not recompile.
pub fn cached_pipelines(&self) -> usize {
+31
View File
@@ -157,6 +157,24 @@ impl Intermediates {
self.allocations += 1;
}
}
/// TRACES: FR-PLAT-AND-5
/// Drop the pool, leaving it as [`Intermediates::new`] left it.
///
/// The size is reset along with the slots, not merely because it is tidy:
/// [`Self::ensure`] only refills when the count is short *or* the size
/// differs, so a pool cleared while still claiming its old dimensions is
/// indistinguishable from one that never held anything — which is fine
/// here, and would stop being fine the moment `ensure` grew a fast path
/// that trusted the stored size. `allocations` deliberately keeps
/// counting: it exists so a test can see textures being made, and a
/// counter reset on eviction would hide a reallocation storm rather than
/// report one.
fn release(&mut self) {
self.slots.clear();
self.width = 0;
self.height = 0;
}
}
/// Runs the detail stage.
@@ -526,6 +544,19 @@ impl DetailRunner {
self.cache.len()
}
/// TRACES: FR-PLAT-AND-5
/// Give back everything this stage is only holding to be fast.
///
/// Both pools and the pipeline cache. Nothing here is state: a pool slot
/// is re-created by the next [`Intermediates::ensure`] and a pipeline by
/// the next compile-on-miss, so the only cost of this call is the work of
/// doing both again.
pub(crate) fn release_caches(&mut self) {
self.cache.clear();
self.pool.release();
self.reduced.release();
}
/// How many intermediate textures have been allocated since this pass was
/// created. For tests — see [`crate::MaskPass::allocations`] for the
/// regression this shape of counter exists to catch.
+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"
);
}