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