From fc9da49c671bbd01d373fff2da920c0a3a5fa816 Mon Sep 17 00:00:00 2001 From: Alban Gicquel <6368237+albanmgicquel@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:16:46 -0400 Subject: [PATCH] tecs-winit-host: on macOS the window's frame is paced to the display and never waits for a drawable on the main thread (a window manager's click tap no longer holds clicks for half a second) The polled loop spent most of each frame in nextDrawable on the main thread, which serves a window manager's Accessibility requests (Magnet, BetterTouchTool) one per frame while their event tap holds the click: winit#1737. The frame now runs in RedrawRequested at a WaitUntil deadline one refresh apart, and a worker thread acquires each drawable, so a due frame without one is put off and re-checked (at most 100 ms) instead of waited for. Other platforms and the benchmark view keep polling. --- README.md | 54 ++++ docs/gfx/benchmarks.md | 4 + native/rust/winit-host/src/graphics.rs | 85 +++++- native/rust/winit-host/src/main.rs | 92 +++++- native/rust/winit-host/src/pacing.rs | 371 +++++++++++++++++++++++++ 5 files changed, 589 insertions(+), 17 deletions(-) create mode 100644 native/rust/winit-host/src/pacing.rs diff --git a/README.md b/README.md index 7ac7e39d..5f3b9595 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,60 @@ fixed. Physics history stores the creation pose before the first solver result, then snapshots before later fixed steps, keeping rendering between two real simulation poses without changing either. +### The windowed frame loop on macOS + +The window host used to poll: `ControlFlow::Poll`, with the whole frame in +`about_to_wait`. Under FIFO presentation that frame spends most of its time in +`get_current_texture`, which on Metal is `nextDrawable` waiting for the next +vsync, on the main thread. Measured on a game, the main thread was blocked +there for 90 percent of the run, and it showed up somewhere nobody looks: with +Magnet running, every click and the start of every title-bar drag waited about +half a second, less on a 120 Hz display, while the host's own logs showed +frames every 16.7 ms and input delivered within a millisecond. + +The delay sits in front of the process. Window managers such as Magnet and +BetterTouchTool (its snap areas) install an event tap that filters mouse-down +events, and before letting a click through they ask the clicked application +about its window over Accessibility. That request is a message on the main run +loop, which a polled loop serves one per turn, and every turn is a frame +blocked until vsync, so a few dozen requests cost a few dozen frames. The +press and release then reach the window together, a few milliseconds apart. +winit#1737 is the same report from other engines and apps, and Godot ships a +workaround that detects Magnet by its bundle identifier. + +The host now paces the windowed frame on macOS instead (`pacing` in the +winit host). The event loop sleeps in `WaitUntil` until a frame is due, one +monitor refresh after the last; the frame is requested with `request_redraw` +and runs in `RedrawRequested`; and a worker thread acquires each drawable as +soon as the previous frame is presented, so a due frame whose drawable is not +in hand yet is put off and checked again every millisecond rather than waited +for. Between frames the run loop is asleep in the kernel, where an +Accessibility request wakes it at once. Pacing alone was not enough: a timer +deadline does not stay in phase with vsync, so with every drawable queued the +acquire still blocked for the phase difference, up to a whole frame, and clicks +were still held for up to a hundred milliseconds. With the frame gated on a +free drawable, measured with a diagnostic build that counted presented +drawables, the share of the run spent blocked fell to nothing and the oldest +click on arrival to about 16 ms, the tap's own cost. + +The alternatives that lost: a `CADisplayLink` driving the frame paced well but +still blocked in acquire without the gate, and with it was no better than the +timer while adding a macOS 14 dependency; one frame of latency instead of two +halved the frame rate, because a single queued drawable is not released before +the next frame wants it; and counting presented drawables needs a handler on +the `CAMetalDrawable`, which wgpu does not expose, so it meant patching +wgpu-hal. Acquiring on a worker uses only wgpu's public API (`Surface` and +`SurfaceTexture` are `Send` and `Sync`) and answers exactly the question the +gate asks. One consequence to know: wgpu-hal's Metal acquire reads the hosting +`NSWindow`'s `occlusionState` to skip occluded windows, and that read now +happens on the worker. A reconfigure first waits for the acquisition in flight +and drops what it acquired, since a surface is not configured with a texture +outstanding. A frame put off for 100 ms runs anyway and waits for its drawable +as before, so a surface that stops answering cannot stop the game. Other +platforms keep the polled loop: nothing there has shown the failure, and the +render benchmark's interactive view keeps it too, since it measures uncapped +presentation. + ## Platform services this tree does not carry Three services the SDL implementation shipped are settled differently here, and diff --git a/docs/gfx/benchmarks.md b/docs/gfx/benchmarks.md index 6cc71d83..a3f993d2 100644 --- a/docs/gfx/benchmarks.md +++ b/docs/gfx/benchmarks.md @@ -26,6 +26,10 @@ supported. Otherwise they use nonblocking `Mailbox` presentation, which can render faster than the display. A surface supporting only synchronized FIFO presentation is rejected. The host prints the chosen mode. +The interactive view also keeps the host's polled frame loop on every platform. +An ordinary window on macOS is paced to the display instead, one frame per +refresh, so its frame rate is the display's. + The interactive frame rate includes window presentation and excludes initial scene setup. It counts submitted frames and uses elapsed wall time, including the cost of its statistics. It does not write a timing report. Use the fixed diff --git a/native/rust/winit-host/src/graphics.rs b/native/rust/winit-host/src/graphics.rs index 4e8b023f..1f949fb7 100644 --- a/native/rust/winit-host/src/graphics.rs +++ b/native/rust/winit-host/src/graphics.rs @@ -46,6 +46,7 @@ use crate::graph::{ parse_graph, ClearMode, DepthMode, Graph, Input, TargetAllocator, TargetFormat, TargetStore, MAX_OUTPUTS, }; +use crate::pacing::{Acquire, Acquirer}; use crate::packet::{ parse_frame, Batch, RetainedScene, CAST_FANOUT, INSTANCE_STRIDE, LANE_BLEND, LANE_COUNT, LANE_OPAQUE, LIGHT_STRIDE, MAX_LIGHTS, SAMPLER_COUNT, SCENE_FLOATS, @@ -478,7 +479,10 @@ pub struct Graphics { rendering_view: bool, asset_revision: u64, view_compositor: Option, - surface: Option>, + surface: Option>>, + /// Acquires the surface's drawables off the main thread, when the window's + /// frame loop asked for it. See `crate::pacing`. + acquirer: Option>, offscreen: Option, capture_requested: bool, captured: Option>, @@ -547,6 +551,16 @@ pub struct Graphics { failed: Arc>>, } +/// The window surface as the drawable source of an [`Acquirer`]. +struct SurfaceSource(Arc>); + +impl Acquire for SurfaceSource { + type Output = CurrentSurfaceTexture; + fn acquire(&self) -> CurrentSurfaceTexture { + self.0.get_current_texture() + } +} + fn benchmark_present_mode(supported: &[wgpu::PresentMode]) -> Result { [wgpu::PresentMode::Immediate, wgpu::PresentMode::Mailbox] .into_iter() @@ -557,7 +571,15 @@ fn benchmark_present_mode(supported: &[wgpu::PresentMode]) -> Result, display: OwnedDisplayHandle, benchmark: bool) -> Result { + /// Creates the renderer for a window. `threaded_acquire` acquires each + /// drawable on a worker thread (see `crate::pacing`), so that + /// [`Graphics::drawable_ready`] can answer without waiting for a vsync. + pub fn new( + window: Arc, + display: OwnedDisplayHandle, + benchmark: bool, + threaded_acquire: bool, + ) -> Result { let size = window.inner_size(); let instance = Instance::new(InstanceDescriptor { display: Some(Box::new(display)), @@ -615,7 +637,40 @@ impl Graphics { ); } let pack = load_pack()?; - Self::assemble(Some(surface), device, queue, config, pack) + let surface = Arc::new(surface); + let mut graphics = Self::assemble(Some(Arc::clone(&surface)), device, queue, config, pack)?; + if threaded_acquire { + graphics.acquirer = Some(Acquirer::new(Arc::new(SurfaceSource(surface)))); + } + Ok(graphics) + } + + /// Reports whether the next frame can acquire its drawable without waiting. + /// Always true without a worker thread, and without a surface. + pub fn drawable_ready(&mut self) -> bool { + self.acquirer.as_mut().is_none_or(Acquirer::is_ready) + } + + /// Reconfigures the surface, first dropping any drawable acquired for the + /// old configuration. + fn configure_surface(&mut self) { + if let Some(acquirer) = &mut self.acquirer { + acquirer.settle(); + } + if let Some(surface) = &self.surface { + surface.configure(&self.device, &self.config); + } + } + + /// The next drawable: from the worker thread when there is one (waiting + /// for it only if the frame loop ran out of patience), else acquired here. + fn acquire_frame(&mut self) -> Option { + if let Some(acquirer) = &mut self.acquirer { + return acquirer.take(); + } + self.surface + .as_ref() + .map(|surface| surface.get_current_texture()) } /// Uses the production frame graph on a fixed GPU target without a desktop surface. @@ -663,7 +718,7 @@ impl Graphics { /// Split out so a test can hand in a headless device and the same code /// answers. fn assemble( - surface: Option>, + surface: Option>>, device: Device, queue: Queue, config: SurfaceConfiguration, @@ -943,6 +998,7 @@ impl Graphics { asset_revision: 0, view_compositor: None, surface, + acquirer: None, offscreen, capture_requested: false, captured: None, @@ -1011,9 +1067,7 @@ impl Graphics { } self.config.width = width; self.config.height = height; - if let Some(surface) = &self.surface { - surface.configure(&self.device, &self.config); - } + self.configure_surface(); } /// Makes one image resident, replacing whatever already lived under its id. @@ -1951,8 +2005,8 @@ impl Graphics { let _ = self.image_bind_group(batch.image, batch.sampler); } - let (frame, reconfigure) = if let Some(surface) = &self.surface { - match surface.get_current_texture() { + let (frame, reconfigure) = if let Some(acquired) = self.acquire_frame() { + match acquired { CurrentSurfaceTexture::Success(frame) => (Some(frame), false), CurrentSurfaceTexture::Suboptimal(frame) => (Some(frame), true), CurrentSurfaceTexture::Timeout | CurrentSurfaceTexture::Occluded => { @@ -1968,9 +2022,7 @@ impl Graphics { // here and drawn next frame, which is one dropped frame rather // than a dead window. The queued writes are flushed as above. self.queue.submit(std::iter::empty()); - if let Some(surface) = &self.surface { - surface.configure(&self.device, &self.config); - } + self.configure_surface(); return Ok(false); } CurrentSurfaceTexture::Validation => { @@ -2266,9 +2318,12 @@ impl Graphics { self.queue.present(frame); } if reconfigure { - if let Some(surface) = &self.surface { - surface.configure(&self.device, &self.config); - } + self.configure_surface(); + } + // The worker starts on the next drawable now, while this thread goes + // back to the event loop. + if let Some(acquirer) = &mut self.acquirer { + acquirer.request(); } Ok(true) } diff --git a/native/rust/winit-host/src/main.rs b/native/rust/winit-host/src/main.rs index 87864a0b..69f55ac2 100644 --- a/native/rust/winit-host/src/main.rs +++ b/native/rust/winit-host/src/main.rs @@ -8,6 +8,7 @@ mod graph; mod graphics; #[cfg(test)] mod lightingtests; +mod pacing; mod packet; #[cfg(test)] mod pipelinetests; @@ -74,6 +75,11 @@ struct App { shutdown: bool, failure: Option, frame_parked: bool, + /// Paces the windowed frame on macOS (see `pacing`); `None` polls, with the + /// frame in `about_to_wait`. + pacer: Option, + /// Frames until the refresh period is read from the monitor again. + refresh_check: u32, stats_started: Option, stats_frames: u32, pending_captures: Vec, @@ -95,6 +101,7 @@ impl App { })?; bridge.init().context("initialize the Tecs application")?; let now = Instant::now(); + let pacer = paced_frame_loop(&config).then(|| pacing::FramePacer::new(now)); Ok(Self { bridge, config, @@ -111,6 +118,8 @@ impl App { shutdown: false, failure: None, frame_parked: false, + pacer, + refresh_check: 0, stats_started: None, stats_frames: 0, pending_captures: Vec::new(), @@ -172,6 +181,7 @@ impl App { Arc::clone(&window), event_loop.owned_display_handle(), self.config.benchmark_view, + self.pacer.is_some(), )?; self.bridge.attach_window(&state)?; self.window_id = Some(window.id()); @@ -367,6 +377,44 @@ impl App { Ok(()) } + /// Runs one paced frame (see `pacing`), from the `RedrawRequested` the pacer + /// asked for, or from one AppKit sent on its own (a live resize) when a + /// drawable is free. One AppKit sent while every drawable is taken is left + /// to the next due frame, which is at most a period away. + fn paced_frame(&mut self, event_loop: &ActiveEventLoop) { + let Some(pacer) = self.pacer.as_ref() else { + return; + }; + if !pacer.redraw_requested() && !self.graphics.as_mut().is_none_or(Graphics::drawable_ready) + { + return; + } + if self.refresh_check == 0 { + self.refresh_check = 60; + let rate = self + .window + .as_ref() + .and_then(|window| window.current_monitor()) + .and_then(|monitor| monitor.refresh_rate_millihertz()); + if let Some(pacer) = self.pacer.as_mut() { + pacer.set_refresh_millihertz(rate); + } + } + self.refresh_check -= 1; + let now = Instant::now(); + if let Some(pacer) = self.pacer.as_mut() { + pacer.frame_started(now); + } + if let Err(error) = self.tick(event_loop) { + self.fail(event_loop, error); + } + if self.frame_parked { + if let Some(pacer) = self.pacer.as_mut() { + pacer.retry_soon(Instant::now()); + } + } + } + fn tick(&mut self, event_loop: &ActiveEventLoop) -> Result<()> { if self.shutdown || event_loop.exiting() { return Ok(()); @@ -500,6 +548,10 @@ impl ApplicationHandler for App { if self.shutdown || event_loop.exiting() || Some(window_id) != self.window_id { return; } + if matches!(event, WindowEvent::RedrawRequested) && self.pacer.is_some() { + self.paced_frame(event_loop); + return; + } let result = match event { WindowEvent::CloseRequested => { let timestamp = self.timestamp(); @@ -666,8 +718,32 @@ impl ApplicationHandler for App { } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - if let Err(error) = self.tick(event_loop) { - self.fail(event_loop, error); + if self.pacer.is_none() { + if let Err(error) = self.tick(event_loop) { + self.fail(event_loop, error); + } + return; + } + let ready = self.graphics.as_mut().is_none_or(Graphics::drawable_ready); + let Some(pacer) = self.pacer.as_mut() else { + return; + }; + let flow = match pacer.step(Instant::now(), ready) { + pacing::Step::Redraw => { + if let Some(window) = &self.window { + window.request_redraw(); + ControlFlow::Wait + } else { + // No window to redraw: the frame runs here. + self.paced_frame(event_loop); + ControlFlow::Poll + } + } + pacing::Step::WaitUntil(instant) => ControlFlow::WaitUntil(instant), + pacing::Step::Wait => ControlFlow::Wait, + }; + if !event_loop.exiting() { + event_loop.set_control_flow(flow); } } @@ -932,6 +1008,7 @@ fn run() -> Result<()> { return run_headless(config); } let event_loop = EventLoop::new().context("create the winit event loop")?; + // A paced loop sets its own control flow in `about_to_wait`. event_loop.set_control_flow(ControlFlow::Poll); let mut app = App::new(config)?; event_loop @@ -943,6 +1020,17 @@ fn run() -> Result<()> { Ok(()) } +/// Whether the window's frame is paced by `pacing` rather than polled. +/// +/// On macOS the polled loop spends most of each frame blocked on a drawable on +/// the main thread, which delays every click by as many frames as a window +/// manager's event tap makes Accessibility requests (see `pacing`). Elsewhere the +/// polled loop stays: nothing there has shown the same failure. The render +/// benchmark's view measures uncapped frame rates and polls everywhere. +fn paced_frame_loop(config: &Config) -> bool { + cfg!(target_os = "macos") && !config.benchmark_view +} + /// Assembles the material dispatch into a shader pack a release loads. /// /// `--pack-shaders `. This is the packaging diff --git a/native/rust/winit-host/src/pacing.rs b/native/rust/winit-host/src/pacing.rs new file mode 100644 index 00000000..722de7b9 --- /dev/null +++ b/native/rust/winit-host/src/pacing.rs @@ -0,0 +1,371 @@ +//! Paces the windowed frame on macOS without ever making the main thread wait +//! for a drawable. +//! +//! The main thread is the one AppKit serves the rest of the system on. Some of +//! what it serves is on someone else's clock: a window manager with a filtering +//! event tap (Magnet, BetterTouchTool's snap areas) holds every mouse-down until +//! it has asked the clicked app about its window over Accessibility, and that +//! request is a message the main run loop answers when it gets to it. A loop that +//! spends each frame blocked in `nextDrawable` until the next vsync answers one +//! such message per frame, so a click and the start of a title-bar drag wait +//! tens of frames. See winit#1737 and the design record in the README. +//! +//! Two pieces fix it. [`FramePacer`] decides when a frame is due: one refresh +//! period after the last, run through winit's `request_redraw`, with the event +//! loop sleeping in `WaitUntil` between frames. [`Acquirer`] takes the waiting +//! out of the main thread: a worker thread calls `get_current_texture` as soon +//! as the previous frame is presented, and a due frame is put off, re-checked +//! every millisecond, until the drawable is in hand. A frame put off for longer +//! than [`MAX_DEFERRAL`] runs anyway, waiting for the drawable as the old loop did, +//! so a surface that never answers cannot stop the game. + +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// How often a frame waiting for a drawable checks again. +pub const RETRY: Duration = Duration::from_millis(1); + +/// How long a due frame waits for a drawable before it runs anyway. +pub const MAX_DEFERRAL: Duration = Duration::from_millis(100); + +/// The period used until the monitor reports a refresh rate. +pub const DEFAULT_PERIOD: Duration = Duration::from_nanos(16_666_667); + +/// What the event loop does after `about_to_wait`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Step { + /// Request a redraw; the frame runs in the `RedrawRequested` that follows. + Redraw, + /// Sleep until the instant, or until an event arrives. + WaitUntil(Instant), + /// Sleep until an event arrives (a requested redraw is on its way). + Wait, +} + +/// When the next windowed frame is due, and whether one is put off waiting for +/// a drawable. +#[derive(Debug)] +pub struct FramePacer { + period: Duration, + deadline: Instant, + deferred_since: Option, + redraw_requested: bool, +} + +impl FramePacer { + /// A pacer whose first frame is due at `now`. + pub fn new(now: Instant) -> Self { + Self { + period: DEFAULT_PERIOD, + deadline: now, + deferred_since: None, + redraw_requested: false, + } + } + + /// Sets the refresh period from a monitor's rate in millihertz; `None` or a + /// rate under 1 Hz keeps the current period. + pub fn set_refresh_millihertz(&mut self, rate: Option) { + if let Some(rate) = rate.filter(|rate| *rate >= 1000) { + self.period = Duration::from_secs_f64(1000.0 / f64::from(rate)); + } + } + + #[cfg(test)] + pub fn period(&self) -> Duration { + self.period + } + + /// Decides the next step after the event loop's `about_to_wait`. + /// `drawable_ready` reports whether the next frame could acquire its + /// drawable without waiting. + pub fn step(&mut self, now: Instant, drawable_ready: bool) -> Step { + if self.redraw_requested { + return Step::Wait; + } + if now < self.deadline { + return Step::WaitUntil(self.deadline); + } + if !drawable_ready { + let since = *self.deferred_since.get_or_insert(now); + if now.duration_since(since) < MAX_DEFERRAL { + return Step::WaitUntil(now + RETRY); + } + } + self.deferred_since = None; + self.redraw_requested = true; + Step::Redraw + } + + /// Records that a frame is starting at `now` and moves the deadline one + /// period on. A frame that started more than a period late is due a period + /// from now instead, so a stall is not followed by a burst of catch-up frames. + pub fn frame_started(&mut self, now: Instant) { + self.redraw_requested = false; + self.deferred_since = None; + let next = self.deadline + self.period; + self.deadline = if next <= now { now + self.period } else { next }; + } + + /// Makes the next frame due a [`RETRY`] from `now`, for a frame that parked + /// before finishing and polls again rather than waiting out a period. + pub fn retry_soon(&mut self, now: Instant) { + self.deadline = now + RETRY; + } + + /// Whether the event loop is waiting for a redraw this pacer asked for. + pub fn redraw_requested(&self) -> bool { + self.redraw_requested + } +} + +/// Something a worker thread can block on to produce the next drawable. +pub trait Acquire: Send + Sync + 'static { + type Output: Send + 'static; + fn acquire(&self) -> Self::Output; +} + +/// Acquires drawables on a worker thread, one at a time, so the main thread +/// asks whether one is ready instead of waiting for it. +pub struct Acquirer { + requests: Option>, + results: Receiver, + ready: Option, + in_flight: bool, + worker: Option>, +} + +impl Acquirer { + pub fn new(source: std::sync::Arc) -> Self { + let (requests, wanted) = mpsc::channel::<()>(); + let (deliver, results) = mpsc::channel(); + let worker = std::thread::Builder::new() + .name("tecs-acquire".to_owned()) + .spawn(move || { + while wanted.recv().is_ok() { + if deliver.send(source.acquire()).is_err() { + break; + } + } + }) + .expect("spawn the drawable acquisition thread"); + Self { + requests: Some(requests), + results, + ready: None, + in_flight: false, + worker: Some(worker), + } + } + + /// Starts acquiring the next drawable unless one is already held or on its way. + pub fn request(&mut self) { + if self.ready.is_none() && !self.in_flight { + if let Some(requests) = &self.requests { + self.in_flight = requests.send(()).is_ok(); + } + } + } + + /// Reports whether a drawable is in hand, starting an acquisition if none is. + pub fn is_ready(&mut self) -> bool { + self.request(); + if self.in_flight { + match self.results.try_recv() { + Ok(output) => { + self.ready = Some(output); + self.in_flight = false; + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => self.in_flight = false, + } + } + self.ready.is_some() + } + + /// Takes the acquired drawable, waiting for it if it is not in hand yet. + /// Returns `None` only when the worker has stopped. + pub fn take(&mut self) -> Option { + self.request(); + if self.ready.is_none() && self.in_flight { + self.ready = self.results.recv().ok(); + self.in_flight = false; + } + self.ready.take() + } + + /// Waits for an acquisition in flight and drops whatever was acquired, so + /// the surface can be reconfigured with nothing outstanding. + pub fn settle(&mut self) { + if self.in_flight { + let _ = self.results.recv(); + self.in_flight = false; + } + self.ready = None; + } +} + +impl Drop for Acquirer { + fn drop(&mut self) { + self.settle(); + self.requests = None; + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + #[test] + fn a_frame_is_due_once_per_period() { + let t0 = Instant::now(); + let mut pacer = FramePacer::new(t0); + pacer.set_refresh_millihertz(Some(50_000)); + assert_eq!(pacer.period(), ms(20)); + assert_eq!(pacer.step(t0, true), Step::Redraw); + assert_eq!(pacer.step(t0, true), Step::Wait); + pacer.frame_started(t0); + assert_eq!(pacer.step(t0 + ms(5), true), Step::WaitUntil(t0 + ms(20))); + assert_eq!(pacer.step(t0 + ms(20), true), Step::Redraw); + pacer.frame_started(t0 + ms(21)); + assert_eq!(pacer.step(t0 + ms(22), true), Step::WaitUntil(t0 + ms(40))); + } + + #[test] + fn a_frame_without_a_drawable_is_put_off_and_bounded() { + let t0 = Instant::now(); + let mut pacer = FramePacer::new(t0); + assert_eq!(pacer.step(t0, false), Step::WaitUntil(t0 + RETRY)); + assert_eq!( + pacer.step(t0 + ms(3), false), + Step::WaitUntil(t0 + ms(3) + RETRY) + ); + assert_eq!(pacer.step(t0 + ms(4), true), Step::Redraw); + pacer.frame_started(t0 + ms(4)); + let due = t0 + DEFAULT_PERIOD; + assert_eq!(pacer.step(due, false), Step::WaitUntil(due + RETRY)); + assert_eq!(pacer.step(due + MAX_DEFERRAL, false), Step::Redraw); + } + + #[test] + fn a_stall_does_not_bring_catch_up_frames() { + let t0 = Instant::now(); + let mut pacer = FramePacer::new(t0); + pacer.set_refresh_millihertz(Some(100_000)); + pacer.step(t0, true); + pacer.frame_started(t0 + ms(250)); + assert_eq!( + pacer.step(t0 + ms(251), true), + Step::WaitUntil(t0 + ms(260)) + ); + } + + #[test] + fn a_parked_frame_polls_again_soon() { + let t0 = Instant::now(); + let mut pacer = FramePacer::new(t0); + pacer.step(t0, true); + pacer.frame_started(t0); + pacer.retry_soon(t0 + ms(2)); + assert_eq!( + pacer.step(t0 + ms(2), true), + Step::WaitUntil(t0 + ms(2) + RETRY) + ); + assert_eq!(pacer.step(t0 + ms(3), true), Step::Redraw); + } + + #[test] + fn an_unknown_refresh_rate_keeps_the_period() { + let mut pacer = FramePacer::new(Instant::now()); + pacer.set_refresh_millihertz(None); + pacer.set_refresh_millihertz(Some(0)); + assert_eq!(pacer.period(), DEFAULT_PERIOD); + pacer.set_refresh_millihertz(Some(120_000)); + assert_eq!(pacer.period(), Duration::from_secs_f64(1.0 / 120.0)); + } + + /// Hands out numbered drawables, each only once the test releases it. + struct Gate { + released: Mutex>, + count: Mutex, + } + + impl Acquire for Gate { + type Output = u32; + fn acquire(&self) -> u32 { + self.released.lock().unwrap().recv().unwrap(); + let mut count = self.count.lock().unwrap(); + *count += 1; + *count + } + } + + fn gated() -> (Acquirer, Sender<()>) { + let (release, released) = mpsc::channel(); + let gate = Arc::new(Gate { + released: Mutex::new(released), + count: Mutex::new(0), + }); + (Acquirer::new(gate), release) + } + + fn ready_within(acquirer: &mut Acquirer, limit: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < limit { + if acquirer.is_ready() { + return true; + } + std::thread::sleep(ms(1)); + } + false + } + + #[test] + fn the_main_thread_asks_rather_than_waits() { + let (mut acquirer, release) = gated(); + assert!(!acquirer.is_ready()); + std::thread::sleep(ms(10)); + assert!(!acquirer.is_ready(), "nothing was released"); + release.send(()).unwrap(); + assert!(ready_within(&mut acquirer, ms(2000))); + assert_eq!(acquirer.take(), Some(1)); + assert!(!acquirer.is_ready()); + release.send(()).unwrap(); + assert_eq!(acquirer.take(), Some(2), "take waits when it has to"); + } + + #[test] + fn settle_drops_the_drawable_in_hand_and_in_flight() { + let (mut acquirer, release) = gated(); + release.send(()).unwrap(); + assert!(ready_within(&mut acquirer, ms(2000))); + acquirer.settle(); + assert!(!acquirer.is_ready()); + release.send(()).unwrap(); + acquirer.settle(); + release.send(()).unwrap(); + assert_eq!( + acquirer.take(), + Some(3), + "the settled ones were dropped, not kept" + ); + } + + #[test] + fn dropping_stops_the_worker() { + let (mut acquirer, release) = gated(); + acquirer.request(); + release.send(()).unwrap(); + drop(acquirer); + assert!(release.send(()).is_err(), "the worker is gone"); + } +}