Skip to main content

frust_shell_common/
render_split.rs

1//! Render-thread-split plumbing shared by every shell.
2//!
3//! # What lives here
4//!
5//! The split moves `encode→acquire→blit→present` off the UI thread onto a
6//! dedicated render thread: the UI thread keeps
7//! `rebuild→layout→paint`, then hands the finished [`Scene`] across. This
8//! module is the *vocabulary* for that handoff — the shells
9//! own the threads and the `wgpu`/`vello` resources, this crate owns the
10//! platform-free channel types and the pure lifecycle/kill-switch logic they
11//! coordinate through.
12//!
13//! - [`render_channel`] — the single UI→render link: a **depth-1, latest-wins**
14//!   scene-handoff slot (a newer [`SceneFrame`] replaces an un-taken one; the
15//!   render thread always takes the freshest, dropping stale frames) fused with
16//!   a FIFO lifecycle-command queue behind **one** [`std::sync::Condvar`], so
17//!   the render thread has a single wait point ([`RenderReceiver::wait_next`]).
18//!   Depth 1 is deliberate — Flutter's merged-mode precedent shows pipeline
19//!   depth drops to 1 when threads merge; deeper queues add latency
20//!   for no mobile win.
21//! - [`scene_return_channel`] — the reverse, render→UI give-back link: a
22//!   **non-blocking, depth-1** [`Mutex`]-only slot (no
23//!   [`Condvar`] — the UI thread only ever polls it, never parks) the render
24//!   thread pushes a drained scene back through once it is done reading it, so
25//!   a shell's split `submit_frame` can `Scene::reset()` and reuse the buffer
26//!   next frame instead of reallocating one via `Scene::new()` every frame —
27//!   restoring frust-scene's documented reuse contract (`scene.rs`'s
28//!   `Scene::reset` docs) in split mode.
29//! - [`RenderCommand`] / [`RenderEvent`] / [`RenderPhase`] — the surface
30//!   lifecycle vocabulary (created/changed/destroyed/pause/resume) as **owned
31//!   commands**, modelled on `frust-render`'s `SurfacePhase` machine: a pure,
32//!   host-testable [`next_render_phase`] transition table gates whether the
33//!   render thread [`may render`](RenderPhase::can_render).
34//! - [`Ack`] / [`AckWaiter`] — the cross-thread acknowledgment barrier that
35//!   makes [`RenderCommand::Pause`] and [`RenderCommand::SurfaceDestroyed`]
36//!   *synchronous*: the UI thread blocks until the render thread has honored
37//!   the command. This is the correctness anchor for two platform hazards:
38//!   Android can destroy the `ANativeWindow` while the render
39//!   thread still holds the surface, and iOS can kill a process that submits
40//!   Metal work after the app backgrounds. Both are barriers, not shared
41//!   mutable flags.
42//! - [`SceneFrame`] / [`FrameMeta`] / [`SurfaceSize`] — the per-frame payload
43//!   crossing the handoff: the scene plus the frame clock, the surface
44//!   dimensions, and (for the single-emitter perf recording)
45//!   the UI thread's [`UiSpans`] half of the frame timing, which the render
46//!   thread folds together with its own [`RenderSpans`] via
47//!   [`FramePasses::from_split`](crate::perf::FramePasses::from_split).
48//! - [`render_thread_enabled`] / [`NO_RENDER_THREAD_VAR`] — the single kill
49//!   switch the shells consult, parsed exactly like [`crate::frame_gate`]'s
50//!   `FRUST_NO_FRAME_GATE` (compile-time define *or* runtime env, any non-`"0"`
51//!   value). When engaged, a shell keeps the pre-split single-thread path (kept
52//!   as an escape hatch until the split's on-device throughput is fully
53//!   validated).
54//! - [`thread_tuning_enabled`] / [`NO_THREAD_TUNING_VAR`] — the render
55//!   thread's scheduling switch, parsed the same way: on (the default), a
56//!   shell's render thread takes `frust_reactive::thread_policy`'s frame class;
57//!   engaged, it keeps its previous fixed priority.
58//!
59//! # Benchmark scenario markers
60//!
61//! The channel carries one thing beside the scene: the benchmark
62//! scenario-window edges [`crate::perf::mark_scenario_start`] raises, so
63//! each is logged stamped with the frame that actually carried it. Three
64//! properties of that route are load-bearing here.
65//!
66//! - **`perf-trace`-only.** Every marker field, call and queue in this
67//!   module is behind the feature; a release-lean build has no marker code
68//!   in the channel at all, and a `perf-trace` build with `FRUST_TRACE` off
69//!   pays one cached bool read per [`RenderSender::send_scene`].
70//! - **Per-thread, not process-wide.** [`RenderSender::send_scene`] moves
71//!   the **calling** thread's raised markers into the inbox, and [`drain`]
72//!   stages the taken frame's markers onto the **calling** (render) thread,
73//!   which is the thread about to record that frame. No shared queue and no
74//!   flag decide who owns a marker; the thread that raised it does, until it
75//!   hands a frame off.
76//! - **A marker from a thread that hands no frame off is never emitted.**
77//!   Only the UI thread's own queue crosses this channel, so a marker raised
78//!   on, say, a blocking-pool thread stays there and dies with it — see
79//!   [`crate::perf::mark_scenario_start`], which documents the rule and why
80//!   the alternative (attaching it to some other thread's frame) is the
81//!   cross-thread guess this route exists to remove.
82//!
83//! # Layering choice
84//!
85//! Like [`crate::perf`] and [`crate::frame_gate`], this is shell-owned and
86//! platform-free: it takes **no** `frust-render`/`wgpu`/`vello` dependency, no
87//! `unsafe`, and no `frust-reactive`, preserving this crate's
88//! compiles-everywhere, reactive-free charter (see `docs/ARCHITECTURE.md`'s
89//! Layer Dependencies). The scene payload ([`SceneFrame`]) and the surface
90//! handle a [`RenderCommand::SurfaceCreated`] carries are therefore *generic*
91//! parameters (`S`/`W`): a shell instantiates `S = frust_scene::Scene` and `W`
92//! = its own raw-window wrapper, while these host tests instantiate cheap
93//! stand-ins, so the whole channel is exercised without a GPU or a platform.
94//!
95//! # Wiring
96//!
97//! This module ships the channel types + pure logic; the desktop, Android,
98//! and iOS shells each spawn their own render thread on top of it, gated by
99//! [`render_thread_enabled`].
100//!
101//! [`Scene`]: https://docs.rs/frust-scene
102//! [`UiSpans`]: crate::perf::UiSpans
103//! [`RenderSpans`]: crate::perf::RenderSpans
104
105use std::sync::atomic::{AtomicBool, Ordering};
106use std::sync::{Arc, Condvar, Mutex};
107use std::time::Duration;
108
109use frust_core::RetainedLedger;
110use frust_core::anim::FrameTime;
111
112#[cfg(feature = "perf-trace")]
113use crate::perf::MarkerQueue;
114use crate::perf::UiSpans;
115
116// ---------------------------------------------------------------------
117// Resident-key ledger hand-off (render side -> UI thread)
118// ---------------------------------------------------------------------
119
120/// The resident-key ledger the render side last published. One slot per
121/// process: a shell runs one renderer, and the slot is the same whether that
122/// renderer lives on the UI thread (single-thread path) or on the render thread
123/// (the split). The lock is held only to swap or clone an `Arc`.
124static PUBLISHED_RETAINED_LEDGER: Mutex<Option<Arc<RetainedLedger>>> = Mutex::new(None);
125
126/// Publish the ledger describing the frame the renderer just finished. Called
127/// by the render side (after submit) with the retained pages it holds resident,
128/// stamped with the paint serial of the last scene it committed
129/// ([`FrameMeta::paint_serial`] as named to the renderer before the encode);
130/// replaces the previous one. May block only for the instant the UI thread
131/// holds the slot to clone an `Arc`.
132pub fn publish_retained_ledger(ledger: RetainedLedger) {
133    let mut slot = PUBLISHED_RETAINED_LEDGER
134        .lock()
135        .unwrap_or_else(std::sync::PoisonError::into_inner);
136    *slot = Some(Arc::new(ledger));
137}
138
139/// Publish the ledger for a renderer's resident retained pages: `frame` is the
140/// last committed paint serial, `pages` its `(key, extent_px, scale)` triples.
141/// Render-agnostic (shell-common stays free of `frust-render`), so each shell
142/// adapts its own `SurfaceRenderer` to this one call.
143///
144/// The slot behind [`publish_retained_ledger`] is process-global, which is
145/// sound only because a process runs ONE renderer: a second surface would
146/// publish over the first's ledger. The publish takes the slot's lock for an
147/// `Arc` swap and nothing else, and the UI thread only ever `try_lock`s it, so
148/// the UI thread never waits on this.
149pub fn publish_resident_pages(frame: u64, pages: impl IntoIterator<Item = (u64, (u32, u32), f32)>) {
150    publish_retained_ledger(RetainedLedger::from_entries(frame, pages));
151}
152
153/// Publish the EMPTY ledger — what a surface destroy or a device-scale change
154/// leaves the renderer holding. A shell calls it (or [`publish_retained_ledger`]
155/// with the renderer's own, equally empty, ledger) before a destroy returns or
156/// its barrier is acknowledged, so the UI thread's first paint over a
157/// re-created surface names no page the torn-down one held.
158pub fn publish_empty_retained_ledger() {
159    publish_retained_ledger(RetainedLedger::empty());
160}
161
162/// The most recently published ledger, for the UI thread to hand to
163/// `RenderRoot::set_retained_ledger` before painting. **Never blocks on the
164/// render thread**: if the render side is mid-publish (or nothing was ever
165/// published) this returns `None` and the caller keeps the ledger it already
166/// has — a one-frame-stale ledger is the design: the renderer refuses a
167/// `DrawRetained` of a page it no longer holds, and the ledger's paint serial
168/// keeps a widget from drawing a page holding an older recording than its
169/// latest (see `frust_core::RetainedLedger::covers`). Empty until the engine's
170/// retained-page store publishes.
171pub fn latest_retained_ledger() -> Option<Arc<RetainedLedger>> {
172    match PUBLISHED_RETAINED_LEDGER.try_lock() {
173        Ok(slot) => slot.clone(),
174        Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner().clone(),
175        Err(std::sync::TryLockError::WouldBlock) => None,
176    }
177}
178
179/// Whether the render side owes the UI thread one more paint because a frame
180/// was refused for drawing a retained page the renderer no longer held. One
181/// flag per process, like the ledger slot beside it.
182static RETAINED_REPAINT_OWED: AtomicBool = AtomicBool::new(false);
183
184/// Record that a frame was just refused for drawing a retained page the
185/// renderer no longer held (the store was cleared and the empty ledger
186/// published): the refused frame presented nothing, so the UI thread owes one
187/// more paint against that empty ledger. Called by the render side — which
188/// cannot reach the UI thread's state — right after it published; an atomic
189/// store, never blocks.
190///
191/// For the shells whose frame clock ticks on regardless of dirtiness (the
192/// Android Choreographer, the iOS `CADisplayLink`) and whose frame gate would
193/// otherwise skip every tick of an idle screen. The desktop and web shells
194/// turn the refusal into a `Redraw` follow-up of the frame itself instead.
195pub fn request_retained_repaint() {
196    RETAINED_REPAINT_OWED.store(true, Ordering::Release);
197}
198
199/// Take the owed repaint [`request_retained_repaint`] recorded: `true` once per
200/// request (however many requests landed since the last take), then `false`.
201/// Read by the UI thread's frame gate past its surface-ready check, ORed into
202/// an input that forces the frame to run. An atomic swap, never blocks.
203pub fn take_retained_repaint() -> bool {
204    RETAINED_REPAINT_OWED.swap(false, Ordering::AcqRel)
205}
206
207// ---------------------------------------------------------------------
208// Kill switch
209// ---------------------------------------------------------------------
210
211/// The render-thread-split kill-switch environment/compile-time variable: when
212/// set to any non-`"0"` value, [`render_thread_enabled`] is `false` and a shell
213/// keeps the pre-split single-thread frame path.
214///
215/// Parsed exactly like [`crate::frame_gate::NO_FRAME_GATE_VAR`] — either the
216/// compile-time `--define` or the runtime process env engages it.
217pub const NO_RENDER_THREAD_VAR: &str = "FRUST_NO_RENDER_THREAD";
218
219/// Whether a shell should run the render-thread split — the **single switch**
220/// every shell consults. `true` unless the
221/// [`NO_RENDER_THREAD_VAR`] kill switch is engaged (compile-time define or
222/// runtime env, any non-`"0"` value), mirroring [`crate::perf::enabled`]'s and
223/// [`crate::frame_gate`]'s `option_env!` + runtime-env parsing precedent.
224///
225/// Read once at shell startup: a shell that takes the split path spawns the
226/// render thread, a shell where this is `false` keeps the single-thread path
227/// verbatim.
228pub fn render_thread_enabled() -> bool {
229    !render_thread_kill_switch(
230        option_env!("FRUST_NO_RENDER_THREAD"),
231        std::env::var(NO_RENDER_THREAD_VAR).ok().as_deref(),
232    )
233}
234
235/// The pure decision [`render_thread_enabled`] negates: a non-empty, non-`"0"`
236/// value from either the compile-time or runtime source engages the kill
237/// switch. Split out so it is directly unit-testable without touching the
238/// process environment (see [`crate::frame_gate`]'s `kill_switch`).
239fn render_thread_kill_switch(compile_time: Option<&str>, runtime: Option<&str>) -> bool {
240    fn is_set_non_zero(value: Option<&str>) -> bool {
241        matches!(value, Some(v) if v != "0")
242    }
243    is_set_non_zero(compile_time) || is_set_non_zero(runtime)
244}
245
246/// The thread-tuning kill-switch environment/compile-time variable: when set to
247/// any non-`"0"` value, [`thread_tuning_enabled`] is `false` and a shell's
248/// render thread keeps its previous fixed priority (Android's
249/// `THREAD_PRIORITY_DISPLAY` nice value, iOS's user-interactive QoS) instead of
250/// the frame scheduling class.
251///
252/// The same variable turns off the reactive runtime's background-pool policy;
253/// `frust-reactive` reads it there itself (its `thread_policy` module), since
254/// this crate stays reactive-free. Both sides parse it like
255/// [`NO_RENDER_THREAD_VAR`].
256pub const NO_THREAD_TUNING_VAR: &str = "FRUST_NO_THREAD_TUNING";
257
258/// Whether a shell should schedule its render thread through the frame class —
259/// `true` unless the [`NO_THREAD_TUNING_VAR`] kill switch is engaged
260/// (compile-time define or runtime env, any non-`"0"` value), parsed exactly
261/// like [`render_thread_enabled`]. Read once, on the render thread, before its
262/// loop starts.
263pub fn thread_tuning_enabled() -> bool {
264    !render_thread_kill_switch(
265        option_env!("FRUST_NO_THREAD_TUNING"),
266        std::env::var(NO_THREAD_TUNING_VAR).ok().as_deref(),
267    )
268}
269
270// ---------------------------------------------------------------------
271// Frame payload
272// ---------------------------------------------------------------------
273
274/// The surface dimensions a [`SceneFrame`] / [`RenderCommand`] carries — the
275/// physical (device-pixel) swapchain size plus the HiDPI scale factor, so the
276/// render thread can (re)configure the surface without consulting the UI
277/// thread. Physical-at-the-boundary matches the render thread's swapchain
278/// needs (`docs/CODE_STANDARDS.md`'s physical-at-FFI, logical-inside rule).
279#[derive(Debug, Clone, Copy, PartialEq)]
280pub struct SurfaceSize {
281    /// Physical (device-pixel) width of the surface.
282    pub width: u32,
283    /// Physical (device-pixel) height of the surface.
284    pub height: u32,
285    /// HiDPI scale factor (physical / logical), already sanitized by the
286    /// shell's [`sanitize_scale`](crate::sanitize_scale) at the FFI boundary.
287    pub scale: f64,
288}
289
290/// Per-frame metadata riding the scene-handoff channel alongside the scene
291/// itself.
292#[derive(Debug, Clone, Copy, PartialEq)]
293pub struct FrameMeta {
294    /// The shell's frame clock for this frame (`Choreographer`/`CADisplayLink`/
295    /// desktop epoch), threaded through to `PaintCtx::frame_time` so the render
296    /// thread advances animations against the same clock the UI thread painted
297    /// with. Only *differences* of two [`FrameTime`]s carry meaning (see its
298    /// docs).
299    pub frame_time: FrameTime,
300    /// The surface size this scene was laid out for — the render thread checks
301    /// it against the live swapchain configuration before encoding.
302    pub size: SurfaceSize,
303    /// Monotonically increasing per-frame id the UI thread stamps, so a dropped
304    /// (latest-wins-replaced) frame is observable in diagnostics and so a
305    /// render-side report can be paired back to the frame that produced it.
306    pub frame_id: u64,
307    /// The paint serial the render root minted for this scene
308    /// (`frust_core::PaintOutcome::paint_serial`). The render thread names it
309    /// to the renderer (`SurfaceRenderer::note_scene_serial`) before encoding
310    /// the scene, so the resident-key ledger it publishes once the scene is
311    /// committed carries it — and a scene this slot drops never does. A
312    /// separate field from `frame_id`: that one counts hand-offs, this one
313    /// counts paints, and only the paint's own number can be compared against
314    /// what a widget recorded during it.
315    pub paint_serial: u64,
316}
317
318/// One frame handed from the UI thread to the render thread across
319/// [`render_channel`]: the finished scene, its
320/// [`FrameMeta`], and the UI thread's [`UiSpans`] half of the frame timing
321/// (the render thread is the single perf emitter).
322///
323/// Generic over the scene type `S` so this crate stays render-free: a shell
324/// instantiates `SceneFrame<frust_scene::Scene>`, host tests use a cheap
325/// stand-in.
326#[derive(Debug, Clone)]
327pub struct SceneFrame<S> {
328    /// The finished scene the render thread encodes (`frust_scene::Scene` in a
329    /// real shell — `Scene` is `Send`, verified by its compile-time tripwire).
330    pub scene: S,
331    /// This frame's metadata (clock, surface size, id).
332    pub meta: FrameMeta,
333    /// The UI thread's `rebuild`/`layout`/`paint` timing, folded with the
334    /// render thread's [`RenderSpans`](crate::perf::RenderSpans) via
335    /// [`FramePasses::from_split`](crate::perf::FramePasses::from_split) into
336    /// the one recorded frame.
337    pub ui_spans: UiSpans,
338}
339
340// ---------------------------------------------------------------------
341// Lifecycle: phase machine (modelled on frust-render's SurfacePhase)
342// ---------------------------------------------------------------------
343
344/// A surface-lifecycle event that drives a render-thread [`RenderPhase`]
345/// transition — the pure, `Copy` counterpart of a [`RenderCommand`] (mirroring
346/// `frust-render`'s `SurfaceEvent`/callback split, keeping the transition table
347/// host-testable without the owned `Ack`/window payloads).
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349pub enum RenderEvent {
350    /// A surface became available (`surfaceCreated`/`resumed`/`set_surface`).
351    SurfaceCreated,
352    /// The existing surface was resized/reconfigured (rotation, inset change).
353    SurfaceChanged,
354    /// The surface is being torn down (`surfaceDestroyed`/`suspended`).
355    SurfaceDestroyed,
356    /// The app is backgrounding: stop submitting until [`Self::Resume`].
357    Pause,
358    /// The app returned to the foreground with its surface intact.
359    Resume,
360}
361
362/// The render thread's view of surface lifecycle state,
363/// modelled on `frust-render`'s `SurfacePhase`: the render loop renders a
364/// handed-off [`SceneFrame`] only while [`can_render`](Self::can_render) — i.e.
365/// only in [`RenderPhase::Active`]. [`RenderPhase::Paused`] is the cross-thread
366/// backgrounding barrier (a leftover scene must NOT be submitted after a
367/// `Pause`, per the iOS process-kill hazard).
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub enum RenderPhase {
370    /// No usable surface: nothing to render into (initial state, or after a
371    /// [`RenderEvent::SurfaceDestroyed`]).
372    NoSurface,
373    /// A configured surface is available and frames may be submitted.
374    Active,
375    /// The app backgrounded ([`RenderEvent::Pause`]); the surface may still
376    /// exist but the render thread must not submit until [`RenderEvent::Resume`].
377    Paused,
378}
379
380impl RenderPhase {
381    /// Whether the render thread may submit a frame in this phase — only
382    /// [`RenderPhase::Active`], mirroring `SurfacePhase::can_render`. A render
383    /// loop checks this before encoding a handed-off [`SceneFrame`], so a scene
384    /// left in the latest-wins slot when a `Pause`/`Destroy` is processed is
385    /// dropped rather than submitted.
386    pub fn can_render(self) -> bool {
387        matches!(self, RenderPhase::Active)
388    }
389}
390
391/// Pure render-phase transition table, the analogue of
392/// `frust-render`'s `next_phase`. Total by design:
393///
394/// - `SurfaceCreated` → [`Active`](RenderPhase::Active) (create or recreate),
395/// - `SurfaceDestroyed` → [`NoSurface`](RenderPhase::NoSurface),
396/// - `SurfaceChanged` → the current phase unchanged (a resize never changes
397///   *whether* we can render — mirrors "resize stays Ready"),
398/// - `Pause` → [`Paused`](RenderPhase::Paused) unless there is no surface (you
399///   cannot pause what was never created),
400/// - `Resume` → [`Active`](RenderPhase::Active) unless there is no surface (you
401///   cannot resume onto a surface that is gone; the shell must recreate it
402///   first via `SurfaceCreated`).
403pub fn next_render_phase(current: RenderPhase, event: RenderEvent) -> RenderPhase {
404    match event {
405        RenderEvent::SurfaceCreated => RenderPhase::Active,
406        RenderEvent::SurfaceDestroyed => RenderPhase::NoSurface,
407        RenderEvent::SurfaceChanged => current,
408        RenderEvent::Pause => match current {
409            RenderPhase::NoSurface => RenderPhase::NoSurface,
410            _ => RenderPhase::Paused,
411        },
412        RenderEvent::Resume => match current {
413            RenderPhase::NoSurface => RenderPhase::NoSurface,
414            _ => RenderPhase::Active,
415        },
416    }
417}
418
419// ---------------------------------------------------------------------
420// Lifecycle: acknowledgment barrier
421// ---------------------------------------------------------------------
422
423/// The render-thread side of an acknowledgment barrier: the render thread holds
424/// this (moved out of a [`RenderCommand::Pause`]/[`RenderCommand::SurfaceDestroyed`])
425/// while honoring the command, then [`acknowledge`](Self::acknowledge)s it —
426/// unblocking the UI thread's paired [`AckWaiter`].
427///
428/// Dropping an `Ack` without an explicit [`acknowledge`](Self::acknowledge)
429/// still signals (a safety net so a render thread that returns early — or
430/// panics past the command — can never deadlock the UI thread), but a render
431/// loop should acknowledge explicitly *after* the pause/destroy work is done,
432/// which is exactly the barrier the two platform hazards need (Android window
433/// release, iOS backgrounding).
434#[derive(Debug)]
435pub struct Ack {
436    shared: Arc<AckShared>,
437}
438
439/// The UI-thread side of an acknowledgment barrier: the UI thread
440/// [`wait`](Self::wait)s on this after sending a [`RenderCommand::Pause`]/
441/// [`RenderCommand::SurfaceDestroyed`], blocking until the render thread has
442/// [`acknowledge`](Ack::acknowledge)d (or dropped) the paired [`Ack`].
443#[derive(Debug)]
444pub struct AckWaiter {
445    shared: Arc<AckShared>,
446}
447
448#[derive(Debug)]
449struct AckShared {
450    done: Mutex<bool>,
451    signal: Condvar,
452}
453
454/// Create a linked [`AckWaiter`] / [`Ack`] barrier pair: the UI thread keeps
455/// the waiter, the render thread receives the ack (inside the command). Used by
456/// [`RenderSender::pause`]/[`RenderSender::destroy_surface`]; exposed directly
457/// for shells building lifecycle commands by hand.
458pub fn ack_pair() -> (AckWaiter, Ack) {
459    let shared = Arc::new(AckShared {
460        done: Mutex::new(false),
461        signal: Condvar::new(),
462    });
463    (
464        AckWaiter {
465            shared: shared.clone(),
466        },
467        Ack { shared },
468    )
469}
470
471impl Ack {
472    /// Signal the paired [`AckWaiter`] that the command has been honored,
473    /// consuming the ack. Equivalent to dropping it (the signal fires in
474    /// [`Drop`]), but reads as the deliberate end-of-command acknowledgment the
475    /// barrier contract expects.
476    pub fn acknowledge(self) {
477        // The Drop impl performs the signal; consuming `self` here runs it.
478    }
479}
480
481impl Drop for Ack {
482    fn drop(&mut self) {
483        let mut done = self.shared.done.lock().unwrap();
484        *done = true;
485        drop(done);
486        self.shared.signal.notify_all();
487    }
488}
489
490impl AckWaiter {
491    /// Block the UI thread until the render thread has acknowledged (or dropped)
492    /// the paired [`Ack`]. Returns immediately if already acknowledged. This is
493    /// the barrier: after it returns, the caller may safely proceed to release
494    /// the window (Android) or let the app background (iOS).
495    pub fn wait(self) {
496        let mut done = self.shared.done.lock().unwrap();
497        while !*done {
498            done = self.shared.signal.wait(done).unwrap();
499        }
500    }
501
502    /// Block the UI thread until the render thread acknowledges the paired
503    /// [`Ack`] **or** `timeout` elapses, whichever comes first. Returns `true` if
504    /// the ack fired (the barrier was honored), `false` on timeout.
505    ///
506    /// This is the **bounded** counterpart of [`Self::wait`]: the correctness fix
507    /// (receiver-liveness — see [`RenderReceiver`]'s [`Drop`]) means a live render
508    /// thread's ack always fires, but a render thread wedged mid-command (a GPU
509    /// driver hang, not a clean exit) would still block [`Self::wait`] forever.
510    /// A timeout lets the caller **degrade instead of hang** — proceed to release
511    /// the window / let the app background after logging — so a stuck render
512    /// thread never trips a platform watchdog (iOS backgrounding kill, Android
513    /// ANR). Each call site picks a named, doc-commented per-platform deadline
514    /// safely under its watchdog budget.
515    #[must_use = "the caller must handle a timeout (proceed degraded) rather than assume the barrier was honored"]
516    pub fn wait_timeout(self, timeout: Duration) -> bool {
517        let done = self.shared.done.lock().unwrap();
518        let (done, _timeout_result) = self
519            .shared
520            .signal
521            .wait_timeout_while(done, timeout, |done| !*done)
522            .unwrap();
523        *done
524    }
525
526    /// Whether the paired [`Ack`] has been acknowledged yet, without blocking —
527    /// a non-consuming diagnostic peek (the barrier proper is [`Self::wait`]).
528    pub fn completed(&self) -> bool {
529        *self.shared.done.lock().unwrap()
530    }
531}
532
533// ---------------------------------------------------------------------
534// Lifecycle: command vocabulary
535// ---------------------------------------------------------------------
536
537/// A lifecycle command the UI thread sends to the render thread across
538/// [`render_channel`], as an **owned** value — not a shared
539/// mutable flag. Generic over the surface-handle type `W` a
540/// [`Self::SurfaceCreated`] carries (`frust-render`'s raw-window wrapper in a
541/// real shell; a stand-in in host tests), keeping this crate render-free.
542///
543/// [`Self::Pause`] and [`Self::SurfaceDestroyed`] carry an [`Ack`]: the UI
544/// thread blocks on the paired [`AckWaiter`] until the render thread honors
545/// them (the window-release / backgrounding barrier). The others are
546/// fire-and-forget. The command's effect on the render thread's [`RenderPhase`]
547/// is given by [`Self::event`] → [`next_render_phase`].
548#[derive(Debug)]
549pub enum RenderCommand<W> {
550    /// A surface became available: the render thread takes ownership of `window`
551    /// and (re)configures its swapchain to `size`. Fire-and-forget.
552    SurfaceCreated {
553        /// The raw surface handle the render thread takes ownership of.
554        window: W,
555        /// The initial physical surface size.
556        size: SurfaceSize,
557    },
558    /// The existing surface was resized/reconfigured (rotation, inset change).
559    /// Fire-and-forget.
560    SurfaceChanged {
561        /// The new physical surface size.
562        size: SurfaceSize,
563    },
564    /// The surface is being torn down: the render thread must drop every
565    /// surface-derived `wgpu` resource **before acknowledging**, so the UI
566    /// thread can safely release the underlying window (the Android
567    /// `ANativeWindow`-release hazard). Blocks the UI thread via [`Ack`].
568    SurfaceDestroyed {
569        /// Acknowledged once surface resources are dropped.
570        ack: Ack,
571    },
572    /// The app is backgrounding: the render thread must stop submitting frames
573    /// **before acknowledging**, so the app never submits Metal/Vulkan work
574    /// after it backgrounds (the iOS process-kill hazard). Blocks the UI thread
575    /// via [`Ack`].
576    Pause {
577        /// Acknowledged once the render thread has quiesced.
578        ack: Ack,
579    },
580    /// The app returned to the foreground with its surface intact: resume
581    /// submitting. Fire-and-forget.
582    Resume,
583}
584
585impl<W> RenderCommand<W> {
586    /// The pure [`RenderEvent`] this command drives on the render thread's
587    /// [`RenderPhase`] — the `Copy` projection that feeds [`next_render_phase`]
588    /// (borrowing `self`, leaving the owned `Ack`/`window` in place).
589    pub fn event(&self) -> RenderEvent {
590        match self {
591            RenderCommand::SurfaceCreated { .. } => RenderEvent::SurfaceCreated,
592            RenderCommand::SurfaceChanged { .. } => RenderEvent::SurfaceChanged,
593            RenderCommand::SurfaceDestroyed { .. } => RenderEvent::SurfaceDestroyed,
594            RenderCommand::Pause { .. } => RenderEvent::Pause,
595            RenderCommand::Resume => RenderEvent::Resume,
596        }
597    }
598
599    /// Whether this command carries an [`Ack`] the UI thread blocks on — `true`
600    /// for [`Self::Pause`]/[`Self::SurfaceDestroyed`], `false` for the
601    /// fire-and-forget variants.
602    pub fn requires_ack(&self) -> bool {
603        matches!(
604            self,
605            RenderCommand::SurfaceDestroyed { .. } | RenderCommand::Pause { .. }
606        )
607    }
608}
609
610// ---------------------------------------------------------------------
611// The UI→render channel: latest-wins scene slot + FIFO command queue
612// ---------------------------------------------------------------------
613
614/// The shared state behind [`render_channel`]: a depth-1 latest-wins scene slot
615/// and a FIFO command queue, both under one mutex + condvar so the render
616/// thread has a single wait point.
617#[derive(Debug)]
618struct Inbox<S, W> {
619    /// The freshest un-taken scene (depth-1 latest-wins): a newer send replaces
620    /// it, incrementing [`Self::dropped`].
621    latest: Option<SceneFrame<S>>,
622    /// Count of scenes replaced (dropped) before the render thread took them —
623    /// the latest-wins drop counter, observable via
624    /// [`RenderReceiver::dropped_frames`].
625    dropped: u64,
626    /// Pending lifecycle commands in FIFO order.
627    commands: Vec<RenderCommand<W>>,
628    /// Benchmark scenario markers moved out of the UI thread's own queue
629    /// since the render thread last took a scene, waiting to travel with the
630    /// next one ([`crate::perf::mark_scenario_start`]).
631    ///
632    /// They live here rather than on [`SceneFrame`] deliberately: the shells
633    /// build that struct by literal and destructure [`RenderBatch`], so a new
634    /// field there would be a breaking edit to three shell crates for a
635    /// benchmark-only concern. Keeping them beside the slot also gives the
636    /// right latest-wins behaviour for free — when a newer scene replaces an
637    /// un-taken one, the replaced build's markers stay queued and ride the
638    /// frame that supersedes it, which is the frame that actually drew that
639    /// build's result.
640    ///
641    /// Private, `perf-trace`-only, and bounded like every other leg of the
642    /// route (see [`crate::perf::MarkerQueue`]): a channel whose render
643    /// thread stopped taking scenes must stop accumulating markers rather
644    /// than grow one un-drained queue for the life of the process.
645    #[cfg(feature = "perf-trace")]
646    pending_markers: MarkerQueue,
647    /// Cleared when the [`RenderSender`] is dropped, so a blocked
648    /// [`RenderReceiver::wait_next`] wakes and reports disconnection (the render
649    /// loop's clean-exit signal).
650    sender_alive: bool,
651    /// Cleared when the [`RenderReceiver`] is dropped (the render thread exited —
652    /// panic-unwind, a clean early `return`, or a hung thread's drop). Once
653    /// `false`, [`RenderSender::send_command`]/[`RenderSender::send_scene`] drop
654    /// (rather than queue) new work: an ack-carrying command dropped here fires
655    /// its [`Ack`]'s [`Drop`] safety net, so a UI thread blocked on the paired
656    /// [`AckWaiter`] can never wedge on a command the departed render thread will
657    /// never drain. Symmetric with [`Self::sender_alive`].
658    receiver_alive: bool,
659}
660
661#[derive(Debug)]
662struct Channel<S, W> {
663    inbox: Mutex<Inbox<S, W>>,
664    signal: Condvar,
665}
666
667/// The UI-thread handle to the render channel: sends scenes
668/// (latest-wins) and lifecycle commands (FIFO). Single-producer by design (the
669/// UI thread), so it is deliberately not [`Clone`].
670#[derive(Debug)]
671pub struct RenderSender<S, W> {
672    channel: Arc<Channel<S, W>>,
673}
674
675/// The render-thread handle to the render channel: the
676/// single wait point ([`Self::wait_next`]) draining pending commands plus the
677/// freshest scene each wakeup.
678#[derive(Debug)]
679pub struct RenderReceiver<S, W> {
680    channel: Arc<Channel<S, W>>,
681}
682
683/// One wakeup's worth of work handed to the render thread by
684/// [`RenderReceiver::wait_next`]/[`RenderReceiver::try_next`]: the lifecycle
685/// commands to process (FIFO), then the freshest scene to render (if any). A
686/// render loop processes `commands` first (updating its [`RenderPhase`]), then
687/// renders `scene` only if the resulting phase [`can_render`](RenderPhase::can_render).
688#[derive(Debug)]
689pub struct RenderBatch<S, W> {
690    /// Pending lifecycle commands in FIFO order.
691    pub commands: Vec<RenderCommand<W>>,
692    /// The freshest scene handed off since the last drain (latest-wins), or
693    /// `None` if no new scene arrived.
694    pub scene: Option<SceneFrame<S>>,
695    /// `true` once the [`RenderSender`] has been dropped and no work remains —
696    /// the render loop's signal to exit cleanly.
697    pub disconnected: bool,
698}
699
700/// Create the UI→render channel: a depth-1 latest-wins
701/// scene slot fused with a FIFO lifecycle-command queue behind one condvar.
702///
703/// `S` is the scene payload type (`frust_scene::Scene` in a real shell), `W`
704/// the surface-handle type a [`RenderCommand::SurfaceCreated`] carries — both
705/// generic so this crate stays render-free (see the module docs' Layering
706/// choice).
707pub fn render_channel<S, W>() -> (RenderSender<S, W>, RenderReceiver<S, W>) {
708    let channel = Arc::new(Channel {
709        inbox: Mutex::new(Inbox {
710            latest: None,
711            dropped: 0,
712            commands: Vec::new(),
713            #[cfg(feature = "perf-trace")]
714            pending_markers: MarkerQueue::new(),
715            sender_alive: true,
716            receiver_alive: true,
717        }),
718        signal: Condvar::new(),
719    });
720    (
721        RenderSender {
722            channel: channel.clone(),
723        },
724        RenderReceiver { channel },
725    )
726}
727
728impl<S, W> RenderSender<S, W> {
729    /// Hand a finished frame to the render thread (**depth-1 latest-wins**): if
730    /// an un-taken scene is still in the slot it is replaced (the drop counter
731    /// still increments — see [`Inbox::dropped`]) and **returned** to the
732    /// caller instead of being silently dropped in the lock — a shell can
733    /// reclaim the stale frame's scene buffer the same way
734    /// it reclaims one off [`scene_return_channel`]. `None` if the slot was
735    /// empty. A pure widening of the original fire-and-forget signature — a
736    /// caller that doesn't care may still ignore the return value. Wakes the
737    /// render thread's [`RenderReceiver::wait_next`].
738    ///
739    /// Also picks up any benchmark scenario markers **this** thread raised
740    /// since the last send ([`Inbox::pending_markers`]), so they cross with
741    /// this handoff instead of being stamped with a frame number guessed on
742    /// this side of the split (`perf-trace` builds only; see the module
743    /// docs' scenario-marker section).
744    pub fn send_scene(&self, frame: SceneFrame<S>) -> Option<SceneFrame<S>> {
745        let mut inbox = self.channel.inbox.lock().unwrap();
746        if !inbox.receiver_alive {
747            // The render thread is gone (see `Inbox::receiver_alive`): don't
748            // queue `frame` into a slot no one will ever take — hand it straight
749            // back so the caller can still reclaim its buffer. `frame` carries no
750            // `Ack`, so nothing else needs firing either way.
751            //
752            // The markers raised for it are drained and discarded rather than
753            // left queued: no frame will ever be recorded for them now, and a
754            // queue this send stopped draining is a queue that grows for the
755            // rest of the process.
756            #[cfg(feature = "perf-trace")]
757            drop(crate::perf::take_pending_markers());
758            return Some(frame);
759        }
760        // Before the slot is written: every marker this thread raised up to
761        // this moment belongs to the build being handed off now (or to an
762        // earlier one whose scene this send replaces — same frame, once it is
763        // recorded). Moving them out of the calling thread's queue is what
764        // makes them this handoff's, so `FrameStats::record` over on the
765        // render thread can never see a marker that is still being built here.
766        #[cfg(feature = "perf-trace")]
767        inbox
768            .pending_markers
769            .absorb(crate::perf::take_pending_markers());
770        let stale = inbox.latest.replace(frame);
771        if stale.is_some() {
772            inbox.dropped += 1;
773        }
774        drop(inbox);
775        self.channel.signal.notify_one();
776        stale
777    }
778
779    /// Queue a lifecycle command (FIFO) and wake the render thread. For the
780    /// ack-carrying [`RenderCommand::Pause`]/[`RenderCommand::SurfaceDestroyed`]
781    /// prefer [`Self::pause`]/[`Self::destroy_surface`], which build the barrier
782    /// pair and return the [`AckWaiter`] to block on.
783    pub fn send_command(&self, command: RenderCommand<W>) {
784        let mut inbox = self.channel.inbox.lock().unwrap();
785        if !inbox.receiver_alive {
786            // The render thread is gone (see `Inbox::receiver_alive`): drop the
787            // command rather than queue it forever. Releasing the inbox lock first,
788            // then dropping `command`, fires any embedded `Ack`'s `Drop` safety net
789            // (Pause/SurfaceDestroyed), so a UI thread blocked on the paired
790            // `AckWaiter` unblocks instead of deadlocking.
791            drop(inbox);
792            drop(command);
793            return;
794        }
795        inbox.commands.push(command);
796        drop(inbox);
797        self.channel.signal.notify_one();
798    }
799
800    /// Send a [`RenderCommand::Pause`] and return the [`AckWaiter`] the UI
801    /// thread must [`wait`](AckWaiter::wait) on **before letting the app
802    /// background** — the iOS process-kill barrier.
803    #[must_use = "the caller must wait() on the returned AckWaiter before backgrounding"]
804    pub fn pause(&self) -> AckWaiter {
805        let (waiter, ack) = ack_pair();
806        self.send_command(RenderCommand::Pause { ack });
807        waiter
808    }
809
810    /// Send a [`RenderCommand::SurfaceDestroyed`] and return the [`AckWaiter`]
811    /// the UI thread must [`wait`](AckWaiter::wait) on **before releasing the
812    /// window** — the Android `ANativeWindow`-release barrier.
813    #[must_use = "the caller must wait() on the returned AckWaiter before releasing the window"]
814    pub fn destroy_surface(&self) -> AckWaiter {
815        let (waiter, ack) = ack_pair();
816        self.send_command(RenderCommand::SurfaceDestroyed { ack });
817        waiter
818    }
819}
820
821impl<S, W> Drop for RenderSender<S, W> {
822    fn drop(&mut self) {
823        let mut inbox = self.channel.inbox.lock().unwrap();
824        inbox.sender_alive = false;
825        drop(inbox);
826        // notify_all: a receiver blocked in wait_next must wake to observe the
827        // disconnection and exit its loop.
828        self.channel.signal.notify_all();
829    }
830}
831
832impl<S, W> Drop for RenderReceiver<S, W> {
833    fn drop(&mut self) {
834        // The render thread is exiting (panic-unwind, a clean early `return`, or a
835        // hung thread being torn down). Symmetric with `Drop for RenderSender`:
836        // mark the receiver gone and drain any undrained work so an ack-carrying
837        // command the render loop never reached (a `Pause`/`SurfaceDestroyed`
838        // still in `commands`, or embedded in `latest` — the latter carries none
839        // today, drained for completeness) fires its `Ack`'s `Drop` safety net.
840        // Without this, that command would sit in the inbox forever (kept alive by
841        // the `Arc<Channel>` the still-blocked UI side holds), the safety net would
842        // never fire, and `AckWaiter::wait()` would deadlock the UI/main thread —
843        // the load-bearing correctness fix this drop impl provides.
844        let mut inbox = self.channel.inbox.lock().unwrap();
845        inbox.receiver_alive = false;
846        let commands = std::mem::take(&mut inbox.commands);
847        let latest = inbox.latest.take();
848        // No thread will ever record a frame for these, so they are dropped
849        // rather than staged — a marker with no frame to name is nothing.
850        #[cfg(feature = "perf-trace")]
851        inbox.pending_markers.clear();
852        drop(inbox);
853        // Drop the drained work *after* releasing the inbox lock — dropping an
854        // `Ack` locks its own (separate) mutex to signal, so ordering here avoids
855        // holding the inbox lock across that notify.
856        drop(commands);
857        drop(latest);
858        // notify_all for symmetry with the sender's drop (no thread blocks on the
859        // channel condvar once the receiver is gone, but a stray waiter must never
860        // be left parked).
861        self.channel.signal.notify_all();
862    }
863}
864
865impl<S, W> RenderReceiver<S, W> {
866    /// Block until there is work — a pending command, a fresh scene, or a
867    /// [`RenderSender`] disconnection — then drain it into one [`RenderBatch`].
868    /// The render thread's single wait point.
869    pub fn wait_next(&self) -> RenderBatch<S, W> {
870        let mut inbox = self.channel.inbox.lock().unwrap();
871        inbox = self
872            .channel
873            .signal
874            .wait_while(inbox, |i| {
875                i.commands.is_empty() && i.latest.is_none() && i.sender_alive
876            })
877            .unwrap();
878        drain(&mut inbox)
879    }
880
881    /// Drain any pending commands + the freshest scene without blocking — for a
882    /// render loop that also polls its own timers/vsync. Returns an empty,
883    /// non-disconnected batch when nothing is pending and the sender is alive.
884    pub fn try_next(&self) -> RenderBatch<S, W> {
885        let mut inbox = self.channel.inbox.lock().unwrap();
886        drain(&mut inbox)
887    }
888
889    /// The running count of scenes dropped by the latest-wins slot (replaced
890    /// before the render thread took them) — a diagnostic for how far the
891    /// render thread is falling behind the UI thread.
892    pub fn dropped_frames(&self) -> u64 {
893        self.channel.inbox.lock().unwrap().dropped
894    }
895}
896
897/// Drain the inbox into a [`RenderBatch`]: all pending commands (FIFO), the
898/// freshest scene (latest-wins), and the disconnection flag. Shared by
899/// [`RenderReceiver::wait_next`]/[`RenderReceiver::try_next`].
900///
901/// When a scene comes out, the markers queued alongside it
902/// ([`Inbox::pending_markers`]) are staged on the **calling** thread — the
903/// render thread, which is about to render this scene and record the frame
904/// that [`crate::perf::FrameStats::record`] will stamp them with. Staging
905/// them on the caller is what keeps the attribution a thread property: the
906/// only thread that can emit them is the one that took them. They are
907/// staged rather than returned on the batch so [`RenderBatch`] keeps the
908/// exact shape the shells destructure. When no scene comes out they stay
909/// queued for the next one.
910fn drain<S, W>(inbox: &mut Inbox<S, W>) -> RenderBatch<S, W> {
911    #[cfg(feature = "perf-trace")]
912    if inbox.latest.is_some() && !inbox.pending_markers.is_empty() {
913        crate::perf::stage_markers(inbox.pending_markers.take());
914    }
915    RenderBatch {
916        commands: std::mem::take(&mut inbox.commands),
917        scene: inbox.latest.take(),
918        disconnected: !inbox.sender_alive,
919    }
920}
921
922// ---------------------------------------------------------------------
923// Scene give-back: a non-blocking depth-1 return slot
924// ---------------------------------------------------------------------
925
926/// The render-thread handle to [`scene_return_channel`]: pushes a drained
927/// scene back for the UI thread to reclaim (`Scene::reset` + reuse) instead
928/// of a shell allocating a fresh one every frame — closing the buffer-reuse
929/// gap a scene crossing [`render_channel`] would otherwise leave (a scene
930/// with no way back, so every split `submit_frame` replaced it with
931/// `Scene::new()`).
932#[derive(Debug)]
933pub struct SceneReturnSender<S> {
934    slot: Arc<Mutex<Option<S>>>,
935}
936
937/// The UI-thread handle to [`scene_return_channel`]: polls (never blocks)
938/// for a scene the render thread has finished with.
939#[derive(Debug)]
940pub struct SceneReturnReceiver<S> {
941    slot: Arc<Mutex<Option<S>>>,
942}
943
944/// Create the render→UI scene give-back channel: a
945/// non-blocking, depth-1 return slot — the reverse-direction, pull-based
946/// counterpart to [`render_channel`]'s UI→render handoff. `Mutex<Option<S>>`
947/// only, no [`Condvar`] and no new dependency: nothing should ever park
948/// waiting on this slot, so there is no wait point to back — preserving this
949/// module's no-`unsafe`, no-new-deps, generic-over-`S` charter (see the
950/// module docs' Layering choice).
951pub fn scene_return_channel<S>() -> (SceneReturnSender<S>, SceneReturnReceiver<S>) {
952    let slot = Arc::new(Mutex::new(None));
953    (
954        SceneReturnSender { slot: slot.clone() },
955        SceneReturnReceiver { slot },
956    )
957}
958
959impl<S> SceneReturnSender<S> {
960    /// Push a drained scene back for the UI thread to reclaim. Depth-1
961    /// latest-wins, mirroring [`RenderSender::send_scene`]: an unpolled scene
962    /// already in the slot is replaced (dropped) rather than queued — the UI
963    /// thread only ever needs one spare, and an unbounded backlog here would
964    /// just be a leak-shaped wait for a UI thread that has stopped polling.
965    ///
966    /// The render thread must call this for **every** [`SceneFrame`] it takes
967    /// off a [`RenderReceiver`] — whether the scene is actually rendered or
968    /// the frame is phase-gated out ([`RenderPhase::Paused`]/[`RenderPhase::NoSurface`]
969    /// after a `Pause`/`SurfaceDestroyed`) — so a scene is never silently
970    /// dropped instead of given back.
971    pub fn give_back(&self, scene: S) {
972        let mut slot = self.slot.lock().unwrap();
973        *slot = Some(scene);
974    }
975}
976
977impl<S> SceneReturnReceiver<S> {
978    /// Non-blocking poll for a returned scene — `None` if the render thread
979    /// hasn't given one back yet (cold start, or it is still busy on the
980    /// current frame). Never blocks: a caller that finds nothing falls back
981    /// to allocating a fresh scene (`Scene::new()`).
982    pub fn try_recv(&self) -> Option<S> {
983        self.slot.lock().unwrap().take()
984    }
985}
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990    use std::sync::atomic::{AtomicBool, Ordering};
991    #[cfg(feature = "perf-trace")]
992    use std::sync::mpsc;
993    use std::thread;
994
995    /// Serialises the tests touching the process-wide ledger slot (the test
996    /// harness runs tests in parallel).
997    static LEDGER_SLOT_TESTS: Mutex<()> = Mutex::new(());
998
999    #[test]
1000    fn retained_ledger_slot_publishes_and_never_blocks() {
1001        let _serial = LEDGER_SLOT_TESTS
1002            .lock()
1003            .unwrap_or_else(std::sync::PoisonError::into_inner);
1004        publish_retained_ledger(RetainedLedger::from_entries(5, [(9, (4, 4), 1.0)]));
1005        let got = latest_retained_ledger().expect("published");
1006        assert_eq!(got.frame, 5);
1007        assert!(got.is_resident(frust_core::RetainedKey(9), (4, 4), 1.0));
1008        // A held slot (render side mid-publish) yields None rather than waiting.
1009        let guard = PUBLISHED_RETAINED_LEDGER.lock().unwrap();
1010        assert!(latest_retained_ledger().is_none());
1011        drop(guard);
1012        publish_retained_ledger(RetainedLedger::empty());
1013        assert!(latest_retained_ledger().unwrap().entries.is_empty());
1014    }
1015
1016    #[test]
1017    fn a_destroy_time_publish_leaves_the_empty_ledger_for_the_next_paint() {
1018        let _serial = LEDGER_SLOT_TESTS
1019            .lock()
1020            .unwrap_or_else(std::sync::PoisonError::into_inner);
1021        // A live surface's ledger, then the surface goes away.
1022        publish_retained_ledger(RetainedLedger::from_entries(7, [(3, (8, 8), 2.0)]));
1023        publish_empty_retained_ledger();
1024        let got = latest_retained_ledger().expect("the destroy published");
1025        assert!(got.entries.is_empty(), "the next paint names no page");
1026        assert!(!got.is_resident(frust_core::RetainedKey(3), (8, 8), 2.0));
1027    }
1028
1029    #[test]
1030    fn a_retained_repaint_is_taken_once_per_request() {
1031        // The only test touching the process-wide repaint flag.
1032        assert!(!take_retained_repaint(), "nothing owed at rest");
1033        request_retained_repaint();
1034        assert!(take_retained_repaint(), "a refusal owes one paint");
1035        assert!(!take_retained_repaint(), "and only one");
1036        // Requests that land before a take coalesce into one owed paint.
1037        request_retained_repaint();
1038        request_retained_repaint();
1039        assert!(take_retained_repaint());
1040        assert!(!take_retained_repaint());
1041    }
1042    use std::time::{Duration, Instant};
1043
1044    // -----------------------------------------------------------------
1045    // Kill switch (pure — the env-reading wrapper's cache-free counterpart,
1046    // mirroring frame_gate's kill_switch tests)
1047    // -----------------------------------------------------------------
1048
1049    #[test]
1050    fn render_thread_kill_switch_off_when_neither_set() {
1051        assert!(!render_thread_kill_switch(None, None));
1052    }
1053
1054    #[test]
1055    fn render_thread_kill_switch_on_when_compile_time_set_non_zero() {
1056        assert!(render_thread_kill_switch(Some("1"), None));
1057    }
1058
1059    #[test]
1060    fn render_thread_kill_switch_on_when_runtime_set_non_zero() {
1061        assert!(render_thread_kill_switch(None, Some("1")));
1062    }
1063
1064    #[test]
1065    fn render_thread_kill_switch_off_when_either_is_literal_zero_and_other_unset() {
1066        assert!(!render_thread_kill_switch(Some("0"), None));
1067        assert!(!render_thread_kill_switch(None, Some("0")));
1068    }
1069
1070    #[test]
1071    fn thread_tuning_switch_names_the_variable_the_reactive_pool_reads() {
1072        assert_eq!(
1073            NO_THREAD_TUNING_VAR,
1074            frust_reactive::thread_policy::NO_THREAD_TUNING_VAR,
1075            "the render thread and the background pool must answer to one switch"
1076        );
1077    }
1078
1079    #[test]
1080    fn render_thread_kill_switch_on_when_either_source_wins() {
1081        assert!(render_thread_kill_switch(Some("0"), Some("1")));
1082        assert!(render_thread_kill_switch(Some("1"), Some("0")));
1083    }
1084
1085    #[test]
1086    fn render_thread_enabled_default_process_env_is_on() {
1087        // A normal test run sets neither the compile-time define nor the runtime
1088        // env, so the split is enabled by default (the kill switch is the
1089        // opt-out, mirroring frame_gate). This is the "kill-switch off path".
1090        assert!(render_thread_enabled());
1091    }
1092
1093    // -----------------------------------------------------------------
1094    // RenderPhase transition table (modelled on frust-render's SurfacePhase)
1095    // -----------------------------------------------------------------
1096
1097    const ALL_PHASES: [RenderPhase; 3] = [
1098        RenderPhase::NoSurface,
1099        RenderPhase::Active,
1100        RenderPhase::Paused,
1101    ];
1102
1103    #[test]
1104    fn created_always_lands_in_active() {
1105        for phase in ALL_PHASES {
1106            assert_eq!(
1107                next_render_phase(phase, RenderEvent::SurfaceCreated),
1108                RenderPhase::Active,
1109                "creating (or recreating) a surface must reach Active from {phase:?}"
1110            );
1111        }
1112    }
1113
1114    #[test]
1115    fn destroyed_always_lands_in_no_surface() {
1116        for phase in ALL_PHASES {
1117            assert_eq!(
1118                next_render_phase(phase, RenderEvent::SurfaceDestroyed),
1119                RenderPhase::NoSurface,
1120                "destroying a surface must reach NoSurface from {phase:?}"
1121            );
1122        }
1123    }
1124
1125    #[test]
1126    fn changed_keeps_the_current_phase() {
1127        for phase in ALL_PHASES {
1128            assert_eq!(
1129                next_render_phase(phase, RenderEvent::SurfaceChanged),
1130                phase,
1131                "a resize must not change whether we can render, from {phase:?}"
1132            );
1133        }
1134    }
1135
1136    #[test]
1137    fn pause_barriers_from_active_but_not_from_no_surface() {
1138        assert_eq!(
1139            next_render_phase(RenderPhase::Active, RenderEvent::Pause),
1140            RenderPhase::Paused
1141        );
1142        assert_eq!(
1143            next_render_phase(RenderPhase::Paused, RenderEvent::Pause),
1144            RenderPhase::Paused
1145        );
1146        // You cannot pause what was never created.
1147        assert_eq!(
1148            next_render_phase(RenderPhase::NoSurface, RenderEvent::Pause),
1149            RenderPhase::NoSurface
1150        );
1151    }
1152
1153    #[test]
1154    fn resume_reactivates_unless_the_surface_is_gone() {
1155        assert_eq!(
1156            next_render_phase(RenderPhase::Paused, RenderEvent::Resume),
1157            RenderPhase::Active
1158        );
1159        assert_eq!(
1160            next_render_phase(RenderPhase::Active, RenderEvent::Resume),
1161            RenderPhase::Active
1162        );
1163        // Cannot resume onto a surface that is gone — must be recreated first.
1164        assert_eq!(
1165            next_render_phase(RenderPhase::NoSurface, RenderEvent::Resume),
1166            RenderPhase::NoSurface
1167        );
1168    }
1169
1170    #[test]
1171    fn only_active_can_render() {
1172        assert!(RenderPhase::Active.can_render());
1173        assert!(!RenderPhase::NoSurface.can_render());
1174        assert!(!RenderPhase::Paused.can_render());
1175    }
1176
1177    // -----------------------------------------------------------------
1178    // RenderCommand event/ack projections
1179    // -----------------------------------------------------------------
1180
1181    #[test]
1182    fn command_event_projection_matches_each_variant() {
1183        // `()` for both the scene and window type params — a cheap host stand-in.
1184        let created: RenderCommand<()> = RenderCommand::SurfaceCreated {
1185            window: (),
1186            size: test_size(),
1187        };
1188        assert_eq!(created.event(), RenderEvent::SurfaceCreated);
1189        assert!(!created.requires_ack());
1190
1191        let changed: RenderCommand<()> = RenderCommand::SurfaceChanged { size: test_size() };
1192        assert_eq!(changed.event(), RenderEvent::SurfaceChanged);
1193        assert!(!changed.requires_ack());
1194
1195        let resume: RenderCommand<()> = RenderCommand::Resume;
1196        assert_eq!(resume.event(), RenderEvent::Resume);
1197        assert!(!resume.requires_ack());
1198
1199        let (_w, ack) = ack_pair();
1200        let paused: RenderCommand<()> = RenderCommand::Pause { ack };
1201        assert_eq!(paused.event(), RenderEvent::Pause);
1202        assert!(paused.requires_ack());
1203
1204        let (_w, ack) = ack_pair();
1205        let destroyed: RenderCommand<()> = RenderCommand::SurfaceDestroyed { ack };
1206        assert_eq!(destroyed.event(), RenderEvent::SurfaceDestroyed);
1207        assert!(destroyed.requires_ack());
1208    }
1209
1210    // -----------------------------------------------------------------
1211    // Scene handoff: depth-1 latest-wins semantics
1212    // -----------------------------------------------------------------
1213
1214    fn test_size() -> SurfaceSize {
1215        SurfaceSize {
1216            width: 1080,
1217            height: 1920,
1218            scale: 2.0,
1219        }
1220    }
1221
1222    /// A scene frame carrying a `u32` scene id — a cheap host stand-in for a
1223    /// real `frust_scene::Scene`.
1224    fn frame(id: u32) -> SceneFrame<u32> {
1225        SceneFrame {
1226            scene: id,
1227            meta: FrameMeta {
1228                frame_time: FrameTime::from_nanos(u64::from(id)),
1229                size: test_size(),
1230                frame_id: u64::from(id),
1231                paint_serial: u64::from(id),
1232            },
1233            ui_spans: UiSpans::default(),
1234        }
1235    }
1236
1237    #[test]
1238    fn latest_wins_replaces_an_untaken_scene() {
1239        let (tx, rx) = render_channel::<u32, ()>();
1240        tx.send_scene(frame(1));
1241        tx.send_scene(frame(2));
1242        tx.send_scene(frame(3));
1243
1244        // Only the freshest survives; the two older frames were dropped.
1245        let batch = rx.try_next();
1246        assert_eq!(batch.scene.expect("a scene is pending").scene, 3);
1247        assert_eq!(rx.dropped_frames(), 2, "two stale frames were replaced");
1248
1249        // The slot is now empty.
1250        let batch = rx.try_next();
1251        assert!(batch.scene.is_none(), "the slot is drained after a take");
1252    }
1253
1254    #[test]
1255    fn taking_between_sends_drops_nothing() {
1256        let (tx, rx) = render_channel::<u32, ()>();
1257        tx.send_scene(frame(1));
1258        assert_eq!(rx.try_next().scene.unwrap().scene, 1);
1259        tx.send_scene(frame(2));
1260        assert_eq!(rx.try_next().scene.unwrap().scene, 2);
1261        assert_eq!(
1262            rx.dropped_frames(),
1263            0,
1264            "each scene was taken before the next"
1265        );
1266    }
1267
1268    #[test]
1269    fn send_scene_returns_none_when_the_slot_was_empty() {
1270        let (tx, _rx) = render_channel::<u32, ()>();
1271        assert!(
1272            tx.send_scene(frame(1)).is_none(),
1273            "the first send has nothing stale to hand back"
1274        );
1275    }
1276
1277    #[test]
1278    fn send_scene_returns_the_overwritten_stale_frame() {
1279        // An untaken scene replaced by a newer send must be
1280        // handed back to the caller (to reclaim its buffer), not silently
1281        // dropped in the lock.
1282        let (tx, rx) = render_channel::<u32, ()>();
1283        assert!(tx.send_scene(frame(1)).is_none());
1284        let stale = tx
1285            .send_scene(frame(2))
1286            .expect("the untaken frame(1) must be returned");
1287        assert_eq!(stale.scene, 1, "the returned frame is the one replaced");
1288
1289        // Latest-wins semantics are unchanged by the give-back: the freshest
1290        // scene is still what the render thread takes, and the drop counter
1291        // still increments exactly as before.
1292        let batch = rx.try_next();
1293        assert_eq!(batch.scene.expect("a scene is pending").scene, 2);
1294        assert_eq!(rx.dropped_frames(), 1);
1295    }
1296
1297    #[test]
1298    fn send_scene_after_receiver_death_hands_the_frame_back() {
1299        // The dead-receiver path (see `Inbox::receiver_alive`) must not queue
1300        // the frame, but should still let the caller reclaim its buffer rather
1301        // than drop it on the floor.
1302        let (tx, rx) = render_channel::<u32, ()>();
1303        drop(rx);
1304        let returned = tx
1305            .send_scene(frame(1))
1306            .expect("a scene sent after receiver death must still be handed back");
1307        assert_eq!(returned.scene, 1);
1308    }
1309
1310    // -----------------------------------------------------------------
1311    // Scene give-back channel: non-blocking depth-1
1312    // return slot, render thread -> UI thread.
1313    // -----------------------------------------------------------------
1314
1315    #[test]
1316    fn scene_return_try_recv_is_none_before_any_give_back() {
1317        let (_tx, rx) = scene_return_channel::<u32>();
1318        assert!(rx.try_recv().is_none());
1319    }
1320
1321    #[test]
1322    fn scene_return_round_trips_a_single_scene() {
1323        let (tx, rx) = scene_return_channel::<u32>();
1324        tx.give_back(7);
1325        assert_eq!(rx.try_recv(), Some(7), "the given-back scene is polled out");
1326        assert!(
1327            rx.try_recv().is_none(),
1328            "the slot is drained after a take, like the forward channel"
1329        );
1330    }
1331
1332    #[test]
1333    fn scene_return_is_depth_1_latest_wins() {
1334        // Mirrors `render_channel`'s forward-slot semantics: a second give-back
1335        // before the UI thread polls replaces (not queues) the first.
1336        let (tx, rx) = scene_return_channel::<u32>();
1337        tx.give_back(1);
1338        tx.give_back(2);
1339        assert_eq!(
1340            rx.try_recv(),
1341            Some(2),
1342            "only the most recently given-back scene survives"
1343        );
1344    }
1345
1346    #[test]
1347    fn scene_return_never_blocks_the_ui_thread() {
1348        // The whole point of the non-blocking design: polling an empty slot
1349        // returns immediately rather than parking, even with no render-thread
1350        // counterpart ever constructed to give one back.
1351        let (_tx, rx) = scene_return_channel::<u32>();
1352        let start = Instant::now();
1353        assert!(rx.try_recv().is_none());
1354        assert!(
1355            start.elapsed() < Duration::from_millis(50),
1356            "try_recv must return immediately, never park"
1357        );
1358    }
1359
1360    #[test]
1361    fn wait_next_blocks_until_a_scene_arrives() {
1362        let (tx, rx) = render_channel::<u32, ()>();
1363        let handle = thread::spawn(move || rx.wait_next().scene.map(|f| f.scene));
1364        // Give the render thread a moment to park in wait_next, then hand it a
1365        // scene; the join proves it woke and took it.
1366        thread::sleep(Duration::from_millis(20));
1367        tx.send_scene(frame(7));
1368        assert_eq!(handle.join().unwrap(), Some(7));
1369    }
1370
1371    // -----------------------------------------------------------------
1372    // Command ordering + disconnection
1373    // -----------------------------------------------------------------
1374
1375    #[test]
1376    fn commands_drain_in_fifo_order_with_the_latest_scene() {
1377        let (tx, rx) = render_channel::<u32, ()>();
1378        tx.send_command(RenderCommand::SurfaceCreated {
1379            window: (),
1380            size: test_size(),
1381        });
1382        tx.send_scene(frame(1));
1383        tx.send_command(RenderCommand::SurfaceChanged { size: test_size() });
1384        tx.send_scene(frame(2)); // replaces frame(1)
1385
1386        let batch = rx.try_next();
1387        let events: Vec<RenderEvent> = batch.commands.iter().map(RenderCommand::event).collect();
1388        assert_eq!(
1389            events,
1390            vec![RenderEvent::SurfaceCreated, RenderEvent::SurfaceChanged],
1391            "commands preserve FIFO order"
1392        );
1393        assert_eq!(
1394            batch.scene.unwrap().scene,
1395            2,
1396            "only the freshest scene rides along"
1397        );
1398        assert!(!batch.disconnected);
1399    }
1400
1401    #[test]
1402    fn wait_next_wakes_and_reports_disconnection_when_sender_dropped() {
1403        let (tx, rx) = render_channel::<u32, ()>();
1404        let handle = thread::spawn(move || rx.wait_next().disconnected);
1405        thread::sleep(Duration::from_millis(20));
1406        drop(tx); // must wake the parked receiver
1407        assert!(
1408            handle.join().unwrap(),
1409            "dropping the sender wakes wait_next with a disconnection"
1410        );
1411    }
1412
1413    #[test]
1414    fn pending_work_drains_before_disconnection_is_reported() {
1415        let (tx, rx) = render_channel::<u32, ()>();
1416        tx.send_scene(frame(5));
1417        drop(tx);
1418        // The buffered scene is still delivered; disconnected is also set so the
1419        // loop exits after handling it.
1420        let batch = rx.try_next();
1421        assert_eq!(batch.scene.unwrap().scene, 5);
1422        assert!(batch.disconnected);
1423    }
1424
1425    // -----------------------------------------------------------------
1426    // Acknowledgment barrier (pause / destroy)
1427    // -----------------------------------------------------------------
1428
1429    #[test]
1430    fn ack_unblocks_the_waiter_on_acknowledge() {
1431        let (waiter, ack) = ack_pair();
1432        assert!(!waiter.completed());
1433        ack.acknowledge();
1434        assert!(waiter.completed());
1435        waiter.wait(); // returns immediately
1436    }
1437
1438    #[test]
1439    fn ack_unblocks_the_waiter_on_drop_as_a_safety_net() {
1440        let (waiter, ack) = ack_pair();
1441        drop(ack); // a render thread that returns early must not deadlock the UI
1442        assert!(waiter.completed());
1443    }
1444
1445    #[test]
1446    fn pause_barrier_blocks_the_ui_thread_until_the_render_thread_quiesces() {
1447        // The plan's iOS backgrounding contract: the UI thread must not proceed
1448        // (let the app background) until the render thread has honored Pause.
1449        let (tx, rx) = render_channel::<u32, ()>();
1450        let quiesced = Arc::new(AtomicBool::new(false));
1451        let quiesced_render = quiesced.clone();
1452
1453        let render = thread::spawn(move || {
1454            loop {
1455                let batch = rx.wait_next();
1456                for command in batch.commands {
1457                    if let RenderCommand::Pause { ack } = command {
1458                        // Simulate stopping submission *before* acknowledging.
1459                        thread::sleep(Duration::from_millis(30));
1460                        quiesced_render.store(true, Ordering::SeqCst);
1461                        ack.acknowledge();
1462                        return;
1463                    }
1464                }
1465                if batch.disconnected {
1466                    return;
1467                }
1468            }
1469        });
1470
1471        let waiter = tx.pause();
1472        waiter.wait();
1473        // The barrier guarantees the render thread quiesced before wait returned.
1474        assert!(
1475            quiesced.load(Ordering::SeqCst),
1476            "the render thread must have quiesced before the UI thread proceeded"
1477        );
1478        render.join().unwrap();
1479    }
1480
1481    #[test]
1482    fn destroy_surface_barrier_orders_resource_teardown_before_window_release() {
1483        // The plan's Android ANativeWindow-release contract: the UI thread must
1484        // not release the window until the render thread has dropped its
1485        // surface resources.
1486        let (tx, rx) = render_channel::<u32, ()>();
1487        let resources_dropped = Arc::new(AtomicBool::new(false));
1488        let resources_dropped_render = resources_dropped.clone();
1489
1490        let render = thread::spawn(move || {
1491            loop {
1492                let batch = rx.wait_next();
1493                for command in batch.commands {
1494                    if let RenderCommand::SurfaceDestroyed { ack } = command {
1495                        thread::sleep(Duration::from_millis(30));
1496                        resources_dropped_render.store(true, Ordering::SeqCst);
1497                        ack.acknowledge();
1498                        return;
1499                    }
1500                }
1501                if batch.disconnected {
1502                    return;
1503                }
1504            }
1505        });
1506
1507        let waiter = tx.destroy_surface();
1508        waiter.wait();
1509        assert!(
1510            resources_dropped.load(Ordering::SeqCst),
1511            "surface resources must be dropped before the UI thread releases the window"
1512        );
1513        render.join().unwrap();
1514    }
1515
1516    #[test]
1517    fn a_leftover_scene_is_not_rendered_after_a_pause() {
1518        // The render loop gates on RenderPhase: a scene left in the slot when a
1519        // Pause is processed must be dropped, not submitted (the iOS
1520        // submit-after-background hazard). This exercises the phase-gating
1521        // composition the shells rely on.
1522        let (tx, rx) = render_channel::<u32, ()>();
1523        tx.send_scene(frame(1)); // a frame still in the slot...
1524        let _waiter = tx.pause(); // ...when a Pause arrives
1525
1526        let mut phase = RenderPhase::Active;
1527        let mut submitted: Option<u32> = None;
1528        let batch = rx.try_next();
1529        for command in batch.commands {
1530            phase = next_render_phase(phase, command.event());
1531            if let RenderCommand::Pause { ack } = command {
1532                ack.acknowledge();
1533            }
1534        }
1535        if phase.can_render() {
1536            submitted = batch.scene.map(|f| f.scene);
1537        }
1538        assert_eq!(phase, RenderPhase::Paused);
1539        assert!(
1540            submitted.is_none(),
1541            "a leftover scene must not be submitted after a Pause"
1542        );
1543    }
1544
1545    // -----------------------------------------------------------------
1546    // Receiver-liveness: a render thread that exits
1547    // before draining an ack-carrying command must never deadlock the UI
1548    // thread. Every assertion here is timeout-bounded so a *regression* FAILS
1549    // (the timeout expires, returning `false`) rather than hanging the suite.
1550    // -----------------------------------------------------------------
1551
1552    /// A deadline generous enough that the correct path (the ack fires the
1553    /// instant the receiver drops) always beats it, yet finite so a regression
1554    /// fails the test instead of wedging the whole `cargo test` run.
1555    const REGRESSION_DEADLINE: Duration = Duration::from_secs(5);
1556
1557    #[test]
1558    fn receiver_drop_drains_a_queued_orphaned_ack() {
1559        // The barrier command is queued while the receiver is still alive...
1560        let (tx, rx) = render_channel::<u32, ()>();
1561        let waiter = tx.pause();
1562        // ...then the render thread exits before draining it (drops its receiver).
1563        drop(rx);
1564        // The orphaned `Ack` must have fired via `RenderReceiver::drop`, so the
1565        // UI-side waiter unblocks rather than deadlocking.
1566        assert!(
1567            waiter.wait_timeout(REGRESSION_DEADLINE),
1568            "dropping the receiver must drain the queued Pause's Ack so the UI waiter unblocks"
1569        );
1570    }
1571
1572    #[test]
1573    fn command_sent_after_receiver_death_fires_its_ack() {
1574        // The receiver is already gone before the barrier command is sent: the
1575        // sender must drop (not queue) it, still firing the embedded Ack.
1576        let (tx, rx) = render_channel::<u32, ()>();
1577        drop(rx);
1578        let waiter = tx.destroy_surface();
1579        assert!(
1580            waiter.wait_timeout(REGRESSION_DEADLINE),
1581            "an ack-carrying command sent after receiver death must fire its Ack safety net"
1582        );
1583    }
1584
1585    #[test]
1586    fn receiver_death_mid_flight_unblocks_a_waiting_ui_thread() {
1587        // The closest reproduction of the live hazard: the UI thread is *already*
1588        // blocked on the barrier when the render thread dies. A background thread
1589        // sends the barrier and blocks on it (bounded); the main thread drops the
1590        // receiver a moment later, standing in for the render thread's exit.
1591        let (tx, rx) = render_channel::<u32, ()>();
1592        let (report_tx, report_rx) = std::sync::mpsc::channel();
1593        let ui = thread::spawn(move || {
1594            let honored = tx.destroy_surface().wait_timeout(REGRESSION_DEADLINE);
1595            report_tx.send(honored).unwrap();
1596        });
1597        thread::sleep(Duration::from_millis(20));
1598        drop(rx); // render thread exits without draining the command
1599
1600        // The UI thread must have unblocked; a regression would leave it parked
1601        // until its own wait_timeout expired `false`.
1602        let honored = report_rx
1603            .recv_timeout(Duration::from_secs(10))
1604            .expect("the UI thread must report back, not stay deadlocked");
1605        assert!(
1606            honored,
1607            "receiver drop must unblock a UI thread already waiting on the barrier"
1608        );
1609        ui.join().unwrap();
1610    }
1611
1612    #[test]
1613    fn a_scene_sent_after_receiver_death_is_dropped_not_queued() {
1614        // The scene half of the same contract: sending after the receiver is gone
1615        // must not stash a frame in a slot no one will take.
1616        let (tx, rx) = render_channel::<u32, ()>();
1617        drop(rx);
1618        tx.send_scene(frame(1)); // must be a no-op, not a panic or a leak
1619    }
1620
1621    // -----------------------------------------------------------------
1622    // Bounded barrier waits (wait_timeout)
1623    // -----------------------------------------------------------------
1624
1625    #[test]
1626    fn wait_timeout_returns_true_when_acknowledged() {
1627        let (waiter, ack) = ack_pair();
1628        ack.acknowledge();
1629        assert!(
1630            waiter.wait_timeout(REGRESSION_DEADLINE),
1631            "an acknowledged barrier must report honored"
1632        );
1633    }
1634
1635    #[test]
1636    fn wait_timeout_expires_false_when_never_acknowledged() {
1637        // Hold the `Ack` for the whole call so it can never fire: the wait must
1638        // expire and report the timeout (the degrade-not-hang signal).
1639        let (waiter, _ack) = ack_pair();
1640        assert!(
1641            !waiter.wait_timeout(Duration::from_millis(20)),
1642            "wait_timeout must return false when the ack never fires"
1643        );
1644    }
1645
1646    // ---------------------------------------------------------------
1647    // Scenario markers riding the handoff
1648    //
1649    // The channel is generic, so these drive `render_channel::<u32, ()>()`
1650    // with the same cheap stand-in payload every other test here uses — no
1651    // GPU, no platform, no real scene.
1652    //
1653    // Every test that asserts an attribution runs the UI leg on a REAL
1654    // second thread: the queues are per-thread now, so a single-threaded
1655    // simulation could not tell the split apart from the inline executor —
1656    // it would drain on the raising thread and pass no matter what the
1657    // channel did. Each thread that raises markers takes `perf`'s marker
1658    // test guard of its own (the force switch is per-thread too), and the
1659    // two threads step through a fixed interleaving with `lockstep_pair`
1660    // rather than racing.
1661    // ---------------------------------------------------------------
1662
1663    /// A `FrameStats` in the shape a benchmark process runs: enabled, raw
1664    /// export on, small ring.
1665    #[cfg(feature = "perf-trace")]
1666    fn bench_stats() -> crate::perf::FrameStats {
1667        crate::perf::FrameStats::with_capacity_enabled_and_raw(8, true, true)
1668    }
1669
1670    /// One recorded frame's worth of pass durations — the values are
1671    /// irrelevant here, only the recording is.
1672    #[cfg(feature = "perf-trace")]
1673    fn record_one(stats: &mut crate::perf::FrameStats) {
1674        stats.record(crate::perf::FramePasses::from_split(
1675            UiSpans::default(),
1676            crate::perf::RenderSpans::default(),
1677        ));
1678    }
1679
1680    /// One end of the two-thread choreography these tests step through:
1681    /// [`Self::signal`] releases the peer's next [`Self::wait`].
1682    #[cfg(feature = "perf-trace")]
1683    struct Lockstep {
1684        to_peer: mpsc::Sender<()>,
1685        from_peer: mpsc::Receiver<()>,
1686    }
1687
1688    #[cfg(feature = "perf-trace")]
1689    impl Lockstep {
1690        /// Let the peer proceed to its next step.
1691        fn signal(&self) {
1692            self.to_peer
1693                .send(())
1694                .expect("the peer thread must still be running");
1695        }
1696
1697        /// Block until the peer reaches its matching `signal`. Deadlined so a
1698        /// broken interleaving fails this one test instead of hanging the
1699        /// whole run.
1700        fn wait(&self) {
1701            self.from_peer
1702                .recv_timeout(Duration::from_secs(5))
1703                .expect("the peer thread must reach its next step");
1704        }
1705    }
1706
1707    /// The two ends of one choreography — hand one to each thread.
1708    #[cfg(feature = "perf-trace")]
1709    fn lockstep_pair() -> (Lockstep, Lockstep) {
1710        let (ui_tx, ui_rx) = mpsc::channel();
1711        let (render_tx, render_rx) = mpsc::channel();
1712        (
1713            Lockstep {
1714                to_peer: ui_tx,
1715                from_peer: render_rx,
1716            },
1717            Lockstep {
1718                to_peer: render_tx,
1719                from_peer: ui_rx,
1720            },
1721        )
1722    }
1723
1724    #[cfg(feature = "perf-trace")]
1725    #[test]
1726    fn markers_land_on_the_frames_that_carried_them_across_the_split() {
1727        // The interleaving that motivated this whole route: the UI thread
1728        // raises BOTH edges of an S3 window before the render thread has
1729        // emitted a single line, so no frame number the UI side could have
1730        // guessed would have been right. `start` is raised in the build that
1731        // applies the op (frame 1), `end` in the build after it (frame 2) —
1732        // the harness window is the half-open [1, 2), i.e. exactly frame 1.
1733        let _guard = crate::perf::marker_test_guard(true);
1734        let (tx, rx) = render_channel::<u32, ()>();
1735        let mut stats = bench_stats();
1736        let (ui_step, render_step) = lockstep_pair();
1737
1738        thread::scope(|scope| {
1739            scope.spawn(move || {
1740                let _ui_guard = crate::perf::marker_test_guard(true);
1741                // Build k: open the window and hand its scene off.
1742                crate::perf::mark_scenario_start("s3-create1k");
1743                tx.send_scene(frame(1));
1744                ui_step.signal();
1745                // The render thread has taken build k but has not recorded it
1746                // yet — the window where the UI thread runs ahead.
1747                ui_step.wait();
1748                // Build k+1: close the window and hand its scene off, still
1749                // before any raw line exists.
1750                crate::perf::mark_scenario_end("s3-create1k");
1751                tx.send_scene(frame(2));
1752                ui_step.signal();
1753            });
1754
1755            render_step.wait();
1756            let batch1 = rx.try_next();
1757            assert!(batch1.scene.is_some(), "the render thread took build k");
1758            render_step.signal();
1759            render_step.wait();
1760
1761            // Now the render thread catches up: frame 1, then frame 2.
1762            record_one(&mut stats);
1763            let batch2 = rx.try_next();
1764            assert!(batch2.scene.is_some(), "the render thread took build k+1");
1765            record_one(&mut stats);
1766        });
1767
1768        assert_eq!(
1769            stats.marker_log(),
1770            [
1771                "bench-scenario-start n=1 s3-create1k",
1772                "bench-scenario-end n=2 s3-create1k",
1773            ],
1774            "each edge names the recorded frame that actually carried it"
1775        );
1776    }
1777
1778    #[cfg(feature = "perf-trace")]
1779    #[test]
1780    fn a_window_whose_scene_is_replaced_collapses_onto_the_first_recorded_frame() {
1781        // Latest-wins: build k's scene is superseded before the render thread
1782        // takes it, so build k never becomes a frame of its own. Both edges
1783        // ride the frame that did draw that build's result — frame 1 — and
1784        // the half-open window [1, 1) is empty, which is the honest answer:
1785        // the op's own frame was dropped.
1786        let _guard = crate::perf::marker_test_guard(true);
1787        let (tx, rx) = render_channel::<u32, ()>();
1788        let mut stats = bench_stats();
1789        let (ui_step, render_step) = lockstep_pair();
1790
1791        thread::scope(|scope| {
1792            scope.spawn(move || {
1793                let _ui_guard = crate::perf::marker_test_guard(true);
1794                crate::perf::mark_scenario_start("s3-update");
1795                tx.send_scene(frame(1));
1796                crate::perf::mark_scenario_end("s3-update");
1797                let stale = tx.send_scene(frame(2));
1798                assert!(stale.is_some(), "build k's scene was replaced, not taken");
1799                ui_step.signal();
1800            });
1801
1802            render_step.wait();
1803            let batch = rx.try_next();
1804            assert_eq!(batch.scene.map(|f| f.scene), Some(2));
1805            record_one(&mut stats);
1806        });
1807
1808        assert_eq!(
1809            stats.marker_log(),
1810            [
1811                "bench-scenario-start n=1 s3-update",
1812                "bench-scenario-end n=1 s3-update",
1813            ]
1814        );
1815        assert_eq!(rx.dropped_frames(), 1);
1816    }
1817
1818    #[cfg(feature = "perf-trace")]
1819    #[test]
1820    fn a_marker_raised_after_the_handoff_never_lands_on_the_frame_in_flight() {
1821        // The former "record must not steal the queue" property, now a
1822        // property of the threads themselves: the marker is raised on the UI
1823        // thread AFTER build k crossed the channel and BEFORE the render
1824        // thread records frame 1, so it is sitting in the UI thread's own
1825        // queue while frame 1 is emitted. The render thread cannot reach that
1826        // queue at all, so frame 1 carries nothing and the marker rides
1827        // frame 2 — no flag, no ordering rule, just ownership.
1828        let _guard = crate::perf::marker_test_guard(true);
1829        let (tx, rx) = render_channel::<u32, ()>();
1830        let mut stats = bench_stats();
1831        let (ui_step, render_step) = lockstep_pair();
1832
1833        thread::scope(|scope| {
1834            scope.spawn(move || {
1835                let _ui_guard = crate::perf::marker_test_guard(true);
1836                tx.send_scene(frame(1));
1837                ui_step.signal();
1838
1839                ui_step.wait();
1840                crate::perf::mark_scenario_start("s3-update");
1841                ui_step.signal();
1842
1843                ui_step.wait();
1844                tx.send_scene(frame(2));
1845                ui_step.signal();
1846            });
1847
1848            render_step.wait();
1849            assert!(rx.try_next().scene.is_some(), "build k crossed");
1850            render_step.signal();
1851
1852            render_step.wait();
1853            record_one(&mut stats);
1854            assert!(
1855                stats.marker_log().is_empty(),
1856                "a marker still queued on the UI thread cannot reach frame 1"
1857            );
1858            render_step.signal();
1859
1860            render_step.wait();
1861            assert!(rx.try_next().scene.is_some(), "build k+1 crossed");
1862            record_one(&mut stats);
1863        });
1864
1865        assert_eq!(stats.marker_log(), ["bench-scenario-start n=2 s3-update"]);
1866    }
1867
1868    #[cfg(feature = "perf-trace")]
1869    #[test]
1870    fn markers_wait_in_the_inbox_until_a_scene_is_actually_taken() {
1871        // A drain that yields no scene must leave the inbox queue alone:
1872        // there is no frame for those markers to name yet. (A command-only
1873        // wakeup is exactly this case.)
1874        let _guard = crate::perf::marker_test_guard(true);
1875        let (tx, rx) = render_channel::<u32, ()>();
1876        let mut stats = bench_stats();
1877        let (ui_step, render_step) = lockstep_pair();
1878
1879        thread::scope(|scope| {
1880            scope.spawn(move || {
1881                let _ui_guard = crate::perf::marker_test_guard(true);
1882                tx.send_scene(frame(1));
1883                ui_step.signal();
1884
1885                ui_step.wait();
1886                crate::perf::mark_scenario_start("s3-swap");
1887                tx.send_command(RenderCommand::SurfaceChanged { size: test_size() });
1888                ui_step.signal();
1889
1890                ui_step.wait();
1891                tx.send_scene(frame(2));
1892                ui_step.signal();
1893            });
1894
1895            render_step.wait();
1896            assert!(rx.try_next().scene.is_some());
1897            record_one(&mut stats);
1898            assert!(stats.marker_log().is_empty(), "frame 1 carried no marker");
1899            render_step.signal();
1900
1901            render_step.wait();
1902            let batch = rx.try_next();
1903            assert!(
1904                batch.scene.is_none(),
1905                "a command-only wakeup takes no scene"
1906            );
1907            record_one(&mut stats);
1908            assert!(
1909                stats.marker_log().is_empty(),
1910                "no scene was taken, so nothing may be attributed to frame 2"
1911            );
1912            render_step.signal();
1913
1914            // The next real handoff carries it, onto frame 3.
1915            render_step.wait();
1916            assert!(rx.try_next().scene.is_some());
1917            record_one(&mut stats);
1918        });
1919
1920        assert_eq!(stats.marker_log(), ["bench-scenario-start n=3 s3-swap"]);
1921    }
1922
1923    #[cfg(feature = "perf-trace")]
1924    #[test]
1925    fn a_frame_carrying_no_marker_emits_no_marker_line() {
1926        let _guard = crate::perf::marker_test_guard(true);
1927        let (tx, rx) = render_channel::<u32, ()>();
1928        let mut stats = bench_stats();
1929
1930        tx.send_scene(frame(1));
1931        assert!(rx.try_next().scene.is_some());
1932        record_one(&mut stats);
1933
1934        assert!(stats.marker_log().is_empty());
1935        assert_eq!(
1936            stats.total_frames(),
1937            1,
1938            "the frame itself is still recorded"
1939        );
1940    }
1941
1942    #[cfg(feature = "perf-trace")]
1943    #[test]
1944    fn receiver_death_discards_markers_no_frame_will_ever_name() {
1945        // Symmetric with the orphaned-ack drain in `Drop for RenderReceiver`:
1946        // the render thread is gone, so no frame will ever be recorded for
1947        // the markers riding its inbox. They are discarded there — and the
1948        // dead-receiver send drains the raising thread's own queue too, so a
1949        // marker raised post-mortem is neither attributed to a later channel
1950        // nor left accumulating on the UI thread forever.
1951        let _guard = crate::perf::marker_test_guard(true);
1952        let (tx, rx) = render_channel::<u32, ()>();
1953        let (next_tx, next_rx) = render_channel::<u32, ()>();
1954        let mut stats = bench_stats();
1955        let (ui_step, render_step) = lockstep_pair();
1956
1957        thread::scope(|scope| {
1958            scope.spawn(move || {
1959                let _ui_guard = crate::perf::marker_test_guard(true);
1960                crate::perf::mark_scenario_start("s3-clear");
1961                tx.send_scene(frame(1));
1962                assert!(
1963                    crate::perf::take_pending_markers().is_empty(),
1964                    "send_scene moved the marker out of this thread's queue"
1965                );
1966                ui_step.signal();
1967
1968                // The render thread has died; its inbox (marker included) is
1969                // gone with it.
1970                ui_step.wait();
1971                crate::perf::mark_scenario_end("s3-clear");
1972                assert!(
1973                    tx.send_scene(frame(2)).is_some(),
1974                    "a send to a dead receiver hands the frame straight back"
1975                );
1976                assert!(
1977                    crate::perf::take_pending_markers().is_empty(),
1978                    "the post-mortem marker was discarded, not left queued"
1979                );
1980
1981                // A later channel in the same process must inherit nothing.
1982                next_tx.send_scene(frame(3));
1983                ui_step.signal();
1984            });
1985
1986            render_step.wait();
1987            drop(rx);
1988            render_step.signal();
1989
1990            render_step.wait();
1991            assert!(next_rx.try_next().scene.is_some());
1992            record_one(&mut stats);
1993        });
1994
1995        assert!(
1996            stats.marker_log().is_empty(),
1997            "nothing leaked from the dead channel into the next one"
1998        );
1999    }
2000
2001    #[cfg(feature = "perf-trace")]
2002    #[test]
2003    fn a_marker_raised_on_a_thread_that_hands_no_frame_off_is_never_emitted() {
2004        // The documented best-effort limit, asserted rather than assumed: a
2005        // worker thread that neither hands a frame across the channel nor
2006        // records one (the `spawn_blocking` shape) raises into its own queue,
2007        // which dies with it. Attaching it to a frame some other thread
2008        // produced would be exactly the cross-thread guess this route exists
2009        // to remove.
2010        let _guard = crate::perf::marker_test_guard(true);
2011        let (tx, rx) = render_channel::<u32, ()>();
2012        let mut stats = bench_stats();
2013        let (ui_step, render_step) = lockstep_pair();
2014
2015        thread::scope(|scope| {
2016            scope.spawn(move || {
2017                let _ui_guard = crate::perf::marker_test_guard(true);
2018                // A third thread raises a window's opening edge and exits.
2019                thread::scope(|inner| {
2020                    inner.spawn(|| {
2021                        let _worker_guard = crate::perf::marker_test_guard(true);
2022                        crate::perf::mark_scenario_start("s4-parse");
2023                    });
2024                });
2025                // The frame-producing thread then hands off as usual.
2026                tx.send_scene(frame(1));
2027                ui_step.signal();
2028            });
2029
2030            render_step.wait();
2031            assert!(rx.try_next().scene.is_some());
2032            record_one(&mut stats);
2033        });
2034
2035        assert!(
2036            stats.marker_log().is_empty(),
2037            "the worker thread's marker belongs to no handed-off frame"
2038        );
2039    }
2040
2041    #[cfg(feature = "perf-trace")]
2042    #[test]
2043    fn an_undrained_inbox_stops_growing_at_the_queue_cap() {
2044        // The inbox leg's bound: a render thread that has stopped taking
2045        // scenes must not let the UI thread's markers pile up without limit.
2046        // Two handoffs' worth arrive with no drain between them, so the
2047        // second pushes the inbox past its cap — the OLDEST edges go, and the
2048        // count comes out on its own line beside the frame that finally
2049        // carried the survivors.
2050        let _guard = crate::perf::marker_test_guard(true);
2051        let (tx, rx) = render_channel::<u32, ()>();
2052        let mut stats = bench_stats();
2053        let (ui_step, render_step) = lockstep_pair();
2054
2055        let per_send = crate::perf::MARKER_QUEUE_CAP * 3 / 4;
2056        thread::scope(|scope| {
2057            scope.spawn(move || {
2058                let _ui_guard = crate::perf::marker_test_guard(true);
2059                for send in 0..2u32 {
2060                    for i in 0..per_send {
2061                        crate::perf::mark_scenario_start(&format!("s3-op{send}-{i}"));
2062                    }
2063                    tx.send_scene(frame(send + 1));
2064                }
2065                ui_step.signal();
2066            });
2067
2068            render_step.wait();
2069            assert!(rx.try_next().scene.is_some());
2070            record_one(&mut stats);
2071        });
2072
2073        let dropped = per_send * 2 - crate::perf::MARKER_QUEUE_CAP;
2074        let log = stats.marker_log();
2075        assert_eq!(
2076            log.len(),
2077            crate::perf::MARKER_QUEUE_CAP + 1,
2078            "a capped inbox's worth of markers, plus one overflow notice"
2079        );
2080        assert_eq!(
2081            log[0],
2082            format!("bench-scenario-start n=1 s3-op0-{dropped}"),
2083            "the oldest edges were the ones dropped"
2084        );
2085        assert_eq!(
2086            log[crate::perf::MARKER_QUEUE_CAP],
2087            format!("frust-perf marker-overflow n=1 dropped={dropped}")
2088        );
2089    }
2090}