fix(host): the Linux data-plane renice was a silent no-op everywhere — fall back to RealtimeKit, and boost the audio threads at all
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/<pid>/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.
This commit is contained in:
Generated
+1
@@ -3115,6 +3115,7 @@ dependencies = [
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<pid>/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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -472,6 +472,9 @@ fn pad_audio_thread<C: crate::audio::AudioCapturer>(
|
||||
open: impl Fn() -> anyhow::Result<C>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
// 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) => {
|
||||
|
||||
Reference in New Issue
Block a user