diff --git a/Cargo.toml b/Cargo.toml index 0cdadbe8..76796e42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ futures = "0.3" raw-window-handle = "0.6" # ── GPUI core crate (provides Platform trait, event types, geometry, etc.) ─── -gpui = { package = "gpui-pre", version = "=0.3.7", default-features = false } +gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false } # ── iOS ────────────────────────────────────────────────────────────────────── @@ -80,7 +80,7 @@ uuid = { version = "1", features = ["v4", "v5"] } block2 = "0.6" # wgpu renderer (Metal backend on iOS/Android) -gpui_wgpu = { package = "gpui-pre-wgpu", version = "=0.3.7", features = ["font-kit"] } +gpui_wgpu = { package = "gpui-pre-wgpu", version = "=0.3.8", features = ["font-kit"] } wgpu = "29.0.4" # Text / font stack @@ -111,7 +111,7 @@ android_logger = "0.15" # wgpu renderer (Vulkan backend on Android) # Android uses Cosmic Text and system fonts; font-kit pulls in desktop Fontconfig. -gpui_wgpu = { package = "gpui-pre-wgpu", version = "=0.3.7" } +gpui_wgpu = { package = "gpui-pre-wgpu", version = "=0.3.8" } # rustc-hash is required by the PlatformKeyboardMapper trait signature rustc-hash = "2" @@ -120,7 +120,7 @@ uuid = { version = "1", features = ["v5"] } # ── dev ────────────────────────────────────────────────────────────────────── [dev-dependencies] -gpui = { package = "gpui-pre", version = "=0.3.7", default-features = false, features = ["test-support"] } +gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["test-support"] } # ── profiles ───────────────────────────────────────────────────────────────── diff --git a/example/Cargo.toml b/example/Cargo.toml index e2f3de28..6fdb6877 100644 --- a/example/Cargo.toml +++ b/example/Cargo.toml @@ -16,10 +16,10 @@ path = "src/main.rs" [dependencies] # Pin the mobile facade support without requiring a published crate. -gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "2ec5696c2e975ddd2b8380d12ec00631bd159128", default-features = false, features = ["component"] } +gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "87d10ae5e1299d1be18670a37570c5402f952e34", default-features = false, features = ["component"] } gpui-mobile = { package = "gpui-pre-mobile", path = "..", version = "0.1.0" } -gpui = { package = "gpui-pre", version = "=0.3.7", default-features = false } -reqwest_client = { package = "gpui-pre-reqwest-client", version = "=0.3.7" } +gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false } +reqwest_client = { package = "gpui-pre-reqwest-client", version = "=0.3.8" } reqwest = { package = "gpui-pre-reqwest", version = "0.12.15", default-features = false, features = ["rustls-tls"] } log = "0.4" diff --git a/src/android/frame_source.rs b/src/android/frame_source.rs index d744d85a..dc2fff09 100644 --- a/src/android/frame_source.rs +++ b/src/android/frame_source.rs @@ -37,17 +37,23 @@ //! [`FramePacer`]; this module is the thread-local instance plus the vsync //! thread and `ALooper` glue around it. Without a choreographer (no looper //! available to the vsync thread) the pacer spaces frames by a 60 Hz clock. +//! +//! Each frame also carries when and how it was requested, for GPUI's frame +//! profiling ([`take_signal`]). Like the desktop backends, the vsync thread +//! captures the vsync's timestamp only when GPUI's `profiler` feature is on. use std::{ cell::RefCell, ptr, sync::{ atomic::{AtomicBool, AtomicPtr, Ordering}, - mpsc, OnceLock, + mpsc, LazyLock, OnceLock, }, time::{Duration, Instant}, }; +use gpui::{FrameRequestSource, PlatformFrameSignal}; + use crate::frame_pacer::FramePacer; /// Longest the loop blocks with nothing due; a missed wakeup costs at most @@ -81,6 +87,14 @@ static POST_REQUESTED: AtomicBool = AtomicBool::new(false); /// A frame callback fired and its frame has not been handed to the pacer yet. static FRAME_DUE: AtomicBool = AtomicBool::new(false); +/// The timestamp of the vsync behind [`FRAME_DUE`], handed from the vsync +/// thread to the pacer together with it. Empty unless profiling. +static VSYNC_SIGNAL: LazyLock = LazyLock::new(PlatformFrameSignal::new); + +/// A vsync timestamp older than this when its callback runs is not trusted; +/// the callback's own arrival time stands in for it. +const MAX_VSYNC_AGE: Duration = Duration::from_secs(1); + /// Demand raised from a thread that is not the loop thread; the loop turns it /// into [`schedule_frame`] on its next iteration. static OFF_THREAD_DEMAND: AtomicBool = AtomicBool::new(false); @@ -169,13 +183,42 @@ fn vsync_thread(ready: mpsc::Sender) { } unsafe extern "C" fn on_vsync( - _frame_time_nanos: std::os::raw::c_long, + frame_time_nanos: std::os::raw::c_long, _data: *mut std::ffi::c_void, ) { + if let Some(at) = PlatformFrameSignal::capture(|| vsync_instant(frame_time_nanos)) { + VSYNC_SIGNAL.record(at, FrameRequestSource::NativeCallback); + } FRAME_DUE.store(true, Ordering::Release); wake_looper(&MAIN_LOOPER); } +/// The vsync's timestamp as an [`Instant`]. `frame_time_nanos` is on +/// `CLOCK_MONOTONIC`, but an `Instant` cannot be built from raw nanoseconds, +/// so both clocks are sampled and the callback's arrival time is stepped back +/// by the vsync's age. The arrival time stands in when the clock read fails +/// or the age is implausible (the 32-bit `long` of this callback truncates +/// the timestamp). +fn vsync_instant(frame_time_nanos: std::os::raw::c_long) -> Instant { + let received_at = Instant::now(); + let mut now = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: `now` is a valid out-pointer for the duration of the call. + if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) } != 0 { + return received_at; + } + let now_nanos = i128::from(now.tv_sec) * 1_000_000_000 + i128::from(now.tv_nsec); + let age_nanos = now_nanos - i128::from(frame_time_nanos); + if !(0..=MAX_VSYNC_AGE.as_nanos() as i128).contains(&age_nanos) { + return received_at; + } + received_at + .checked_sub(Duration::from_nanos(age_nanos as u64)) + .unwrap_or(received_at) +} + fn wake_looper(looper: &AtomicPtr) { let looper = looper.load(Ordering::Acquire); if !looper.is_null() { @@ -191,7 +234,7 @@ fn with_pacer(f: impl FnOnce(&FramePacer) -> R) -> Option { let slot = slot.borrow(); let pacer = slot.as_ref()?; if FRAME_DUE.swap(false, Ordering::AcqRel) { - pacer.on_vsync(); + pacer.on_vsync(VSYNC_SIGNAL.take().map(|(at, _)| at)); } Some(f(pacer)) }) @@ -224,6 +267,17 @@ pub(crate) fn take_frame() -> bool { with_pacer(|pacer| pacer.take_frame(Instant::now())).unwrap_or(false) } +/// When and how the frame the loop just took with [`take_frame`] was +/// requested, for `RequestFrameOptions::signal_at` / `signal_source`. The +/// window's frame callback drains it; `None` when the time is unknown. +/// +/// Unlike the other entry points this does not hand the pacer a vsync that +/// arrived meanwhile: that vsync owes the *next* frame, and draining it here +/// would charge its time to this one. +pub(crate) fn take_signal() -> Option<(Instant, FrameRequestSource)> { + PACER.with(|slot| slot.borrow().as_ref()?.take_signal()) +} + /// How long the loop may block on its looper: zero with a frame due, up to /// the next delayed dispatcher task or the fallback frame otherwise, and /// never more than [`IDLE_POLL_CAP`]. `can_draw` is whether the loop would diff --git a/src/android/window.rs b/src/android/window.rs index 3b9ee101..17f38294 100644 --- a/src/android/window.rs +++ b/src/android/window.rs @@ -35,9 +35,9 @@ use anyhow::{Context as _, Result}; use futures::channel::oneshot; use gpui::{ - self, AtlasKey, AtlasTile, Capslock, DispatchEventResult, GpuSpecs, Modifiers, PlatformAtlas, - PlatformDisplay, PlatformInputHandler, PlatformWindow, PromptButton, PromptLevel, - RequestFrameOptions, WindowBackgroundAppearance, WindowBounds, WindowControlArea, + self, AtlasKey, AtlasTile, Capslock, DispatchEventResult, FrameRequestSource, GpuSpecs, + Modifiers, PlatformAtlas, PlatformDisplay, PlatformInputHandler, PlatformWindow, PromptButton, + PromptLevel, RequestFrameOptions, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowVisibility, }; use gpui_wgpu::{wgpu, GpuContext, WgpuRenderer, WgpuSurfaceConfig}; @@ -1629,10 +1629,19 @@ impl PlatformWindow for AndroidPlatformWindow { // A freshly attached surface has an empty swapchain; repaint in full. let surface_new = force_render_once.swap(false, std::sync::atomic::Ordering::AcqRel); + // When and why `frame_source` let this frame through: a vsync, or + // demand the pacer served by itself. + let (signal_at, signal_source) = super::frame_source::take_signal().map_or( + (None, FrameRequestSource::NativeCallback), + |(at, source)| (Some(at), source), + ); + let mut cb = send_callback.lock(); cb(RequestFrameOptions { require_presentation: false, force_render: text_dirty || surface_new, + signal_at, + signal_source, }); }); } diff --git a/src/frame_pacer.rs b/src/frame_pacer.rs index 504eee10..cda8757c 100644 --- a/src/frame_pacer.rs +++ b/src/frame_pacer.rs @@ -26,6 +26,13 @@ //! previous frame, and [`poll_timeout`](Self::poll_timeout) tells the loop //! how long it may sleep before then. //! +//! The pacer also remembers why each frame became due, for GPUI's +//! [`RequestFrameOptions`](gpui::RequestFrameOptions): a vsync is a +//! [`NativeCallback`](FrameRequestSource::NativeCallback) stamped with the +//! vsync's own time, while demand served at once or by the fallback clock is +//! a [`LocalSchedule`](FrameRequestSource::LocalSchedule) stamped with the +//! moment the pacer let it through. +//! //! Main thread only, like everything in GPUI. use std::{ @@ -33,6 +40,8 @@ use std::{ time::{Duration, Instant}, }; +use gpui::FrameRequestSource; + /// Posts one vsync callback that will call [`FramePacer::on_vsync`]. pub(crate) type PostVsync = Box; @@ -52,6 +61,10 @@ pub(crate) struct FramePacer { fallback_due_at: Cell>, /// Fallback only: when the previous frame was taken, to space the next. last_frame_at: Cell>, + /// When and how the frame that is due (or was just taken) was requested, + /// until [`take_signal`](Self::take_signal) drains it. The first request + /// wins, so a frame that stays owed reports its oldest request. + signal: Cell>, } impl FramePacer { @@ -66,6 +79,7 @@ impl FramePacer { due: Cell::new(false), fallback_due_at: Cell::new(None), last_frame_at: Cell::new(None), + signal: Cell::new(None), } } @@ -78,6 +92,7 @@ impl FramePacer { due: Cell::new(false), fallback_due_at: Cell::new(None), last_frame_at: Cell::new(None), + signal: Cell::new(None), } } @@ -94,7 +109,10 @@ impl FramePacer { .get() .is_none_or(|last| now.duration_since(last) >= self.interval); match &self.post_vsync { - Some(_) if interval_elapsed => self.due.set(true), + Some(_) if interval_elapsed => { + self.due.set(true); + self.record_signal(now, FrameRequestSource::LocalSchedule); + } Some(post) => { self.posted.set(true); post(); @@ -110,9 +128,13 @@ impl FramePacer { } /// The posted vsync callback fired: the frame it owes is now due. - pub(crate) fn on_vsync(&self) { + /// `signal_at` is the vsync's timestamp, `None` when it was not captured. + pub(crate) fn on_vsync(&self, signal_at: Option) { self.posted.set(false); self.due.set(true); + if let Some(at) = signal_at { + self.record_signal(at, FrameRequestSource::NativeCallback); + } } /// Takes the due frame, if any. The loop draws when this returns `true` @@ -126,12 +148,26 @@ impl FramePacer { Some(due_at) if due_at <= now => { self.fallback_due_at.set(None); self.last_frame_at.set(Some(now)); + self.record_signal(due_at, FrameRequestSource::LocalSchedule); true } _ => false, } } + /// Drains when and how the frame [`take_frame`](Self::take_frame) handed + /// out was requested; `None` when the request time is unknown (a vsync + /// whose timestamp was not captured, or a frame the pacer did not pace). + pub(crate) fn take_signal(&self) -> Option<(Instant, FrameRequestSource)> { + self.signal.take() + } + + fn record_signal(&self, at: Instant, source: FrameRequestSource) { + if self.signal.get().is_none() { + self.signal.set(Some((at, source))); + } + } + /// Whether a frame is wanted: posted, due, or waiting on the fallback /// clock. #[cfg(test)] @@ -224,7 +260,7 @@ mod tests { let now = now + Duration::from_millis(1); pacer.schedule(now); - pacer.on_vsync(); + pacer.on_vsync(None); assert_eq!(pacer.poll_timeout(now), Some(Duration::ZERO)); assert!(pacer.take_frame(now)); assert!(!pacer.take_frame(now), "a vsync owes exactly one frame"); @@ -241,7 +277,7 @@ mod tests { let now = now + Duration::from_millis(1); pacer.schedule(now); - pacer.on_vsync(); + pacer.on_vsync(None); pacer.schedule(now); assert_eq!(posts.get(), 1); @@ -275,7 +311,7 @@ mod tests { let now = now + Duration::from_millis(1); pacer.schedule(now); - pacer.on_vsync(); + pacer.on_vsync(None); // The loop skips drawing while inactive … assert!(pacer.has_demand()); // … and draws the owed frame once it is active again. @@ -295,8 +331,8 @@ mod tests { assert_eq!(posts.get(), 2); // The stale callback and the fresh one both fire: still one frame. - pacer.on_vsync(); - pacer.on_vsync(); + pacer.on_vsync(None); + pacer.on_vsync(None); assert!(pacer.take_frame(now)); assert!(!pacer.take_frame(now)); } @@ -330,4 +366,73 @@ mod tests { assert!(pacer.take_frame(t0 + Duration::from_millis(5))); assert!(!pacer.take_frame(t0 + Duration::from_millis(5))); } + + #[test] + fn vsync_frame_reports_the_vsync_time_as_a_native_callback() { + let now = Instant::now(); + let (pacer, _posts) = vsync_pacer_after_frame(now); + let _ = pacer.take_signal(); + let vsync_at = now + Duration::from_millis(3); + + pacer.schedule(now + Duration::from_millis(1)); + assert_eq!(pacer.take_signal(), None, "nothing is due before the vsync"); + pacer.on_vsync(Some(vsync_at)); + assert!(pacer.take_frame(vsync_at + Duration::from_millis(1))); + + assert_eq!( + pacer.take_signal(), + Some((vsync_at, FrameRequestSource::NativeCallback)) + ); + assert_eq!(pacer.take_signal(), None, "a signal is drained once"); + } + + #[test] + fn demand_served_at_once_is_a_local_schedule() { + let pacer = FramePacer::with_vsync(Box::new(|| {}), INTERVAL); + let now = Instant::now(); + + pacer.schedule(now); + assert!(pacer.take_frame(now + Duration::from_millis(1))); + + assert_eq!( + pacer.take_signal(), + Some((now, FrameRequestSource::LocalSchedule)) + ); + } + + #[test] + fn clock_fallback_reports_the_deadline_it_waited_for() { + let pacer = FramePacer::with_clock(INTERVAL); + let t0 = Instant::now(); + pacer.schedule(t0); + assert!(pacer.take_frame(t0)); + let _ = pacer.take_signal(); + + pacer.schedule(t0 + Duration::from_millis(1)); + assert!(pacer.take_frame(t0 + Duration::from_millis(20))); + + assert_eq!( + pacer.take_signal(), + Some((t0 + INTERVAL, FrameRequestSource::LocalSchedule)) + ); + } + + #[test] + fn an_owed_frame_keeps_its_first_request_time() { + let now = Instant::now(); + let (pacer, _posts) = vsync_pacer_after_frame(now); + let _ = pacer.take_signal(); + let first = now + Duration::from_millis(2); + + pacer.schedule(now + Duration::from_millis(1)); + pacer.resume(now + Duration::from_millis(1)); + pacer.on_vsync(Some(first)); + pacer.on_vsync(Some(first + INTERVAL)); + assert!(pacer.take_frame(first + Duration::from_secs(1))); + + assert_eq!( + pacer.take_signal(), + Some((first, FrameRequestSource::NativeCallback)) + ); + } } diff --git a/src/ios/ffi.rs b/src/ios/ffi.rs index 418c8801..fa8583b5 100644 --- a/src/ios/ffi.rs +++ b/src/ios/ffi.rs @@ -13,7 +13,10 @@ //! gpui_ios_request_frame(ptr) // called every CADisplayLink tick; returns whether GPUI wants another //! ``` -use gpui::{App, AppContext, Application, RequestFrameOptions, WindowOptions}; +use gpui::{ + App, AppContext, Application, FrameRequestSource, PlatformFrameSignal, RequestFrameOptions, + WindowOptions, +}; use std::ffi::c_void; use std::rc::Rc; use std::sync::OnceLock; @@ -313,6 +316,9 @@ pub extern "C" fn gpui_ios_request_frame(window_ptr: *mut c_void) -> bool { if window_ptr.is_null() { return false; } + // The host calls in from its `CADisplayLink` callback, so this is when the + // OS asked for the frame (captured only when GPUI profiles). + let signal_at = PlatformFrameSignal::capture(std::time::Instant::now); // Safety: window_ptr must be a valid pointer to an IosWindow let window = unsafe { &*(window_ptr as *const super::window::IosWindow) }; @@ -333,6 +339,8 @@ pub extern "C" fn gpui_ios_request_frame(window_ptr: *mut c_void) -> bool { if let Some(mut cb) = callback { cb(RequestFrameOptions { force_render: text_dirty, + signal_at, + signal_source: FrameRequestSource::NativeCallback, ..Default::default() }); // Restore the callback for the next frame