fix(linux): zerocopy worker survives the host binary being replaced on disk
apple / swift (push) Successful in 1m10s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m29s
apple / screenshots (push) Successful in 5m39s
windows-host / package (push) Successful in 7m45s
ci / bench (push) Successful in 5m30s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m4s
arch / build-publish (push) Successful in 11m21s
docker / deploy-docs (push) Successful in 11s
android / android (push) Successful in 15m23s
deb / build-publish (push) Successful in 13m39s
ci / rust (push) Successful in 17m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m56s

A pacman canary upgrade under a running host (0.5284→0.5338, 09:24 today)
unlinked /usr/bin/punktfunk-host; current_exe() then readlinked to
"<path> (deleted)", every worker spawn failed ENOENT, and each session
silently fell back to the CPU linear-copy capture — observed as the box
"regressing" to ~90 fps at 5-7 MP until a service restart.

- RemoteImporter::spawn now pins a read fd to /proc/self/exe (once, kept for
  the process lifetime) and execs the worker via /proc/self/fd/<n>. The magic
  link names the running image's inode, not its path, so the spawn survives
  replacement/deletion — and the worker is always byte-for-byte the host's own
  build, so a mid-upgrade spawn can't hit a worker-protocol skew either. If
  the fd draws number 3 (the worker's socket slot — the pre-exec dup2 would
  clobber it) it is re-numbered; if /proc is unavailable the old path-based
  spawn remains as fallback.
- argv[0] is set to "punktfunk-host" and the worker prctl-renames its comm to
  "pf-zerocopy" — exec-by-fd-path would otherwise show a bare fd number in ps
  and top.
- zerocopy-probe now also spawns the worker (handshake + modifier query), so
  the probe catches spawn-level breakage, not just FFI/GPU bring-up.

