Repository navigation
Wake for the next frame on time on macOS - #5
Conversation
macOS coalesces timers. The headless window system's display thread slept until each refresh with `std::thread::sleep`, and a native window waits for a redraw it held back with winit's `WaitUntil`: either can be woken milliseconds late, up to half a frame at 60 Hz. On an M5 Max the headless refreshes at 60 Hz came 3 to 5.5 ms late at the median and up to 8 ms, and a guest that drew every frame saw 20 to 23 ms between frames at p95, not 16.7. src/macos_deadline.rs is a one-shot libdispatch timer made with `DISPATCH_TIMER_STRICT` and no leeway, which fires within a fraction of a millisecond of its time. The headless display thread waits for each refresh on it; a plain sleep remains where the timer cannot be made, and on other systems. The native loop arms it for the earliest redraw held back for a window that is shown and in sight, and disarms it when the platform suspends. When it fires it only wakes the loop, as a guest's command does: no guest code runs on libdispatch's thread. The loop's own `WaitUntil` is as it was. Nothing stays armed while nothing waits. The timer's state is freed by its cancel handler, which libdispatch runs once, after the last event handler has returned: dropping the timer does not wait for a notification on its way, which may be waiting for a lock its owner holds. A fire before the stored time, for a time since replaced or a tick early from rounding nanoseconds to ticks, sets the timer again instead of being lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
syrusakbary
left a comment
There was a problem hiding this comment.
Deep review of adb93c5, including the full diff, headless scheduling, winit deadline selection, and libdispatch timer/cancellation semantics. I found no blocking correctness issue. The timer only wakes the existing scheduler, preserving the distinction between a host wake and a presented frame; keeping the existing frame policy and WaitUntil fallback is appropriate for the native Wasmer adaptation.
The lifecycle looks sound: timer updates are serialized with the stored deadline, stale/early callbacks recheck that deadline, callbacks run after releasing the mutex, and the context is freed by the cancellation handler after in-flight event handling completes. The headless callback owns a thread handle, and the winit callback uses a weak reference rather than borrowing the app. Idle/disarmed paths were also checked.
Validation: all 93 native library tests passed with --features winit, including the eight timer tests. I did not independently run the real-window or latency benchmarks reported in the PR. It would be useful to retain the benchmark harness as an ignored/manual test so future changes can reproduce the distribution; avoid a tight latency threshold in ordinary CI because DISPATCH_TIMER_STRICT remains a best-effort scheduling request (Apple's timer-source contract). One nonblocking simplification suggestion is inline.
|
|
||
| // libdispatch is part of libSystem, which every program on macOS links. | ||
| #[link(name = "System")] | ||
| unsafe extern "C" { |
There was a problem hiding this comment.
[P3] Consider using the existing dispatch2 bindings
Cargo.lock already contains dispatch2 0.3.1 for the native window stack. Making it a direct macOS dependency would let this module use its typed DispatchQueue/DispatchSource bindings and DispatchRetained instead of maintaining the hand-written ABI declarations, type-erased Object pointers, constants, and manual reference releases. The crate exposes the timer and C callback APIs needed here (DispatchSource, DispatchRetained). Keep the explicit cancel-handler ownership of Context, since source reference counting alone does not replace that lifecycle guarantee. This is a maintainability suggestion, not a finding that the current cancellation code is incorrect.
The timer declared libdispatch's functions, constants and types itself, and passed its objects around as untyped pointers that it released by hand. dispatch2, already in Cargo.lock for the native window stack, has typed bindings for all of it: queues, timer sources, `dispatch_time`, and references that release themselves. It is now a dependency on macOS, without its default features, at the version the lock has. The context its handlers share now holds a reference to the source of its own, so setting the timer takes no unsafe code. The cancel handler still owns the context and frees it: reference counting alone does not say when the last event handler has returned. One declaration stays: `dispatch_source_create`, typed as dispatch2 types it. dispatch2's `DispatchSource::new` panics where libdispatch makes no source, which it does for arguments it rejects; declared here, the timer answers `None` instead, and its owners wait with a sleep as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The numbers that showed macOS waking the headless display late came from a
measurement that was not kept. `frame_pacing`, an ignored test on the host's
test rig, takes it again: a guest that draws every frame on a 60 Hz display
with a real clock, 600 frames, with the strict timer and then with the sleep
it falls back on, which a switch that exists only in tests forces. It prints
the distribution of frame intervals and of how late each refresh came, and
checks none of it: a strict timer is a request the system does its best
with, and a busy machine is not a failure.
cargo test --release --features cli --lib frame_pacing -- --ignored --nocapture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS, the timers that pace frames are coalesced and often fire milliseconds late. Two of ours are affected: the headless window system's display thread sleeps until each refresh with
std::thread::sleep, and a native window waits for a redraw it held back with winit'sControlFlow::WaitUntil. At 60 Hz the headless refreshes arrived up to 8 ms late, so a guest that draws every frame saw a frame-interval p95 of 20–23 ms. This PR wakes both from a one-shot libdispatch timer made withDISPATCH_TIMER_STRICTand zero leeway. With it, refreshes arrive within 0.1 ms of their deadline and the p95 is 16.7 ms. A browser engine running as a guest (WebKit compiled to WASIX) measured the same change: its 60 Hz frame-interval p95 went from 23–25 ms to 16.7 ms.Change
src/macos_deadline.rs(macOS only) addsDeadline, a one-shot timer:DISPATCH_SOURCE_TYPE_TIMERwithDISPATCH_TIMER_STRICT, zero leeway and intervalDISPATCH_TIME_FOREVER, on a private serial queue.dispatch2's typed bindings (queues, sources,dispatch_time, self-releasing references).dispatch20.3.1 was already inCargo.lockfor winit; it is now a direct macOS dependency with only itsallocfeature, and the lock gains no package. The one declaration kept isdispatch_source_create, typed asdispatch2types it:DispatchSource::newpanics where libdispatch makes no source, andDeadline::newanswersNonethere instead, so its owners fall back to sleeping.arm(Some(t))replaces the pending time andarm(None)disarms. The event handler calls the owner'snotifyonly onceInstant::now() >= t.Instant(CLOCK_UPTIME_RAW) anddispatch_time(DISPATCH_TIME_NOW, …)(mach_absolute_time) count the same uptime.Deadlinecancels the source without waiting for a notification in flight, since that notification may be blocked on a lock the dropper holds.src/headless.rs: the display thread waits for each refresh boundary withDeadline::wait_until. The timer unparks the thread, andpark_timeoutremains as a fallback. If the timer cannot be created, it falls back to a plain sleep. The code for other systems is unchanged.src/native/app.rs:about_to_waitarms the timer for the earliest held-back redraw of a window that is shown and in sight, andsuspendeddisarms it.Weak<Shared>and, if the loop is running, callsproxy.send_event(()), the same wake-up a guest's command sends. No guest code runs on libdispatch's thread.ControlFlow::WaitUntilis set as before, so the loop still wakes if the timer does not.Apple warns that strict timers cost power. This one is armed only while a frame is awaited, fires once, and nothing stays armed while idle.
Measurements
All runs were on an Apple M5 Max running macOS 27.0, on AC power, with debug builds.
Headless, 60 Hz. The
#[ignore]testframe_pacingon the crate's test rig (src/host/tests.rs). It prints the distributions and asserts nothing about them, since a strict timer is a request the system does its best with:Pacedheadless system with a real clock. A stand-in guest asks for a redraw, polls untilWindowRedrawRequestedarrives, records the record's timestamp, and repeats 600 times.std::thread::sleepuntil the boundary, through a switch that exists only in test builds.thread::sleep(3 runs)thread::sleep, poll with 1 ms sleeps (2 runs)The sleep's lateness depends on the machine's state. In an earlier session the same day it was 3.3 ms at the median and 4.2 ms at most, with a frame-interval p95 of 20.0–20.1 ms. With the strict timer, results stayed the same across sessions.
A later release-build run of the kept test, on a busy machine (load 9–16): the strict timer kept a p95 interval of 16.68–16.69 ms and lateness under 0.07 ms at p95 in 4 of 5 runs, while the sleep gave a p95 of 19.1–19.9 ms and 2.9–3.3 ms late at the median in all 5.
Native window, built-in 120 Hz display. This harness is not part of this PR.
gui_wasmer --window --inactiveand thepixelsfeature. It asks for a redraw as soon as it is told to draw, presents a 160×120 frame, and runs 1200 frames. Intervals are between theWindowRedrawRequestedtimestamps.WaitUntilalone.Tests
All run locally on macOS arm64 (M5 Max), with Rust 1.95 unless noted.
cargo fmt --checkmake lint(clippy-D warnings,cliandcli,winit,clipboard,webgpu,pixels,--all-targets)make checkmake testframe_pacingignored), 2 ABI sweep, 4 embedder, 1 manifest test (2 ignored, as onmain)macos_deadlinetests 15× in a row, 30× six at a time, and 10× next to 36 busy loops on 18 corescargo build --features cli,winit,clipboard,webgpu,pixels --all-targetsWGUI_TEST_WINDOWS=1 cargo test --features cli,winit,pixels --test native_windowsThe timer tests are written for slow CI runners:
CI lints only on Linux, where this module is compiled out. On macOS-15, CI builds it and runs its tests but does not lint it.
🤖 Generated with Claude Code