diff --git a/apps/darkroom-android/src/lib.rs b/apps/darkroom-android/src/lib.rs index 79b20b3..6eda761 100644 --- a/apps/darkroom-android/src/lib.rs +++ b/apps/darkroom-android/src/lib.rs @@ -51,7 +51,47 @@ fn android_main(app: slint::android::AndroidApp) { // After the data dir and before anything asks whether a model is present. install_bundled_face_models(&app); - if let Err(e) = slint::android::init(app) { + // TRACES: FR-PLAT-AND-5 + // The listener is the whole reason this is not the one-line + // `slint::android::init(app)`. Slint owns the event loop on Android, so + // the platform's lifecycle and memory events reach the application only if + // it asks for them here — and it must ask *before* the loop starts, which + // is why this sits between the data directory and `dr_ui::run`. + // + // The listener runs inside `poll_events`, on the same thread the event + // loop and every interface cache live on, which is what lets + // `dr_ui::memory` be a thread-local registry of plain `Fn()` rather than a + // cross-thread channel (see its module documentation). + // + // # Why two events and not eight + // + // FR-PLAT-AND-5 names `onTrimMemory`, whose `TRIM_MEMORY_*` levels grade + // how badly the system wants the memory back. Those levels do not exist + // here: `ComponentCallbacks2` is a Java interface implemented by an + // `Activity` or `Application`, and this app has neither — it is a bare + // `NativeActivity`, whose native callback table offers only the ungraded + // `onLowMemory`. android-activity surfaces exactly that as `LowMemory`. + // Reading the grades would mean shipping a Java subclass to forward them, + // which is a distribution-manifest change and not this one. + // + // `Stop` recovers the one grade that matters most anyway, and for free. + // It is the moment the activity stops being visible — `TRIM_MEMORY_UI_HIDDEN` + // in all but name — and it is the cheapest possible time to give memory + // back, because nothing that is freed has to be drawn again before anyone + // sees it. `Pause` deliberately does not qualify: a permission dialog or + // the share sheet pauses an activity that is still on screen behind it, + // and throwing away its render pipeline would make every such interruption + // cost a full re-render. + use slint::android::android_activity::{MainEvent, PollEvent}; + if let Err(e) = slint::android::init_with_event_listener(app, |event| match event { + PollEvent::Main(MainEvent::LowMemory) => { + dr_ui::memory::relieve(dr_ui::memory::Level::Critical); + } + PollEvent::Main(MainEvent::Stop) => { + dr_ui::memory::relieve(dr_ui::memory::Level::UiHidden); + } + _ => {} + }) { log::error!("Slint Android backend failed to initialise: {e}"); return; } diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index 90109bc..0a2d2e7 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -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 { diff --git a/core/dr-gpu/src/detail.rs b/core/dr-gpu/src/detail.rs index 5c70c49..14553b3 100644 --- a/core/dr-gpu/src/detail.rs +++ b/core/dr-gpu/src/detail.rs @@ -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. diff --git a/core/dr-gpu/tests/detail_stage.rs b/core/dr-gpu/tests/detail_stage.rs index ab18ef5..dd14be0 100644 --- a/core/dr-gpu/tests/detail_stage.rs +++ b/core/dr-gpu/tests/detail_stage.rs @@ -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" + ); +} diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index d900dba..42d8fe8 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -3066,6 +3066,33 @@ impl DevelopSession { Ok((image, rw, rh)) } + /// TRACES: FR-PLAT-AND-5 | NFR-RES-1 + /// Give back the GPU memory this session is holding only to be fast. + /// + /// The edit is untouched: the graph and its history are CPU-side by + /// design (ARCH §6.1), so the photograph, the undo stack and the viewport + /// all survive and the next frame simply costs what the first one did. + /// + /// # What is not released, and what it is waiting on + /// + /// The demosaiced source is the largest single allocation a session holds + /// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is + /// deliberately kept. Dropping it would need the session to be able to + /// rebuild itself from the file, and rebuilding a session from a durable + /// record is FR-PLAT-AND-3, which is not built. Freeing it now would not + /// be an eviction; it would be closing the photograph without telling + /// anyone. Likewise the subject distance fields and the segmentation map: + /// each is guarded by a key recording what it was built from, and freeing + /// one without invalidating its key is the failure `AdjustPass` documents + /// under `colour_key`. + /// + /// So this is the part of the GPU tier that can be given back and asked + /// for again with no other machinery, which is exactly as far as an + /// eviction should go. + pub fn release_gpu_caches(&mut self) { + self.adjust.release_caches(); + } + /// The displayed size, for sizing the viewport. /// /// The *framed* size, not the sensor's: cropping and quarter turns change diff --git a/ui/dr-ui/src/identity_ui.rs b/ui/dr-ui/src/identity_ui.rs index 5f8415f..68b0803 100644 --- a/ui/dr-ui/src/identity_ui.rs +++ b/ui/dr-ui/src/identity_ui.rs @@ -111,6 +111,21 @@ impl IdentityController { fn clear_picks(&self) { self.picked.borrow_mut().clear(); } + + /// TRACES: FR-PLAT-AND-5 | NFR-RES-1 + /// Drop the decoded rail portraits. + /// + /// The one in-memory image cache in this crate that is unbounded by + /// anything but the library: one decoded portrait per person, kept for as + /// long as the person exists. On a library with a few hundred named people + /// that is worth tens of megabytes of nothing but a saved decode. + /// + /// Costless to lose. `refresh` rebuilds any portrait it does not find, so + /// the only consequence is the JPEG decode this cache exists to skip, and + /// only for the people the rail is actually showing at the time. + pub fn clear_covers(&self) { + self.covers.borrow_mut().clear(); + } } /// Push the people rail and the face grid into the window. diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 432ab9a..8cf7e8e 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -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) -> 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) -> Result<()> { // The current develop session, if the file yielded sensor data. let session: Rc>> = 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. // diff --git a/ui/dr-ui/src/memory.rs b/ui/dr-ui/src/memory.rs new file mode 100644 index 0000000..37e2a9a --- /dev/null +++ b/ui/dr-ui/src/memory.rs @@ -0,0 +1,276 @@ +//! TRACES: FR-PLAT-AND-5 | NFR-RES-1 | FR-NC-6b +//! Giving memory back when the platform asks for it. +//! +//! Android kills the process that will not shrink. It does not negotiate and +//! it does not warn twice, and the app it kills is the one holding the most — +//! which, on a photo editor, is always this one. So the question this module +//! answers is not "how much can be freed" but "in what order", because the +//! caches differ enormously in what losing them costs. +//! +//! # The order, and why it is that order +//! +//! FR-PLAT-AND-5 states it: GPU tiles first, then proxies, then thumbnails. +//! Read as a rule rather than a list, it is *cheapest to rebuild goes first* — +//! a GPU allocation is remade from data already in memory, a proxy is remade +//! from a file already on disk, and a thumbnail may cost a network fetch. +//! [`Tier`] is that order written down where the code can be held to it, so +//! adding a cache means choosing its tier rather than choosing its position in +//! a hand-maintained sequence. +//! +//! What each tier actually reaches in this build is documented on the variant, +//! including where it reaches nothing yet. An empty tier is worth keeping +//! visible: it says the order is complete and the coverage is not. +//! +//! # Why this is a registry rather than a function that frees things +//! +//! Every cache worth evicting lives behind an `Rc>` owned by a +//! local in [`crate::run`], which is a two-thousand-line function whose +//! callbacks each hold their own handle. There is no central object to reach +//! them through, and inventing one to serve eviction alone would be a large +//! change to how the interface is wired for a small change in what it does. +//! +//! So `run` hands this module a closure per cache as it builds each one, and +//! this module owns only the ordering. The registration is next to the thing +//! being registered, which is also the property that keeps it honest: a cache +//! added later is one line away from being evictable, and a cache removed +//! takes its sink with it. +//! +//! # Everything here is single-threaded, and that is not a limitation +//! +//! The registry is a `thread_local`, holding `Fn()` rather than `Fn() + Send`, +//! because the pressure signal already arrives on the thread that owns the +//! caches. Slint's Android backend calls the event listener from inside +//! `poll_events`, which runs on the same thread as the event loop, which is +//! the thread `run` built everything on. Marshalling through +//! `invoke_from_event_loop` would add a hop and a lifetime question to solve a +//! problem that does not exist — and would arrive *after* the moment the +//! system asked, which for a memory warning is the one thing that matters. +//! +//! Anything reached from a worker thread — the thumbnail store, the original +//! cache — is on disk and bounded by its own budget (NFR-RES-4), and is not +//! what a memory warning is about. + +use std::cell::RefCell; + +/// How hard the platform is asking. +/// +/// Two levels rather than Android's eight, because two is what the platform +/// actually delivers to this app. `ComponentCallbacks2.onTrimMemory` and its +/// `TRIM_MEMORY_*` grades are a Java callback on an `Activity` or +/// `Application`; a `NativeActivity` receives only `ANativeActivityCallbacks`, +/// whose memory callback is the ungraded `onLowMemory` — which is what +/// android-activity surfaces as `MainEvent::LowMemory`. Modelling grades the +/// entry point cannot observe would be modelling a wish. +/// +/// [`Self::UiHidden`] recovers the one distinction that *is* observable and is +/// worth acting on, because it is the cheapest moment to give memory back: +/// nothing is on screen, so nothing that is freed has to be drawn again before +/// the user notices. It corresponds to `TRIM_MEMORY_UI_HIDDEN` in intent and +/// is derived from the activity being stopped rather than from a memory +/// warning at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + /// The app is no longer on screen. Free what only a visible window needs. + UiHidden, + /// The system says it is short of memory. Free everything that can be + /// rebuilt. + Critical, +} + +/// What a cache costs to lose, as an order. +/// +/// Declared in eviction order and iterated in declaration order by +/// [`Tier::ORDER`], so the sequence FR-PLAT-AND-5 specifies is a property of +/// this type rather than of each call site. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tier { + /// GPU allocations that are rebuilt from data the process still holds. + /// + /// The develop session's compiled pipelines, its detail intermediates and + /// its output textures. Rebuilt by the next render from the demosaiced + /// source, which is still resident — see + /// [`DevelopSession::release_gpu_caches`](crate::DevelopSession::release_gpu_caches) + /// for what is deliberately kept and what that is waiting on. + /// + /// First because it is both the largest evictable pool on a mobile GPU and + /// the cheapest to refill: no I/O, no network, one frame's work. + Gpu, + /// Decoded image data rebuilt by reading a file again. + /// + /// **Nothing registers here in this build, and the tier is kept anyway.** + /// There is no in-memory proxy cache: the only decoded full-size frame in + /// the process is the open develop session's, which belongs to + /// [`Tier::Gpu`] and cannot be dropped until a session can be rebuilt from + /// a durable record (FR-PLAT-AND-3). The on-disk original cache is a + /// different thing wearing the same word — freeing disk relieves no memory + /// pressure, and it already has a budget and an LRU of its own + /// (`dr_catalog::Cache`, NFR-RES-4). + Proxies, + /// Decoded thumbnails, rebuilt by decoding a stored JPEG again — or, at + /// worst, by fetching one. + /// + /// Last because this is the tier a user sees losing: an evicted portrait + /// is a rail that redraws, and an evicted grid cell is a photograph that + /// greys out and comes back. + Thumbnails, +} + +impl Tier { + /// The eviction order, in one place. + pub const ORDER: [Tier; 3] = [Tier::Gpu, Tier::Proxies, Tier::Thumbnails]; + + /// Whether this tier is given up at this level of pressure. + /// + /// Hiding the window frees the GPU tier and nothing else. That is not + /// caution about the rest — it is that a backgrounded app has no window to + /// draw and therefore no use at all for a render pipeline, while its + /// thumbnails are exactly what the user will be looking at half a second + /// after they come back. Under [`Level::Critical`] the process is being + /// measured against being killed, and a slow return beats no return. + fn evicted_at(self, level: Level) -> bool { + match level { + Level::UiHidden => matches!(self, Tier::Gpu), + Level::Critical => true, + } + } +} + +/// A cache that has offered itself up, and the tier it goes in. +/// +/// Named because the registry is a `Vec` of these and the nested type is hard +/// to read at the use site rather than because either half means anything on +/// its own. +type Sink = (Tier, Box); + +thread_local! { + /// Registered sinks, in the order they were registered within a tier. + /// + /// Within a tier the order is registration order and nothing depends on + /// it; between tiers it is [`Tier::ORDER`], which everything depends on. + static SINKS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Offer a cache up for eviction at `tier`. +/// +/// Called as each cache is built, so that the registration reads next to the +/// thing it is about. The closure is kept for the life of the thread; it must +/// therefore hold weak or shared handles rather than borrow anything, which is +/// the natural shape here because everything it can reach is already an `Rc`. +pub(crate) fn evict_at(tier: Tier, sink: impl Fn() + 'static) { + SINKS.with_borrow_mut(|sinks| sinks.push((tier, Box::new(sink)))); +} + +/// TRACES: FR-PLAT-AND-5 +/// Give memory back, in [`Tier::ORDER`], as far down as `level` calls for. +/// +/// Safe to call when nothing is registered — before the window is built, or on +/// a platform that never asks — in which case it does nothing at all. +/// +/// The registry is taken out of the cell for the duration rather than borrowed +/// across the calls. A sink runs arbitrary interface code, and interface code +/// that registered another cache, or called this again, would otherwise meet a +/// `RefCell` it had already borrowed and abort the process. Freeing memory is +/// the wrong moment to be brittle about re-entry. +pub fn relieve(level: Level) { + let taken: Vec<(Tier, Box)> = SINKS.with_borrow_mut(std::mem::take); + let mut run = 0usize; + for tier in Tier::ORDER { + if !tier.evicted_at(level) { + continue; + } + for (t, sink) in &taken { + if *t == tier { + sink(); + run += 1; + } + } + } + // Put them back, keeping anything a sink registered while it ran — after, + // so the order within a tier stays registration order. + SINKS.with_borrow_mut(|sinks| { + let added = std::mem::replace(sinks, taken); + sinks.extend(added); + }); + log::info!("memory pressure ({level:?}): ran {run} eviction(s)"); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::rc::Rc; + + /// Registers one sink per tier, backwards, and hands back what they saw. + fn recorder() -> Rc>> { + let seen = Rc::new(RefCell::new(Vec::new())); + for tier in [Tier::Thumbnails, Tier::Proxies, Tier::Gpu] { + let seen = seen.clone(); + evict_at(tier, move || seen.borrow_mut().push(tier)); + } + seen + } + + fn reset() { + SINKS.with_borrow_mut(|s| s.clear()); + } + + #[test] + fn eviction_runs_cheapest_to_rebuild_first() { + // Registered deliberately backwards, because the guarantee is about + // the tier and not about who registered first. A handler that simply + // ran its list would pass every other assertion here and fail this + // one — and on a device it would throw away thumbnails to keep a + // render pipeline that nothing was going to draw. + reset(); + let seen = recorder(); + relieve(Level::Critical); + assert_eq!( + *seen.borrow(), + vec![Tier::Gpu, Tier::Proxies, Tier::Thumbnails] + ); + reset(); + } + + #[test] + fn hiding_the_window_costs_only_the_gpu() { + // The cheap moment: give back what a window that is not on screen + // cannot use, and keep what the user will be looking at when they come + // back. Widening this to everything would make every task switch a + // reload of the grid. + reset(); + let seen = recorder(); + relieve(Level::UiHidden); + assert_eq!(*seen.borrow(), vec![Tier::Gpu]); + reset(); + } + + #[test] + fn pressure_before_anything_is_registered_is_not_a_failure() { + // The launch window: `android_main` installs the listener before + // `run` builds a single cache, so the first minutes of a cold start + // can deliver a warning to an empty registry. + reset(); + relieve(Level::Critical); + } + + #[test] + fn a_sink_may_register_another_without_deadlocking() { + // Guards the re-entry the take-and-restore exists for: a sink is + // interface code, and interface code that reached this module again + // would otherwise meet a borrow it already held. + reset(); + let seen = Rc::new(RefCell::new(0usize)); + { + let seen = seen.clone(); + evict_at(Tier::Gpu, move || { + *seen.borrow_mut() += 1; + evict_at(Tier::Thumbnails, || {}); + }); + } + relieve(Level::Critical); + assert_eq!(*seen.borrow(), 1); + // And the one it added survived, rather than being dropped with the + // temporary list. + assert_eq!(SINKS.with_borrow(|sinks| sinks.len()), 2); + reset(); + } +}