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:
2026-08-14 20:59:39 +02:00
parent 4676d20dc1
commit b21b2f6ce9
6 changed files with 78 additions and 8 deletions
Generated
+1
View File
@@ -3115,6 +3115,7 @@ dependencies = [
"punktfunk-core",
"tracing",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"zbus",
]
[[package]]
+4
View File
@@ -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
+61 -8
View File
@@ -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) => {