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