// Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 //! Drives animation frames from the display's vsync using the Android Choreographer. //! Optimized for ultra-low latency: self-re-entrant Choreographer callbacks wake //! the main loop directly on vsync without thread round-trip serialization delay. use std::os::fd::FromRawFd; use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::thread::JoinHandle; use crate::android_activity::AndroidAppWaker; use crate::ndk::looper::{ForeignLooper, ThreadLooper}; mod ffi { use core::ffi::{c_int, c_long, c_void}; #[repr(C)] pub struct AChoreographer { _unused: [u8; 0], } #[repr(C)] pub struct ALooper { _unused: [u8; 0], } pub type FrameCallback = unsafe extern "C" fn(frame_time_nanos: c_long, data: *mut c_void); pub type FdCallback = unsafe extern "C" fn(fd: c_int, events: c_int, data: *mut c_void) -> c_int; pub const ALOOPER_POLL_CALLBACK: c_int = -1; pub const ALOOPER_EVENT_INPUT: c_int = 0x0001; #[link(name = "android")] unsafe extern "C" { pub fn AChoreographer_getInstance() -> *mut AChoreographer; pub fn AChoreographer_postFrameCallback( choreographer: *mut AChoreographer, callback: FrameCallback, data: *mut c_void, ); pub fn ALooper_forThread() -> *mut ALooper; pub fn ALooper_addFd( looper: *mut ALooper, fd: c_int, ident: c_int, events: c_int, callback: Option, data: *mut c_void, ) -> c_int; } } /// ALooper wake-fd race (2026-10-30, ROOT CAUSE of the 120 Hz slot drops): /// `Looper::pollInner` (system/core/libutils/Looper.cpp) drains the wake fd /// in the SAME epoll batch as any ready input fd, and `pollOnce` returns the /// input's ident — the `POLL_WAKE` result is a local that gets dropped. The /// `ALooper_wake` byte is consumed WITHOUT `PollEvent::Wake` ever reaching /// Slint, and it cannot be recovered (the byte is gone). With motion events /// arriving 1-4 ms after the edge (always, for a real finger at 120-240 Hz /// input rate; ~1 in 5 frames for 20 Hz adb input) the wake is lost and the /// locked render decision (`saw_wake && slot-guard`) drops the dirty frame /// for that slot — measured: 8-12% of slots dropped, uniform `dec=1/0/1/0`. /// /// Fix: a dedicated eventfd registered with the main looper under /// `ALOOPER_POLL_CALLBACK`. The Looper invokes its C callback INSIDE /// `pollInner` (before pollOnce returns, and regardless of which ident /// pollOnce hands back), so the delivery can never be swallowed by an input /// response. The trampoline publishes the edge ns into `delivered_edge`; the /// main loop treats "new edge delivered" exactly like `saw_wake`. struct Shared { state: Mutex, cond: Condvar, driving: AtomicBool, looper: Mutex>, main_waker: AndroidAppWaker, choreographer: *mut ffi::AChoreographer, /// Dedicated vsync-delivery eventfd (see the wake-fd race note above). /// Raw fd (>=0 once registered; -1 = unavailable). The fd is leaked on /// purpose: it is registered with the MAIN looper, which outlives the /// driver (same lifetime convention as the forgotten Timer). evfd_raw: AtomicI32, /// Edge (CLOCK_MONOTONIC ns) of the latest vsync DELIVERED to the main /// thread via the eventfd trampoline. The main loop swaps it out each /// iteration; a value > last-seen means "saw a wake this iteration" — /// immune to the ALooper wake-fd swallow. delivered_edge: AtomicU64, } /// Trampoline: runs on the MAIN thread inside ALooper_pollOnce (see the /// race note above). Publishes the edge that `frame_callback` just wrote. unsafe extern "C" fn vsync_fd_trampoline(fd: i32, _events: i32, data: *mut core::ffi::c_void) -> i32 { // DRAIN FIRST (level-triggered): the eventfd is a counter; if we never // read it, it stays readable forever and ALooper_pollOnce returns // POLL_CALLBACK on EVERY poll — the main loop busy-spins at ~10 kHz // (measured: 1812 spin iterations per run, wake→render latency stretched // to +5.5 ms phase in the affected window). let mut counter: u64 = 0; libc::read(fd, &mut counter as *mut u64 as *mut core::ffi::c_void, 8); let shared = &*(data as *const Shared); let edge = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS .load(Ordering::Acquire); if edge > 0 { shared.delivered_edge.store(edge, Ordering::Release); } 1 // keep the fd registered } unsafe impl Send for Shared {} unsafe impl Sync for Shared {} #[derive(Default)] struct State { animating: bool, /// Window is dirty (pending_redraw). The Choreographer callback chain /// re-posts while `animating || dirty` (2026-07-09): gating on /// `animating` alone let the chain die during flings (pointer /// inactivity >200 ms) and on any `has_active_animations` flicker — /// with the early waker disabled the loop then froze until the next /// input event (measured: 30 frames in 4 s of drag, then silence). /// `set_state` is called by the main loop on EVERY iteration, so the /// dirty mirror can be at most one iteration (≈8 ms) stale — the 6 ms /// poll backstop for dirty windows bounds the damage of a chain break. dirty: bool, quit: bool, callback_posted: bool, } pub struct VsyncDriver { shared: Arc, handle: Option>, waker_handle: Option>, } impl VsyncDriver { pub fn new(main_waker: AndroidAppWaker) -> Self { let shared = Arc::new(Shared { state: Mutex::new(State::default()), cond: Condvar::new(), driving: AtomicBool::new(false), looper: Mutex::new(None), main_waker: main_waker.clone(), choreographer: std::ptr::null_mut(), evfd_raw: AtomicI32::new(-1), delivered_edge: AtomicU64::new(0), }); // Register the delivery eventfd with the MAIN looper (this runs on // the main thread, from run_event_loop). ALOOPER_POLL_CALLBACK makes // the Looper invoke the trampoline itself inside pollInner. unsafe { let evfd = libc::eventfd(0, libc::EFD_NONBLOCK); if evfd >= 0 { let looper = ffi::ALooper_forThread(); let r = ffi::ALooper_addFd( looper, evfd, ffi::ALOOPER_POLL_CALLBACK, ffi::ALOOPER_EVENT_INPUT, Some(vsync_fd_trampoline), Arc::as_ptr(&shared) as *mut core::ffi::c_void, ); if r >= 0 { log::info!("VSYNC EVFD registered fd={evfd}"); shared.evfd_raw.store(evfd, Ordering::Release); // Leak: the fd belongs to the main looper for the life of // the process (the driver's drop must not close it). std::mem::forget(std::os::fd::OwnedFd::from_raw_fd(evfd)); } else { log::error!("VSYNC EVFD ALooper_addFd failed"); libc::close(evfd); } } } let handle = std::thread::Builder::new() .name("slint vsync".into()) .spawn({ let shared = shared.clone(); move || vsync_thread(shared) }) .ok(); // EARLY WAKER (the real 120Hz fix, part 2): the Choreographer // callback only reaches the UI thread ~2.5-6 ms AFTER the vsync // edge — with a 2.5-5 ms Skia render plus present, that leaves // no phase margin inside the 8.3 ms slot (measured: 51% of frames // landed one slot late). This thread learns the vsync grid from // the callback's `frame_time_nanos` (vkprobe::note_vsync_edge) and // wakes the main loop slightly BEFORE each edge, so render start ≈ // the edge and present lands at a constant ~edge+3 ms phase. // The Choreographer's late wake remains as fallback (the render // freshness guard in the event loop prevents double renders). let waker_handle = std::thread::Builder::new() .name("slint early-waker".into()) .spawn({ let shared = shared.clone(); move || early_waker_thread(shared) }) .ok(); Self { shared, handle, waker_handle, } } pub fn is_driving(&self) -> bool { self.shared.driving.load(Ordering::Acquire) } /// Consume the latest vsync edge delivered to the main thread via the /// eventfd trampoline (0 = nothing new this iteration). Call ONCE per /// main-loop iteration, after poll_events. pub fn take_delivered_edge(&self) -> u64 { self.shared.delivered_edge.swap(0, Ordering::AcqRel) } /// (edge delivery happens in `frame_callback`, which owns &Shared) /// Push (animating, dirty) into the driver every main-loop iteration. /// The vsync thread re-posts the first Choreographer callback, and /// `frame_callback` re-posts the chain, while `animating || dirty`. pub fn set_state(&self, animating: bool, dirty: bool) { let mut state = self.shared.state.lock().unwrap(); let was_active = state.animating || state.dirty; let now_active = animating || dirty; state.animating = animating; state.dirty = dirty; if !was_active && now_active { drop(state); // Wake the vsync thread: it may be parked in `poll_once` (the // Choreographer queue drained once the previous active phase's // self-sustaining callback chain ended). Waking the looper is // what makes it re-check the state and post the first frame // callback for this new phase. if let Some(looper) = self.shared.looper.lock().unwrap().as_ref() { looper.wake(); } self.shared.cond.notify_one(); } } } impl Drop for VsyncDriver { fn drop(&mut self) { let mut state = self.shared.state.lock().unwrap(); state.quit = true; drop(state); self.shared.cond.notify_one(); if let Some(looper) = self.shared.looper.lock().unwrap().as_ref() { looper.wake(); } if let Some(handle) = self.handle.take() { let _ = handle.join(); } if let Some(handle) = self.waker_handle.take() { let _ = handle.join(); } } } /// Wakes the main loop a few hundred microseconds before each vsync edge /// (see the `early_waker_handle` docs in `VsyncDriver::new`). Runs at 120 Hz /// only while animating; otherwise parks in short sleeps. fn early_waker_thread(shared: Arc) { let waker = shared.main_waker.clone(); // Wake slightly BEFORE the edge so poll-returns + render-start lands on // the edge; with a 2.5-5 ms Skia render the present then sits at a // constant ~edge+3 ms (middle of the slot, ~2.5 ms of deadline margin // both ways). const LEAD_NS: u64 = 800_000; loop { { let state = shared.state.lock().unwrap(); if state.quit { return; } if !state.animating { drop(state); std::thread::sleep(std::time::Duration::from_millis(5)); continue; } } let edge = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS .load(Ordering::Relaxed); let period = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS .load(Ordering::Relaxed); let now = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns(); // Grid unknown or stale (no callbacks for ~100 ms, e.g. the app // went to the background): park briefly and re-check. if edge == 0 || period == 0 || now == 0 || now > edge + 100_000_000 { std::thread::sleep(std::time::Duration::from_millis(10)); continue; } // Next edge at/after (now + LEAD). let mut target = edge + period; while target < now.saturating_add(LEAD_NS) { target += period; } let delay = target.saturating_sub(now + LEAD_NS); if delay > 0 { std::thread::sleep(std::time::Duration::from_nanos(delay)); } let landed = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns() .saturating_sub(target); // WAKE PROBE: log every attempt (120/s, non-blocking logger) so we // can see the nanosleep landing distribution: `land` is ns relative // to the edge target (negative = early). H1 (hypotheses-120hz.md): // if land is systematically > +1 ms the waker skips every wake and // the late Choreographer wake (~edge+5) wins every slot. if i_slint_renderer_skia::wgpu_30_surface::vkprobe::EW_ON .load(Ordering::Relaxed) { log::info!("EW land={}ms", (landed as i64 - 800_000) / 1000); } // Wake only if we landed within ~1 ms of the edge. If we overshot // (CPU starvation), the Choreographer's late wake (~5 ms after the // edge) covers the frame instead. // // EW_ON (2026-07-09) now gates the WAKE itself, not just the EW log. // The waker was built on the refuted "callback 3-6 ms late" model; // the Choreographer fires at edge+0.52 ms (measured) and is the // single correct wake source. Two wake sources race the slot guard // and each renders at its own phase — the measured render-phase // wander (-4..+4 ms) that turned a 120 Hz stream into 12-17 ms // gaps (drops) and 4-6 ms gaps (doubles). With the Choreographer // alone, the render phase is pinned to edge+~0.5-1.0 ms. if landed <= 1_000_000 && i_slint_renderer_skia::wgpu_30_surface::vkprobe::EW_ON .load(Ordering::Relaxed) { waker.wake(); } } } // Jank-hunt: frame-alignment probes. `frame_time_nanos` is the Choreographer // vsync timestamp (System.nanoTime epoch = CLOCK_MONOTONIC on Android); we // ALSO stamp CLOCK_MONOTONIC here so VSYNC and PRES lines share one clock and // we can measure, per presented frame, how far past the vsync edge it landed // (phase). If presents systematically land in the back half of the 8.3 ms // window the compositor shows them one vsync late → effective 60 Hz + judder // even though our frame-to-frame cadence looks like 120 Hz. unsafe fn mon_ns() -> u64 { #[repr(C)] struct Ts { tv_sec: i64, tv_nsec: i64, } unsafe extern "C" { fn clock_gettime(clkid: i32, tp: *mut Ts) -> i32; } const CLOCK_MONOTONIC: i32 = 1; let mut ts = Ts { tv_sec: 0, tv_nsec: 0 }; if unsafe { clock_gettime(CLOCK_MONOTONIC, &mut ts) } == 0 { ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64 } else { 0 } } unsafe extern "C" fn frame_callback( frame_time_nanos: core::ffi::c_long, data: *mut core::ffi::c_void, ) { let shared = &*(data as *const Shared); // Publish the exact vsync edge (CLOCK_MONOTONIC ns — the same epoch as // `mon_ns()` above) FIRST: the trampoline reads VSYNC_EDGE_NS, and the // slot guard in the event loop keys off it. i_slint_renderer_skia::wgpu_30_surface::vkprobe::note_vsync_edge( frame_time_nanos as u64, ); // CRITICAL PATH: deliver the edge to the main loop. (1) the dedicated // eventfd — its trampoline runs inside ALooper_pollOnce and CANNOT be // swallowed by a co-batched input response (the wake-fd race); (2) // ALooper_wake as secondary (still the normal PollEvent::Wake path when // no other fd is ready in the batch). The VSYNC log line below is a // file write (50-200us typical) — kept after both wakes so it cannot // push the main loop's render start later in the slot. let evfd = shared.evfd_raw.load(Ordering::Acquire); if evfd >= 0 { let one: u64 = 1; libc::write( evfd, &one as *const u64 as *const core::ffi::c_void, core::mem::size_of::(), ); } shared.main_waker.wake(); let mon = unsafe { mon_ns() }; if mon > 0 { log::info!("VSYNC ftn={} mon={}", frame_time_nanos, mon); } let mut state = shared.state.lock().unwrap(); if (state.animating || state.dirty) && !state.quit { unsafe { ffi::AChoreographer_postFrameCallback( shared.choreographer, frame_callback, data, ); } } else { state.callback_posted = false; } } fn vsync_thread(shared: Arc) { let looper = ThreadLooper::prepare(); *shared.looper.lock().unwrap() = Some(looper.as_foreign().clone()); let choreographer = unsafe { ffi::AChoreographer_getInstance() }; if choreographer.is_null() { return; } let ptr = Arc::as_ptr(&shared) as *mut Shared; unsafe { (*ptr).choreographer = choreographer; } shared.driving.store(true, Ordering::Release); let raw_shared = Arc::into_raw(shared); loop { { let mut state = unsafe { &(*raw_shared).state }.lock().unwrap(); if state.quit { break; } // Post the FIRST frame callback ONLY when we're animating AND no // callback is already pending. While animating, `frame_callback` // re-posts itself on every vsync (self-sustaining chain), so a // callback is always pending — we must NOT post another here, or // the pending-callback count grows by one every vsync (1, 2, 3, // 4…) and each vsync fires N callbacks → N main-loop wakes with N // unbounded. That was the ~1600 iters/s busy-spin. See // docs/BUGS/scroll-jank-120hz.md. if (state.animating || state.dirty) && !state.callback_posted { state.callback_posted = true; unsafe { ffi::AChoreographer_postFrameCallback( choreographer, frame_callback, raw_shared as *mut core::ffi::c_void, ); } } } // Block until the next Choreographer message (a vsync fires the frame // callback) or a wake (set_animating/quit). While animating the // self-sustaining chain keeps delivering one message per vsync; when // the animation stops the chain ends and this blocks until the next // animation's set_animating(true) wakes the looper. let _ = looper.poll_once(); } let _ = unsafe { Arc::from_raw(raw_shared) }; }