Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/rds-desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions crates/rds-desktop/src/decode_cpu.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//! Read-only thread CPU clock; unsupported/failed observations stay absent.
use std::time::Duration;
pub(super) fn now() -> Option<Duration> {
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()?,
))
}
4 changes: 4 additions & 0 deletions crates/rds-desktop/src/decode_cpu_unavailable.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
use std::time::Duration;
pub(super) fn now() -> Option<Duration> {
None
}
71 changes: 70 additions & 1 deletion crates/rds-desktop/src/decode_work.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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"
);
}
Expand Down Expand Up @@ -136,8 +161,20 @@ pub(crate) async fn run<T: Send + 'static>(
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
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions crates/rds-desktop/src/render/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<objc2_foundation::NSObject>);

#[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<Option<String>, crate::DesktopError> {
use objc2_app_kit::{NSPasteboard, NSPasteboardTypeString};
Expand Down
1 change: 1 addition & 0 deletions crates/rds-desktop/src/render/viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))?;
Expand Down
15 changes: 15 additions & 0 deletions docs/native-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 14 additions & 0 deletions docs/research.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading