fix(pf-vdisplay): a helper that outran the pipe buffer had its output thrown away as a timeout

`output_within` read stdout/stderr only after the child exited, and its doc justified that with
"these helpers emit at most a few hundred KiB, well under any real pipe pressure". A pipe holds
64 KiB. Anything past that blocks the helper in `write()`, so it never exits, the budget kills it,
and a successful query is reported to the caller as `TimedOut` with its answer discarded.

The busiest caller is the one that trips it: `pw-dump` on a populated PipeWire graph clears 64 KiB
routinely and is polled from the 45 s gamescope loops. Confirmed empirically — a child writing
1 MiB into an undrained pipe never exits.

Both pipes are now drained on their own threads, concurrently with the wait.

That makes the joins load-bearing, which exposed the second half: the Unix `tree::Guard` was an
empty stub whose doc claimed `Child::kill` "already ends the only process there is". It never did
for this crate's Linux helpers — `pkexec`, `systemd-run`, `systemctl --user` and the `sh -c`
wrappers all fork — and a surviving grandchild holds the pipes' write ends, so a reader would wait
for an EOF that never arrives. The child is now the leader of its own process group and the guard
`killpg`s it, which is the Unix shape of the Job object the Windows half already used.

Also gates `pf-frame`, `pf-gpu` and `pf-encode` to Windows: every use site of all three is
`cfg(windows)`, and between them they dragged FFmpeg, ash and openh264 into the Linux build for
nothing (sweep item 13.19).
This commit is contained in:
2026-08-11 08:22:16 +02:00
parent bde4276632
commit dd9bbaf1c5
3 changed files with 124 additions and 26 deletions
+14 -5
View File
@@ -16,13 +16,9 @@ publish = false
[dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
pf-frame = { path = "../pf-frame" }
pf-gpu = { path = "../pf-gpu" }
pf-host-config = { path = "../pf-host-config" }
pf-paths = { path = "../pf-paths" }
pf-win-display = { path = "../pf-win-display" }
# The Windows admission gate consults NVENC's session budget (can_open_another_session).
pf-encode = { path = "../pf-encode" }
anyhow = "1"
tracing = "0.1"
# The platform-neutral policy/identity/custom-preset state is serde-serialized (persisted + the mgmt
@@ -41,8 +37,12 @@ hex = "0.4"
# the shipped host's dependency closure through this crate is unchanged.
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[target.'cfg(target_os = "linux")'.dependencies]
# `proc`'s process-group tree guard is Unix-wide, not Linux-only: the module is compiled on every
# platform and its tests run on whatever the developer is sitting at (macOS, here).
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
# The Mutter backend drives D-Bus RemoteDesktop + ScreenCast.RecordVirtual via ashpd on a tokio
# runtime; the gamescope restore worker + portal handshakes use tokio too.
ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] }
@@ -61,6 +61,15 @@ bitflags = "2"
x11rb = { version = "0.13", default-features = false }
[target.'cfg(target_os = "windows")'.dependencies]
# Windows-only, all three, and gated here rather than unconditionally so the LINUX build does not
# drag their closures in for nothing: `pf-frame` for the DXGI capture identity + the CTA-861.3 HDR
# luminance fields, `pf-gpu` for the render-adapter LUID, and `pf-encode` for the admission gate's
# NVENC session budget (`can_open_another_session`, admission.rs, itself `#[cfg(windows)]`). Every
# use site of all three is Windows-gated — verified by grep — and between them they pull FFmpeg,
# ash and openh264, none of which a Linux host reaches through this crate.
pf-frame = { path = "../pf-frame" }
pf-gpu = { path = "../pf-gpu" }
pf-encode = { path = "../pf-encode" }
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] }
+2 -2
View File
@@ -429,7 +429,7 @@ pub fn preset_fields(preset: Preset) -> Option<EffectivePolicy> {
}
/// The persisted policy store: the loaded file value (or `None` when no file exists) behind its
/// JSON path. Mirrors [`pf_gpu::GpuPrefStore`] — private dir, temp-write + atomic rename,
/// JSON path. Mirrors `pf_gpu::GpuPrefStore` — private dir, temp-write + atomic rename,
/// in-memory rollback if the disk write fails.
pub struct DisplayPolicyStore {
path: PathBuf,
@@ -513,7 +513,7 @@ impl DisplayPolicyStore {
}
/// The process-wide display-policy store (config-dir file), loaded once on first access — the same
/// global-accessor shape as [`pf_gpu::prefs`], because display setup happens deep in the
/// global-accessor shape as `pf_gpu::prefs`, because display setup happens deep in the
/// capture/vdisplay path where no app state is threaded.
pub fn prefs() -> &'static DisplayPolicyStore {
static STORE: OnceLock<DisplayPolicyStore> = OnceLock::new();
+108 -19
View File
@@ -27,6 +27,7 @@ const POLL: Duration = Duration::from_millis(20);
/// Stdout/stderr are left as the caller configured them (inherited by default), so this is for
/// commands run for their exit status alone — see [`output_within`] when the output is read.
pub(crate) fn status_within(cmd: &mut Command, budget: Duration) -> Result<ExitStatus> {
tree::prepare(cmd);
let mut child = cmd.spawn()?;
let tree = tree::Guard::attach(&child);
let deadline = Instant::now() + budget;
@@ -51,35 +52,65 @@ pub(crate) fn status_within(cmd: &mut Command, budget: Duration) -> Result<ExitS
/// Run `cmd` to completion and capture its stdout/stderr, killing it if it outlives `budget`.
///
/// The output is read only after the child has exited, so a helper that fills the pipe buffer and
/// stalls is caught by the budget rather than deadlocking the reader (these helpers emit at most a
/// few hundred KiB, well under any real pipe pressure).
/// Both pipes are drained **concurrently with the wait**, on their own threads. Reading them only
/// after exit — the obvious shape, and what this did originally — deadlocks on any helper that
/// outtalks the pipe buffer: a pipe holds **64 KiB** on Linux (`/proc/sys/fs/pipe-max-size`'s page
/// default), not the "few hundred KiB" the old comment claimed, so a chatty helper blocks in
/// `write()`, never reaches exit, is killed at the budget, and its output is discarded as a
/// timeout. `pw-dump` on a populated PipeWire graph clears 64 KiB routinely, and it is polled from
/// the 45 s gamescope loops — so the failure was not hypothetical, it was the busiest caller.
pub(crate) fn output_within(cmd: &mut Command, budget: Duration) -> Result<Output> {
tree::prepare(cmd);
let mut child = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
let tree = tree::Guard::attach(&child);
// Taken off the `Child` so the reader threads own them outright: `wait_with_output` must not
// also be reading these, and `try_wait` below needs `&mut child` while they run.
let (stdout, stderr) = (child.stdout.take(), child.stderr.take());
let (out_rx, err_rx) = (drain(stdout), drain(stderr));
let deadline = Instant::now() + budget;
loop {
let status = loop {
match child.try_wait()? {
Some(_) => {
// Exited: `wait_with_output` now only drains already-buffered pipes — but only if
// nothing else still holds their WRITE end. A grandchild that outlived the helper
// does, and `wait_with_output` reads to an EOF that would then never arrive, which
// is the one way this "bounded" helper could still hang forever. End the tree first.
Some(status) => {
// The helper is gone, but a grandchild it left behind still holds the pipes' WRITE
// ends, so the readers below would wait for an EOF that never arrives. Ending the
// tree closes them — this is what makes the joins bounded.
tree.terminate();
return child.wait_with_output();
break status;
}
None if Instant::now() >= deadline => {
tree.terminate();
let _ = child.kill();
let _ = child.wait();
let _ = child.wait(); // reap it — never leave a zombie behind
return Err(timed_out(cmd, budget));
}
None => std::thread::sleep(POLL),
}
}
};
// A panicking reader thread cannot lose the call, only its half of the output.
let stdout = out_rx.join().unwrap_or_default();
let stderr = err_rx.join().unwrap_or_default();
Ok(Output {
status,
stdout,
stderr,
})
}
/// Read one of a child's pipes to EOF on its own thread, so the child never blocks in `write()`
/// waiting for us to catch up. Returns whatever was read; a read error yields the partial buffer,
/// because the caller's failure signal is the budget, not a short pipe.
fn drain<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::thread::JoinHandle<Vec<u8>> {
std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut r) = pipe {
let _ = r.read_to_end(&mut buf);
}
buf
})
}
fn timed_out(cmd: &Command, budget: Duration) -> Error {
@@ -215,6 +246,10 @@ mod tree {
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
/// Nothing to arrange before the spawn: job membership is assigned to the live process, so
/// [`Guard::attach`] does all of it. The Unix twin has to act here instead.
pub(super) fn prepare(_cmd: &mut std::process::Command) {}
/// Owns a Job object holding the spawned helper and everything it spawns. `None` when the job
/// could not be set up (see the module doc: degrade, don't fail).
pub(super) struct Guard(Option<HANDLE>);
@@ -294,18 +329,54 @@ mod tree {
}
}
/// The Unix half: `Child::kill` already ends the only process there is (see the Windows module doc
/// for why that is not true there). Kept as a real type rather than `cfg`ing the call sites, so the
/// two platforms read as one flow.
/// The Unix half — a **process group**, which is what Unix offers in place of a Job object.
///
/// This used to be an empty stub whose doc said `Child::kill` "already ends the only process there
/// is". That was never true of this crate's Linux helpers, which are the ones the module header is
/// about: `pkexec`, `systemd-run`, `systemctl --user` and the `sh -c` wrappers all fork, so the
/// process that hangs is routinely a grandchild `Child::kill` cannot reach — and with the reader
/// threads in [`output_within`] blocking until every write end of the pipe closes, a surviving
/// grandchild is exactly what would keep a "bounded" call waiting forever.
///
/// [`prepare`] puts the child in a new process group (it becomes the leader, so the group id is its
/// pid) and [`Guard::terminate`] `killpg`s that group, reaching every descendant that has not
/// deliberately left it. `process_group` changes only the group — not the session — so the helper
/// keeps its controlling terminal and login session, which `pkexec`'s polkit session lookup needs.
///
/// Best-effort in the same way as the Windows half: a failed `killpg` is ignored, and the
/// single-process `Child::kill` on the timeout path still runs.
#[cfg(not(windows))]
mod tree {
pub(super) struct Guard;
use std::os::unix::process::CommandExt;
/// The child's process-group id, captured while the child is still ours to reap.
pub(super) struct Guard(Option<i32>);
/// Make the child the leader of its own process group, so its descendants are reachable as one.
pub(super) fn prepare(cmd: &mut std::process::Command) {
cmd.process_group(0);
}
impl Guard {
pub(super) fn attach(_child: &std::process::Child) -> Self {
Self
pub(super) fn attach(child: &std::process::Child) -> Self {
// `prepare` asked for `process_group(0)`, so the group id IS the child's pid.
Self(i32::try_from(child.id()).ok())
}
/// End every process still in the group. A no-op once they have all exited, so this is safe
/// to call on the success path as well as the timeout one.
pub(super) fn terminate(&self) {
let Some(pgid) = self.0 else { return };
// `killpg` is a signal to a group we created and whose leader is the child we spawned;
// it cannot name a process we did not start. The one theoretical hazard is pid reuse
// between the leader's reap and this call, which needs a brand-new process to land on
// exactly that pid AND be a group leader — Linux hands out pids sequentially to
// `pid_max`, so there is no window to speak of, and the alternative (not killing) is
// the unbounded wait this module exists to prevent.
// SAFETY: a plain signal send by group id. No pointer is passed, nothing is aliased,
// and the result is deliberately ignored — ESRCH just means the group is already gone.
unsafe { libc::killpg(pgid, libc::SIGKILL) };
}
pub(super) fn terminate(&self) {}
}
}
@@ -332,6 +403,24 @@ mod tests {
);
}
/// A helper whose output exceeds one pipe buffer must still be captured IN FULL.
///
/// This is the case that fails against a `wait_with_output`-after-exit implementation: the
/// child blocks in `write()` with the pipe full, never exits, and the budget turns a perfectly
/// successful query into a `TimedOut` with its output thrown away. 1 MiB is ~16× a Linux pipe
/// (64 KiB) and ~64× the smallest macOS one, so it cannot be absorbed by a buffer on either.
#[test]
fn a_child_that_outruns_the_pipe_buffer_is_captured_in_full() {
const BYTES: usize = 1024 * 1024;
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(format!("yes punktfunk | head -c {BYTES}; echo done >&2"));
let out = output_within(&mut cmd, Duration::from_secs(20)).expect("must not time out");
assert!(out.status.success(), "helper failed: {:?}", out.status);
assert_eq!(out.stdout.len(), BYTES, "stdout was truncated");
assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "done");
}
/// The normal path is unaffected: a quick command still yields its status and its output.
#[test]
fn a_quick_child_returns_normally() {