diff --git a/crates/rds-desktop/Cargo.toml b/crates/rds-desktop/Cargo.toml index 39b1045..fdb308b 100644 --- a/crates/rds-desktop/Cargo.toml +++ b/crates/rds-desktop/Cargo.toml @@ -31,14 +31,14 @@ wgpu = { version = "30.0.1", default-features = false, features = ["std", "parki winit = { version = "0.30.13", default-features = false, features = ["rwh_06", "x11", "wayland", "wayland-dlopen"], optional = true } pollster = { version = "0.4", optional = true } serde.workspace = true -rustix = { workspace = true, features = ["event"], optional = true } +rustix = { workspace = true, features = ["event", "time"], optional = true } # The same thin AppKit bindings already used by winit, for a Dock icon when # launched directly from the CLI as well as from a packaged application. [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.5.2", optional = true } objc2-app-kit = { version = "0.2.2", default-features = false, features = ["std", "NSApplication", "NSImage", "NSAlert", "NSButton", "NSPasteboard"], optional = true } -objc2-foundation = { version = "0.2.2", default-features = false, features = ["std", "NSData", "NSObject"], optional = true } +objc2-foundation = { version = "0.2.2", default-features = false, features = ["std", "NSData", "NSObject", "NSProcessInfo"], optional = true } [dev-dependencies] noq.workspace = true diff --git a/crates/rds-desktop/src/decode_cpu.rs b/crates/rds-desktop/src/decode_cpu.rs new file mode 100644 index 0000000..f485f1e --- /dev/null +++ b/crates/rds-desktop/src/decode_cpu.rs @@ -0,0 +1,10 @@ +//! Read-only thread CPU clock; unsupported/failed observations stay absent. +use std::time::Duration; +pub(super) fn now() -> Option { + use rustix::time::{ClockId, DynamicClockId, clock_gettime_dynamic}; + let time = clock_gettime_dynamic(DynamicClockId::Known(ClockId::ThreadCPUTime)).ok()?; + Some(Duration::new( + u64::try_from(time.tv_sec).ok()?, + u32::try_from(time.tv_nsec).ok()?, + )) +} diff --git a/crates/rds-desktop/src/decode_cpu_unavailable.rs b/crates/rds-desktop/src/decode_cpu_unavailable.rs new file mode 100644 index 0000000..fbd58ed --- /dev/null +++ b/crates/rds-desktop/src/decode_cpu_unavailable.rs @@ -0,0 +1,4 @@ +use std::time::Duration; +pub(super) fn now() -> Option { + None +} diff --git a/crates/rds-desktop/src/decode_work.rs b/crates/rds-desktop/src/decode_work.rs index 798b4ba..43f0564 100644 --- a/crates/rds-desktop/src/decode_work.rs +++ b/crates/rds-desktop/src/decode_work.rs @@ -9,6 +9,13 @@ use std::sync::{ use std::time::{Duration, Instant}; use tokio::{sync::Semaphore, task::AbortHandle}; +#[cfg(all(feature = "x11", any(target_os = "linux", target_os = "macos")))] +#[path = "decode_cpu.rs"] +mod cpu; +#[cfg(not(all(feature = "x11", any(target_os = "linux", target_os = "macos"))))] +#[path = "decode_cpu_unavailable.rs"] +mod cpu; + const BUDGET: u8 = 0; const QUEUED: u8 = 1; const NATIVE: u8 = 2; @@ -20,6 +27,8 @@ pub(crate) struct Probe { phase: AtomicU8, admitted_ms: AtomicU64, native_ms: AtomicU64, + finished_ms: AtomicU64, + native_cpu_us: AtomicU64, seq: u64, bytes: usize, width: u32, @@ -33,6 +42,8 @@ impl Probe { phase: AtomicU8::new(BUDGET), admitted_ms: AtomicU64::new(0), native_ms: AtomicU64::new(0), + finished_ms: AtomicU64::new(0), + native_cpu_us: AtomicU64::new(u64::MAX), seq: header.seq, bytes, width: header.width, @@ -60,6 +71,16 @@ impl Probe { let admitted = self.admitted_ms.load(Ordering::Relaxed); let native = self.native_ms.load(Ordering::Relaxed); let elapsed = self.elapsed_ms(); + let work_ms = (phase >= NATIVE).then(|| { + let end = if phase == DONE { + self.finished_ms.load(Ordering::Relaxed) + } else { + elapsed + }; + end.saturating_sub(native) + }); + let cpu_us = self.native_cpu_us.load(Ordering::Relaxed); + let cpu_ms = (cpu_us != u64::MAX).then_some(cpu_us / 1000); tracing::warn!( reason, frame_seq = self.seq, @@ -80,7 +101,11 @@ impl Probe { } else { native.saturating_sub(admitted) }), - native_work_ms = (phase >= NATIVE).then(|| elapsed.saturating_sub(native)), + native_work_ms = work_ms, + native_cpu_ms = cpu_ms, + native_non_cpu_ms = work_ms + .zip(cpu_ms) + .map(|(wall, cpu)| wall.saturating_sub(cpu)), "desktop decode scheduling health" ); } @@ -136,8 +161,20 @@ pub(crate) async fn run( let task_probe = probe.clone(); let task = tokio::task::spawn_blocking(move || { let _slot = slot; + let cpu_started = cpu::now(); task_probe.mark(NATIVE); let result = work(); + let cpu_finished = cpu::now(); + task_probe + .finished_ms + .store(task_probe.elapsed_ms(), Ordering::Relaxed); + if let Some(micros) = cpu_finished + .zip(cpu_started) + .and_then(|(end, start)| end.checked_sub(start)) + .and_then(|duration| u64::try_from(duration.as_micros()).ok()) + { + task_probe.native_cpu_us.store(micros, Ordering::Relaxed); + } task_probe.mark(DONE); task_probe.report("completed_slowly"); result @@ -172,6 +209,38 @@ mod tests { height: 64, } } + #[cfg(all(feature = "x11", any(target_os = "linux", target_os = "macos")))] + #[tokio::test] + async fn thread_cpu_observation_distinguishes_work_from_native_waiting() { + static SLOTS: Semaphore = Semaphore::const_new(1); + let probe = Probe::new(&header(), 12); + run(&SLOTS, Duration::from_secs(3), probe.clone(), || { + let start = cpu::now().expect("supported platform thread clock"); + let limit = Instant::now() + Duration::from_secs(2); + while cpu::now().unwrap().saturating_sub(start) < Duration::from_millis(20) { + assert!( + Instant::now() < limit, + "CPU fixture was indefinitely starved" + ); + std::hint::spin_loop(); + } + std::thread::sleep(Duration::from_millis(80)); + }) + .await + .unwrap(); + let cpu_us = probe.native_cpu_us.load(Ordering::Acquire); + let wall_ms = + probe.finished_ms.load(Ordering::Acquire) - probe.native_ms.load(Ordering::Acquire); + assert!(cpu_us >= 20_000, "actual native CPU work must be observed"); + assert!( + wall_ms >= 100, + "native waiting remains part of elapsed time" + ); + assert!( + cpu_us < wall_ms * 500, + "sleep cannot be reported as CPU work" + ); + } #[test] fn cancellation_before_native_start_aborts_queued_decode() { static SLOTS: Semaphore = Semaphore::const_new(1); diff --git a/crates/rds-desktop/src/render/platform.rs b/crates/rds-desktop/src/render/platform.rs index 0cfdb24..d9fd787 100644 --- a/crates/rds-desktop/src/render/platform.rs +++ b/crates/rds-desktop/src/render/platform.rs @@ -2,6 +2,41 @@ #![cfg_attr(target_os = "macos", allow(unsafe_code))] use winit::event_loop::EventLoop; +#[cfg(target_os = "macos")] +pub(super) struct RemoteActivity(objc2::rc::Retained); + +#[cfg(target_os = "macos")] +impl Drop for RemoteActivity { + fn drop(&mut self) { + // SAFETY: the owned token came from this process's activity API and + // ends once, when the native viewer event loop returns or unwinds. + unsafe { objc2_foundation::NSProcessInfo::processInfo().endActivity(&self.0) }; + } +} + +#[cfg(target_os = "macos")] +pub(super) fn remote_activity() -> RemoteActivity { + use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; + // SAFETY: Foundation owns the retained activity token; the RAII guard + // pairs its begin/end. This user-requested stream must remain responsive + // while covered, without preventing idle system/display sleep or locking. + let token = unsafe { + NSProcessInfo::processInfo().beginActivityWithOptions_reason( + NSActivityOptions::NSActivityUserInitiatedAllowingIdleSystemSleep, + &NSString::from_str("Remote desktop session"), + ) + }; + RemoteActivity(token) +} + +#[cfg(not(target_os = "macos"))] +pub(super) struct RemoteActivity; + +#[cfg(not(target_os = "macos"))] +pub(super) fn remote_activity() -> RemoteActivity { + RemoteActivity +} + #[cfg(target_os = "macos")] pub(super) fn paste_text() -> Result, crate::DesktopError> { use objc2_app_kit::{NSPasteboard, NSPasteboardTypeString}; diff --git a/crates/rds-desktop/src/render/viewer.rs b/crates/rds-desktop/src/render/viewer.rs index 67addb2..053df7f 100644 --- a/crates/rds-desktop/src/render/viewer.rs +++ b/crates/rds-desktop/src/render/viewer.rs @@ -418,6 +418,7 @@ impl Viewer { Ok((Self { event_loop, app }, handle, receiver)) } pub fn run(mut self) -> Result<(), DesktopError> { + let _activity = super::platform::remote_activity(); self.event_loop .run_app(&mut self.app) .map_err(|e| DesktopError::Capture(e.to_string()))?; diff --git a/docs/native-viewer.md b/docs/native-viewer.md index c74e5ac..88ab4ec 100644 --- a/docs/native-viewer.md +++ b/docs/native-viewer.md @@ -394,3 +394,18 @@ automation tool's clock. It records only sequence, event class and local timing; key/button codes, coordinates, text and clipboard contents are excluded. These records locate delay; neither input dispatch nor a frame read proves physical display response. + +## Native work and macOS activity + +Slow decode diagnostics now separate elapsed native work from that worker's +thread CPU time. Completed work fixes its end timestamp before reporting, so +later reporting delay cannot inflate the native stage. A failed or unavailable +CPU clock stays absent. Elapsed time minus CPU time includes scheduling and +other native waiting; it is not a diagnosis of a particular OS cause. + +The macOS viewer owns a Foundation user-initiated activity for its event-loop +lifetime, including a covered window's active reference processing. The guard +ends the activity when the loop returns or unwinds. Its option explicitly +allows idle system sleep and does not keep the display awake or prevent lock. +This is scoped application activity, not a global power/QoS setting. Installed +measurements still decide whether it improves a particular latency episode. diff --git a/docs/research.md b/docs/research.md index 01cc5f5..0e0a1b0 100644 --- a/docs/research.md +++ b/docs/research.md @@ -593,3 +593,17 @@ retry end that hold. This is application repair above QUIC, without replacing its loss detection or enlarging reader/decode budgets. The [follow-up report](reports/rds-reference-recovery-20261002.md) distinguishes the real-stream regression, installed diagnostics and still-open native acceptance. + +## 2026-10-02 native scheduling observations + +[Apple's activity guidance](https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/PrioritizeWorkAtTheAppLevel.html) +recommends a scoped activity for ongoing user-initiated work. +[The allowing-idle-sleep option](https://developer.apple.com/documentation/foundation/processinfo/activityoptions/userinitiatedallowingidlesystemsleep) +preserves idle system sleep. RDS uses that option for its native viewer; it does +not claim App Nap was proven to cause the observed pauses and does not enable +the stronger latency-critical option. The existing pinned Rustix dependency +also supplies a read-only [thread CPU clock](https://docs.rs/rustix/1.1.5/rustix/time/enum.ClockId.html) +to distinguish actual computation from elapsed native waiting on Linux/macOS. +The fixture combines real CPU work and sleeping on one blocking worker and +requires its CPU observation to exclude the wait. These observations do not +replace installed latency/quality acceptance.