mirror of
https://github.com/zed-industries/zed.git
synced 2026-10-20 11:46:49 +00:00
gpui: Journal platform frame requests and skipped frames (#64958)
## Summary Pass captured platform frame request timing to `on_request_frame` and emit foreground journal records. This PR provides timing data; it does not classify platform waits or change latency histogram/reporting policy. `RequestFrameOptions` carries an optional captured time and a native-callback/local-schedule source. A per-window atomic preserves the earliest coalesced request timestamp and its matching source until main-thread delivery. Journal records include window identity, captured time when available, foreground handling time, and source. No request IDs or second journal stream are introduced. ## Completed frame skips (retained for review) Keep completed GPUI decisions not to render as sealing boundaries, with no-render-needed, inactive-throttle, and thermal-throttle reasons. They seal at foreground completion without satisfying or clearing pending demand. Hang serialization uses `sealed_by: "frame_skipped"`. Platform frame requests do not seal or count as foreground work. Reentrant deferrals remain non-sealing raw diagnostic records with their own window/time payload; they cannot be represented as completed skip reasons or sealing skip boundaries. Raw point diagnostics are not copied into snapshots. Hang qualification still uses foreground execution durations and occupancy; infinite foreground hang detection remains follow-up work. The skip-sealing portion is intentionally retained for review and can be split out if requested. The signal-to-present histogram, refresh estimation, platform-wait filtering, and Frame Duration Report changes are deferred. The complete prior checkpoint is preserved on `platform-frame-latency-followup`; it is not a second PR yet. ## Platform sources - **macOS:** CoreVideo’s supplied current host timestamp is converted to an `Instant` using paired host-clock/local-clock samples, with receipt-time fallback for invalid timestamps. Capture happens before registry locking/coalescing; synchronous `displayLayer` and key-window draws use call time. `WindowFrameSource::stop()` clears stale signals after detaching the subscription. - **Windows:** VSyncProvider time immediately after waiting, before redraw invalidation. Compositor-paced waits are tagged `NativeCallback`; failed or short waits followed by the existing fallback sleep are tagged `LocalSchedule`. Coalescing retains the earliest timestamp and its matching source together; hidden/minimized slots are cleared. Wait and redraw behavior are unchanged. - **Wayland:** compositor milliseconds converted against CLOCK_MONOTONIC with wrapping subtraction and a 60-second plausibility bound; receipt-time fallback. Demand pings retain enqueue time; retries retain scheduled deadlines. - **X11:** scheduled calloop refresh deadline; expose requests have no captured time. - **Web:** no captured timestamp yet, but requests are still journaled with foreground handling time. Captured-time option fields remain available regardless of the profiler feature so independently featured backends share one API shape. Built-in backends collect timestamps only with `gpui/profiler`: disabled capture does not evaluate clock/conversion closures, the accumulator has no timestamp storage or atomic operations, and callback delivery and actual scheduling clocks remain unchanged. Wayland's protocol does not guarantee the clock epoch. Windows fully covered-window behavior and X11 compositor gating beyond the timer remain limitations of these sources. Live compositor/display-off/lock workflows remain unverified. Skip-boundary changes touch `journal.rs` and `hang.rs`. This PR will merge before the parallel hang-watchdog work; that follow-up PR will resolve its conflicts against these changes. ## Verification Latest feature-gating checks: the profiler-disabled capture regression passed; enabled coalescing and window integration tests passed; macOS and GPUI/Web Wasm compiled with profiling both disabled and enabled. An optimized probe built from the shared implementation confirmed disabled capture/accumulator paths contain no calls or atomic instructions. Clippy, formatting, and whitespace checks passed. Latest-head CI passed Linux, Windows, and macOS tests and Clippy, along with Wasm and style checks. Latest type/guard cleanup: seven focused journal, hang, and window tests passed, along with the profiler-disabled GPUI library check, Clippy, formatting, and whitespace checks. The consecutive profiler guards are grouped without shortening the foreground-turn guard lifetime. - Previous extracted checkpoint: `cargo test -p gpui --features profiler --lib -- --test-threads=1`: **431 passed**. - Focused TestPlatform tests exercise request/source emission and pending demand surviving throttling, observing callback execution and draw counts. No wall-clock sleeps are used by the new window tests. - `corgi check -p gpui_macos --lib`: passed. - `./script/clippy -p gpui -p gpui_macos -p input_latency_ui`: passed, including repository hygiene checks. - GPUI + Web Wasm check with profiler and repository CI build-std/atomics flags: passed. - Formatting and `git diff --check`: passed. Corgi's parallel suite passed 436 of 437 tests but encountered the previously observed hang-monitor shutdown channel timing failure. That test passed in the serial Cargo suite and was not changed or disabled. The preceding checkpoint passed Linux, Windows, macOS, and Wasm CI. CI also passed on the current head, `3641f14ab0`. Local cross-target checks remain constrained by missing Linux system libraries and Windows SDK tooling on the macOS host. ## Suggested .rules additions When manually driving platform frame requests in GPUI tests, account for test-mode automatic draws during effect flushing. For throttle-routing tests, configure a comfortably long throttle interval instead of relying on wall-clock sleeps or short scheduling deadlines. Release Notes: - [GPUI] Added captured platform frame request timing and foreground journal records, including sealing boundaries for completed decisions not to render.
This commit is contained in:
parent
7bbd162ddf
commit
cd4fc8de4c
17 changed files with 1050 additions and 77 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -7685,6 +7685,7 @@ dependencies = [
|
|||
"core-foundation-sys",
|
||||
"core-graphics 0.24.0",
|
||||
"core-text",
|
||||
"core-video",
|
||||
"ctor",
|
||||
"dispatch2",
|
||||
"foreign-types 0.5.0",
|
||||
|
|
@ -7706,6 +7707,7 @@ dependencies = [
|
|||
"parking_lot",
|
||||
"pathfinder_geometry",
|
||||
"raw-window-handle",
|
||||
"scheduler",
|
||||
"semver",
|
||||
"smallvec",
|
||||
"strum 0.28.0",
|
||||
|
|
@ -7842,6 +7844,7 @@ dependencies = [
|
|||
"parking_lot",
|
||||
"rand 0.9.4",
|
||||
"raw-window-handle",
|
||||
"scheduler",
|
||||
"smallvec",
|
||||
"uuid",
|
||||
"windows 0.62.2",
|
||||
|
|
|
|||
|
|
@ -798,6 +798,17 @@ pub struct A11yCallbacks {
|
|||
pub deactivation: Box<dyn Fn() + Send + 'static>,
|
||||
}
|
||||
|
||||
/// The source of a platform frame request's timestamp.
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum FrameRequestSource {
|
||||
/// An OS callback or compositor-paced frame request.
|
||||
#[default]
|
||||
NativeCallback,
|
||||
/// A refresh timer, retry, queued wakeup, or fallback sleep paced by the app.
|
||||
LocalSchedule,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
|
||||
#[expect(missing_docs)]
|
||||
pub struct RequestFrameOptions {
|
||||
|
|
@ -805,6 +816,103 @@ pub struct RequestFrameOptions {
|
|||
pub require_presentation: bool,
|
||||
/// Force refresh of all rendering states when true.
|
||||
pub force_render: bool,
|
||||
/// When the platform first requested this frame, before main-thread dispatch.
|
||||
///
|
||||
/// `None` means the captured request time is unavailable.
|
||||
/// Coalesced requests carry their first request time, not their delivery time.
|
||||
/// Built-in backends collect timestamps only with the `profiler` feature.
|
||||
pub signal_at: Option<Instant>,
|
||||
/// Distinguishes native callbacks from local scheduling requests.
|
||||
pub signal_source: FrameRequestSource,
|
||||
}
|
||||
|
||||
/// Preserves the first platform frame request time across coalesced notifications.
|
||||
///
|
||||
/// Producers may record from a platform thread without waiting for the UI thread.
|
||||
/// The consumer drains the timestamp before dispatching the request on the UI thread.
|
||||
/// The timestamp offset and source share one atomic word so coalescing cannot
|
||||
/// mix a request's time with another request's source. The low bit encodes the
|
||||
/// source; the remaining bits encode nanoseconds from `origin`. `u64::MAX` is empty.
|
||||
/// Without the `profiler` feature, this accumulator has no timestamp storage.
|
||||
pub struct PlatformFrameSignal {
|
||||
#[cfg(feature = "profiler")]
|
||||
origin: Instant,
|
||||
#[cfg(feature = "profiler")]
|
||||
first_signal: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for PlatformFrameSignal {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformFrameSignal {
|
||||
/// Creates an empty signal accumulator.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
#[cfg(feature = "profiler")]
|
||||
origin: Instant::now(),
|
||||
#[cfg(feature = "profiler")]
|
||||
first_signal: std::sync::atomic::AtomicU64::new(u64::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures a platform request timestamp only when profiling is enabled.
|
||||
///
|
||||
/// The closure is not invoked in profiler-disabled builds.
|
||||
#[inline]
|
||||
pub fn capture(capture: impl FnOnce() -> Instant) -> Option<Instant> {
|
||||
if cfg!(feature = "profiler") {
|
||||
Some(capture())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a platform frame request, retaining the first undrained timestamp.
|
||||
#[inline]
|
||||
pub fn record(&self, at: Instant, source: FrameRequestSource) {
|
||||
#[cfg(feature = "profiler")]
|
||||
{
|
||||
let nanoseconds = at
|
||||
.saturating_duration_since(self.origin)
|
||||
.as_nanos()
|
||||
.min(u128::from((u64::MAX >> 1) - 1)) as u64;
|
||||
let source_bit = match source {
|
||||
FrameRequestSource::NativeCallback => 0,
|
||||
FrameRequestSource::LocalSchedule => 1,
|
||||
};
|
||||
self.first_signal.fetch_min(
|
||||
(nanoseconds << 1) | source_bit,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
#[cfg(not(feature = "profiler"))]
|
||||
let _ = (at, source);
|
||||
}
|
||||
|
||||
/// Drains the first platform frame request time and source, leaving the accumulator empty.
|
||||
#[inline]
|
||||
pub fn take(&self) -> Option<(Instant, FrameRequestSource)> {
|
||||
#[cfg(feature = "profiler")]
|
||||
{
|
||||
let signal = self
|
||||
.first_signal
|
||||
.swap(u64::MAX, std::sync::atomic::Ordering::Relaxed);
|
||||
(signal != u64::MAX).then(|| {
|
||||
let source = match signal & 1 {
|
||||
0 => FrameRequestSource::NativeCallback,
|
||||
_ => FrameRequestSource::LocalSchedule,
|
||||
};
|
||||
(self.origin + Duration::from_nanos(signal >> 1), source)
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "profiler"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The application's lifecycle phase, as owned and reported by a mobile OS.
|
||||
|
|
@ -3274,6 +3382,51 @@ mod atlas_tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod frame_signal_tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "profiler")]
|
||||
#[test]
|
||||
fn coalesced_signals_retain_the_earliest_until_drained() {
|
||||
for (first_source, later_source) in [
|
||||
(
|
||||
FrameRequestSource::NativeCallback,
|
||||
FrameRequestSource::LocalSchedule,
|
||||
),
|
||||
(
|
||||
FrameRequestSource::LocalSchedule,
|
||||
FrameRequestSource::NativeCallback,
|
||||
),
|
||||
] {
|
||||
let signal = PlatformFrameSignal::new();
|
||||
let first =
|
||||
PlatformFrameSignal::capture(Instant::now).expect("profiling captures requests");
|
||||
assert_eq!(signal.take(), None);
|
||||
signal.record(first + Duration::from_millis(16), later_source);
|
||||
signal.record(first, first_source);
|
||||
signal.record(first + Duration::from_millis(32), later_source);
|
||||
assert_eq!(signal.take(), Some((first, first_source)));
|
||||
assert_eq!(signal.take(), None);
|
||||
let next = first + Duration::from_millis(48);
|
||||
signal.record(next, later_source);
|
||||
assert_eq!(signal.take(), Some((next, later_source)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "profiler"))]
|
||||
#[test]
|
||||
fn disabled_profiling_does_not_capture_or_store_timestamps() {
|
||||
let captured = PlatformFrameSignal::capture(|| {
|
||||
panic!("profiler-disabled builds must not evaluate the capture closure");
|
||||
});
|
||||
assert_eq!(captured, None);
|
||||
let signal = PlatformFrameSignal::new();
|
||||
signal.record(Instant::now(), FrameRequestSource::LocalSchedule);
|
||||
assert_eq!(signal.take(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ use super::journal::{
|
|||
|
||||
/// Detects foreground hangs by polling the journal.
|
||||
///
|
||||
/// Detection is post-hoc: a hang is reported once an explicit presentation
|
||||
/// or foreground-idle boundary completes its interval. Work that never
|
||||
/// yields back to the foreground is not observed until it does.
|
||||
/// Detection is post-hoc: a hang is reported once an explicit presentation,
|
||||
/// completed frame skip, or foreground-idle boundary completes its interval.
|
||||
/// Work that never yields back to the foreground is not observed until it does.
|
||||
pub struct HangDetector {
|
||||
collector: ForegroundJournalCollector,
|
||||
sealer: IntervalSealer,
|
||||
|
|
@ -145,8 +145,9 @@ pub struct SerializedHangIncident {
|
|||
/// For presentation-sealed incidents, how long the submitted frame had
|
||||
/// been dirty, in milliseconds.
|
||||
pub dirty_to_present_ms: Option<f64>,
|
||||
/// What closed the incident: `"present"`, `"idle"`, or `"power_transition"`. This labels the
|
||||
/// boundary, not the hang's cause — the cause is the first contributor.
|
||||
/// What closed the incident: `"present"`, `"frame_skipped"`, `"idle"`, or
|
||||
/// `"power_transition"`. This labels the boundary, not the hang's cause —
|
||||
/// the cause is the first contributor.
|
||||
pub sealed_by: &'static str,
|
||||
/// Fraction of the active window the foreground spent working,
|
||||
/// `0.0..=1.0`. Low values with a high `dirty_to_present_ms` indicate
|
||||
|
|
@ -294,10 +295,13 @@ impl SerializedHangIncident {
|
|||
IntervalBoundary::Presented(presented) => {
|
||||
presented.dirty_to_present_duration().map(as_millis)
|
||||
}
|
||||
IntervalBoundary::Idle { .. } | IntervalBoundary::PowerTransition { .. } => None,
|
||||
IntervalBoundary::FrameSkipped(_)
|
||||
| IntervalBoundary::Idle { .. }
|
||||
| IntervalBoundary::PowerTransition { .. } => None,
|
||||
},
|
||||
sealed_by: match snapshot.boundary {
|
||||
IntervalBoundary::Presented(_) => "present",
|
||||
IntervalBoundary::FrameSkipped(_) => "frame_skipped",
|
||||
IntervalBoundary::Idle { .. } => "idle",
|
||||
IntervalBoundary::PowerTransition { .. } => "power_transition",
|
||||
},
|
||||
|
|
@ -483,8 +487,9 @@ mod tests {
|
|||
use crate::profiler::{ActionTiming, FrameTiming, PresentTiming, TaskTiming, YieldTime};
|
||||
|
||||
use super::super::journal::{
|
||||
FRAME_DEADLINE, ForegroundEvent, ForegroundJournalEntry, FrameSnapshot, FrameStateChange,
|
||||
InputTiming, IntervalBoundary, PollSummary, PresentedFrame, SmallPollFlush,
|
||||
FRAME_DEADLINE, ForegroundEvent, ForegroundJournalEntry, FrameSkipReason, FrameSkipped,
|
||||
FrameSnapshot, FrameStateChange, InputTiming, IntervalBoundary, IntervalSealer,
|
||||
PlatformSignal, PollSummary, PresentedFrame, SmallPollFlush,
|
||||
install_test_foreground_journal, record_present,
|
||||
};
|
||||
use super::{
|
||||
|
|
@ -500,6 +505,84 @@ mod tests {
|
|||
// today.
|
||||
const FRAME_BUDGET: Duration = HANG_THRESHOLD;
|
||||
|
||||
#[test]
|
||||
fn completed_skips_preserve_real_hangs_and_serialize_their_boundary() {
|
||||
let startup = scheduler::Instant::now();
|
||||
let at = |ms: u64| startup + Duration::from_millis(ms);
|
||||
let window_id = WindowId::from(1);
|
||||
for discontinuous in [false, true] {
|
||||
for trigger in [HangTrigger::Threshold, HangTrigger::Budget] {
|
||||
let mut sealer = IntervalSealer::new(startup);
|
||||
let mut entries = Vec::new();
|
||||
match trigger {
|
||||
HangTrigger::Threshold => entries.push(ForegroundJournalEntry::Event(
|
||||
task_poll_event(at(10), at(30)),
|
||||
)),
|
||||
HangTrigger::Budget => entries.extend([
|
||||
ForegroundJournalEntry::Event(task_poll_event(at(10), at(16))),
|
||||
ForegroundJournalEntry::Event(task_poll_event(at(20), at(26))),
|
||||
]),
|
||||
}
|
||||
if discontinuous {
|
||||
entries.push(ForegroundJournalEntry::Discontinuity { lost: 1 });
|
||||
}
|
||||
entries.push(ForegroundJournalEntry::Boundary(
|
||||
IntervalBoundary::FrameSkipped(FrameSkipped {
|
||||
window_id,
|
||||
at: at(60),
|
||||
reason: FrameSkipReason::NoRenderNeeded,
|
||||
}),
|
||||
));
|
||||
let snapshots = sealer.push_entries(entries);
|
||||
let [snapshot] = snapshots.as_slice() else {
|
||||
panic!("expected one snapshot, got {snapshots:?}");
|
||||
};
|
||||
let incident = HangIncident::detect(snapshot.clone(), HANG_THRESHOLD, FRAME_BUDGET);
|
||||
if discontinuous && trigger == HangTrigger::Budget {
|
||||
assert!(incident.is_none(), "skips must not exempt journal gaps");
|
||||
continue;
|
||||
}
|
||||
let incident = incident.expect("real work still qualifies across a skip");
|
||||
assert_eq!(incident.trigger, trigger);
|
||||
let serialized = SerializedHangIncident::convert(startup, &incident, 10, None);
|
||||
assert_eq!(serialized.sealed_by, "frame_skipped");
|
||||
assert_eq!(serialized.dirty_to_present_ms, None);
|
||||
assert_eq!(serialized.start_ms, 10.0);
|
||||
assert_eq!(serialized.active_ms, 50.0);
|
||||
assert_eq!(serialized.journal_discontinuous, discontinuous);
|
||||
assert_eq!(serialized.event_count, incident.contributors.len());
|
||||
let json = serde_json::to_value(&serialized).expect("serialize incident");
|
||||
assert_eq!(json["sealed_by"], "frame_skipped");
|
||||
assert!(json["dirty_to_present_ms"].is_null());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_platform_signals_without_work_are_not_hangs() {
|
||||
let start = scheduler::Instant::now();
|
||||
let window_id = WindowId::from(1);
|
||||
let mut sealer = IntervalSealer::new(start);
|
||||
let snapshots = sealer.push_entries([
|
||||
ForegroundJournalEntry::PlatformSignal(PlatformSignal {
|
||||
window_id,
|
||||
signal_at: Some(start),
|
||||
handled_at: start + Duration::from_secs(5),
|
||||
source: crate::FrameRequestSource::NativeCallback,
|
||||
}),
|
||||
ForegroundJournalEntry::Boundary(IntervalBoundary::FrameSkipped(FrameSkipped {
|
||||
window_id,
|
||||
at: start + Duration::from_secs(6),
|
||||
reason: FrameSkipReason::NoRenderNeeded,
|
||||
})),
|
||||
]);
|
||||
let [snapshot] = snapshots.as_slice() else {
|
||||
panic!("expected one snapshot, got {snapshots:?}");
|
||||
};
|
||||
assert_eq!(snapshot.occupancy(), Duration::ZERO);
|
||||
assert!(HangIncident::detect(snapshot.clone(), HANG_THRESHOLD, FRAME_BUDGET).is_none());
|
||||
}
|
||||
|
||||
/// A hang that outlives the frame deadline stays in one incident with
|
||||
/// the frame it starved: the deadline only unblocks idle boundaries, so
|
||||
/// the eventual presentation seals the hang together with its
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
//! bound the stream by the number of slow polls while preserving their exact
|
||||
//! count and total duration.
|
||||
//!
|
||||
//! Presentation and the foreground going idle are explicit [`IntervalBoundary`]
|
||||
//! entries in the same stream. Independent [`ForegroundJournalCollector`]s
|
||||
//! Presentation, completed frame skips, and foreground idle are explicit
|
||||
//! [`IntervalBoundary`] entries in the same stream. Independent [`ForegroundJournalCollector`]s
|
||||
//! feed entries to [`IntervalSealer`], a pure state machine that groups all work
|
||||
//! preceding each boundary into a [`FrameSnapshot`]. The sealer does not infer
|
||||
//! boundaries from elapsed time or from incidental event kinds such as draws.
|
||||
|
|
@ -42,8 +42,8 @@ pub const TASK_POLL_FLOOR: Duration = Duration::from_micros(100);
|
|||
/// foreground-idle boundaries, so a window that never presents (hidden or
|
||||
/// occluded windows receive no frame callbacks) cannot suppress boundaries
|
||||
/// indefinitely. Expiry only unblocks: the open interval still seals at a
|
||||
/// real presentation or idle boundary, keeping a starving hang and the frame
|
||||
/// it starved in one interval.
|
||||
/// presentation, completed frame skip, or idle boundary, keeping a starving
|
||||
/// hang and the callback it starved in one interval.
|
||||
pub const FRAME_DEADLINE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Growth in [`system_suspended_time`] below this is attributed to reading
|
||||
|
|
@ -139,6 +139,41 @@ pub struct InputTiming {
|
|||
pub caused_invalidation: bool,
|
||||
}
|
||||
|
||||
/// A frame callback entering the foreground, without implying foreground work.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct PlatformSignal {
|
||||
/// The window receiving the callback.
|
||||
pub window_id: WindowId,
|
||||
/// When the platform requested the callback, if its clock is supported.
|
||||
pub signal_at: Option<Instant>,
|
||||
/// When foreground handling began.
|
||||
pub handled_at: Instant,
|
||||
/// Whether the callback came from the platform or local scheduling.
|
||||
pub source: crate::FrameRequestSource,
|
||||
}
|
||||
|
||||
/// Why a completed frame request did not draw a new frame.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum FrameSkipReason {
|
||||
/// The callback completed without needing a new render.
|
||||
NoRenderNeeded,
|
||||
/// An inactive window deferred drawing.
|
||||
InactiveFrameRateLimit,
|
||||
/// Thermal throttling deferred drawing.
|
||||
ThermalFrameRateLimit,
|
||||
}
|
||||
|
||||
/// A completed frame request that returned without drawing a new frame.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct FrameSkipped {
|
||||
/// The window whose callback returned.
|
||||
pub window_id: WindowId,
|
||||
/// When the foreground completed this callback.
|
||||
pub at: Instant,
|
||||
/// Why no new frame was drawn.
|
||||
pub reason: FrameSkipReason,
|
||||
}
|
||||
|
||||
/// Exact count and total duration of task polls below [`TASK_POLL_FLOOR`].
|
||||
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PollSummary {
|
||||
|
|
@ -189,6 +224,8 @@ impl PresentedFrame {
|
|||
pub enum IntervalBoundary {
|
||||
/// A newly drawn frame was submitted to the platform.
|
||||
Presented(PresentedFrame),
|
||||
/// A callback completed without drawing, excluding reentrant callbacks.
|
||||
FrameSkipped(FrameSkipped),
|
||||
/// The foreground returned to an idle platform loop with no unexpired
|
||||
/// frame pending.
|
||||
Idle {
|
||||
|
|
@ -209,6 +246,7 @@ impl IntervalBoundary {
|
|||
pub fn end_time(&self) -> Instant {
|
||||
match self {
|
||||
Self::Presented(presented) => presented.presentation.present_end,
|
||||
Self::FrameSkipped(skipped) => skipped.at,
|
||||
Self::Idle { ended_at } | Self::PowerTransition { ended_at } => *ended_at,
|
||||
}
|
||||
}
|
||||
|
|
@ -217,7 +255,7 @@ impl IntervalBoundary {
|
|||
pub fn dirty_at(&self) -> Option<Instant> {
|
||||
match self {
|
||||
Self::Presented(presented) => presented.frame.dirty_at,
|
||||
Self::Idle { .. } | Self::PowerTransition { .. } => None,
|
||||
Self::FrameSkipped(_) | Self::Idle { .. } | Self::PowerTransition { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -251,6 +289,15 @@ pub enum ForegroundJournalEntry {
|
|||
Boundary(IntervalBoundary),
|
||||
/// A change to pending-frame state. This is metadata, not foreground work.
|
||||
FrameState(FrameStateChange),
|
||||
/// A callback entering the foreground; not work or an interval boundary.
|
||||
PlatformSignal(PlatformSignal),
|
||||
/// A nested frame request deferred while an enclosing draw was still running.
|
||||
ReentrantFrameSkipped {
|
||||
/// The window whose nested frame request was deferred.
|
||||
window_id: WindowId,
|
||||
/// When the nested frame request was deferred.
|
||||
at: Instant,
|
||||
},
|
||||
/// One or more logical entries were unavailable at this point in the
|
||||
/// stream. Consumers must not infer interval boundaries across this gap.
|
||||
Discontinuity {
|
||||
|
|
@ -595,6 +642,18 @@ impl ForegroundJournalWriter {
|
|||
self.record_entry(ForegroundJournalEntry::FrameState(change));
|
||||
}
|
||||
|
||||
fn record_platform_signal(&mut self, signal: PlatformSignal) {
|
||||
self.record_entry(ForegroundJournalEntry::PlatformSignal(signal));
|
||||
}
|
||||
|
||||
fn record_frame_skipped(&mut self, skipped: FrameSkipped) {
|
||||
// Even a no-render callback can schedule or invalidate the next frame.
|
||||
// Skipping does not establish that the window's pending demand is met.
|
||||
self.record_entry(ForegroundJournalEntry::Boundary(
|
||||
IntervalBoundary::FrameSkipped(skipped),
|
||||
));
|
||||
}
|
||||
|
||||
fn record_frame_pending(&mut self, window_id: WindowId, dirty_at: Instant) {
|
||||
let window = self
|
||||
.windows
|
||||
|
|
@ -973,6 +1032,26 @@ pub(crate) fn record_present(timing: PresentTiming, frame: Option<FrameTiming>)
|
|||
with_journal(|journal| journal.record_present(timing, frame));
|
||||
}
|
||||
|
||||
pub(crate) fn record_platform_signal(signal: PlatformSignal) {
|
||||
with_journal(|journal| journal.record_platform_signal(signal));
|
||||
}
|
||||
|
||||
pub(crate) fn record_reentrant_frame_skipped(window_id: WindowId, at: Instant) {
|
||||
with_journal(|journal| {
|
||||
journal.record_entry(ForegroundJournalEntry::ReentrantFrameSkipped { window_id, at });
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn record_frame_skipped(window_id: WindowId, at: Instant, reason: FrameSkipReason) {
|
||||
with_journal(|journal| {
|
||||
journal.record_frame_skipped(FrameSkipped {
|
||||
window_id,
|
||||
at,
|
||||
reason,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn record_frame_pending(window_id: WindowId, dirty_at: Instant) {
|
||||
with_journal(|journal| journal.record_frame_pending(window_id, dirty_at));
|
||||
}
|
||||
|
|
@ -1345,14 +1424,17 @@ impl IntervalSealer {
|
|||
}
|
||||
if self.is_empty() {
|
||||
self.interval_start = self.interval_start.max(boundary.end_time());
|
||||
} else {
|
||||
}
|
||||
if !self.is_empty() || matches!(boundary, IntervalBoundary::FrameSkipped(_)) {
|
||||
snapshots.push(self.seal(boundary));
|
||||
}
|
||||
}
|
||||
// Pending-frame state gates boundaries on the writer side;
|
||||
// the entries remain in the stream for consumers that want
|
||||
// dirty timing, but the sealer has no use for them.
|
||||
ForegroundJournalEntry::FrameState(_) => {}
|
||||
ForegroundJournalEntry::FrameState(_)
|
||||
| ForegroundJournalEntry::PlatformSignal(_)
|
||||
| ForegroundJournalEntry::ReentrantFrameSkipped { .. } => {}
|
||||
ForegroundJournalEntry::Discontinuity { lost } => self.note_lost(lost),
|
||||
}
|
||||
}
|
||||
|
|
@ -1412,6 +1494,145 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::{WindowId, profiler::YieldTime};
|
||||
|
||||
#[test]
|
||||
fn platform_signals_are_journaled_without_sealing_or_occupying_time() {
|
||||
let (journal, _guard) = install_test_foreground_journal(32, 4);
|
||||
let mut collector = journal.collector();
|
||||
let start = Instant::now();
|
||||
let mut sealer = IntervalSealer::new(start);
|
||||
let window_id = WindowId::from(1);
|
||||
for source in [
|
||||
crate::FrameRequestSource::NativeCallback,
|
||||
crate::FrameRequestSource::LocalSchedule,
|
||||
] {
|
||||
for signal_at in [None, Some(start + Duration::from_secs(5))] {
|
||||
record_platform_signal(PlatformSignal {
|
||||
window_id,
|
||||
signal_at,
|
||||
handled_at: start + Duration::from_millis(5),
|
||||
source,
|
||||
});
|
||||
let entries = collector.collect_unseen().entries;
|
||||
let [ForegroundJournalEntry::PlatformSignal(signal)] = entries.as_slice() else {
|
||||
panic!("expected a raw platform signal, got {entries:?}");
|
||||
};
|
||||
assert_eq!(signal.window_id, window_id);
|
||||
assert_eq!(signal.signal_at, signal_at);
|
||||
assert_eq!(signal.handled_at, start + Duration::from_millis(5));
|
||||
assert_eq!(signal.source, source);
|
||||
assert!(sealer.push_entries(entries).is_empty());
|
||||
}
|
||||
}
|
||||
let ended_at = start + Duration::from_millis(10);
|
||||
// An enclosing work span may complete after the callback metadata.
|
||||
let snapshots = sealer.push_entries([
|
||||
input_entry(start, ended_at),
|
||||
ForegroundJournalEntry::Boundary(IntervalBoundary::Idle { ended_at }),
|
||||
]);
|
||||
let [snapshot] = snapshots.as_slice() else {
|
||||
panic!("expected one snapshot, got {snapshots:?}");
|
||||
};
|
||||
assert_eq!(snapshot.interval_start, start);
|
||||
assert_eq!(snapshot.interval_end(), ended_at);
|
||||
assert_eq!(snapshot.occupancy(), Duration::from_millis(10));
|
||||
assert_eq!(snapshot.events.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_skips_seal_without_retiring_pending_demand() {
|
||||
let start = Instant::now();
|
||||
let at = start + Duration::from_millis(10);
|
||||
let window_id = WindowId::from(1);
|
||||
for reason in [
|
||||
FrameSkipReason::NoRenderNeeded,
|
||||
FrameSkipReason::InactiveFrameRateLimit,
|
||||
FrameSkipReason::ThermalFrameRateLimit,
|
||||
] {
|
||||
let (mut writer, mut collector) = test_journal(ForegroundRunnableCounter::new());
|
||||
writer.begin_turn();
|
||||
writer.record_frame_pending(window_id, start);
|
||||
writer.record_frame_skipped(FrameSkipped {
|
||||
window_id,
|
||||
at,
|
||||
reason,
|
||||
});
|
||||
|
||||
let mut sealer = IntervalSealer::new(start);
|
||||
let snapshots = sealer.push_entries(collector.collect_unseen().entries);
|
||||
let [snapshot] = snapshots.as_slice() else {
|
||||
panic!("expected a completed skip, got {snapshots:?}");
|
||||
};
|
||||
assert!(
|
||||
matches!(snapshot.boundary, IntervalBoundary::FrameSkipped(skipped)
|
||||
if skipped.window_id == window_id && skipped.at == at && skipped.reason == reason)
|
||||
);
|
||||
assert_eq!(snapshot.interval_end(), at);
|
||||
assert_eq!(snapshot.boundary.dirty_at(), None);
|
||||
assert_eq!(snapshot.occupancy(), Duration::ZERO);
|
||||
|
||||
writer.record_event(ForegroundEvent::Input(InputTiming {
|
||||
kind: "test",
|
||||
start: at,
|
||||
end: at + Duration::from_millis(1),
|
||||
caused_invalidation: false,
|
||||
}));
|
||||
writer.end_turn(at + Duration::from_millis(1));
|
||||
assert!(
|
||||
!collector
|
||||
.collect_unseen()
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, ForegroundJournalEntry::Boundary(_))),
|
||||
"pending demand must still block idle boundaries"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reentrant_skip_does_not_seal_the_enclosing_draw() {
|
||||
let (journal, _guard) = install_test_foreground_journal(32, 4);
|
||||
let mut collector = journal.collector();
|
||||
let start = Instant::now();
|
||||
let window_id = WindowId::from(1);
|
||||
let mut sealer = IntervalSealer::new(start);
|
||||
record_platform_signal(PlatformSignal {
|
||||
window_id,
|
||||
signal_at: None,
|
||||
handled_at: start + Duration::from_millis(5),
|
||||
source: crate::FrameRequestSource::LocalSchedule,
|
||||
});
|
||||
record_reentrant_frame_skipped(window_id, start + Duration::from_millis(6));
|
||||
let entries = collector.collect_unseen().entries;
|
||||
assert!(matches!(
|
||||
entries.as_slice(),
|
||||
[
|
||||
ForegroundJournalEntry::PlatformSignal(_),
|
||||
ForegroundJournalEntry::ReentrantFrameSkipped {
|
||||
window_id: skipped_window_id,
|
||||
at,
|
||||
}
|
||||
]
|
||||
if *skipped_window_id == window_id && *at == start + Duration::from_millis(6)
|
||||
));
|
||||
assert!(sealer.push_entries(entries).is_empty());
|
||||
|
||||
let frame = FrameTiming {
|
||||
window_id,
|
||||
dirty_at: Some(start),
|
||||
invalidations: 1,
|
||||
draw_start: start,
|
||||
draw_end: start + Duration::from_millis(20),
|
||||
};
|
||||
record_draw(frame);
|
||||
record_present(presentation_timing(window_id, frame.draw_end), Some(frame));
|
||||
let snapshots = sealer.push_entries(collector.collect_unseen().entries);
|
||||
let [snapshot] = snapshots.as_slice() else {
|
||||
panic!("expected the enclosing presentation, got {snapshots:?}");
|
||||
};
|
||||
assert!(matches!(snapshot.boundary, IntervalBoundary::Presented(_)));
|
||||
assert_eq!(snapshot.occupancy(), Duration::from_millis(20));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn wake_rechecks_native_visibility(cx: &mut crate::TestAppContext) {
|
||||
use crate::PlatformWindow as _;
|
||||
|
|
@ -1617,7 +1838,9 @@ mod tests {
|
|||
IntervalBoundary::Presented(presented) => {
|
||||
presented.dirty_to_present_duration()
|
||||
}
|
||||
IntervalBoundary::Idle { .. } | IntervalBoundary::PowerTransition { .. } => None,
|
||||
IntervalBoundary::FrameSkipped(_)
|
||||
| IntervalBoundary::Idle { .. }
|
||||
| IntervalBoundary::PowerTransition { .. } => None,
|
||||
},
|
||||
Some(Duration::from_millis(5))
|
||||
);
|
||||
|
|
@ -2444,6 +2667,7 @@ mod tests {
|
|||
IntervalBoundary::Idle { .. } => 0,
|
||||
IntervalBoundary::Presented(_) => 1,
|
||||
IntervalBoundary::PowerTransition { .. } => 2,
|
||||
IntervalBoundary::FrameSkipped(_) => 3,
|
||||
},
|
||||
interval_end: snapshot.interval_end().duration_since(origin).as_micros() as u64,
|
||||
events: snapshot
|
||||
|
|
@ -2543,6 +2767,7 @@ mod tests {
|
|||
IntervalBoundary::Idle { .. } => 0,
|
||||
IntervalBoundary::Presented(_) => 1,
|
||||
IntervalBoundary::PowerTransition { .. } => 2,
|
||||
IntervalBoundary::FrameSkipped(_) => 3,
|
||||
},
|
||||
interval_end,
|
||||
events: std::mem::take(&mut events),
|
||||
|
|
@ -2553,7 +2778,9 @@ mod tests {
|
|||
interval_start = interval_end;
|
||||
}
|
||||
}
|
||||
ForegroundJournalEntry::FrameState(_) => {}
|
||||
ForegroundJournalEntry::FrameState(_)
|
||||
| ForegroundJournalEntry::PlatformSignal(_)
|
||||
| ForegroundJournalEntry::ReentrantFrameSkipped { .. } => {}
|
||||
ForegroundJournalEntry::Discontinuity { lost } => {
|
||||
dropped_events += lost;
|
||||
journal_discontinuous = true;
|
||||
|
|
|
|||
|
|
@ -1707,8 +1707,18 @@ impl Window {
|
|||
let mut deferred_force_render = false;
|
||||
move |request_frame_options| {
|
||||
#[cfg(feature = "profiler")]
|
||||
let _foreground_turn = profiler::journal::foreground_turn();
|
||||
// This must be checked before anything else: if this request
|
||||
let (_foreground_turn, window_id) = {
|
||||
let foreground_turn = profiler::journal::foreground_turn();
|
||||
let window_id = handle.window_id();
|
||||
profiler::journal::record_platform_signal(profiler::journal::PlatformSignal {
|
||||
window_id,
|
||||
signal_at: request_frame_options.signal_at,
|
||||
handled_at: Instant::now(),
|
||||
source: request_frame_options.signal_source,
|
||||
});
|
||||
(foreground_turn, window_id)
|
||||
};
|
||||
// This must be checked before accessing App: if this request
|
||||
// arrived re-entrantly while a draw is on this thread's stack
|
||||
// (e.g. via a nested message pump in the Windows window
|
||||
// procedure), drawing would nest draws, and even touching the
|
||||
|
|
@ -1726,6 +1736,8 @@ impl Window {
|
|||
if draw_in_progress() {
|
||||
log::debug!("deferring re-entrant window draw request");
|
||||
deferred_force_render |= request_frame_options.force_render;
|
||||
#[cfg(feature = "profiler")]
|
||||
profiler::journal::record_reentrant_frame_skipped(window_id, Instant::now());
|
||||
return;
|
||||
}
|
||||
// Take the deferred flag first: `||` short-circuits, and leaving
|
||||
|
|
@ -1747,15 +1759,15 @@ impl Window {
|
|||
{
|
||||
None
|
||||
} else if !active.get() && !input_rate_tracker.borrow_mut().is_high_rate() {
|
||||
inactive_frame_interval
|
||||
inactive_frame_interval.map(|interval| (interval, true))
|
||||
} else if let Some(ThermalState::Critical | ThermalState::Serious) = thermal_state {
|
||||
Some(Duration::from_micros(16667))
|
||||
Some((Duration::from_micros(16667), false))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let now = Instant::now();
|
||||
if let Some(min_interval) = min_frame_interval {
|
||||
if let Some((min_interval, _inactive_throttle)) = min_frame_interval {
|
||||
if let Some(last_frame) = last_frame_time.get()
|
||||
&& now.duration_since(last_frame) < min_interval
|
||||
{
|
||||
|
|
@ -1772,6 +1784,19 @@ impl Window {
|
|||
// unserved; platforms that stop requesting frames for
|
||||
// idle windows need a wakeup to deliver the retry.
|
||||
invalidator.wake_platform();
|
||||
#[cfg(feature = "profiler")]
|
||||
{
|
||||
let reason = if _inactive_throttle {
|
||||
profiler::journal::FrameSkipReason::InactiveFrameRateLimit
|
||||
} else {
|
||||
profiler::journal::FrameSkipReason::ThermalFrameRateLimit
|
||||
};
|
||||
profiler::journal::record_frame_skipped(
|
||||
window_id,
|
||||
Instant::now(),
|
||||
reason,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -1795,7 +1820,8 @@ impl Window {
|
|||
|| needs_present.get()
|
||||
|| input_rate_tracker.borrow_mut().is_high_rate();
|
||||
|
||||
if invalidator.is_dirty() || force_render {
|
||||
let should_draw = invalidator.is_dirty() || force_render;
|
||||
if should_draw {
|
||||
measure("frame duration", || {
|
||||
handle
|
||||
.update(&mut cx, |_, window, cx| {
|
||||
|
|
@ -1834,6 +1860,14 @@ impl Window {
|
|||
if invalidator.is_dirty() || !next_frame_callbacks.borrow().is_empty() {
|
||||
invalidator.wake_platform();
|
||||
}
|
||||
#[cfg(feature = "profiler")]
|
||||
if !should_draw {
|
||||
profiler::journal::record_frame_skipped(
|
||||
window_id,
|
||||
Instant::now(),
|
||||
profiler::journal::FrameSkipReason::NoRenderNeeded,
|
||||
);
|
||||
}
|
||||
}
|
||||
}));
|
||||
invalidator.set_platform_waker(platform_window.frame_waker());
|
||||
|
|
@ -7707,6 +7741,155 @@ mod tests {
|
|||
canvas, div, hsla, point, px, size,
|
||||
};
|
||||
|
||||
#[cfg(feature = "profiler")]
|
||||
#[gpui::test]
|
||||
fn test_frame_requests_and_skips_are_journaled(cx: &mut TestAppContext) {
|
||||
use crate::FrameRequestSource;
|
||||
use crate::profiler::journal::{
|
||||
self, ForegroundJournalEntry, FrameSkipReason, IntervalBoundary,
|
||||
};
|
||||
use scheduler::Instant;
|
||||
|
||||
cx.app.borrow_mut().mode = crate::app::GpuiMode::Production;
|
||||
let window = cx.add_window(|_, _| EmptyView);
|
||||
let platform_window = cx.test_window(window.into());
|
||||
platform_window.simulate_frame_request(RequestFrameOptions::default());
|
||||
let (journal, _guard) = journal::install_test_foreground_journal(64, 4);
|
||||
let mut collector = journal.collector();
|
||||
|
||||
for (signal_at, source) in [
|
||||
(Some(Instant::now()), FrameRequestSource::NativeCallback),
|
||||
(Some(Instant::now()), FrameRequestSource::LocalSchedule),
|
||||
(None, FrameRequestSource::NativeCallback),
|
||||
] {
|
||||
platform_window.simulate_frame_request(RequestFrameOptions {
|
||||
signal_at,
|
||||
signal_source: source,
|
||||
require_presentation: true,
|
||||
..Default::default()
|
||||
});
|
||||
let entries = collector.collect_unseen().entries;
|
||||
let signal_index = entries
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
ForegroundJournalEntry::PlatformSignal(signal)
|
||||
if signal.window_id == window.window_id()
|
||||
&& signal.signal_at == signal_at
|
||||
&& signal.source == source
|
||||
&& signal_at.is_none_or(|at| signal.handled_at >= at)
|
||||
)
|
||||
})
|
||||
.expect("frame request metadata");
|
||||
let skip_index = entries
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
ForegroundJournalEntry::Boundary(IntervalBoundary::FrameSkipped(skipped))
|
||||
if skipped.window_id == window.window_id()
|
||||
&& skipped.reason == FrameSkipReason::NoRenderNeeded
|
||||
)
|
||||
})
|
||||
.expect("completed no-render boundary");
|
||||
assert!(signal_index < skip_index);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "profiler")]
|
||||
#[gpui::test]
|
||||
fn test_frame_demand_survives_throttling(cx: &mut TestAppContext) {
|
||||
use crate::profiler::journal::{
|
||||
self, ForegroundJournalEntry, FrameSkipReason, IntervalBoundary,
|
||||
};
|
||||
use scheduler::Instant;
|
||||
|
||||
// Test mode draws during effect flushing, bypassing platform frame throttling.
|
||||
cx.app.borrow_mut().mode = crate::app::GpuiMode::Production;
|
||||
let (journal, _guard) = journal::install_test_foreground_journal(256, 4);
|
||||
let mut collector = journal.collector();
|
||||
for initially_dirty in [true, false] {
|
||||
// Keep the throttle closed without depending on short wall-clock deadlines.
|
||||
let window = cx
|
||||
.app
|
||||
.borrow_mut()
|
||||
.open_window(
|
||||
WindowOptions {
|
||||
inactive_frame_interval: Some(Duration::from_secs(3600)),
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| cx.new(|_| EmptyView),
|
||||
)
|
||||
.expect("open inactive test window");
|
||||
let platform_window = cx.test_window(window.into());
|
||||
platform_window.simulate_active_status_change(false);
|
||||
platform_window.simulate_frame_request(RequestFrameOptions::default());
|
||||
let callback_ran = Rc::new(Cell::new(false));
|
||||
let draws_before = window
|
||||
.update(cx, |_, window, _| {
|
||||
let draws_before = window
|
||||
.frame_duration_snapshot()
|
||||
.draw_duration_histogram
|
||||
.len();
|
||||
if initially_dirty {
|
||||
window.refresh();
|
||||
}
|
||||
window.on_next_frame({
|
||||
let callback_ran = callback_ran.clone();
|
||||
move |window, _| {
|
||||
callback_ran.set(true);
|
||||
window.refresh();
|
||||
}
|
||||
});
|
||||
draws_before
|
||||
})
|
||||
.expect("schedule throttled frame");
|
||||
collector.collect_unseen();
|
||||
platform_window.simulate_frame_request(RequestFrameOptions {
|
||||
signal_at: Some(Instant::now()),
|
||||
..Default::default()
|
||||
});
|
||||
window
|
||||
.update(cx, |_, window, _| {
|
||||
assert_eq!(
|
||||
window
|
||||
.frame_duration_snapshot()
|
||||
.draw_duration_histogram
|
||||
.len(),
|
||||
draws_before,
|
||||
);
|
||||
})
|
||||
.expect("inspect deferred frame");
|
||||
assert!(!callback_ran.get());
|
||||
assert!(collector.collect_unseen().entries.iter().any(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
ForegroundJournalEntry::Boundary(IntervalBoundary::FrameSkipped(skipped))
|
||||
if skipped.window_id == window.window_id()
|
||||
&& skipped.reason == FrameSkipReason::InactiveFrameRateLimit
|
||||
)
|
||||
}));
|
||||
platform_window.simulate_frame_request(RequestFrameOptions {
|
||||
signal_at: Some(Instant::now()),
|
||||
require_presentation: true,
|
||||
..Default::default()
|
||||
});
|
||||
window
|
||||
.update(cx, |_, window, _| {
|
||||
assert_eq!(
|
||||
window
|
||||
.frame_duration_snapshot()
|
||||
.draw_duration_histogram
|
||||
.len(),
|
||||
draws_before + 1,
|
||||
);
|
||||
})
|
||||
.expect("inspect presented frame");
|
||||
assert!(callback_ran.get());
|
||||
}
|
||||
}
|
||||
|
||||
/// Visibility transitions reach observers exactly once each, with the new
|
||||
/// state already stored on the window, and never wake the platform for a
|
||||
/// frame: the platform requests one itself when it resumes presenting.
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ use gpui::{
|
|||
AnyWindowHandle, Bounds, Capslock, CursorStyle, DevicePixels, DisplayId, ExternalDragPayload,
|
||||
FileDragPaths, FileDropEvent, ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke,
|
||||
Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent,
|
||||
MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformInput,
|
||||
MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformFrameSignal, PlatformInput,
|
||||
PlatformKeyboardLayout, PlatformWindow, Point, ScrollDelta, ScrollWheelEvent, SharedString,
|
||||
Size, TouchPhase, WindowButtonLayout, WindowKind, WindowParams, point, profiler, px, size,
|
||||
};
|
||||
|
|
@ -558,11 +558,11 @@ impl WaylandClientStatePtr {
|
|||
let surface_id = surface_id.clone();
|
||||
if let Err(err) = state.loop_handle.insert_source(
|
||||
Timer::from_duration(FRAME_RETRY_INTERVAL),
|
||||
move |_, _, this| {
|
||||
move |deadline, _, this| {
|
||||
let client = this.get_client();
|
||||
let window = get_window(&mut client.borrow_mut(), &surface_id);
|
||||
if let Some(window) = window {
|
||||
window.retry_timer_fired();
|
||||
window.retry_timer_fired(deadline);
|
||||
}
|
||||
TimeoutAction::Drop
|
||||
},
|
||||
|
|
@ -1544,12 +1544,47 @@ impl Dispatch<WlCallback, ObjectId> for WaylandClientStatePtr {
|
|||
};
|
||||
drop(state);
|
||||
|
||||
if let wl_callback::Event::Done { .. } = event {
|
||||
window.frame_callback_fired();
|
||||
if let wl_callback::Event::Done { callback_data } = event {
|
||||
window.frame_callback_fired(PlatformFrameSignal::capture(|| {
|
||||
frame_callback_signal_at(callback_data)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_callback_signal_at(callback_data: u32) -> Instant {
|
||||
let mut monotonic = libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
};
|
||||
// wl_surface.frame specifies an unspecified epoch. Mutter and Sway use
|
||||
// CLOCK_MONOTONIC, but reject implausible ages for other compositor clocks.
|
||||
let result = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut monotonic) };
|
||||
let received_at = Instant::now();
|
||||
if result != 0 || monotonic.tv_sec < 0 || !(0..1_000_000_000).contains(&monotonic.tv_nsec) {
|
||||
return received_at;
|
||||
}
|
||||
let monotonic_millis =
|
||||
(monotonic.tv_sec as u64 * 1_000 + monotonic.tv_nsec as u64 / 1_000_000) as u32;
|
||||
frame_callback_instant(callback_data, monotonic_millis, received_at)
|
||||
}
|
||||
|
||||
fn frame_callback_instant(
|
||||
callback_data: u32,
|
||||
monotonic_millis: u32,
|
||||
received_at: Instant,
|
||||
) -> Instant {
|
||||
// The wire timestamp wraps every ~49 days. A future timestamp also wraps
|
||||
// when subtracted, so the bound rejects it without guessing another epoch.
|
||||
let age_millis = monotonic_millis.wrapping_sub(callback_data);
|
||||
if age_millis > 60_000 {
|
||||
return received_at;
|
||||
}
|
||||
received_at
|
||||
.checked_sub(Duration::from_millis(u64::from(age_millis)))
|
||||
.unwrap_or(received_at)
|
||||
}
|
||||
|
||||
pub(crate) fn get_window(
|
||||
state: &mut RefMut<WaylandClientState>,
|
||||
surface_id: &ObjectId,
|
||||
|
|
@ -2980,6 +3015,43 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_callback_timestamp_preserves_queue_delay() {
|
||||
let received_at = Instant::now();
|
||||
assert_eq!(
|
||||
frame_callback_instant(1_000, 1_025, received_at),
|
||||
received_at - Duration::from_millis(25),
|
||||
);
|
||||
assert_eq!(
|
||||
frame_callback_instant(1_000, 1_000, received_at),
|
||||
received_at,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_callback_timestamp_wraps() {
|
||||
let received_at = Instant::now();
|
||||
assert_eq!(
|
||||
frame_callback_instant(u32::MAX - 9, 15, received_at),
|
||||
received_at - Duration::from_millis(25),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_callback_timestamp_rejects_implausible_clock() {
|
||||
let received_at = Instant::now();
|
||||
for (callback_data, monotonic_millis) in [(1_001, 1_000), (0, 60_001), (0, u32::MAX)] {
|
||||
assert_eq!(
|
||||
frame_callback_instant(callback_data, monotonic_millis, received_at),
|
||||
received_at,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
frame_callback_instant(0, 60_000, received_at),
|
||||
received_at - Duration::from_secs(60),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeDataOffer {
|
||||
name: &'static str,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::{
|
|||
ptr::NonNull,
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use calloop::ping::Ping;
|
||||
|
|
@ -33,11 +34,12 @@ use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1;
|
|||
use crate::linux::wayland::{display::WaylandDisplay, serial::SerialKind};
|
||||
use crate::linux::{Globals, Output, WaylandClientStatePtr, get_window};
|
||||
use gpui::{
|
||||
AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, ExternalDragPayload, GpuSpecs,
|
||||
Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler,
|
||||
PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size,
|
||||
Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea,
|
||||
WindowControls, WindowDecorations, WindowKind, WindowParams, WindowVisibility,
|
||||
AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, ExternalDragPayload,
|
||||
FrameRequestSource, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay,
|
||||
PlatformFrameSignal, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton,
|
||||
PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, WindowAppearance,
|
||||
WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations,
|
||||
WindowKind, WindowParams, WindowVisibility,
|
||||
layer_shell::{Anchor, LayerShellNotSupportedError},
|
||||
popup::PopupOptions,
|
||||
px, size,
|
||||
|
|
@ -543,6 +545,7 @@ pub struct WaylandWindowStatePtr {
|
|||
callbacks: Rc<RefCell<Callbacks>>,
|
||||
frame_loop: Rc<Cell<FrameLoop>>,
|
||||
frame_ping: Ping,
|
||||
scheduled_frame_at: Rc<Cell<Option<Instant>>>,
|
||||
}
|
||||
|
||||
impl WaylandWindowState {
|
||||
|
|
@ -864,6 +867,7 @@ impl WaylandWindow {
|
|||
callbacks: Rc::new(RefCell::new(Callbacks::default())),
|
||||
frame_loop: Rc::new(Cell::new(FrameLoop::Unconfigured)),
|
||||
frame_ping,
|
||||
scheduled_frame_at: Rc::new(Cell::new(None)),
|
||||
});
|
||||
|
||||
// Kick things off
|
||||
|
|
@ -921,7 +925,7 @@ impl WaylandWindowStatePtr {
|
|||
state.children.values().any(|&blocking| blocking)
|
||||
}
|
||||
|
||||
pub fn frame(&self) {
|
||||
pub fn frame(&self, signal_at: Option<Instant>, signal_source: FrameRequestSource) {
|
||||
self.frame_loop.set(FrameLoop::Ticking);
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.resize_throttle = false;
|
||||
|
|
@ -939,6 +943,8 @@ impl WaylandWindowStatePtr {
|
|||
request_frame_callback(RequestFrameOptions {
|
||||
force_render,
|
||||
require_presentation,
|
||||
signal_at,
|
||||
signal_source,
|
||||
});
|
||||
self.update_ime_enabled();
|
||||
drop(callbacks);
|
||||
|
|
@ -990,24 +996,30 @@ impl WaylandWindowStatePtr {
|
|||
self.frame_loop.set(FrameLoop::Parked);
|
||||
}
|
||||
|
||||
pub fn frame_callback_fired(&self) {
|
||||
pub fn frame_callback_fired(&self, signal_at: Option<Instant>) {
|
||||
// Another wl_surface commit may have carried this callback while a retry
|
||||
// timer owned the render-loop wakeup.
|
||||
self.state.borrow_mut().pending_frame_callback = None;
|
||||
if self.frame_loop.get() == FrameLoop::AwaitingCallback {
|
||||
self.frame();
|
||||
self.frame(signal_at, FrameRequestSource::NativeCallback);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scheduled_frame_fired(&self) {
|
||||
if self.frame_loop.get() == FrameLoop::Scheduled {
|
||||
self.frame();
|
||||
self.frame(
|
||||
self.scheduled_frame_at.take(),
|
||||
FrameRequestSource::LocalSchedule,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retry_timer_fired(&self) {
|
||||
pub fn retry_timer_fired(&self, signal_at: Instant) {
|
||||
if self.frame_loop.get() == FrameLoop::RetryScheduled {
|
||||
self.frame();
|
||||
self.frame(
|
||||
PlatformFrameSignal::capture(|| signal_at),
|
||||
FrameRequestSource::LocalSchedule,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1019,6 +1031,8 @@ impl WaylandWindowStatePtr {
|
|||
match self.frame_loop.get() {
|
||||
FrameLoop::Parked => {
|
||||
self.frame_loop.set(FrameLoop::Scheduled);
|
||||
self.scheduled_frame_at
|
||||
.set(PlatformFrameSignal::capture(Instant::now));
|
||||
self.frame_ping.ping();
|
||||
}
|
||||
FrameLoop::Ticking => {
|
||||
|
|
@ -1140,7 +1154,7 @@ impl WaylandWindowStatePtr {
|
|||
let initial_configure = self.frame_loop.get() == FrameLoop::Unconfigured;
|
||||
drop(state);
|
||||
if initial_configure {
|
||||
self.frame();
|
||||
self.frame(None, FrameRequestSource::NativeCallback);
|
||||
} else {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,10 +60,11 @@ use crate::linux::{
|
|||
use crate::linux::{LinuxCommon, LinuxKeyboardLayout, X11Window, modifiers_from_xinput_info};
|
||||
|
||||
use gpui::{
|
||||
AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke,
|
||||
Modifiers, ModifiersChangedEvent, MouseButton, Pixels, PlatformDisplay, PlatformInput,
|
||||
PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size,
|
||||
TouchPhase, WindowButtonLayout, WindowParams, WindowVisibility, point, px,
|
||||
AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent,
|
||||
FrameRequestSource, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Pixels,
|
||||
PlatformDisplay, PlatformFrameSignal, PlatformInput, PlatformKeyboardLayout, PlatformWindow,
|
||||
Point, RequestFrameOptions, ScrollDelta, Size, TouchPhase, WindowButtonLayout, WindowParams,
|
||||
WindowVisibility, point, px,
|
||||
};
|
||||
use gpui_wgpu::{CompositorGpuHint, GpuContext};
|
||||
|
||||
|
|
@ -736,6 +737,8 @@ impl X11Client {
|
|||
window.refresh(RequestFrameOptions {
|
||||
require_presentation: true,
|
||||
force_render: false,
|
||||
signal_at: None,
|
||||
signal_source: FrameRequestSource::NativeCallback,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2012,6 +2015,8 @@ impl X11ClientState {
|
|||
window.refresh(RequestFrameOptions {
|
||||
require_presentation: false,
|
||||
force_render: false,
|
||||
signal_at: PlatformFrameSignal::capture(|| instant),
|
||||
signal_source: FrameRequestSource::LocalSchedule,
|
||||
});
|
||||
}
|
||||
xcb_connection
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ core-foundation.workspace = true
|
|||
core-foundation-sys.workspace = true
|
||||
core-graphics.workspace = true
|
||||
core-text.workspace = true
|
||||
core-video.workspace = true
|
||||
ctor.workspace = true
|
||||
dispatch2.workspace = true
|
||||
# WARNING: If you change this, you must also publish a new version of zed-font-kit to crates.io
|
||||
|
|
@ -56,6 +57,7 @@ objc2-user-notifications.workspace = true
|
|||
parking_lot.workspace = true
|
||||
pathfinder_geometry.workspace = true
|
||||
raw-window-handle.workspace = true
|
||||
scheduler.workspace = true
|
||||
semver.workspace = true
|
||||
smallvec.workspace = true
|
||||
strum.workspace = true
|
||||
|
|
|
|||
|
|
@ -52,14 +52,18 @@
|
|||
|
||||
use anyhow::Result;
|
||||
use core_graphics::display::CGDirectDisplayID;
|
||||
use core_video::host_time;
|
||||
use dispatch2::{
|
||||
_dispatch_source_type_data_add, DispatchObject, DispatchQueue, DispatchRetained, DispatchSource,
|
||||
};
|
||||
use gpui::{FrameRequestSource, PlatformFrameSignal};
|
||||
use gpui_util::ResultExt;
|
||||
use scheduler::Instant;
|
||||
use std::{
|
||||
collections::{BTreeMap, btree_map},
|
||||
ffi::c_void,
|
||||
sync::{Mutex, MutexGuard, PoisonError},
|
||||
sync::{Arc, Mutex, MutexGuard, PoisonError},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
static REGISTRY: Mutex<Registry> = Mutex::new(Registry::new());
|
||||
|
|
@ -81,7 +85,13 @@ impl Registry {
|
|||
struct DisplayEntry {
|
||||
link: sys::DisplayLink,
|
||||
running: bool,
|
||||
subscribers: Vec<(SubscriberId, DispatchRetained<DispatchSource>)>,
|
||||
subscribers: Vec<DisplayLinkSubscriber>,
|
||||
}
|
||||
|
||||
struct DisplayLinkSubscriber {
|
||||
id: SubscriberId,
|
||||
frame_requests: DispatchRetained<DispatchSource>,
|
||||
signal: Arc<PlatformFrameSignal>,
|
||||
}
|
||||
|
||||
// SAFETY: Both fields wrapping raw pointers are refcounted handles to
|
||||
|
|
@ -118,25 +128,71 @@ fn debug_assert_main_thread() {
|
|||
|
||||
unsafe extern "C" fn display_link_output_callback(
|
||||
_display_link_out: *mut sys::CVDisplayLink,
|
||||
_current_time: *const sys::CVTimeStamp,
|
||||
current_time: *const sys::CVTimeStamp,
|
||||
_output_time: *const sys::CVTimeStamp,
|
||||
_flags_in: i64,
|
||||
_flags_out: *mut i64,
|
||||
display_id: *mut c_void,
|
||||
) -> i32 {
|
||||
let signal_at = PlatformFrameSignal::capture(|| {
|
||||
// Instant cannot be constructed from Mach ticks, so sample both clock domains.
|
||||
let current_host_time = host_time::get_current_host_time();
|
||||
let received_at = Instant::now();
|
||||
let host_clock_frequency = host_time::get_host_clock_frequency();
|
||||
// SAFETY: CoreVideo owns this timestamp for the lifetime of the callback.
|
||||
let current_time = unsafe { current_time.as_ref() };
|
||||
frame_request_instant(
|
||||
current_time,
|
||||
current_host_time,
|
||||
host_clock_frequency,
|
||||
received_at,
|
||||
)
|
||||
});
|
||||
let display_id = display_id as usize as CGDirectDisplayID;
|
||||
let registry = lock_registry();
|
||||
if let Some(entry) = registry.displays.get(&display_id) {
|
||||
for (_, frame_requests) in &entry.subscribers {
|
||||
frame_requests.merge_data(1);
|
||||
for subscriber in &entry.subscribers {
|
||||
if let Some(signal_at) = signal_at {
|
||||
subscriber
|
||||
.signal
|
||||
.record(signal_at, FrameRequestSource::NativeCallback);
|
||||
}
|
||||
subscriber.frame_requests.merge_data(1);
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Maps a CoreVideo host timestamp using a paired clock sample.
|
||||
///
|
||||
/// Invalid timestamps or clock conversions fall back to callback receipt time.
|
||||
fn frame_request_instant(
|
||||
timestamp: Option<&sys::CVTimeStamp>,
|
||||
current_host_time: u64,
|
||||
host_clock_frequency: f64,
|
||||
received_at: Instant,
|
||||
) -> Instant {
|
||||
let Some(timestamp) = timestamp.filter(|timestamp| {
|
||||
timestamp.version == 0 && timestamp.flags & sys::kCVTimeStampHostTimeValid != 0
|
||||
}) else {
|
||||
return received_at;
|
||||
};
|
||||
if !host_clock_frequency.is_finite() || host_clock_frequency <= 0.0 {
|
||||
return received_at;
|
||||
}
|
||||
current_host_time
|
||||
.checked_sub(timestamp.host_time)
|
||||
.and_then(|elapsed_ticks| {
|
||||
Duration::try_from_secs_f64(elapsed_ticks as f64 / host_clock_frequency).ok()
|
||||
})
|
||||
.and_then(|elapsed| received_at.checked_sub(elapsed))
|
||||
.unwrap_or(received_at)
|
||||
}
|
||||
|
||||
fn subscribe(
|
||||
display_id: CGDirectDisplayID,
|
||||
frame_requests: DispatchRetained<DispatchSource>,
|
||||
signal: Arc<PlatformFrameSignal>,
|
||||
) -> Result<SubscriberId> {
|
||||
debug_assert_main_thread();
|
||||
|
||||
|
|
@ -175,7 +231,11 @@ fn subscribe(
|
|||
anyhow::bail!("display link registry entry vanished for display {display_id}");
|
||||
}
|
||||
};
|
||||
entry.subscribers.push((subscriber_id, frame_requests));
|
||||
entry.subscribers.push(DisplayLinkSubscriber {
|
||||
id: subscriber_id,
|
||||
frame_requests,
|
||||
signal,
|
||||
});
|
||||
let link_to_start = if entry.running {
|
||||
None
|
||||
} else {
|
||||
|
|
@ -192,7 +252,14 @@ fn subscribe(
|
|||
let mut registry = lock_registry();
|
||||
if let Some(entry) = registry.displays.get_mut(&display_id) {
|
||||
entry.running = false;
|
||||
entry.subscribers.retain(|(id, _)| *id != subscriber_id);
|
||||
entry.subscribers.retain(|subscriber| {
|
||||
if subscriber.id == subscriber_id {
|
||||
subscriber.signal.take();
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
|
@ -209,7 +276,9 @@ fn unsubscribe(display_id: CGDirectDisplayID, subscriber_id: SubscriberId) {
|
|||
let Some(entry) = registry.displays.get_mut(&display_id) else {
|
||||
return;
|
||||
};
|
||||
entry.subscribers.retain(|(id, _)| *id != subscriber_id);
|
||||
entry
|
||||
.subscribers
|
||||
.retain(|subscriber| subscriber.id != subscriber_id);
|
||||
if entry.subscribers.is_empty() && entry.running {
|
||||
entry.running = false;
|
||||
Some(entry.link.clone())
|
||||
|
|
@ -231,6 +300,7 @@ fn unsubscribe(display_id: CGDirectDisplayID, subscriber_id: SubscriberId) {
|
|||
pub struct WindowFrameSource {
|
||||
frame_requests: DispatchRetained<DispatchSource>,
|
||||
registration: Option<(CGDirectDisplayID, SubscriberId)>,
|
||||
signal: Arc<PlatformFrameSignal>,
|
||||
}
|
||||
|
||||
impl WindowFrameSource {
|
||||
|
|
@ -252,20 +322,27 @@ impl WindowFrameSource {
|
|||
Self {
|
||||
frame_requests,
|
||||
registration: None,
|
||||
signal: Arc::new(PlatformFrameSignal::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, display_id: CGDirectDisplayID) -> Result<()> {
|
||||
self.stop();
|
||||
let subscriber_id = subscribe(display_id, self.frame_requests.clone())?;
|
||||
let subscriber_id =
|
||||
subscribe(display_id, self.frame_requests.clone(), self.signal.clone())?;
|
||||
self.registration = Some((display_id, subscriber_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn take_signal(&self) -> Option<(Instant, FrameRequestSource)> {
|
||||
self.signal.take()
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some((display_id, subscriber_id)) = self.registration.take() {
|
||||
unsubscribe(display_id, subscriber_id);
|
||||
}
|
||||
self.signal.take();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -316,7 +393,7 @@ mod sys {
|
|||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct CVTimeStamp {
|
||||
pub version: u32,
|
||||
pub video_time_scale: i32,
|
||||
|
|
@ -459,3 +536,57 @@ mod sys {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_timestamp_preserves_request_age_at_different_clock_rates() {
|
||||
let received_at = Instant::now();
|
||||
for (host_time, current_host_time, frequency, age) in [
|
||||
(4_000_000, 10_000_000, 1e9, Duration::from_millis(6)),
|
||||
(1_760_000, 2_000_000, 24e6, Duration::from_millis(10)),
|
||||
] {
|
||||
let timestamp = timestamp(host_time);
|
||||
assert_eq!(
|
||||
frame_request_instant(Some(×tamp), current_host_time, frequency, received_at),
|
||||
received_at - age,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_host_timestamps_fall_back_to_receipt_time() {
|
||||
let received_at = Instant::now();
|
||||
let valid = timestamp(1_000);
|
||||
let mut missing_host_time = valid;
|
||||
missing_host_time.flags = 0;
|
||||
let mut unknown_version = valid;
|
||||
unknown_version.version = 1;
|
||||
for timestamp in [None, Some(&missing_host_time), Some(&unknown_version)] {
|
||||
assert_eq!(
|
||||
frame_request_instant(timestamp, 2_000, 1e9, received_at),
|
||||
received_at,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
frame_request_instant(Some(&valid), 999, 1e9, received_at),
|
||||
received_at,
|
||||
);
|
||||
for frequency in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MIN_POSITIVE] {
|
||||
assert_eq!(
|
||||
frame_request_instant(Some(&valid), 2_000, frequency, received_at),
|
||||
received_at,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp(host_time: u64) -> sys::CVTimeStamp {
|
||||
sys::CVTimeStamp {
|
||||
host_time,
|
||||
flags: sys::kCVTimeStampHostTimeValid,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@ use cocoa::{
|
|||
use dispatch2::DispatchQueue;
|
||||
use gpui::{
|
||||
AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, CursorStyle, ExternalDragPayload,
|
||||
ExternalPaths, FileDropEvent, ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers,
|
||||
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
|
||||
PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point,
|
||||
PromptButton, PromptLevel, RequestFrameOptions, SharedString, Size, SystemWindowTab,
|
||||
WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind,
|
||||
WindowParams, WindowVisibility, point, px, size,
|
||||
ExternalPaths, FileDropEvent, ForegroundExecutor, FrameRequestSource, KeyDownEvent, Keystroke,
|
||||
Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
|
||||
Pixels, PlatformAtlas, PlatformDisplay, PlatformFrameSignal, PlatformInput,
|
||||
PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions,
|
||||
SharedString, Size, SystemWindowTab, WindowAppearance, WindowBackgroundAppearance,
|
||||
WindowBounds, WindowControlArea, WindowKind, WindowParams, WindowVisibility, point, px, size,
|
||||
};
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use image::RgbaImage;
|
||||
|
|
@ -3116,6 +3116,7 @@ extern "C" fn window_did_change_screen(this: &Object, _: Sel, _: id) {
|
|||
}
|
||||
|
||||
extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) {
|
||||
let signal_at = PlatformFrameSignal::capture(scheduler::Instant::now);
|
||||
let window_state = unsafe { get_window_state(this) };
|
||||
let lock = window_state.lock();
|
||||
let is_active = unsafe { lock.native_window.isKeyWindow() == YES };
|
||||
|
|
@ -3169,7 +3170,11 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id)
|
|||
lock.renderer.set_presents_with_transaction(true);
|
||||
lock.stop_display_link();
|
||||
drop(lock);
|
||||
callback(Default::default());
|
||||
callback(RequestFrameOptions {
|
||||
signal_at,
|
||||
signal_source: FrameRequestSource::NativeCallback,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut lock = window_state.lock();
|
||||
lock.request_frame_callback = Some(callback);
|
||||
|
|
@ -3284,13 +3289,18 @@ extern "C" fn set_frame_size(this: &Object, _: Sel, size: NSSize) {
|
|||
}
|
||||
|
||||
extern "C" fn display_layer(this: &Object, _: Sel, _: id) {
|
||||
let signal_at = PlatformFrameSignal::capture(scheduler::Instant::now);
|
||||
let window_state = unsafe { get_window_state(this) };
|
||||
let mut lock = window_state.lock();
|
||||
if let Some(mut callback) = lock.request_frame_callback.take() {
|
||||
lock.renderer.set_presents_with_transaction(true);
|
||||
lock.stop_display_link();
|
||||
drop(lock);
|
||||
callback(Default::default());
|
||||
callback(RequestFrameOptions {
|
||||
signal_at,
|
||||
signal_source: FrameRequestSource::NativeCallback,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut lock = window_state.lock();
|
||||
lock.request_frame_callback = Some(callback);
|
||||
|
|
@ -3305,8 +3315,20 @@ extern "C" fn step(view: *mut c_void) {
|
|||
let mut lock = window_state.lock();
|
||||
|
||||
if let Some(mut callback) = lock.request_frame_callback.take() {
|
||||
let (signal_at, signal_source) = lock
|
||||
.frame_source
|
||||
.as_ref()
|
||||
.and_then(WindowFrameSource::take_signal)
|
||||
.map_or(
|
||||
(None, FrameRequestSource::NativeCallback),
|
||||
|(at, source)| (Some(at), source),
|
||||
);
|
||||
drop(lock);
|
||||
callback(Default::default());
|
||||
callback(RequestFrameOptions {
|
||||
signal_at,
|
||||
signal_source,
|
||||
..Default::default()
|
||||
});
|
||||
window_state.lock().request_frame_callback = Some(callback);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ use std::sync::Arc;
|
|||
use std::{cell::Cell, cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, DispatchEventResult, GpuSpecs,
|
||||
Modifiers, MouseButton, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput,
|
||||
PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions,
|
||||
ResizeEdge, Scene, Size, TextInputConfiguration, TextInputStateChange, WindowAppearance,
|
||||
WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations,
|
||||
WindowInsets, WindowParams, WindowVisibility, px,
|
||||
AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, DispatchEventResult,
|
||||
FrameRequestSource, GpuSpecs, Modifiers, MouseButton, Pixels, PlatformAtlas, PlatformDisplay,
|
||||
PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel,
|
||||
RequestFrameOptions, ResizeEdge, Scene, Size, TextInputConfiguration, TextInputStateChange,
|
||||
WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls,
|
||||
WindowDecorations, WindowInsets, WindowParams, WindowVisibility, px,
|
||||
};
|
||||
use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig, wgpu};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
|
@ -388,6 +388,8 @@ impl WebWindow {
|
|||
callback(RequestFrameOptions {
|
||||
require_presentation: true,
|
||||
force_render: true,
|
||||
signal_at: None,
|
||||
signal_source: FrameRequestSource::NativeCallback,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
|
@ -459,6 +461,8 @@ impl WebWindowInner {
|
|||
callback(RequestFrameOptions {
|
||||
require_presentation: false,
|
||||
force_render: false,
|
||||
signal_at: None,
|
||||
signal_source: FrameRequestSource::NativeCallback,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ log.workspace = true
|
|||
parking_lot.workspace = true
|
||||
rand.workspace = true
|
||||
raw-window-handle.workspace = true
|
||||
scheduler.workspace = true
|
||||
smallvec.workspace = true
|
||||
uuid.workspace = true
|
||||
windows.workspace = true
|
||||
|
|
|
|||
|
|
@ -1361,9 +1361,15 @@ impl WindowsWindowInner {
|
|||
// will rebuild the scene with fresh atlas textures.
|
||||
self.state.renderer.borrow_mut().mark_drawable();
|
||||
}
|
||||
let (signal_at, signal_source) = self.state.frame_signal.take().map_or(
|
||||
(None, FrameRequestSource::NativeCallback),
|
||||
|(at, source)| (Some(at), source),
|
||||
);
|
||||
request_frame(RequestFrameOptions {
|
||||
require_presentation: false,
|
||||
force_render,
|
||||
signal_at,
|
||||
signal_source,
|
||||
});
|
||||
|
||||
self.state.callbacks.request_frame.set(Some(request_frame));
|
||||
|
|
|
|||
|
|
@ -39,9 +39,20 @@ use windows::{
|
|||
use crate::*;
|
||||
use gpui::*;
|
||||
|
||||
struct TrackedWindow {
|
||||
handle: SafeHwnd,
|
||||
frame_signal: Arc<PlatformFrameSignal>,
|
||||
}
|
||||
|
||||
impl TrackedWindow {
|
||||
fn as_raw(&self) -> HWND {
|
||||
self.handle.as_raw()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WindowsPlatform {
|
||||
inner: Rc<WindowsPlatformInner>,
|
||||
raw_window_handles: Arc<RwLock<SmallVec<[SafeHwnd; 4]>>>,
|
||||
raw_window_handles: Arc<RwLock<SmallVec<[TrackedWindow; 4]>>>,
|
||||
// The below members will never change throughout the entire lifecycle of the app.
|
||||
headless: bool,
|
||||
icon: HICON,
|
||||
|
|
@ -63,7 +74,7 @@ pub struct WindowsPlatform {
|
|||
|
||||
struct WindowsPlatformInner {
|
||||
state: WindowsPlatformState,
|
||||
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
|
||||
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[TrackedWindow; 4]>>>,
|
||||
// The below members will never change throughout the entire lifecycle of the app.
|
||||
validation_number: usize,
|
||||
main_receiver: PriorityQueueReceiver<RunnableVariant>,
|
||||
|
|
@ -382,7 +393,8 @@ impl WindowsPlatform {
|
|||
.spawn(move || {
|
||||
let vsync_provider = VSyncProvider::new();
|
||||
loop {
|
||||
vsync_provider.wait_for_vsync();
|
||||
let signal_source = vsync_provider.wait_for_vsync();
|
||||
let signal_at = PlatformFrameSignal::capture(scheduler::Instant::now);
|
||||
if check_device_lost(&directx_device.device)
|
||||
|| invalidate_devices.fetch_and(false, Ordering::Acquire)
|
||||
{
|
||||
|
|
@ -401,7 +413,19 @@ impl WindowsPlatform {
|
|||
};
|
||||
for hwnd in all_windows.read().iter() {
|
||||
unsafe {
|
||||
let _ = RedrawWindow(Some(hwnd.as_raw()), None, None, RDW_INVALIDATE);
|
||||
if let Some(signal_at) = signal_at {
|
||||
if IsWindowVisible(hwnd.as_raw()).as_bool()
|
||||
&& !IsIconic(hwnd.as_raw()).as_bool()
|
||||
{
|
||||
hwnd.frame_signal.record(signal_at, signal_source);
|
||||
} else {
|
||||
// Hidden windows may not consume WM_PAINT until shown again.
|
||||
hwnd.frame_signal.take();
|
||||
}
|
||||
}
|
||||
RedrawWindow(Some(hwnd.as_raw()), None, None, RDW_INVALIDATE)
|
||||
.ok()
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -643,7 +667,10 @@ impl Platform for WindowsPlatform {
|
|||
) -> Result<Box<dyn PlatformWindow>> {
|
||||
let window = WindowsWindow::new(handle, options, self.generate_creation_info())?;
|
||||
let handle = window.get_raw_handle();
|
||||
self.raw_window_handles.write().push(handle.into());
|
||||
self.raw_window_handles.write().push(TrackedWindow {
|
||||
handle: handle.into(),
|
||||
frame_signal: window.state.frame_signal.clone(),
|
||||
});
|
||||
|
||||
Ok(Box::new(window))
|
||||
}
|
||||
|
|
@ -1276,7 +1303,7 @@ pub(crate) struct WindowCreationInfo {
|
|||
|
||||
struct PlatformWindowCreateContext {
|
||||
inner: Option<Result<Rc<WindowsPlatformInner>>>,
|
||||
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
|
||||
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[TrackedWindow; 4]>>>,
|
||||
validation_number: usize,
|
||||
main_sender: Option<PriorityQueueSender<RunnableVariant>>,
|
||||
main_receiver: Option<PriorityQueueReceiver<RunnableVariant>>,
|
||||
|
|
@ -1504,7 +1531,7 @@ fn handle_gpu_device_lost(
|
|||
directx_devices: &mut DirectXDevices,
|
||||
platform_window: HWND,
|
||||
validation_number: usize,
|
||||
all_windows: &std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
|
||||
all_windows: &std::sync::Weak<RwLock<SmallVec<[TrackedWindow; 4]>>>,
|
||||
text_system: &std::sync::Weak<DirectWriteTextSystem>,
|
||||
) -> Result<()> {
|
||||
// Here we wait a bit to ensure the system has time to recover from the device lost state.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::{
|
|||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gpui::FrameRequestSource;
|
||||
use gpui_util::ResultExt;
|
||||
use windows::Win32::{
|
||||
Foundation::HWND,
|
||||
|
|
@ -37,7 +38,7 @@ impl VSyncProvider {
|
|||
Self { interval, f }
|
||||
}
|
||||
|
||||
pub(crate) fn wait_for_vsync(&self) {
|
||||
pub(crate) fn wait_for_vsync(&self) -> FrameRequestSource {
|
||||
let vsync_start = Instant::now();
|
||||
let wait_succeeded = (self.f)();
|
||||
let elapsed = vsync_start.elapsed();
|
||||
|
|
@ -51,6 +52,9 @@ impl VSyncProvider {
|
|||
if !wait_succeeded || elapsed < VSYNC_INTERVAL_THRESHOLD {
|
||||
log::trace!("VSyncProvider::wait_for_vsync() took less time than expected");
|
||||
std::thread::sleep(self.interval);
|
||||
FrameRequestSource::LocalSchedule
|
||||
} else {
|
||||
FrameRequestSource::NativeCallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,3 +83,37 @@ fn retrieve_duration(counts: u64, ticks_per_second: u64) -> Duration {
|
|||
let ticks_per_microsecond = ticks_per_second / 1_000_000;
|
||||
Duration::from_micros(counts / ticks_per_microsecond)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn successful_compositor_wait_reports_native_callback() {
|
||||
let provider = VSyncProvider {
|
||||
interval: Duration::ZERO,
|
||||
f: Box::new(|| {
|
||||
std::thread::sleep(VSYNC_INTERVAL_THRESHOLD * 2);
|
||||
true
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
provider.wait_for_vsync(),
|
||||
FrameRequestSource::NativeCallback
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_compositor_wait_reports_local_schedule_even_after_threshold() {
|
||||
let provider = VSyncProvider {
|
||||
interval: Duration::ZERO,
|
||||
f: Box::new(|| {
|
||||
std::thread::sleep(VSYNC_INTERVAL_THRESHOLD * 2);
|
||||
false
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(provider.wait_for_vsync(), FrameRequestSource::LocalSchedule);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ pub struct WindowsWindowState {
|
|||
pub restore_from_minimized: Cell<Option<Box<dyn FnMut(RequestFrameOptions)>>>,
|
||||
|
||||
pub callbacks: Callbacks,
|
||||
pub frame_signal: Arc<PlatformFrameSignal>,
|
||||
pub input_handler: Cell<Option<PlatformInputHandler>>,
|
||||
pub ime_enabled: Cell<bool>,
|
||||
pub pending_surrogate: Cell<Option<u16>>,
|
||||
|
|
@ -168,6 +169,7 @@ impl WindowsWindowState {
|
|||
restore_from_minimized: Cell::new(restore_from_minimized),
|
||||
min_size,
|
||||
callbacks,
|
||||
frame_signal: Arc::new(PlatformFrameSignal::new()),
|
||||
input_handler: Cell::new(input_handler),
|
||||
ime_enabled: Cell::new(true),
|
||||
pending_surrogate: Cell::new(pending_surrogate),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue