gpui: Make ForegroundJournal aware of sleep and window visibility (#64269)
## Summary Keep foreground-work measurements and per-window presentation expectations valid across observed sleep/wake and visibility changes. Simplified after review to about 530 changed lines including tests and configuration. ## Scope - Keep all measurement state in the foreground-local journal writer. Use `PowerState` and `PowerTransition`; no new locks, counters, or changes to the ring protocol. - Work that started at or before the last power transition (or while suspended) is dropped when it completes: task polls before small-poll folding, and actions, inputs, draws, and presents through the same `power_interrupted_since(start)` check. No per-activity state is added; every measurement already carries its start time. - Track visibility and pending frames together per window. Hiding or sleeping clears the presentation expectation; on recovery `Window::refresh_visibility` re-records it from the window's actual dirty state, so the journal never guesses. Old latency associations remain invalid. - Repeated notifications for the same power state are ignored, so a duplicate wake does not invalidate in-flight work. - The journal subscribes to GPUI's existing power notifications and rechecks native window visibility after wake. App only registers these subscriptions (two initialization lines). - Keep the existing one-second pending-frame deadline and foreground-turn sealing behavior. No watchdog, recurring timer, test-app exceptions, unfinished-hang detection, start/end ring notices, or reader-side deduplication. - Remove lifecycle counters. Keep measurement version 2 on reports and a valid dirty-to-present sample count for weighting averages. ## Telemetry - `Hang Incidents` gains `report_window_seconds` and stall percentiles over every incident in the window (`stall_p50_ms`, `stall_p95_ms`, `stall_max_ms`, from an `hdrhistogram`), so stall distributions no longer depend on the censored `incidents[]` list. Reports are now sent even when no incident occurred, so summing `report_window_seconds` gives observed running time for incident rates. - `Frame Duration Report` gains dirty-to-present percentiles (`dirty_to_present_p50_ms`, `dirty_to_present_p95_ms`, `dirty_to_present_max_ms`) alongside the existing average, and `average_present_interval_ms` (mean frame-to-frame interval while animating, a proxy for the display's refresh interval when normalizing dirty-to-present). Detection remains completion-based. This does not solve hangs that never return or force an interval to seal when no further foreground events arrive. Existing legacy hang logs/trace triggers are unchanged. Late or coalesced platform notifications remain a coverage limitation. ## Validation - `corgi test -p gpui --features profiler --lib`: **392 tests passed**. - Focused lifecycle/wake tests also passed a 20-seed run. - The extension-suggestion regression that failed on all three CI platforms now passes locally after removal of the watchdog/test-construction changes. - `corgi check -p gpui` and `corgi check -p zed -p input_latency_ui` passed. - Formatting and `git diff --check` passed. - Corgi configuration includes only the three existing GPUI image/font fixtures needed to run its tests. Native platform sleep/wake testing and review remain outstanding. The README review marker remains for the human author to remove manually after review. Release Notes: - Improved the accuracy of hang and frame-latency telemetry around sleep, wake, and hidden windows. - [GPUI] Added a `PowerTransition` interval boundary and `measurement_version` to profiler hang reports.
This commit is contained in:
1 parent
2b4169b5db
commit
04d586b80e
10 files changed
+408
-62
No files matched your search
Generated
+1
@@ -22938,6 +22938,7 @@ dependencies = [
|
||||
"gpui",
|
||||
"gpui_platform",
|
||||
"gpui_tokio",
|
||||
"hdrhistogram",
|
||||
"http_client",
|
||||
"image",
|
||||
"image_viewer",
|
||||
|
||||
+6
-1
@@ -64,7 +64,12 @@ profiles = ["release"]
|
||||
proto = ["proto/**"]
|
||||
livekit_api = ["vendored/protocol/**"]
|
||||
media = ["src/bindings.h"]
|
||||
gpui = ["README.md"]
|
||||
gpui = [
|
||||
"README.md",
|
||||
"examples/image/exif-orientation-rotate-180.jpg",
|
||||
"../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
|
||||
"../../assets/fonts/lilex/Lilex-Regular.ttf",
|
||||
]
|
||||
git = ["src/checkpoint.gitignore"]
|
||||
git_ui = ["src/commit_message_prompt.txt"]
|
||||
editor = ["../grammars/src/**"]
|
||||
|
||||
@@ -942,6 +942,8 @@ impl App {
|
||||
|
||||
init_app_menus(platform.as_ref(), &app.borrow());
|
||||
SystemWindowTabController::init(&mut app.borrow_mut());
|
||||
#[cfg(feature = "profiler")]
|
||||
crate::profiler::journal::observe_power(&app.borrow());
|
||||
|
||||
platform.on_keyboard_layout_change(Box::new({
|
||||
let app = Rc::downgrade(&app);
|
||||
|
||||
+84
-22
@@ -961,17 +961,26 @@ impl WindowProfiler {
|
||||
return;
|
||||
};
|
||||
|
||||
journal::record_input(journal::InputTiming {
|
||||
kind,
|
||||
start: started_at,
|
||||
end: Instant::now(),
|
||||
caused_invalidation,
|
||||
});
|
||||
if !journal::power_interrupted_since(started_at) {
|
||||
journal::record_input(journal::InputTiming {
|
||||
kind,
|
||||
start: started_at,
|
||||
end: Instant::now(),
|
||||
caused_invalidation,
|
||||
});
|
||||
}
|
||||
journal::end_foreground_turn();
|
||||
|
||||
if !caused_invalidation {
|
||||
if !caused_invalidation || !journal::frame_sample_is_valid(self.window_id, started_at) {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.first_input_at
|
||||
.is_some_and(|at| !journal::frame_sample_is_valid(self.window_id, at))
|
||||
{
|
||||
self.first_input_at = None;
|
||||
self.pending_input_count = 0;
|
||||
}
|
||||
|
||||
let arrived_during_draw = self
|
||||
.active_activities
|
||||
@@ -1003,11 +1012,13 @@ impl WindowProfiler {
|
||||
journal::end_foreground_turn();
|
||||
return;
|
||||
};
|
||||
journal::record_action(ActionTiming {
|
||||
name,
|
||||
start,
|
||||
end: Instant::now(),
|
||||
});
|
||||
if !journal::power_interrupted_since(start) {
|
||||
journal::record_action(ActionTiming {
|
||||
name,
|
||||
start,
|
||||
end: Instant::now(),
|
||||
});
|
||||
}
|
||||
journal::end_foreground_turn();
|
||||
}
|
||||
|
||||
@@ -1034,13 +1045,15 @@ impl WindowProfiler {
|
||||
let draw_end = Instant::now();
|
||||
let frame_timing = FrameTiming {
|
||||
window_id: self.window_id,
|
||||
dirty_at,
|
||||
dirty_at: dirty_at.filter(|at| journal::frame_sample_is_valid(self.window_id, *at)),
|
||||
invalidations,
|
||||
draw_start,
|
||||
draw_end,
|
||||
};
|
||||
let draw_duration = frame_timing.draw_duration();
|
||||
self.record_draw_timing(frame_timing);
|
||||
if !journal::power_interrupted_since(draw_start) {
|
||||
self.record_draw_timing(frame_timing);
|
||||
}
|
||||
journal::end_foreground_turn();
|
||||
draw_duration
|
||||
}
|
||||
@@ -1089,21 +1102,33 @@ impl WindowProfiler {
|
||||
window_active: bool,
|
||||
next_frame_scheduled: bool,
|
||||
) {
|
||||
if let Some(first_input_at) = self.first_input_at.take() {
|
||||
if let Some(first_input_at) = self.first_input_at.take()
|
||||
&& journal::frame_sample_is_valid(self.window_id, first_input_at)
|
||||
{
|
||||
let latency_nanos = present_end.duration_since(first_input_at).as_nanos() as u64;
|
||||
self.input_latency_histogram.record(latency_nanos).ok();
|
||||
if self.pending_input_count > 0 {
|
||||
self.events_per_frame_histogram
|
||||
.record(self.pending_input_count)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
if self.pending_input_count > 0 {
|
||||
self.events_per_frame_histogram
|
||||
.record(self.pending_input_count)
|
||||
.ok();
|
||||
self.pending_input_count = 0;
|
||||
}
|
||||
self.pending_input_count = 0;
|
||||
|
||||
let frame = self.pending_frame.take();
|
||||
let frame = self
|
||||
.pending_frame
|
||||
.take()
|
||||
.filter(|frame| journal::frame_sample_is_valid(self.window_id, frame.draw_start))
|
||||
.map(|mut frame| {
|
||||
frame.dirty_at = frame
|
||||
.dirty_at
|
||||
.filter(|at| journal::frame_sample_is_valid(self.window_id, *at));
|
||||
frame
|
||||
});
|
||||
let animation_interval =
|
||||
if frame.is_some() && self.animating_at_last_present && window_active {
|
||||
self.last_present_at
|
||||
.filter(|at| journal::frame_sample_is_valid(self.window_id, *at))
|
||||
.map(|last_present_at| present_end.duration_since(last_present_at))
|
||||
} else {
|
||||
None
|
||||
@@ -1241,6 +1266,43 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
#[test]
|
||||
fn interruptions_drop_latency_samples_but_hiding_keeps_draw_work() {
|
||||
for sleep in [false, true] {
|
||||
let (_journal, _guard) = journal::install_test_foreground_journal(64, 4);
|
||||
let id = WindowId::from(1);
|
||||
let mut profiler = WindowProfiler::new(id).expect("valid histograms");
|
||||
let old = Instant::now() - Duration::from_secs(1);
|
||||
record_test_draw(&mut profiler, old);
|
||||
profiler.record_present_at(old, old, true, true);
|
||||
profiler.first_input_at = Some(old);
|
||||
profiler.pending_input_count = 1;
|
||||
profiler.begin_draw();
|
||||
if sleep {
|
||||
journal::record_power_transition(journal::PowerState::Suspended);
|
||||
journal::record_power_transition(journal::PowerState::Awake);
|
||||
} else {
|
||||
journal::record_window_visibility(id, crate::WindowVisibility::Hidden);
|
||||
journal::record_window_visibility(id, crate::WindowVisibility::Visible);
|
||||
}
|
||||
profiler.end_draw(Some(old), 1);
|
||||
let now = Instant::now();
|
||||
profiler.record_present_at(now, now, true, true);
|
||||
assert_eq!(
|
||||
profiler.draw_duration_histogram.len(),
|
||||
if sleep { 1 } else { 2 }
|
||||
);
|
||||
assert_eq!(profiler.input_latency_histogram.len(), 0);
|
||||
assert_eq!(profiler.dirty_to_present_histogram.len(), 1);
|
||||
assert_eq!(profiler.present_interval_histogram.len(), 0);
|
||||
profiler.begin_draw();
|
||||
profiler.end_draw(Some(Instant::now()), 1);
|
||||
let now = Instant::now();
|
||||
profiler.record_present_at(now, now, true, true);
|
||||
assert_eq!(profiler.dirty_to_present_histogram.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_draw_events_only_while_tracing() {
|
||||
let _trace_test_guard = TraceTestGuard::new();
|
||||
|
||||
@@ -15,6 +15,9 @@ use std::time::Duration;
|
||||
use scheduler::Instant;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Version of the power/visibility-aware measurement rules.
|
||||
pub const MEASUREMENT_VERSION: u32 = 2;
|
||||
|
||||
use super::SerializedLocation;
|
||||
use super::journal::{
|
||||
ForegroundEvent, ForegroundJournal, ForegroundJournalCollector, ForegroundJournalEntry,
|
||||
@@ -111,6 +114,8 @@ impl HangDetector {
|
||||
/// locations as plain data, contributor count capped by the converter.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SerializedHangIncident {
|
||||
/// Identifies the rules used to exclude interrupted measurements.
|
||||
pub measurement_version: u32,
|
||||
/// `"startup"` when the active window began before the first observed
|
||||
/// newly drawn frame finished platform submission (see
|
||||
/// [`HangDetector::first_present_at`]), otherwise `"steady"`.
|
||||
@@ -133,7 +138,7 @@ 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"` or `"idle"`. This labels the
|
||||
/// What closed the incident: `"present"`, `"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,
|
||||
@@ -269,6 +274,7 @@ impl SerializedHangIncident {
|
||||
Some(first_present_at) if active_start >= first_present_at => "steady",
|
||||
_ => "startup",
|
||||
},
|
||||
measurement_version: MEASUREMENT_VERSION,
|
||||
trigger: incident.trigger,
|
||||
start_ms: since_startup(active_start),
|
||||
active_ms: as_millis(active),
|
||||
@@ -281,11 +287,12 @@ impl SerializedHangIncident {
|
||||
IntervalBoundary::Presented(presented) => {
|
||||
presented.dirty_to_present_duration().map(as_millis)
|
||||
}
|
||||
IntervalBoundary::Idle { .. } => None,
|
||||
IntervalBoundary::Idle { .. } | IntervalBoundary::PowerTransition { .. } => None,
|
||||
},
|
||||
sealed_by: match snapshot.boundary {
|
||||
IntervalBoundary::Presented(_) => "present",
|
||||
IntervalBoundary::Idle { .. } => "idle",
|
||||
IntervalBoundary::PowerTransition { .. } => "power_transition",
|
||||
},
|
||||
busy_fraction: (busy_fraction * 1000.0).round() / 1000.0,
|
||||
event_count: snapshot.events.len(),
|
||||
|
||||
@@ -21,10 +21,17 @@ use std::sync::{
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui_util::ResultExt;
|
||||
use scheduler::Instant;
|
||||
|
||||
use super::{ActionTiming, FrameTiming, PresentTiming, TaskTiming};
|
||||
use crate::WindowId;
|
||||
use crate::{App, WindowId, WindowVisibility};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PowerState {
|
||||
Awake,
|
||||
Suspended,
|
||||
}
|
||||
|
||||
/// Task polls shorter than this are folded into a [`PollSummary`] instead of
|
||||
/// being recorded individually. This keeps the stream bounded by the number
|
||||
@@ -182,6 +189,11 @@ pub enum IntervalBoundary {
|
||||
/// When the foreground went idle.
|
||||
ended_at: Instant,
|
||||
},
|
||||
/// A power notification interrupted the measurement interval.
|
||||
PowerTransition {
|
||||
/// When the foreground received the notification.
|
||||
ended_at: Instant,
|
||||
},
|
||||
}
|
||||
|
||||
impl IntervalBoundary {
|
||||
@@ -189,7 +201,7 @@ impl IntervalBoundary {
|
||||
pub fn end_time(&self) -> Instant {
|
||||
match self {
|
||||
Self::Presented(presented) => presented.presentation.present_end,
|
||||
Self::Idle { ended_at } => *ended_at,
|
||||
Self::Idle { ended_at } | Self::PowerTransition { ended_at } => *ended_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +209,7 @@ impl IntervalBoundary {
|
||||
pub fn dirty_at(&self) -> Option<Instant> {
|
||||
match self {
|
||||
Self::Presented(presented) => presented.frame.dirty_at,
|
||||
Self::Idle { .. } => None,
|
||||
Self::Idle { .. } | Self::PowerTransition { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,11 +391,31 @@ impl ForegroundRunnableCounter {
|
||||
}
|
||||
}
|
||||
|
||||
// Visibility and power changes clear `dirty_at`; `Window::refresh_visibility`
|
||||
// re-records the frame from the window's actual dirty state afterwards.
|
||||
struct WindowFrameState {
|
||||
visibility: WindowVisibility,
|
||||
dirty_at: Option<Instant>,
|
||||
visibility_changed_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl WindowFrameState {
|
||||
fn new(visibility: WindowVisibility) -> Self {
|
||||
Self {
|
||||
visibility,
|
||||
dirty_at: None,
|
||||
visibility_changed_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ForegroundJournalWriter {
|
||||
foreground_runnables: ForegroundRunnableCounter,
|
||||
publisher: JournalPublisher,
|
||||
turn_depth: usize,
|
||||
pending_frames: HashMap<WindowId, Instant>,
|
||||
windows: HashMap<WindowId, WindowFrameState>,
|
||||
power: PowerState,
|
||||
power_changed_at: Option<Instant>,
|
||||
retained_since_boundary: bool,
|
||||
small_polls: Option<SmallPollFlush>,
|
||||
}
|
||||
@@ -394,7 +426,9 @@ impl ForegroundJournalWriter {
|
||||
foreground_runnables,
|
||||
publisher,
|
||||
turn_depth: 0,
|
||||
pending_frames: HashMap::new(),
|
||||
windows: HashMap::new(),
|
||||
power: PowerState::Awake,
|
||||
power_changed_at: None,
|
||||
retained_since_boundary: false,
|
||||
small_polls: None,
|
||||
}
|
||||
@@ -434,12 +468,51 @@ impl ForegroundJournalWriter {
|
||||
}
|
||||
|
||||
fn has_unexpired_pending_frame(&mut self, now: Instant) -> bool {
|
||||
if self.pending_frames.is_empty() {
|
||||
return false;
|
||||
for window in self.windows.values_mut() {
|
||||
if window
|
||||
.dirty_at
|
||||
.is_some_and(|at| now.saturating_duration_since(at) >= FRAME_DEADLINE)
|
||||
{
|
||||
window.dirty_at = None;
|
||||
}
|
||||
}
|
||||
self.power == PowerState::Awake
|
||||
&& self
|
||||
.windows
|
||||
.values()
|
||||
.any(|window| window.dirty_at.is_some())
|
||||
}
|
||||
|
||||
fn power_transition(&mut self, power: PowerState, at: Instant) {
|
||||
if self.power == power {
|
||||
return;
|
||||
}
|
||||
self.record_entry(ForegroundJournalEntry::Boundary(
|
||||
IntervalBoundary::PowerTransition { ended_at: at },
|
||||
));
|
||||
self.power = power;
|
||||
self.power_changed_at = Some(at);
|
||||
for window in self.windows.values_mut() {
|
||||
window.dirty_at = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether work that started at `start` spans a sleep or a wake, and so
|
||||
/// must not be measured as uninterrupted foreground work.
|
||||
fn power_interrupted_since(&self, start: Instant) -> bool {
|
||||
self.power == PowerState::Suspended || self.power_changed_at.is_some_and(|at| start <= at)
|
||||
}
|
||||
|
||||
fn set_visibility(&mut self, id: WindowId, visibility: WindowVisibility, at: Instant) {
|
||||
let window = self
|
||||
.windows
|
||||
.entry(id)
|
||||
.or_insert_with(|| WindowFrameState::new(visibility));
|
||||
if window.visibility != visibility {
|
||||
window.visibility = visibility;
|
||||
window.visibility_changed_at = Some(at);
|
||||
window.dirty_at = None;
|
||||
}
|
||||
self.pending_frames
|
||||
.retain(|_, dirty_at| now.saturating_duration_since(*dirty_at) < FRAME_DEADLINE);
|
||||
!self.pending_frames.is_empty()
|
||||
}
|
||||
|
||||
fn fold_small_poll(&mut self, timing: TaskTiming) {
|
||||
@@ -480,17 +553,20 @@ impl ForegroundJournalWriter {
|
||||
}
|
||||
|
||||
fn record_frame_pending(&mut self, window_id: WindowId, dirty_at: Instant) {
|
||||
let should_record = match self.pending_frames.get(&window_id) {
|
||||
Some(previous_dirty_at) => {
|
||||
dirty_at.saturating_duration_since(*previous_dirty_at) >= FRAME_DEADLINE
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if !should_record {
|
||||
let window = self
|
||||
.windows
|
||||
.entry(window_id)
|
||||
.or_insert_with(|| WindowFrameState::new(WindowVisibility::Visible));
|
||||
if self.power == PowerState::Suspended
|
||||
|| !window.visibility.is_visible()
|
||||
|| window
|
||||
.dirty_at
|
||||
.is_some_and(|at| dirty_at.saturating_duration_since(at) < FRAME_DEADLINE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending_frames.insert(window_id, dirty_at);
|
||||
window.dirty_at = Some(dirty_at);
|
||||
self.record_frame_state(FrameStateChange::Pending {
|
||||
window_id,
|
||||
dirty_at,
|
||||
@@ -498,14 +574,19 @@ impl ForegroundJournalWriter {
|
||||
}
|
||||
|
||||
fn record_window_closed(&mut self, window_id: WindowId, at: Instant) {
|
||||
self.pending_frames.remove(&window_id);
|
||||
self.windows.remove(&window_id);
|
||||
self.record_frame_state(FrameStateChange::Closed { window_id, at });
|
||||
}
|
||||
|
||||
fn record_present(&mut self, timing: PresentTiming, frame: Option<FrameTiming>) {
|
||||
if let Some(window) = self.windows.get_mut(&timing.window_id) {
|
||||
window.dirty_at = None;
|
||||
}
|
||||
if self.power_interrupted_since(timing.present_start) {
|
||||
return;
|
||||
}
|
||||
match frame {
|
||||
Some(frame) => {
|
||||
self.pending_frames.remove(&frame.window_id);
|
||||
self.record_entry(ForegroundJournalEntry::Boundary(
|
||||
IntervalBoundary::Presented(PresentedFrame {
|
||||
frame,
|
||||
@@ -514,7 +595,6 @@ impl ForegroundJournalWriter {
|
||||
));
|
||||
}
|
||||
None => {
|
||||
self.pending_frames.remove(&timing.window_id);
|
||||
self.record_event(ForegroundEvent::Present(timing));
|
||||
}
|
||||
}
|
||||
@@ -605,6 +685,58 @@ fn with_journal(f: impl FnOnce(&mut ForegroundJournalWriter)) {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn observe_power(cx: &App) {
|
||||
cx.on_system_sleep(|_| {
|
||||
record_power_transition(PowerState::Suspended);
|
||||
})
|
||||
.detach();
|
||||
// Visibility notifications delivered during sleep may have been missed.
|
||||
cx.on_system_wake(|cx| {
|
||||
record_power_transition(PowerState::Awake);
|
||||
cx.spawn(async |cx| {
|
||||
cx.update(|cx| {
|
||||
for handle in cx.windows() {
|
||||
handle
|
||||
.update(cx, |_, window, cx| window.refresh_visibility(cx))
|
||||
.log_err();
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn record_power_transition(state: PowerState) {
|
||||
with_journal(|journal| journal.power_transition(state, Instant::now()));
|
||||
}
|
||||
|
||||
/// Whether work that started at `start` spans a sleep or a wake. Such work
|
||||
/// is dropped rather than measured.
|
||||
pub(crate) fn power_interrupted_since(start: Instant) -> bool {
|
||||
let mut interrupted = false;
|
||||
with_journal(|journal| interrupted = journal.power_interrupted_since(start));
|
||||
interrupted
|
||||
}
|
||||
|
||||
pub(crate) fn record_window_visibility(id: WindowId, visibility: WindowVisibility) {
|
||||
with_journal(|journal| journal.set_visibility(id, visibility, Instant::now()));
|
||||
}
|
||||
|
||||
/// Whether a frame sample that started at `start` was measured while the
|
||||
/// window stayed visible and the system stayed awake.
|
||||
pub(crate) fn frame_sample_is_valid(id: WindowId, start: Instant) -> bool {
|
||||
let mut valid = true;
|
||||
with_journal(|journal| {
|
||||
valid = !journal.power_interrupted_since(start)
|
||||
&& journal.windows.get(&id).is_none_or(|window| {
|
||||
window.visibility.is_visible()
|
||||
&& window.visibility_changed_at.is_none_or(|at| start > at)
|
||||
});
|
||||
});
|
||||
valid
|
||||
}
|
||||
|
||||
// TODO(gpui-profiler): the turn brackets in the dispatchers and
|
||||
// WindowProfiler are bare begin/end call pairs rather than uses of this
|
||||
// guard. A caught unwind between a pair leaves `turn_depth` (and potentially
|
||||
@@ -636,6 +768,10 @@ pub(crate) fn end_foreground_turn() {
|
||||
pub(crate) fn record_task_poll(timing: TaskTiming) {
|
||||
FOREGROUND_RUNNABLES.with(ForegroundRunnableCounter::finished);
|
||||
with_journal(|journal| {
|
||||
if journal.power_interrupted_since(timing.start) {
|
||||
journal.end_turn(timing.end.0);
|
||||
return;
|
||||
}
|
||||
if timing.poll_duration() >= TASK_POLL_FLOOR {
|
||||
journal.record_event(ForegroundEvent::TaskPoll(timing));
|
||||
} else {
|
||||
@@ -1100,6 +1236,96 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{WindowId, profiler::YieldTime};
|
||||
|
||||
#[gpui::test]
|
||||
fn wake_rechecks_native_visibility(cx: &mut crate::TestAppContext) {
|
||||
use crate::PlatformWindow as _;
|
||||
let handle = cx.add_window(|_, _| crate::Empty);
|
||||
let platform = cx.test_window(handle.into());
|
||||
platform.on_visibility_change(Box::new(|_| {}));
|
||||
platform.simulate_visibility_change(WindowVisibility::Hidden);
|
||||
handle
|
||||
.update(cx, |_, window, _| assert!(window.is_visible()))
|
||||
.expect("window");
|
||||
cx.update(|cx| {
|
||||
cx.system_wake_observers
|
||||
.clone()
|
||||
.retain(&(), |callback| callback(cx))
|
||||
});
|
||||
cx.run_until_parked();
|
||||
handle
|
||||
.update(cx, |_, window, _| assert!(!window.is_visible()))
|
||||
.expect("window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_interruption_drops_the_poll_before_small_poll_folding() {
|
||||
let (journal, _guard) = install_test_foreground_journal(32, 4);
|
||||
let mut collector = journal.collector();
|
||||
let start = Instant::now();
|
||||
begin_foreground_turn();
|
||||
record_power_transition(PowerState::Suspended);
|
||||
record_power_transition(PowerState::Awake);
|
||||
assert!(power_interrupted_since(start));
|
||||
record_task_poll(task_timing(start, start + Duration::from_micros(10)));
|
||||
assert!(
|
||||
collector
|
||||
.collect_unseen()
|
||||
.entries
|
||||
.iter()
|
||||
.all(|entry| matches!(
|
||||
entry,
|
||||
ForegroundJournalEntry::Boundary(IntervalBoundary::PowerTransition { .. })
|
||||
))
|
||||
);
|
||||
with_journal(|writer| assert!(writer.small_polls.is_none()));
|
||||
begin_foreground_turn();
|
||||
let start = Instant::now();
|
||||
assert!(!power_interrupted_since(start));
|
||||
record_task_poll(task_timing(start, start + TASK_POLL_FLOOR));
|
||||
assert!(
|
||||
collector
|
||||
.collect_unseen()
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| matches!(
|
||||
entry,
|
||||
ForegroundJournalEntry::Event(ForegroundEvent::TaskPoll(_))
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_and_power_changes_clear_pending_frames() {
|
||||
let (mut writer, _) = test_journal(ForegroundRunnableCounter::new());
|
||||
let start = Instant::now();
|
||||
let first = WindowId::from(1);
|
||||
let second = WindowId::from(2);
|
||||
writer.set_visibility(first, WindowVisibility::Hidden, start);
|
||||
writer.record_frame_pending(first, start);
|
||||
assert!(!writer.has_unexpired_pending_frame(start));
|
||||
writer.record_frame_pending(second, start);
|
||||
assert!(writer.has_unexpired_pending_frame(start));
|
||||
writer.set_visibility(second, WindowVisibility::Hidden, start);
|
||||
assert!(!writer.has_unexpired_pending_frame(start));
|
||||
writer.set_visibility(first, WindowVisibility::Visible, start);
|
||||
writer.record_frame_pending(first, start);
|
||||
assert!(writer.has_unexpired_pending_frame(start));
|
||||
writer.power_transition(PowerState::Suspended, start);
|
||||
assert_eq!(writer.windows[&first].dirty_at, None);
|
||||
writer.record_frame_pending(first, start);
|
||||
assert!(!writer.has_unexpired_pending_frame(start));
|
||||
// A repeated notification is not a transition.
|
||||
writer.power_transition(PowerState::Suspended, start + FRAME_DEADLINE);
|
||||
assert_eq!(writer.power_changed_at, Some(start));
|
||||
writer.power_transition(PowerState::Awake, start);
|
||||
writer.record_frame_pending(first, start);
|
||||
assert!(writer.has_unexpired_pending_frame(start));
|
||||
writer.record_present(presentation_timing(first, start + FRAME_DEADLINE), None);
|
||||
assert_eq!(writer.windows[&first].dirty_at, None);
|
||||
writer.record_window_closed(first, start);
|
||||
assert!(!writer.windows.contains_key(&first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_waits_for_its_presentation_boundary() {
|
||||
let start = Instant::now();
|
||||
@@ -1152,7 +1378,7 @@ mod tests {
|
||||
IntervalBoundary::Presented(presented) => {
|
||||
presented.dirty_to_present_duration()
|
||||
}
|
||||
IntervalBoundary::Idle { .. } => None,
|
||||
IntervalBoundary::Idle { .. } | IntervalBoundary::PowerTransition { .. } => None,
|
||||
},
|
||||
Some(Duration::from_millis(5))
|
||||
);
|
||||
@@ -1978,6 +2204,7 @@ mod tests {
|
||||
boundary_kind: match snapshot.boundary {
|
||||
IntervalBoundary::Idle { .. } => 0,
|
||||
IntervalBoundary::Presented(_) => 1,
|
||||
IntervalBoundary::PowerTransition { .. } => 2,
|
||||
},
|
||||
interval_end: snapshot.interval_end().duration_since(origin).as_micros() as u64,
|
||||
events: snapshot
|
||||
@@ -2076,6 +2303,7 @@ mod tests {
|
||||
boundary_kind: match boundary {
|
||||
IntervalBoundary::Idle { .. } => 0,
|
||||
IntervalBoundary::Presented(_) => 1,
|
||||
IntervalBoundary::PowerTransition { .. } => 2,
|
||||
},
|
||||
interval_end,
|
||||
events: std::mem::take(&mut events),
|
||||
@@ -2687,7 +2915,14 @@ mod tests {
|
||||
);
|
||||
prop_assert_eq!(writer.turn_depth, model.turn_depth);
|
||||
prop_assert_eq!(writer.retained_since_boundary, model.retained_since_boundary);
|
||||
prop_assert_eq!(writer.pending_frames.len(), model.pending_frames.len());
|
||||
prop_assert_eq!(
|
||||
writer
|
||||
.windows
|
||||
.values()
|
||||
.filter(|window| window.dirty_at.is_some())
|
||||
.count(),
|
||||
model.pending_frames.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1572,6 +1572,8 @@ impl Window {
|
||||
let invalidator = WindowInvalidator::new(handle.window_id());
|
||||
let active = Rc::new(Cell::new(platform_window.is_active()));
|
||||
let visibility = platform_window.visibility();
|
||||
#[cfg(feature = "profiler")]
|
||||
profiler::journal::record_window_visibility(handle.window_id(), visibility);
|
||||
let hovered = Rc::new(Cell::new(platform_window.is_hovered()));
|
||||
let needs_present = Rc::new(Cell::new(false));
|
||||
let next_frame_callbacks: Rc<RefCell<Vec<FrameCallback>>> = Default::default();
|
||||
@@ -1901,17 +1903,10 @@ impl Window {
|
||||
}));
|
||||
platform_window.on_visibility_change(Box::new({
|
||||
let mut cx = cx.to_async();
|
||||
move |visibility| {
|
||||
move |_| {
|
||||
handle
|
||||
.update(&mut cx, |_, window, cx| {
|
||||
if window.visibility == visibility {
|
||||
return;
|
||||
}
|
||||
window.visibility = visibility;
|
||||
window
|
||||
.visibility_observers
|
||||
.clone()
|
||||
.retain(&(), |callback| callback(visibility, window, cx));
|
||||
window.refresh_visibility(cx);
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
@@ -2151,6 +2146,22 @@ impl Window {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_visibility(&mut self, cx: &mut App) {
|
||||
let visibility = self.platform_window.visibility();
|
||||
if self.visibility != visibility {
|
||||
self.visibility = visibility;
|
||||
#[cfg(feature = "profiler")]
|
||||
profiler::journal::record_window_visibility(self.handle.window_id(), visibility);
|
||||
self.visibility_observers
|
||||
.clone()
|
||||
.retain(&(), |callback| callback(visibility, self, cx));
|
||||
}
|
||||
#[cfg(feature = "profiler")]
|
||||
if self.invalidator.is_dirty() || self.needs_present.get() {
|
||||
profiler::journal::record_frame_pending(self.handle.window_id(), Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the platform is presenting this window's frames (see
|
||||
/// [`WindowVisibility`]).
|
||||
pub fn visibility(&self) -> WindowVisibility {
|
||||
|
||||
@@ -164,6 +164,7 @@ pub fn report_input_latency_telemetry(window: &Window, cx: &mut App) {
|
||||
frames_with_2_events = frames_with_2_events,
|
||||
frames_with_3_events = frames_with_3_events,
|
||||
report_window_seconds = report_window_seconds,
|
||||
measurement_version = gpui::profiler::hang::MEASUREMENT_VERSION,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,6 +265,7 @@ pub fn report_frame_duration_telemetry(window: &Window, cx: &mut App) {
|
||||
let intervals_18to36 = count_frames_in_range(&delta_intervals, MS18_NS, MS36_NS);
|
||||
let intervals_36to100 = count_frames_in_range(&delta_intervals, MS36_NS, MS100_NS);
|
||||
// intervals > 100ms are implicitly total_intervals - (the buckets above)
|
||||
let average_present_interval_ms = delta_intervals.mean() / 1_000_000.0;
|
||||
let average_dirty_to_present_ms = delta_dirty_to_present.mean() / 1_000_000.0;
|
||||
|
||||
telemetry::event!(
|
||||
@@ -278,9 +280,17 @@ pub fn report_frame_duration_telemetry(window: &Window, cx: &mut App) {
|
||||
intervals_18to36 = intervals_18to36,
|
||||
intervals_36to100 = intervals_36to100,
|
||||
total_intervals = total_intervals,
|
||||
average_present_interval_ms = average_present_interval_ms,
|
||||
average_dirty_to_present_ms = average_dirty_to_present_ms,
|
||||
dirty_to_present_samples = delta_dirty_to_present.len(),
|
||||
dirty_to_present_p50_ms =
|
||||
delta_dirty_to_present.value_at_quantile(0.5) as f64 / 1_000_000.0,
|
||||
dirty_to_present_p95_ms =
|
||||
delta_dirty_to_present.value_at_quantile(0.95) as f64 / 1_000_000.0,
|
||||
dirty_to_present_max_ms = delta_dirty_to_present.max() as f64 / 1_000_000.0,
|
||||
root_entity_type_name = window_handle.root_entity_type_name(),
|
||||
report_window_seconds = report_window_seconds,
|
||||
measurement_version = gpui::profiler::hang::MEASUREMENT_VERSION,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ gpui_platform = { workspace = true, features = [
|
||||
"x11",
|
||||
] }
|
||||
gpui_tokio.workspace = true
|
||||
hdrhistogram.workspace = true
|
||||
image = { workspace = true, optional = true }
|
||||
spin = "0.10.0"
|
||||
itertools.workspace = true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::profiler::hang::{HangTrigger, SerializedHangIncident};
|
||||
use hdrhistogram::Histogram;
|
||||
|
||||
/// Cap on incidents per telemetry event. When more accrue between sends, the
|
||||
/// ones with the largest stalls are kept and the incident counts still cover
|
||||
@@ -16,6 +17,8 @@ pub struct Reporter {
|
||||
pending: Vec<SerializedHangIncident>,
|
||||
threshold_incidents: u64,
|
||||
budget_incidents: u64,
|
||||
// Every incident's stall, in milliseconds; `pending` keeps only the largest.
|
||||
stalls: Histogram<u64>,
|
||||
}
|
||||
|
||||
impl Reporter {
|
||||
@@ -25,6 +28,7 @@ impl Reporter {
|
||||
pending: Vec::new(),
|
||||
threshold_incidents: 0,
|
||||
budget_incidents: 0,
|
||||
stalls: Histogram::new(3).expect("3 significant figures is a valid histogram"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +37,7 @@ impl Reporter {
|
||||
HangTrigger::Threshold => self.threshold_incidents += 1,
|
||||
HangTrigger::Budget => self.budget_incidents += 1,
|
||||
}
|
||||
self.stalls.record(incident.stall_ms as u64).ok();
|
||||
self.pending.push(incident);
|
||||
if self.pending.len() > MAX_REPORTED_INCIDENTS {
|
||||
self.pending
|
||||
@@ -47,11 +52,12 @@ impl Reporter {
|
||||
}
|
||||
}
|
||||
|
||||
// Sends even without incidents so summing `report_window_seconds` gives the
|
||||
// observed time when computing incident rates.
|
||||
pub fn send(&mut self) {
|
||||
self.last_send = Instant::now();
|
||||
if self.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let report_window_seconds = now.duration_since(self.last_send).as_secs();
|
||||
self.last_send = now;
|
||||
let mut incidents = std::mem::take(&mut self.pending);
|
||||
incidents.sort_by(|a, b| b.stall_ms.total_cmp(&a.stall_ms));
|
||||
let threshold_incidents = std::mem::take(&mut self.threshold_incidents);
|
||||
@@ -64,7 +70,13 @@ impl Reporter {
|
||||
incidents,
|
||||
total_incidents,
|
||||
threshold_incidents,
|
||||
budget_incidents
|
||||
budget_incidents,
|
||||
stall_p50_ms = self.stalls.value_at_quantile(0.5),
|
||||
stall_p95_ms = self.stalls.value_at_quantile(0.95),
|
||||
stall_max_ms = self.stalls.max(),
|
||||
report_window_seconds,
|
||||
measurement_version = gpui::profiler::hang::MEASUREMENT_VERSION
|
||||
);
|
||||
self.stalls.reset();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user