Verified end-to-end on the dev box: probe with the binary unlinked mid-run
(/proc/self/exe → "(deleted)") still spawns the worker and reports all 13
modifiers. New unit tests cover the pinned spawn and the deleted-file exec;
the latter retries ETXTBSY (fs::copy's write fd leaks into other tests'
forked children until their execs clear it — a copy-then-exec harness
artifact, not a production concern).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 11:34:13 +02:00
parent 7ab97bb1a3
commit f68f6bc590
3 changed files with 128 additions and 9 deletions
@@ -13,12 +13,14 @@ use super::egl::DmabufPlane;
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
use anyhow::{bail, Context, Result};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
use std::path::Path;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
@@ -69,6 +71,57 @@ fn sweep_reaper() {
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
}
/// Fd pinned to this process's own executable image, opened (once, lazily) via the
/// `/proc/self/exe` magic link. The link names the running image's *inode*, not its path, so it
/// resolves even after the installed binary was replaced or deleted — and exec'ing the fd (via
/// [`fd_exec_path`]) then still runs byte-for-byte the build this process is. `current_exe()`
/// instead readlinks to a path: after a package upgrade under a running host that path is
/// "<path> (deleted)" and spawning it fails ENOENT — every capture then silently fell back to
/// the CPU copy — and even while the path exists it may hold a newer build whose worker
/// protocol mismatches this process.
static SELF_EXE: OnceLock<Option<File>> = OnceLock::new();
fn self_exe() -> Option<BorrowedFd<'static>> {
SELF_EXE
.get_or_init(|| {
let f = match File::open("/proc/self/exe") {
Ok(f) => f,
Err(e) => {
tracing::warn!(
error = %e,
"cannot pin /proc/self/exe — worker spawns use the current_exe() path, \
which breaks if this binary is replaced on disk"
);
return None;
}
};
if f.as_raw_fd() != 3 {
return Some(f);
}
// Fd 3 is the slot the spawn hands the worker its socket on (the `dup2` in
// `spawn_exe`) — pinned there, the child would clobber it before exec resolves
// `/proc/self/fd/3`. Re-number: 3 stays occupied by `f` during the clone, so the
// duplicate cannot land on it.
match f.try_clone() {
Ok(clone) => Some(clone),
Err(e) => {
tracing::warn!(error = %e, "re-numbering the pinned exe fd off fd 3 failed");
None
}
}
})
.as_ref()
.map(|f| f.as_fd())
}
/// `/proc/self/fd/<n>` — an exec'able path to `fd`'s inode. The kernel resolves it at exec time
/// inside the forked child, whose fd table is a copy of ours (close-on-exec applies only once
/// the exec succeeds), so it names the pinned inode no matter what sits at the file's original
/// path by then.
fn fd_exec_path(fd: BorrowedFd<'_>) -> PathBuf {
PathBuf::from(format!("/proc/self/fd/{}", fd.as_raw_fd()))
}
/// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process
/// [`super::egl::EglImporter`] surface the capture thread uses.
pub struct RemoteImporter {
@@ -81,12 +134,18 @@ pub struct RemoteImporter {
}
impl RemoteImporter {
/// Spawn the worker from this host binary and complete the readiness handshake. An `Err`
/// here means "no isolated zero-copy available" — callers fall back to the CPU path, exactly
/// like an in-process `EglImporter::new()` failure.
/// Spawn the worker from this host binary and complete the readiness handshake. The worker
/// is exec'd through the pinned [`SELF_EXE`] fd, so it is always the exact image this
/// process runs — even after the installed binary was replaced mid-flight. An `Err` here
/// means "no isolated zero-copy available" — callers fall back to the CPU path, exactly like
/// an in-process `EglImporter::new()` failure.
pub fn spawn() -> Result<RemoteImporter> {
let exe = std::env::current_exe().context("resolve /proc/self/exe for the worker")?;
Self::spawn_exe(&exe)
match self_exe() {
Some(fd) => Self::spawn_exe(&fd_exec_path(fd)),
None => Self::spawn_exe(
&std::env::current_exe().context("resolve /proc/self/exe for the worker")?,
),
}
}
/// [`Self::spawn`] with an explicit executable (separated for tests).
@@ -94,6 +153,8 @@ impl RemoteImporter {
sweep_reaper();
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
let mut cmd = Command::new(exe);
// `exe` is normally an opaque `/proc/self/fd/<n>` — keep `ps` output meaningful.
cmd.arg0("punktfunk-host");
cmd.arg("zerocopy-worker").arg("--fd").arg("3");
let raw = worker_end.as_raw_fd();
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
@@ -102,7 +163,6 @@ impl RemoteImporter {
// the subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3,
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(move || {
if raw == 3 {
let flags = libc::fcntl(3, libc::F_GETFD);
@@ -483,6 +543,48 @@ mod tests {
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
}
#[test]
fn spawn_execs_the_pinned_self_exe() {
// `spawn()` execs this very process's image via the pinned `/proc/self/fd/…` path. Here
// that image is the libtest harness, which rejects `--fd` and exits without a handshake
// — so a "handshake" error proves the exec itself succeeded (an exec failure would read
// "spawn zerocopy-worker" instead).
let Err(err) = RemoteImporter::spawn() else {
panic!("the test harness is not a worker; spawn must fail at the handshake")
};
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
}
#[test]
fn pinned_fd_exec_survives_on_disk_replacement() {
// The 2026-07-10 canary regression: a package upgrade replaced the installed binary and
// every worker spawn ENOENT'd (`current_exe()` readlinked to "<path> (deleted)"). The
// pinned-fd mechanism must keep exec'ing the original image after the file is gone: pin
// a copy of /bin/sh, delete it, then run it through the fd path.
let copy = std::env::temp_dir().join(format!("pf-zerocopy-exe-pin-{}", std::process::id()));
std::fs::copy("/bin/sh", &copy).unwrap();
let pinned = File::open(&copy).unwrap();
std::fs::remove_file(&copy).unwrap();
// Retry ETXTBSY: `fs::copy`'s write fd leaks into other tests' concurrently-forked
// children until their execs clear it (CLOEXEC applies only at exec), and exec'ing a
// file someone holds open for writing is refused. A harness artifact of copy-then-exec,
// not the mechanism under test — production pins a read-only fd on a binary nobody
// write-opens.
let status = loop {
match Command::new(fd_exec_path(pinned.as_fd()))
.arg("-c")
.arg("exit 42")
.status()
{
Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) => {
std::thread::sleep(Duration::from_millis(10))
}
other => break other.expect("exec via /proc/self/fd of a deleted file"),
}
};
assert_eq!(status.code(), Some(42));
}
/// A scripted peer: answers the handshake, then serves canned replies per request.
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) {
let (host, worker) = proto::socketpair_seqpacket().unwrap();
@@ -221,11 +221,20 @@ pub fn drm_fourcc(format: crate::capture::PixelFormat) -> Option<u32> {
}
/// Standalone probe (the `zerocopy-probe` subcommand): initialize the EGL importer + CUDA
/// context and report. De-risks the FFI/linking/GPU-access without needing a capture session.
/// context and report, then exercise the production path — spawn the isolated worker (exec'd
/// from this binary's pinned exe fd), handshake, and query modifiers. De-risks the
/// FFI/linking/GPU-access AND the worker spawn (e.g. the installed binary replaced under a
/// running host) without needing a capture session.
pub fn probe() -> anyhow::Result<()> {
let _importer = EglImporter::new()?;
let ctx = cuda::context()?;
tracing::info!(cuda_ctx = ?ctx, "zero-copy probe OK — EGL display + CUDA context initialized");
let mut worker = client::RemoteImporter::spawn()?;
let modifiers = worker.supported_modifiers(fourcc(b"XR24")).len();
tracing::info!(
modifiers,
"zero-copy probe OK — worker spawned, handshake + modifier query"
);
Ok(())
}
@@ -27,6 +27,14 @@ const FD_CACHE_CAP: usize = 64;
/// Entry point for the hidden `zerocopy-worker` subcommand. `args` are the subcommand's own
/// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
pub fn run_from_args(args: &[String]) -> Result<()> {
// The host execs this worker through its pinned exe fd (`client::self_exe`), so the kernel
// derives our comm from the exec path's basename — a meaningless fd number. Rename so
// `top`/`pkill` see the worker.
// SAFETY: `PR_SET_NAME` copies at most 16 bytes from the given pointer; the C-string literal
// is valid, NUL-terminated, and short enough. No pointer is retained past the call.
unsafe {
libc::prctl(libc::PR_SET_NAME, c"pf-zerocopy".as_ptr());
}
let fd: i32 = args
.iter()
.skip_while(|a| *a != "--fd")