Skip to main content

frust_render/
external_pass.rs

1//! The seam an app — or a crate sitting beside the facade, a 3D renderer
2//! being the motivating one — records its own GPU work through, ahead of the
3//! engine's scene pass and into the engine's own frame encoder.
4//!
5//! # What this closes
6//!
7//! `frust_engine::EngineRenderer` has always been able to draw a texture it
8//! did not create: `bind_texture` registers a `wgpu::TextureView` under a
9//! [`SceneTextureId`], and a display list's
10//! `Command::SceneTexture`/`SceneBuilder::scene_texture` naming that id
11//! composites it. Nothing outside `frust-engine` could reach that, though —
12//! [`crate::SurfaceRenderer`] owns the engine, and the frame's
13//! `wgpu::CommandEncoder` is created and submitted inside one call — so the
14//! only in-tree user of the registry was the engine's own shader-quad
15//! pre-pass. This module is the reachable half: a process-wide registry of
16//! caller-supplied [`ExternalPass`]es, each handed the live frame
17//! ([`ExternalFrame`]) once per frame, before anything of the scene's own is
18//! recorded.
19//!
20//! # The frame a pass is handed
21//!
22//! [`ExternalPass::record`] runs on the render thread, inside the renderer's
23//! `submit`, into the *same* `wgpu::CommandEncoder` the scene pass is about
24//! to be recorded into and ahead of the engine's own shader-quad pre-pass.
25//! That single-encoder ordering is the whole point, and it is the same one
26//! the shader-quad pass relies on: a texture bound during `record` is already
27//! registered when the display list naming it is compiled, and the passes
28//! that write it are already recorded ahead of the pass that samples it. One
29//! encoder gives both orderings for free, with no second submit and no fence.
30//!
31//! A pass records only — it never submits. That is [`crate::SurfaceRenderer`]'s
32//! job, once, at the end of the frame; a pass that submits the frame's encoder
33//! cannot (the encoder is only borrowed) and one that submits work of its own
34//! on its own encoder breaks the ordering it came here for. A pass never
35//! shares the frame's own depth attachment — it renders into attachments it
36//! owns, on a target it owns, sized however that target needs to be.
37//! `frust_gpu::encoder`'s two depth caller rules (clear ownership, and
38//! matching comparison/direction) are for a *host* sharing one depth buffer
39//! across renderers of its own; they have nothing to do with a pass
40//! registered here, which the frame's depth attachment is never handed to.
41//!
42//! # Binding, and what the engine does with it
43//!
44//! [`ExternalFrame::bind_texture`] forwards to the engine's own registry, so
45//! a pass renders into a target *it* owns and then hands the engine a view of
46//! it. Re-binding the same id replaces the previous view (the engine's own
47//! semantics), which is what lets a pass that re-creates its target on resize
48//! keep one stable id. The composited result is always *blended*, never
49//! claimed opaque — the engine never reads the caller's texels, so it cannot
50//! know (`docs/LIMITATIONS.md`'s `engine-scene-texture-always-blended`).
51//!
52//! Binding happens inside `record`, immediately — it does not wait for the
53//! frame to actually submit. So work a pass recorded is submitted only if the
54//! frame is (a refused frame's encoder is dropped unsubmitted, taking every
55//! command a pass recorded with it), but the bind side effect already landed
56//! in the engine's registry and survives the refusal regardless: the next
57//! *accepted* frame composites whatever view was bound, whether or not the
58//! work that was meant to fill it ever ran.
59//!
60//! On a hand-over (one pass unregisters and a successor registers under the
61//! same id before the drain finishes), the queued unbind is NOT cancelled:
62//! it runs at the next drain before any pass, so an id between owners draws
63//! nothing until its new owner binds a view.
64//!
65//! # Lifetime of a registration
66//!
67//! A pass persists until [`unregister_external_pass`] takes it: the registry
68//! is iterated every frame, never drained. Unregistering queues the id for an
69//! `unbind_texture` the next drain performs *before* it runs any pass, so the
70//! engine never keeps a view alive for an id whose owner is gone. Registering
71//! the same id again before that flush does NOT cancel the queued unbind —
72//! the id has an owner again, and the incoming pass binds whatever it wants,
73//! but an id between owners draws nothing until its new owner records a
74//! binding.
75//!
76//! Unregistering is not a barrier. It removes the pass from the registry
77//! under the lock, but a drain that already snapshotted the pass before that
78//! call runs is still mid-flight against its own copy of the `Arc` and will
79//! still call `record` on it once more — no drain that *starts* after
80//! [`unregister_external_pass`] returns ever will. The unbind itself is
81//! queued, not immediate: it lands on the next frame something actually
82//! drains, so it does not happen while the surface presenting it is idle — a
83//! caller that needs the binding gone right now, rather than whenever the
84//! surface next presents, has to make sure a frame gets requested. The queue
85//! it lands in is a map keyed by id, so its size is bounded by the number of
86//! *distinct* ids unregistered since the last drain, never by how many times
87//! any one of them is unregistered.
88//!
89//! # A panicking pass does not take the frame down
90//!
91//! `record` is called inside `catch_unwind`. A pass that panics is reported
92//! (`warn!` on the first panic under an id with a given Arc identity, `debug!`
93//! afterwards) and, *if* the id it panicked under still names the same pass
94//! (nothing else registered under it in the meantime — a pass handing its id
95//! to a successor inside its own `record` before panicking leaves that
96//! successor alone), removed from the registry and queued for the same unbind
97//! an explicit unregistration queues. If a successor already holds the id, the
98//! panic is still reported as a debug message saying the predecessor panicked
99//! after handing over. Either way the frame goes on to record the scene.
100//! This is the same no-panic posture the engine holds on the FFI boundary.
101//! It is a debug/dev net rather than a promise: the workspace's `release`
102//! profile is `panic = "abort"`, where the process is gone before any guard
103//! runs, so the shipped contract is still that a pass must not panic.
104//!
105//! # Who reaches this
106//!
107//! The registry itself is shell-agnostic — a plain process-wide map, with no
108//! device, window or platform in it — but the drain lives on the engine
109//! tier's frame path (`SurfaceRenderer`'s `TierBackend::Engine` arm), so a
110//! registered pass records exactly where an engine-tier surface is presenting
111//! frames and nowhere else. `HeadlessRenderer` drives the engine through its
112//! own frame path and does not drain this registry.
113//!
114//! One more consequence of a *process-wide* registry meeting a *per-surface*
115//! engine: with two engine-tier surfaces live at once, every pass records
116//! into both frames (binding into each surface's own engine, which is what a
117//! caller wants), but a queued unbind is consumed by whichever surface drains
118//! first. One engine-tier surface per process is what every shell does today.
119
120use std::collections::BTreeMap;
121use std::panic::{AssertUnwindSafe, catch_unwind};
122use std::sync::atomic::{AtomicU64, Ordering};
123use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
124
125use frust_gpu::SceneTextureId;
126
127/// Caller-supplied GPU work recorded into the frame ahead of the scene.
128///
129/// Registered with [`register_external_pass`] under a [`SceneTextureId`] the
130/// caller minted (`SceneTextureId::mint`, or `Texture::as_scene_texture` when
131/// it has a `frust_gpu::Texture` to mint from), and called once per frame
132/// with that frame's live [`ExternalFrame`] until
133/// [`unregister_external_pass`] takes it back.
134///
135/// `Send + Sync` because the registry is process-wide and the call happens on
136/// the render thread, which is not the thread that registered the pass on any
137/// shell that splits the two — and, with two engine-tier surfaces live in one
138/// process at once (each presenting through its own render thread), the same
139/// pass can be called by both at the same time: `record` genuinely may run
140/// concurrently on two threads, not merely sequentially from a changing one.
141/// `record` takes `&self`, so a pass mutating state across frames owns its
142/// own interior mutability, and that interior mutability has to tolerate the
143/// concurrent case, not just the sequential one.
144pub trait ExternalPass: Send + Sync {
145    /// Records this pass's work for one frame.
146    ///
147    /// Record passes and staged uploads only — never a submit, and never a
148    /// `wgpu::RenderPass` left open across the return (see
149    /// `frust_gpu::encoder`'s borrowing contract, which the frame's encoder
150    /// is under for exactly the same reason). The shared encoder must be left
151    /// finishable on every exit path: no open render pass AND balanced
152    /// `push_debug_group`/`pop_debug_group` calls (an unbalanced group makes
153    /// `encoder.finish()` fail with wgpu's MissingPop and loses the frame;
154    /// the pass is retired so it self-heals next frame). Bind whatever this
155    /// frame should composite through [`ExternalFrame::bind_texture`]; the
156    /// engine draws it wherever the frame's display list names the id.
157    fn record(&self, frame: &mut ExternalFrame<'_>);
158}
159
160/// The live frame handed to [`ExternalPass::record`]: the device and queue
161/// behind it, the encoder every pass of this frame is recorded into, and the
162/// engine's texture registry to bind results into.
163///
164/// Borrowed for the duration of one `record` call and never longer — nothing
165/// here can be stashed across frames, which is what keeps a pass from
166/// out-living the surface whose device it was handed. `wgpu::Device` and
167/// `wgpu::Queue` are cheaply `Clone` (both `Arc`-backed underneath), so
168/// [`Self::device`]/[`Self::queue`] returning a borrow does not itself stop a
169/// pass from cloning one and stashing the owned handle past this call —
170/// doing that is a contract violation regardless of whether the borrow
171/// checker catches it: the shell that owns the real device may drop and
172/// recreate it (surface loss, a GPU reset), and a clone a pass kept past
173/// that point references a device that looks alive but is not the one
174/// backing any future frame, so GPU work built against it fails or panics
175/// rather than silently rebinding to the shell's current one.
176///
177/// Built fresh for *each* pass a drain runs, scoped to that one pass's own
178/// registered id ([`Self::id`]) — a pass never sees another pass's id, and
179/// [`Self::bind_texture`]/[`Self::unbind_texture`] act only on its own.
180pub struct ExternalFrame<'a> {
181    device: &'a wgpu::Device,
182    queue: &'a wgpu::Queue,
183    encoder: &'a mut wgpu::CommandEncoder,
184    /// The engine whose external-texture registry
185    /// [`Self::bind_texture`]/[`Self::unbind_texture`] write. Deliberately
186    /// not exposed: the engine's own frame API (`encode`, `end_frame`,
187    /// `resize`) belongs to the renderer driving it, and a pass reaching it
188    /// would be recording a second frame inside this one.
189    engine: &'a mut frust_engine::EngineRenderer,
190    /// The id the pass being recorded this call is registered under — what
191    /// [`Self::id`]/[`Self::bind_texture`]/[`Self::unbind_texture`] act on.
192    id: SceneTextureId,
193    frame_index: u64,
194}
195
196impl<'a> ExternalFrame<'a> {
197    /// Wraps one pass's resources for one frame. Crate-private: an
198    /// `ExternalFrame` is only ever built by [`run_external_passes`], fresh
199    /// per pass, around a real in-flight frame.
200    pub(crate) fn new(
201        device: &'a wgpu::Device,
202        queue: &'a wgpu::Queue,
203        encoder: &'a mut wgpu::CommandEncoder,
204        engine: &'a mut frust_engine::EngineRenderer,
205        id: SceneTextureId,
206        frame_index: u64,
207    ) -> Self {
208        Self {
209            device,
210            queue,
211            encoder,
212            engine,
213            id,
214            frame_index,
215        }
216    }
217
218    /// The device this frame is being recorded on — the shell's own live
219    /// device, not a second one.
220    #[must_use]
221    pub fn device(&self) -> &wgpu::Device {
222        self.device
223    }
224
225    /// The queue the frame will be submitted on, for staged writes
226    /// (`write_texture`, `write_buffer`) a pass needs committed before the
227    /// frame reads them.
228    #[must_use]
229    pub fn queue(&self) -> &wgpu::Queue {
230        self.queue
231    }
232
233    /// The frame's own encoder — the one the scene pass is recorded into
234    /// after every pass has run, and the one the renderer submits once.
235    pub fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
236        self.encoder
237    }
238
239    /// The id the pass being recorded this call is registered under —
240    /// what [`Self::bind_texture`]/[`Self::unbind_texture`] act on.
241    #[must_use]
242    pub fn id(&self) -> SceneTextureId {
243        self.id
244    }
245
246    /// How many frames this process has drained passes in, counting from
247    /// zero.
248    ///
249    /// Advances once per drain that has at least one pass to run, so a pass
250    /// animating off it sees a dense sequence rather than one with the
251    /// pass-free frames of other surfaces punched out of it. It is a frame
252    /// *counter*, not a clock: a pass needing wall-clock time reads one.
253    #[must_use]
254    pub fn frame_index(&self) -> u64 {
255        self.frame_index
256    }
257
258    /// Registers `view` under this pass's own id ([`Self::id`]) so this
259    /// frame's `Command::SceneTexture` naming that id composites it,
260    /// returning whatever was bound under it before.
261    ///
262    /// `size` is the view's extent in texels — the rectangle the display
263    /// list's destination is mapped onto. It is a pass's own choice, read
264    /// however its owner reads layout (a widget's own size, typically); this
265    /// type carries no target extent of its own to hand back. `view` must be
266    /// a non-array 2D view of a float-sampleable texture carrying
267    /// `wgpu::TextureUsages::TEXTURE_BINDING`. A zero extent, or one past
268    /// `u16::MAX` on either axis, registers nothing and leaves scenes naming
269    /// the id drawing nothing; so does an id nothing ever bound. Re-binding
270    /// replaces the previous view.
271    pub fn bind_texture(
272        &mut self,
273        size: (u32, u32),
274        view: wgpu::TextureView,
275    ) -> Option<wgpu::TextureView> {
276        self.engine.bind_texture(self.id, size, view)
277    }
278
279    /// Removes the view bound under this pass's own id ([`Self::id`]),
280    /// returning it. A pass tearing its own target down mid-run does this
281    /// itself; a pass that simply stops being registered has it done for it
282    /// (see the module docs).
283    pub fn unbind_texture(&mut self) -> Option<wgpu::TextureView> {
284        self.engine.unbind_texture(self.id)
285    }
286}
287
288/// One registered pass, beside the id it was registered under.
289///
290/// The id rides along with the pass because the maps below are keyed by its
291/// raw `u64`: ordering the drain by that key is what makes the sequence
292/// passes are recorded in — they share one encoder — the same on every frame
293/// and every run, and a [`SceneTextureId`] is not itself ordered.
294struct Registration {
295    id: SceneTextureId,
296    pass: Arc<dyn ExternalPass>,
297}
298
299/// A panic-report slot for one pass, alongside the identity of the pass that
300/// panicked: store the Arc's address as a usize to recognize when the same
301/// pass panics again versus when a fresh pass with a different id panics.
302struct PanicReport {
303    identity: usize,
304}
305
306/// The process-wide registry: the live passes, the ids awaiting an unbind,
307/// and which ids have already had a panic or a reserved-namespace refusal
308/// reported.
309struct Registry {
310    passes: BTreeMap<u64, Registration>,
311    /// Ids whose pass is gone — unregistered, or removed after a panic — and
312    /// whose engine binding the next drain clears. Keyed by id, so this grows
313    /// only with the number of *distinct* ids queued since the last drain,
314    /// never with how many times any one of them is queued.
315    pending_unbind: BTreeMap<u64, SceneTextureId>,
316    /// Ids a panic has already been reported at `warn!` for, alongside the
317    /// identity (Arc pointer) of the pass that panicked. If the same pass id
318    /// panics again with the same Arc, it reports at `debug!` instead of
319    /// `warn!`. If a different Arc binds to the same id, the warn-once flag
320    /// is cleared and the new pass's first panic is a fresh `warn!` too.
321    reported_panics: BTreeMap<u64, PanicReport>,
322    /// Flag for whether a reserved-namespace registration attempt has already
323    /// been reported at `warn!` level. Warn once process-wide (the refusal
324    /// reason is identical for every id), then report at `debug!` for any
325    /// further attempts.
326    reported_reserved: bool,
327}
328
329impl Registry {
330    const fn new() -> Self {
331        Self {
332            passes: BTreeMap::new(),
333            pending_unbind: BTreeMap::new(),
334            reported_panics: BTreeMap::new(),
335            reported_reserved: false,
336        }
337    }
338}
339
340static REGISTRY: Mutex<Registry> = Mutex::new(Registry::new());
341
342/// Frames drained so far — the source of [`ExternalFrame::frame_index`].
343static FRAME_INDEX: AtomicU64 = AtomicU64::new(0);
344
345/// The registry, with poison ignored: one pass's panic must not turn every
346/// later frame's drain into a panic of its own, which is the opposite of what
347/// the `catch_unwind` around `record` is for. (Nothing panics while the guard
348/// is held — `record` is called after it is dropped — so poison here would
349/// only ever be collateral.)
350fn registry() -> MutexGuard<'static, Registry> {
351    REGISTRY.lock().unwrap_or_else(PoisonError::into_inner)
352}
353
354/// Registers `pass` under `id`, to be recorded every frame until
355/// [`unregister_external_pass`] takes it back.
356///
357/// Answers `false` and changes nothing when `id` already has a pass: a
358/// registration is a claim on an id, and silently displacing another
359/// component's pass would leave it registered from its own point of view and
360/// never called. Unregister first to hand an id over deliberately.
361///
362/// Also answers `false` and changes nothing when `id` is inside the reserved
363/// shader-program namespace (`SceneTextureId::is_shader_program`) — that
364/// range belongs to the engine's own offscreen shader-effect targets, and a
365/// caller-registered pass claiming one would silently steal a shader
366/// program's target out from under it. Reported at `warn!` the first time a
367/// given id is refused this way, `debug!` on every later attempt, so a
368/// caller retrying the same doomed registration every frame does not spam
369/// the log.
370///
371/// A `register` landing before the drain has flushed the same id's queued
372/// unbind cancels that unbind: the id has an owner again. A successful
373/// registration also clears any panic already reported for `id`, so a fresh
374/// pass's first panic is reported fresh rather than silently downgraded by
375/// a predecessor's history.
376pub fn register_external_pass(id: SceneTextureId, pass: Arc<dyn ExternalPass>) -> bool {
377    let raw = id.get();
378    if id.is_shader_program() {
379        let should_warn = {
380            let mut registry = registry();
381            let should_warn = !registry.reported_reserved;
382            if should_warn {
383                registry.reported_reserved = true;
384            }
385            should_warn
386        };
387        if should_warn {
388            log::warn!(
389                "external pass registration for scene texture {raw} refused: the id is inside \
390                 the reserved shader-program namespace (further attempts at this id are logged \
391                 at debug level)"
392            );
393        } else {
394            log::debug!(
395                "external pass registration for scene texture {raw} refused: reserved \
396                 shader-program namespace"
397            );
398        }
399        return false;
400    }
401    let pass_identity = Arc::as_ptr(&pass) as *const () as usize;
402    let should_clear_panic_flag = {
403        let registry = registry();
404        if registry.passes.contains_key(&raw) {
405            return false;
406        }
407        registry
408            .reported_panics
409            .get(&raw)
410            .is_some_and(|report| report.identity != pass_identity)
411    };
412    if should_clear_panic_flag {
413        let mut registry = registry();
414        registry.reported_panics.remove(&raw);
415    }
416    let mut registry = registry();
417    registry.passes.insert(raw, Registration { id, pass });
418    true
419}
420
421/// Removes the pass registered under `id` and queues the engine binding it
422/// left behind for the next drain to clear, answering whether there was one.
423pub fn unregister_external_pass(id: SceneTextureId) -> bool {
424    let mut registry = registry();
425    if registry.passes.remove(&id.get()).is_none() {
426        return false;
427    }
428    registry.pending_unbind.insert(id.get(), id);
429    true
430}
431
432/// Runs one frame's external passes into `encoder`, after clearing the engine
433/// bindings of every id unregistered since the last run.
434///
435/// Called by [`crate::SurfaceRenderer`] on the engine tier, immediately
436/// before the shader-quad pre-pass and so before anything of the scene's own
437/// is recorded. Hidden from the crate's documented surface: it names the
438/// engine renderer and the frame's raw `wgpu` resources, which are the
439/// renderer's to hold, and it is reachable only so this crate's own GPU test
440/// can drive the identical code path against an engine of its own — a
441/// surface, and so `submit`, is not something a headless test can produce.
442///
443/// Cheap on a process that registered nothing: two `is_empty` checks under
444/// one uncontended lock, and no encoder work at all.
445#[doc(hidden)]
446pub fn run_external_passes(
447    device: &wgpu::Device,
448    queue: &wgpu::Queue,
449    encoder: &mut wgpu::CommandEncoder,
450    engine: &mut frust_engine::EngineRenderer,
451) {
452    // Snapshot under the lock and release it before any `record` runs: a pass
453    // is caller code, free to register or unregister another pass (its own
454    // included) from inside its `record`, which would deadlock against a held
455    // guard.
456    let (pending_unbind, passes) = {
457        let mut registry = registry();
458        if registry.passes.is_empty() && registry.pending_unbind.is_empty() {
459            return;
460        }
461        let pending_unbind = std::mem::take(&mut registry.pending_unbind);
462        let passes: Vec<(SceneTextureId, Arc<dyn ExternalPass>)> = registry
463            .passes
464            .values()
465            .map(|registration| (registration.id, Arc::clone(&registration.pass)))
466            .collect();
467        (pending_unbind, passes)
468    };
469
470    // Before any pass records: an id with no owner keeps no view alive.
471    for id in pending_unbind.into_values() {
472        engine.unbind_texture(id);
473    }
474    if passes.is_empty() {
475        return;
476    }
477
478    let frame_index = FRAME_INDEX.fetch_add(1, Ordering::Relaxed);
479    for (id, pass) in passes {
480        // Built fresh per pass, scoped to that pass's own id — `device`,
481        // `encoder` and `engine` are reborrowed each iteration rather than
482        // moved, so the outer `&mut` references stay usable for the next
483        // pass once this one's `ExternalFrame` goes out of scope.
484        let mut frame =
485            ExternalFrame::new(device, queue, &mut *encoder, &mut *engine, id, frame_index);
486        // A pass is caller code on the render thread: unwinding out of it
487        // would abandon the frame's encoder mid-recording and take the
488        // surface's whole frame loop with it.
489        if catch_unwind(AssertUnwindSafe(|| pass.record(&mut frame))).is_err() {
490            drop_panicking_pass(id, &pass);
491        }
492    }
493}
494
495/// Retires the pass registered under `id` after it panicked, *if* `pass` is
496/// still the one registered there: report it once, take it out of the
497/// registry, and queue its binding for the next drain to clear.
498///
499/// `pass` is the `Arc` [`run_external_passes`] snapshotted before calling
500/// `record` on it — compared against whatever is registered under `id` right
501/// now via [`Arc::ptr_eq`] rather than blindly removed by id. A pass is
502/// caller code, free to hand its own id to a successor (unregister, then
503/// register a replacement) from inside the very `record` call that then
504/// panics; retiring by id alone would tear that successor out along with the
505/// pass that actually failed, even though it was never in the unwind at all.
506/// The panic is still reported either way — it happened regardless of what
507/// is registered under `id` now — but the registry itself is only touched
508/// when the identity check confirms nothing has taken the id over since.
509fn drop_panicking_pass(id: SceneTextureId, pass: &Arc<dyn ExternalPass>) {
510    let raw = id.get();
511    let pass_identity = Arc::as_ptr(pass) as *const () as usize;
512
513    let (still_registered, should_warn, was_different_pass) = {
514        let mut registry = registry();
515        let registration = registry.passes.get(&raw);
516        let still_registered = registration.is_some_and(|r| Arc::ptr_eq(&r.pass, pass));
517
518        let should_warn = registry
519            .reported_panics
520            .get(&raw)
521            .is_none_or(|report| report.identity != pass_identity);
522
523        let was_different_pass = registration.is_some_and(|r| !Arc::ptr_eq(&r.pass, pass));
524
525        if still_registered {
526            registry.passes.remove(&raw);
527            registry.pending_unbind.insert(raw, id);
528        }
529
530        if should_warn {
531            registry.reported_panics.insert(
532                raw,
533                PanicReport {
534                    identity: pass_identity,
535                },
536            );
537        }
538
539        (still_registered, should_warn, was_different_pass)
540    };
541
542    if was_different_pass && !still_registered {
543        log::debug!(
544            "external pass for scene texture {raw} panicked after handing over; \
545             the successor stays registered"
546        );
547    } else if still_registered && should_warn {
548        log::warn!(
549            "external pass for scene texture {raw} panicked and was unregistered; \
550             the frame was recorded without it (further panics of this id are \
551             logged at debug level)"
552        );
553    } else if still_registered {
554        log::debug!("external pass for scene texture {raw} panicked and was unregistered");
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    /// Serializes the cases: the registry is process-wide, so two cases
563    /// registering at once would see each other's passes. Poison is ignored
564    /// deliberately — one failing case must not cascade into every sibling.
565    static SERIAL: Mutex<()> = Mutex::new(());
566
567    fn serial() -> MutexGuard<'static, ()> {
568        SERIAL.lock().unwrap_or_else(PoisonError::into_inner)
569    }
570
571    /// A pass that records nothing — enough for every claim about the
572    /// registry itself, which never touches a GPU.
573    struct Inert;
574
575    impl ExternalPass for Inert {
576        fn record(&self, _frame: &mut ExternalFrame<'_>) {}
577    }
578
579    /// Snapshot of the registry's own state, since the drain is the only
580    /// thing that reads it and the drain needs a device.
581    fn state(id: SceneTextureId) -> (bool, bool) {
582        let registry = registry();
583        (
584            registry.passes.contains_key(&id.get()),
585            registry.pending_unbind.contains_key(&id.get()),
586        )
587    }
588
589    #[test]
590    fn registering_an_id_twice_keeps_the_first_pass() {
591        let _serial = serial();
592        let id = SceneTextureId::mint();
593
594        assert!(register_external_pass(id, Arc::new(Inert)));
595        assert!(
596            !register_external_pass(id, Arc::new(Inert)),
597            "a second registration must not displace the first"
598        );
599        assert_eq!(state(id), (true, false));
600
601        assert!(unregister_external_pass(id));
602        assert_eq!(
603            state(id),
604            (false, true),
605            "unregistering queues the engine binding for the next drain"
606        );
607    }
608
609    #[test]
610    fn unregistering_an_unregistered_id_answers_false() {
611        let _serial = serial();
612        let id = SceneTextureId::mint();
613
614        assert!(!unregister_external_pass(id));
615        assert_eq!(
616            state(id),
617            (false, false),
618            "an id nothing registered queues no unbind"
619        );
620    }
621
622    #[test]
623    fn re_registering_before_the_drain_queues_an_unbind_first() {
624        let _serial = serial();
625        let id = SceneTextureId::mint();
626
627        assert!(register_external_pass(id, Arc::new(Inert)));
628        assert!(unregister_external_pass(id));
629        assert!(register_external_pass(id, Arc::new(Inert)));
630        assert_eq!(
631            state(id),
632            (true, true),
633            "handing an id over leaves a queued unbind (the id draws nothing until the new owner binds)"
634        );
635
636        assert!(unregister_external_pass(id));
637    }
638
639    #[test]
640    fn a_panicking_pass_is_retired_and_queued_for_unbind() {
641        let _serial = serial();
642        let id = SceneTextureId::mint();
643        let pass: Arc<dyn ExternalPass> = Arc::new(Inert);
644
645        assert!(register_external_pass(id, Arc::clone(&pass)));
646        drop_panicking_pass(id, &pass);
647        assert_eq!(
648            state(id),
649            (false, true),
650            "a panicking pass leaves the registry exactly as an unregistered one does"
651        );
652
653        // The second retirement reports at debug rather than warn; what is
654        // checked here is that reporting twice is not itself a panic and
655        // leaves the state alone.
656        drop_panicking_pass(id, &pass);
657        assert_eq!(state(id), (false, true));
658
659        registry().pending_unbind.remove(&id.get());
660    }
661
662    #[test]
663    fn retiring_a_panic_leaves_a_successor_registered_under_the_same_id_alone() {
664        let _serial = serial();
665        let id = SceneTextureId::mint();
666        let original: Arc<dyn ExternalPass> = Arc::new(Inert);
667        assert!(register_external_pass(id, Arc::clone(&original)));
668
669        // What a pass handing its own id to a replacement inside `record`
670        // does before it panics: unregister, then register the successor —
671        // both while the drain's snapshot still holds `original`.
672        assert!(unregister_external_pass(id));
673        let successor: Arc<dyn ExternalPass> = Arc::new(Inert);
674        assert!(register_external_pass(id, Arc::clone(&successor)));
675
676        // The drain retires by comparing the snapshot it took (`original`)
677        // against whatever is registered now, not by id alone.
678        drop_panicking_pass(id, &original);
679
680        assert_eq!(
681            state(id),
682            (true, true),
683            "the successor is left registered, and the queued unbind from the hand-over persists"
684        );
685
686        assert!(unregister_external_pass(id));
687        registry().pending_unbind.remove(&id.get());
688    }
689
690    #[test]
691    fn a_fresh_registration_with_a_different_arc_clears_the_reported_panic_flag() {
692        let _serial = serial();
693        let id = SceneTextureId::mint();
694        let pass1: Arc<dyn ExternalPass> = Arc::new(Inert);
695        assert!(register_external_pass(id, Arc::clone(&pass1)));
696
697        drop_panicking_pass(id, &pass1);
698        assert!(
699            registry().reported_panics.contains_key(&id.get()),
700            "the first panic of this id is recorded as already reported"
701        );
702
703        let pass2: Arc<dyn ExternalPass> = Arc::new(Inert);
704        assert!(register_external_pass(id, Arc::clone(&pass2)));
705        assert!(
706            !registry().reported_panics.contains_key(&id.get()),
707            "registering a different Arc clears the flag, so a new pass's first panic warns again"
708        );
709
710        assert!(unregister_external_pass(id));
711    }
712
713    #[test]
714    fn re_registering_the_same_arc_keeps_the_reported_panic_flag() {
715        let _serial = serial();
716        let id = SceneTextureId::mint();
717        let pass: Arc<dyn ExternalPass> = Arc::new(Inert);
718        assert!(register_external_pass(id, Arc::clone(&pass)));
719
720        drop_panicking_pass(id, &pass);
721        assert!(
722            registry().reported_panics.contains_key(&id.get()),
723            "the first panic is recorded"
724        );
725
726        // The panic removed the pass and queued an unbind; clean that up before re-registering
727        registry().pending_unbind.remove(&id.get());
728
729        // Re-register the same Arc: the panic flag stays since it's the same identity
730        assert!(register_external_pass(id, Arc::clone(&pass)));
731        assert!(
732            registry().reported_panics.contains_key(&id.get()),
733            "re-registering the same Arc keeps the panic flag (no warn on next panic)"
734        );
735
736        assert!(unregister_external_pass(id));
737    }
738
739    #[test]
740    fn panicking_pass_with_successor_already_registered_logs_debug() {
741        let _serial = serial();
742        let id = SceneTextureId::mint();
743        let original: Arc<dyn ExternalPass> = Arc::new(Inert);
744        assert!(register_external_pass(id, Arc::clone(&original)));
745
746        // Hand over: unregister original, register successor
747        assert!(unregister_external_pass(id));
748        let successor: Arc<dyn ExternalPass> = Arc::new(Inert);
749        assert!(register_external_pass(id, Arc::clone(&successor)));
750
751        // Original panics after handing over - successor stays registered
752        drop_panicking_pass(id, &original);
753
754        assert_eq!(
755            state(id),
756            (true, true),
757            "the successor is left registered and the queued unbind from the hand-over remains"
758        );
759
760        assert!(unregister_external_pass(id));
761    }
762
763    #[test]
764    fn pending_unbind_grows_with_distinct_ids_not_with_repeat_unregisters() {
765        let _serial = serial();
766        let id = SceneTextureId::mint();
767        // Other cases in this module may leave their own residual entries
768        // (under their own distinct ids) in this same process-wide map, so
769        // what is asserted is the delta this case itself causes, not an
770        // absolute length.
771        let before = registry().pending_unbind.len();
772
773        assert!(register_external_pass(id, Arc::new(Inert)));
774        assert!(unregister_external_pass(id));
775        // The same id again finds nothing left to remove, so this queues no
776        // second entry — the map is keyed by id, not appended to per call.
777        assert!(!unregister_external_pass(id));
778        assert_eq!(
779            registry().pending_unbind.len(),
780            before + 1,
781            "repeat unregistration of one id does not grow the queue past one entry"
782        );
783
784        registry().pending_unbind.remove(&id.get());
785    }
786}