From b21b2f6ce92ac90be3f91cefdf2cfa16cfca3c5d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 20:59:39 +0200 Subject: [PATCH] =?UTF-8?q?fix(host):=20the=20Linux=20data-plane=20renice?= =?UTF-8?q?=20was=20a=20silent=20no-op=20everywhere=20=E2=80=94=20fall=20b?= =?UTF-8?q?ack=20to=20RealtimeKit,=20and=20boost=20the=20audio=20threads?= =?UTF-8?q?=20at=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Linux host to date ran its capture/encode/send threads at nice 0: boost_thread_priority's setpriority() needs CAP_SYS_NICE or a raised RLIMIT_NICE, no install channel granted either, and the host binary can never carry a file capability (a capped process's /proc//exe is unreadable to KWin — the 0.26.0-1 incident). A 2026-08-14 field log showed the cost end to end: a fresh game launch's shader-compile storm descheduled the unprioritized threads, 5 ms audio datagrams left late enough to stutter, the client's OWD signal rose, and ABR cut a gigabit-Ethernet session to its 5 Mbps floor at zero loss — while the same box carried 708 Mbps cleanly once the storm passed. The renice now falls back to RealtimeKit (MakeThreadHighPriorityWithPID, one blocking system-bus call per boosted thread) — the same unprivileged broker PipeWire clients use, so nothing enters the permitted set and KWin identification is untouched. Only the nice verb, never MakeThreadRealtime: the SCHED_RR reservations apply to rtkit-granted RR too. zbus rides ashpd's exact backend choice (tokio, no async-io) plus blocking-api, so the resolved graph gains no second I/O backend. And the audio plane is boosted for the first time: the 5 ms Opus capture->encode->send loop (critical — a stall there is directly audible), the PipeWire capture mainloop thread (its process callbacks run there; PipeWire's own module-rt only covers data loops we don't use), and the pad-audio streamer (above-normal, like the session send thread). The first two had no boost call at all; on Windows the audio_thread boost also engages, via the SetThreadPriority arm. --- Cargo.lock | 1 + crates/pf-frame/Cargo.toml | 4 ++ crates/pf-frame/src/thread_qos.rs | 69 ++++++++++++++++--- crates/punktfunk-host/src/audio/linux/mod.rs | 4 ++ crates/punktfunk-host/src/native/audio.rs | 5 ++ crates/punktfunk-host/src/native/pad_audio.rs | 3 + 6 files changed, 78 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2aa855d..1f36f476 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3115,6 +3115,7 @@ dependencies = [ "punktfunk-core", "tracing", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "zbus", ] [[package]] diff --git a/crates/pf-frame/Cargo.toml b/crates/pf-frame/Cargo.toml index 15d3fcf9..4fa77217 100644 --- a/crates/pf-frame/Cargo.toml +++ b/crates/pf-frame/Cargo.toml @@ -21,6 +21,10 @@ tracing = "0.1" # `FramePayload::Cuda` owns a zero-copy `DeviceBuffer`; `libc` for the per-thread `setpriority`. pf-zerocopy = { path = "../pf-zerocopy" } libc = "0.2" +# The rtkit fallback in `thread_qos` (one blocking system-bus call per boosted thread). Same zbus +# the host already pulls via ashpd; `tokio` mirrors ashpd's backend choice so this adds the +# `blocking-api` surface without changing the resolved I/O backend, and no default `async-io`. +zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] } [target.'cfg(target_os = "windows")'.dependencies] # The DXGI capture identity (`WinCaptureTarget`/`D3d11Frame`/`pack_luid`/`make_device`) + the GPU diff --git a/crates/pf-frame/src/thread_qos.rs b/crates/pf-frame/src/thread_qos.rs index 80a748bb..9672bd98 100644 --- a/crates/pf-frame/src/thread_qos.rs +++ b/crates/pf-frame/src/thread_qos.rs @@ -44,10 +44,9 @@ pub fn boost_thread_priority(critical: bool) { // Best-effort nice of the CALLING thread. On Linux `setpriority(PRIO_PROCESS, 0, …)` acts on // the calling thread (the kernel resolves who==0 to the current task/tid), and both call // sites run inside their worker thread — so this nices exactly the capture/encode (critical) - // and send (non-critical) threads, nothing else. Silently no-ops without CAP_SYS_NICE / a - // raised RLIMIT_NICE, which is fine. We deliberately do NOT use SCHED_RR/FIFO by default: a - // realtime CPU class can preempt the compositor AND the game's own render thread, adding the - // very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR). + // and send (non-critical) threads, nothing else. We deliberately do NOT use SCHED_RR/FIFO by + // default: a realtime CPU class can preempt the compositor AND the game's own render thread, + // adding the very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR). let nice = if critical { -10 } else { -5 }; // SAFETY: `setpriority` takes three by-value integers and no pointers, so there is nothing to // alias or outlive. `PRIO_PROCESS` with `who == 0` targets the calling task on Linux and @@ -57,10 +56,24 @@ pub fn boost_thread_priority(critical: bool) { if rc == 0 { tracing::debug!(critical, nice, "thread nice raised"); } else { - tracing::debug!( - critical, - "setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE)" - ); + // The direct call needs CAP_SYS_NICE or a raised RLIMIT_NICE, and the host binary can + // NEVER carry a file capability (a capped process's /proc//exe is unreadable to + // KWin, which kills desktop streaming — the 0.26.0-1 field incident). RealtimeKit is + // the sanctioned unprivileged path: the same broker PipeWire's clients use, present on + // effectively every desktop install. Packaging also ships a `user@.service.d` + // LimitNICE drop-in so the direct call works on rtkit-less boxes — but only from the + // next login, and existing installs upgrade the binary alone; rtkit is what fixes the + // installed base. A 2026-08-14 field log showed exactly this rung missing: every + // fresh-launch shader storm descheduled the unprioritized audio/send threads. + match linux_rtkit::make_high_priority(nice) { + Ok(()) => tracing::debug!(critical, nice, "thread nice raised via rtkit"), + Err(e) => tracing::debug!( + critical, + reason = %e, + "setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE, and rtkit \ + was unavailable)" + ), + } } } #[cfg(not(any(target_os = "windows", target_os = "linux")))] @@ -68,3 +81,43 @@ pub fn boost_thread_priority(critical: bool) { let _ = critical; } } + +/// RealtimeKit fallback for [`boost_thread_priority`]: ask the system-bus broker +/// (`org.freedesktop.RealtimeKit1`) to renice the calling thread when the direct +/// `setpriority` was refused. This is how PulseAudio/PipeWire clients get their boosts on a +/// stock desktop — no capability anywhere, which matters here because a file capability on the +/// host binary breaks KWin's client identification outright. +/// +/// Only the high-priority (nice) verb is used, never `MakeThreadRealtime` — the SCHED_RR +/// reservations in [`boost_thread_priority`]'s comment apply to rtkit-granted RR too (and the +/// RT verb additionally demands an RLIMIT_RTTIME we don't set). +#[cfg(target_os = "linux")] +mod linux_rtkit { + /// One-shot blocking D-Bus call. Must be made from a plain worker thread, never from async + /// context — which already holds for every caller: `boost_thread_priority` acts on the + /// calling thread, so it only ever runs inside the dedicated capture/encode/send threads. + /// The connection is per-call rather than cached: this runs at most a handful of times per + /// session (thread starts), and holding a system-bus connection for the session's lifetime + /// to save microseconds at session start is a bad trade against a wedged bus daemon pinning + /// a socket in every session forever. + pub(super) fn make_high_priority(nice: i32) -> Result<(), zbus::Error> { + // SAFETY: `gettid` takes no arguments, touches no memory, and returns the calling + // thread's kernel tid — always valid on Linux. + let tid = unsafe { libc::syscall(libc::SYS_gettid) } as u64; + let pid = u64::from(std::process::id()); + let conn = zbus::blocking::Connection::system()?; + // `MakeThreadHighPriorityWithPID(u64 process, u64 thread, i32 priority)` — priority is a + // nice level, floored by rtkit's MinNiceLevel (defaults well below our -10). The WithPID + // variant with our own pid is the explicit spelling of "this thread of this process"; + // rtkit still authenticates the caller via the bus, so it grants nothing a plain + // `setpriority` caller couldn't be granted. + conn.call_method( + Some("org.freedesktop.RealtimeKit1"), + "/org/freedesktop/RealtimeKit1", + Some("org.freedesktop.RealtimeKit1"), + "MakeThreadHighPriorityWithPID", + &(pid, tid, nice), + )?; + Ok(()) + } +} diff --git a/crates/punktfunk-host/src/audio/linux/mod.rs b/crates/punktfunk-host/src/audio/linux/mod.rs index d8052ec7..9749e2c9 100644 --- a/crates/punktfunk-host/src/audio/linux/mod.rs +++ b/crates/punktfunk-host/src/audio/linux/mod.rs @@ -682,6 +682,10 @@ fn pw_thread( use pw::{properties::properties, spa}; use spa::param::audio::{AudioFormat, AudioInfoRaw}; use spa::pod::Pod; + // The stream's `process` callbacks run ON this mainloop thread (we never hand PipeWire a + // separate data loop), so PipeWire's own client `module-rt` boost of its data loops does not + // cover it — the ~2.7 ms capture quantum lives or dies by this thread's scheduling. + pf_frame::thread_qos::boost_thread_priority(true); // Setup errors funnel through the ready handshake (mirrors mic_pw_thread's IIFE). let result = (|| -> Result<()> { diff --git a/crates/punktfunk-host/src/native/audio.rs b/crates/punktfunk-host/src/native/audio.rs index 75f03d17..a26d6092 100644 --- a/crates/punktfunk-host/src/native/audio.rs +++ b/crates/punktfunk-host/src/native/audio.rs @@ -100,6 +100,11 @@ pub(super) fn audio_thread( /// pacing exists to prevent — so past this point the debt is forgiven, not repaid. const PACE_REANCHOR: std::time::Duration = std::time::Duration::from_millis(100); let want = punktfunk_core::audio::normalize_channels(channels); + // Same boost the video capture/encode loop takes, and this thread needs it MORE: it paces + // 5 ms datagrams, so a scheduling stall here is directly audible where a late video frame + // is one presentation slip. The 2026-08-14 field log's stutter was exactly this thread + // descheduled by fresh-game-launch shader storms — it carried no priority at all. + pf_frame::thread_qos::boost_thread_priority(true); // Tier and redundancy are ONE decision, budgeted against the session's video bitrate — see // `handshake::audio_budget`. An unparseable `audio.quality` was already warned about there // and fell back to the default, so nothing here can silently downgrade someone's audio. diff --git a/crates/punktfunk-host/src/native/pad_audio.rs b/crates/punktfunk-host/src/native/pad_audio.rs index 9438d642..aa06806f 100644 --- a/crates/punktfunk-host/src/native/pad_audio.rs +++ b/crates/punktfunk-host/src/native/pad_audio.rs @@ -472,6 +472,9 @@ fn pad_audio_thread( open: impl Fn() -> anyhow::Result, stop: Arc, ) { + // Above-normal like the session send thread — this plane is silence-gated and tiny, but when + // a pad speaker/haptics stream IS live it runs the same ≤10 ms cadence as session audio. + crate::native::boost_thread_priority(false); let mut lanes = match build_lanes(kinds) { Ok(l) => l, Err(e) => {