feat: RT for audio

This commit is contained in:
DatCaptainHorse
2026-10-05 14:19:11 +03:00
parent 8c61cb5cbb
commit 030dcc5a8c
3 changed files with 176 additions and 11 deletions
+163 -10
View File
@@ -130,6 +130,15 @@ pub struct Service {
/// Existence only. Whether the thing behind the socket answers correctly is
/// not knowable from here, and a box is not the place to find out.
pub ready: Option<&'static str>,
/// A name on the system bus that exists once this service can be talked to,
/// for the ones that are reached by name and bind no socket of their own.
///
/// Same reason as `ready` and the same rule: `spawn` returns when the child
/// has been forked, which is before it has connected to the bus and asked
/// for its name, and whatever starts next would ask a bus that has no one
/// by that name. For realtime audio the cost is permanent: PipeWire asks
/// once, as it starts, and a refusal is not retried.
pub ready_name: Option<&'static str>,
}
/// How long a service gets to bind its socket before the box gives up on it.
@@ -226,7 +235,46 @@ pub const STACK: &[Service] = &[
cost: "nothing that speaks on the system bus can find it",
required: true,
umask: None,
// 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.
ready: Some("/run/dbus/system_bus_socket"),
ready_name: None,
},
// Realtime priority for audio, which is two daemons because the one that
// grants it asks the other whether to.
//
// PipeWire runs as an ordinary user and a realtime scheduling class is not
// something an ordinary user may give itself, so its threads run at normal
// priority and miss their deadlines whenever a game keeps every CPU busy:
// the symptom is crackling, with a counter of xruns climbing in the audio
// graph. The grant comes from rtkit over the system bus, and rtkit will not
// grant anything without polkit saying yes, so both are here, polkit first.
//
// Both start as root and drop their own privileges, so neither is a third
// user in this table. Both are optional: without them the box has audio at
// normal priority, which is the state it was in before they existed.
Service {
name: "polkitd",
argv: &["/usr/lib/polkit-1/polkitd", "--no-debug"],
env: &[],
user: None,
cost: "rtkit cannot be authorised, so audio runs at normal priority",
required: false,
umask: None,
ready: None,
ready_name: Some("org.freedesktop.PolicyKit1"),
},
Service {
name: "rtkit",
argv: &["/usr/lib/rtkit-daemon"],
env: &[],
user: None,
cost: "audio threads cannot get realtime priority and underrun when the CPUs are busy",
required: false,
umask: None,
ready: None,
ready_name: Some("org.freedesktop.RealtimeKit1"),
},
Service {
name: "dbus-session",
@@ -246,6 +294,7 @@ pub const STACK: &[Service] = &[
// and a bus address that is not bound yet is a service that starts,
// finds nothing, and carries on without a bus.
ready: Some("/run/user/1000/bus"),
ready_name: None,
},
Service {
name: "pipewire",
@@ -263,6 +312,7 @@ pub const STACK: &[Service] = &[
// Both the session manager and the sender connect here, and so does
// the workload once it starts.
ready: Some("/run/pipewire/pipewire-0"),
ready_name: None,
},
Service {
name: "wireplumber",
@@ -280,6 +330,7 @@ pub const STACK: &[Service] = &[
required: false,
umask: None,
ready: None,
ready_name: None,
},
Service {
name: "neswire",
@@ -294,6 +345,7 @@ pub const STACK: &[Service] = &[
required: false,
umask: None,
ready: None,
ready_name: None,
},
Service {
name: "neshub",
@@ -306,6 +358,7 @@ pub const STACK: &[Service] = &[
required: true,
umask: None,
ready: None,
ready_name: None,
},
Service {
name: "nesgamepad",
@@ -323,6 +376,7 @@ pub const STACK: &[Service] = &[
// It dials the hub rather than the other way round, and redials until
// the hub is there, so nothing waits on it.
ready: None,
ready_name: None,
},
];
@@ -382,19 +436,27 @@ impl Stack {
/// rule above decides what it costs: a required service that never binds refuses
/// the box, an optional one is stepped over.
fn await_ready(&self, service: &Service, before: Option<Identity>) -> Result<(), Failure> {
let Some(path) = service.ready else {
return Ok(());
};
// The watch for what was just started, so a service that dies during its
// own bring-up is not waited out for the full timeout.
let started = self.running.last().map(|(_, watched)| watched.pid);
await_path(
service.name,
path,
before,
&|| started.is_none_or(is_alive),
READY_TIMEOUT,
)
if let Some(path) = service.ready {
await_path(
service.name,
path,
before,
&|| started.is_none_or(is_alive),
READY_TIMEOUT,
)?;
}
if let Some(name) = service.ready_name {
await_bus_name(
service.name,
name,
&|| started.is_none_or(is_alive),
READY_TIMEOUT,
)?;
}
Ok(())
}
fn spawn(&mut self, service: &'static Service) -> Result<(), Failure> {
@@ -492,6 +554,59 @@ impl Stack {
}
}
/// Whether `reply` is the system bus saying a name has an owner.
///
/// The answer to `NameHasOwner` as `dbus-send` prints it, a line that reads
/// `boolean true` or `boolean false`. Anything else, including nothing at all, is
/// not an owner.
fn bus_says_owned(reply: &str) -> bool {
reply.lines().any(|line| line.trim() == "boolean true")
}
/// Wait for `name` to have an owner on the system bus.
///
/// Asked of the bus itself with `dbus-send`, which the image has because the bus
/// does: a program that connects, asks and goes is the least that can be done
/// here without init growing a bus client of its own. Polled for the same reason
/// [`await_path`] is, and ends early the same way, when what was started has died.
fn await_bus_name(
service: &str,
name: &str,
alive: &dyn Fn() -> bool,
timeout: std::time::Duration,
) -> Result<(), Failure> {
let deadline = std::time::Instant::now() + timeout;
loop {
let reply = std::process::Command::new("/usr/bin/dbus-send")
.args([
"--system",
"--print-reply",
"--dest=org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus.NameHasOwner",
&format!("string:{name}"),
])
.output();
if let Ok(out) = reply
&& bus_says_owned(&String::from_utf8_lossy(&out.stdout))
{
return Ok(());
}
if !alive() {
return Err(Failure::new(format!(
"{service} exited before it took the name {name} on the system bus"
)));
}
if std::time::Instant::now() >= deadline {
return Err(Failure::new(format!(
"{service} did not take the name {name} on the system bus within {}s",
timeout.as_secs()
)));
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
impl Services for Stack {
fn bring_up(&mut self, video: VideoLimits) -> Result<Vec<String>, Failure> {
self.video = video;
@@ -782,6 +897,27 @@ mod tests {
);
}
#[test]
fn only_an_owned_name_counts_as_ready() {
assert!(bus_says_owned(
"method return time=1.0 sender=org.freedesktop.DBus -> destination=:1.4\n boolean true\n"
));
assert!(!bus_says_owned(" boolean false\n"));
assert!(!bus_says_owned(""), "a failed dbus-send says nothing");
}
/// Both of the realtime-audio daemons talk to the system bus once, as they
/// start, and neither retries, so that bus has to have said it is there.
#[test]
fn the_system_bus_is_waited_for_before_what_connects_to_it() {
let bus = STACK.iter().find(|s| s.name == "dbus-system").unwrap();
assert!(bus.ready.is_some(), "polkitd and rtkit would find no socket");
for name in ["polkitd", "rtkit"] {
let svc = STACK.iter().find(|s| s.name == name).unwrap();
assert!(svc.ready_name.is_some(), "{name} is reached by name");
}
}
/// A service that names no socket is ready when it has been started, and
/// the wait has to be free in that case: most of the table is like this.
#[test]
@@ -1002,6 +1138,23 @@ mod tests {
}
}
/// The grant has to be in place before the audio server asks for it: PipeWire
/// asks once, when it starts, and a daemon that comes up after it is a grant
/// that was never asked for. And polkit before rtkit, which asks it on every
/// request.
#[test]
fn realtime_audio_is_set_up_before_the_audio_server() {
let at = |name: &str| {
STACK
.iter()
.position(|s| s.name == name)
.unwrap_or_else(|| panic!("{name} is not in the table"))
};
assert!(at("dbus-system") < at("polkitd"));
assert!(at("polkitd") < at("rtkit"));
assert!(at("rtkit") < at("pipewire"));
}
/// 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
@@ -21,6 +21,7 @@ static SLEEPERS: &[Service] = &[
required: true,
umask: None,
ready: None,
ready_name: None,
},
Service {
name: "second-sleeper",
@@ -31,6 +32,7 @@ static SLEEPERS: &[Service] = &[
required: true,
umask: None,
ready: None,
ready_name: None,
},
];
+11 -1
View File
@@ -311,9 +311,17 @@ RUN pacman-key --init
# The second was found by the check further down rather than by reading the
# list — dropping a package takes its dependency tree with it, and the part of
# that tree something else was quietly using is not visible from here.
# Created before the install, with the numbers the distribution reserves for them,
# because the packages hand ownership of a few directories to these names and
# the hook that would have made the users goes with systemd. `polkit` is here
# for `rtkit`, which will not grant realtime priority to anything without it;
# see the services table in `nesinit`.
RUN groupadd -r -g 102 polkitd && useradd -r -u 102 -g polkitd -d / -s /usr/bin/nologin polkitd && \
groupadd -r -g 133 rtkit && useradd -r -u 133 -g rtkit -d / -s /usr/bin/nologin rtkit
RUN pacman -Syu --noconfirm --needed \
--assume-installed mesa --assume-installed opengl-driver \
dbus \
dbus polkit rtkit \
iptables iproute2 \
libglvnd libdrm libepoxy libxxf86vm libinput wayland \
expat zlib llvm-libs lm_sensors elfutils libva shaderc vulkan-icd-loader \
@@ -558,6 +566,7 @@ RUN failed=0; \
/usr/bin/nesgamepad /usr/lib/libnescapture_layer.so /usr/lib/libSDL2-2.0.so.0 \
/usr/lib/libVkLayer_nvgpu.so \
/usr/bin/dbus-daemon /usr/bin/pipewire \
/usr/lib/polkit-1/polkitd /usr/lib/rtkit-daemon \
/usr/bin/wireplumber /usr/bin/ip \
/usr/lib/libgallium-*.so /usr/lib/libEGL_mesa.so.0 \
/usr/lib/libvulkan_*.so /usr/lib/dri/*.so /usr/lib/gbm/*.so; do \
@@ -579,6 +588,7 @@ RUN failed=0; \
RUN for required in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \
/usr/bin/nesgamepad /usr/lib/libSDL2-2.0.so.0 \
/usr/bin/dbus-daemon /usr/bin/pipewire \
/usr/lib/polkit-1/polkitd /usr/lib/rtkit-daemon \
/usr/bin/wireplumber /usr/bin/ip /usr/bin/python3 /usr/bin/umu-run; do \
test -x "$required" || { echo "the image is missing $required" >&2; exit 1; }; \
done