fix(nesinit): give the audio graph realtime priority by rlimit, not rtkit

Inside a box, every PipeWire, wireplumber and neswire thread was running
SCHED_OTHER. The rtkit -> polkit -> system bus chain started but granted
nothing. With a game keeping the vCPUs busy, the graph's 5 ms cycles ran
late. pw-top counted no xruns, but neswire sent packets in bursts, and
the client saw 15-25 ms gaps in audio alone (video arriving on time in
the same seconds), which emptied its 10 ms cushion every 30 s or so.

pipewire and neswire now exec with RLIMIT_RTPRIO 95, set before the uid
drop. module-rt checks that first and sets SCHED_FIFO itself, which is
how PipeWire is meant to run outside a desktop session. The workload
doesn't get the grant, so a game can't take the CPU away from the graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DatCaptainHorse
2026-10-16 18:28:47 +03:00
co-authored by Claude Opus 5.5
parent 87092d4693
commit 36f13bfcfc
2 changed files with 67 additions and 2 deletions
+65 -2
View File
@@ -119,6 +119,21 @@ pub struct Service {
/// socket list lives inside a module's arguments, and a drop-in that
/// re-declares that module loads it twice.
pub umask: Option<u32>,
/// Whether this service may give its own threads a realtime class, up to
/// [`REALTIME_PRIORITY`].
///
/// For the two processes that carry the audio graph's 5 ms cycle. Granted
/// as `RLIMIT_RTPRIO` at exec, which PipeWire's `module-rt` checks first
/// and uses to set `SCHED_FIFO` itself. The grant used to come from rtkit,
/// asked over the system bus with polkit's approval, and in a box that
/// chain granted nothing. Measured 2026-10-16: every PipeWire, wireplumber
/// and neswire thread was `SCHED_OTHER`, and cycles that ran late behind a
/// busy game reached the client as 15-25 ms gaps in the stream, which
/// emptied its 10 ms cushion.
///
/// Not given to the workload: a game that gave itself a realtime class
/// could starve the graph that this exists to protect.
pub realtime: bool,
/// A path that exists once this service can be talked to.
///
/// `None` means "started is ready", which is true of anything nothing else
@@ -181,6 +196,14 @@ pub const AUDIO_DIR: &str = "/run/pipewire";
/// Where a service's runtime sockets live.
pub const RUNTIME_DIR: &str = "/run/user/1000";
/// The highest realtime priority a [`Service::realtime`] service may take.
///
/// PipeWire's own packaging grants its group 95 (`limits.d/25-pw-rlimits.conf`),
/// and `module-rt` asks for 88 on the graph and 83 on clients. A limit below
/// what it asks makes it clamp rather than fail, so 95 just means it gets
/// what it asks for.
pub const REALTIME_PRIORITY: libc::rlim_t = 95;
/// Somewhere every service may write.
///
/// # The service user's home is on a read-only root
@@ -235,6 +258,7 @@ pub const STACK: &[Service] = &[
cost: "nothing that speaks on the system bus can find it",
required: true,
umask: None,
realtime: false,
// Now something does connect to it: the two services below, once each
// as they start, and neither one retries. A bus that has been forked and
// has not bound its socket yet is a connection refused to both.
@@ -262,6 +286,7 @@ pub const STACK: &[Service] = &[
cost: "rtkit cannot be authorised, so audio runs at normal priority",
required: false,
umask: None,
realtime: false,
ready: None,
ready_name: Some("org.freedesktop.PolicyKit1"),
},
@@ -273,6 +298,7 @@ pub const STACK: &[Service] = &[
cost: "audio threads cannot get realtime priority and underrun when the CPUs are busy",
required: false,
umask: None,
realtime: false,
ready: None,
ready_name: Some("org.freedesktop.RealtimeKit1"),
},
@@ -290,6 +316,7 @@ pub const STACK: &[Service] = &[
cost: "audio and anything else expecting a session bus will not start",
required: true,
umask: None,
realtime: false,
// Every service after this one is handed this path as its bus address,
// and a bus address that is not bound yet is a service that starts,
// finds nothing, and carries on without a bus.
@@ -309,6 +336,7 @@ pub const STACK: &[Service] = &[
required: true,
// So the workload, which is not this user, can open the socket.
umask: Some(0),
realtime: true,
// Both the session manager and the sender connect here, and so does
// the workload once it starts.
ready: Some("/run/pipewire/pipewire-0"),
@@ -329,6 +357,7 @@ pub const STACK: &[Service] = &[
cost: "audio devices exist but nothing routes them",
required: false,
umask: None,
realtime: false,
ready: None,
ready_name: None,
},
@@ -344,6 +373,7 @@ pub const STACK: &[Service] = &[
cost: "the client gets pictures and no sound",
required: false,
umask: None,
realtime: true,
ready: None,
ready_name: None,
},
@@ -357,6 +387,7 @@ pub const STACK: &[Service] = &[
cost: "the session has no address, so no client can reach it",
required: true,
umask: None,
realtime: false,
ready: None,
ready_name: None,
},
@@ -373,6 +404,7 @@ pub const STACK: &[Service] = &[
cost: "controllers plugged into the client do not reach the game",
required: false,
umask: None,
realtime: false,
// It dials the hub rather than the other way round, and redials until
// the hub is there, so nothing waits on it.
ready: None,
@@ -505,10 +537,11 @@ impl Stack {
command.envs(service.env.iter().copied());
let mask = service.umask;
let realtime = service.realtime;
if let Some((uid, gid)) = service.user {
// SAFETY: the closure runs between fork and exec in the child,
// where only async-signal-safe calls are allowed. These two are,
// and it allocates nothing.
// where only async-signal-safe calls are allowed. These are, and it
// allocates nothing.
unsafe {
command.pre_exec(move || {
if let Some(mask) = mask {
@@ -516,6 +549,17 @@ impl Stack {
// child, between fork and exec.
libc::umask(mask as libc::mode_t);
}
// Before the uid drops: raising a hard limit takes root,
// and once dropped the child could never get it back.
if realtime {
let limit = libc::rlimit {
rlim_cur: REALTIME_PRIORITY,
rlim_max: REALTIME_PRIORITY,
};
if libc::setrlimit(libc::RLIMIT_RTPRIO, &limit) != 0 {
return Err(std::io::Error::last_os_error());
}
}
// gid first: dropping the uid first would lose the
// privilege needed to set the gid at all.
if libc::setgid(gid) != 0 {
@@ -1164,6 +1208,25 @@ mod tests {
assert!(at("rtkit") < at("pipewire"));
}
/// The graph and the sender run its 5 ms cycle and may go realtime; nothing
/// else in the table may, because a realtime thread that is not the graph
/// is one that can take the CPU away from it.
#[test]
fn only_the_audio_graph_may_go_realtime() {
let realtime: Vec<_> = STACK
.iter()
.filter(|s| s.realtime)
.map(|s| s.name)
.collect();
assert_eq!(realtime, ["pipewire", "neswire"]);
assert!(
STACK
.iter()
.filter(|s| s.realtime)
.all(|s| s.user.is_some())
);
}
/// A required service whose absence has a workaround should not be
/// required, and an optional one whose absence has none should not be
/// optional. Only the second half is checkable, and it is the one that
+2
View File
@@ -20,6 +20,7 @@ static SLEEPERS: &[Service] = &[
cost: "nothing: this is a test",
required: true,
umask: None,
realtime: false,
ready: None,
ready_name: None,
},
@@ -31,6 +32,7 @@ static SLEEPERS: &[Service] = &[
cost: "nothing: this is a test",
required: true,
umask: None,
realtime: false,
ready: None,
ready_name: None,
},