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
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:
@@ -13,12 +13,14 @@ use super::egl::DmabufPlane;
|
|||||||
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
|
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fs::File;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
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::process::{Child, Command};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
|
/// 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(_))));
|
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
|
/// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process
|
||||||
/// [`super::egl::EglImporter`] surface the capture thread uses.
|
/// [`super::egl::EglImporter`] surface the capture thread uses.
|
||||||
pub struct RemoteImporter {
|
pub struct RemoteImporter {
|
||||||
@@ -81,12 +134,18 @@ pub struct RemoteImporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RemoteImporter {
|
impl RemoteImporter {
|
||||||
/// Spawn the worker from this host binary and complete the readiness handshake. An `Err`
|
/// Spawn the worker from this host binary and complete the readiness handshake. The worker
|
||||||
/// here means "no isolated zero-copy available" — callers fall back to the CPU path, exactly
|
/// is exec'd through the pinned [`SELF_EXE`] fd, so it is always the exact image this
|
||||||
/// like an in-process `EglImporter::new()` failure.
|
/// 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> {
|
pub fn spawn() -> Result<RemoteImporter> {
|
||||||
let exe = std::env::current_exe().context("resolve /proc/self/exe for the worker")?;
|
match self_exe() {
|
||||||
Self::spawn_exe(&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).
|
/// [`Self::spawn`] with an explicit executable (separated for tests).
|
||||||
@@ -94,6 +153,8 @@ impl RemoteImporter {
|
|||||||
sweep_reaper();
|
sweep_reaper();
|
||||||
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
|
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
|
||||||
let mut cmd = Command::new(exe);
|
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");
|
cmd.arg("zerocopy-worker").arg("--fd").arg("3");
|
||||||
let raw = worker_end.as_raw_fd();
|
let raw = worker_end.as_raw_fd();
|
||||||
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
|
// 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,
|
// 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.
|
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
|
||||||
unsafe {
|
unsafe {
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
cmd.pre_exec(move || {
|
cmd.pre_exec(move || {
|
||||||
if raw == 3 {
|
if raw == 3 {
|
||||||
let flags = libc::fcntl(3, libc::F_GETFD);
|
let flags = libc::fcntl(3, libc::F_GETFD);
|
||||||
@@ -483,6 +543,48 @@ mod tests {
|
|||||||
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
|
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", ©).unwrap();
|
||||||
|
let pinned = File::open(©).unwrap();
|
||||||
|
std::fs::remove_file(©).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.
|
/// A scripted peer: answers the handshake, then serves canned replies per request.
|
||||||
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) {
|
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) {
|
||||||
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
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
|
/// 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<()> {
|
pub fn probe() -> anyhow::Result<()> {
|
||||||
let _importer = EglImporter::new()?;
|
let _importer = EglImporter::new()?;
|
||||||
let ctx = cuda::context()?;
|
let ctx = cuda::context()?;
|
||||||
tracing::info!(cuda_ctx = ?ctx, "zero-copy probe OK — EGL display + CUDA context initialized");
|
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(())
|
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
|
/// 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).
|
/// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
|
||||||
pub fn run_from_args(args: &[String]) -> Result<()> {
|
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
|
let fd: i32 = args
|
||||||
.iter()
|
.iter()
|
||||||
.skip_while(|a| *a != "--fd")
|
.skip_while(|a| *a != "--fd")
|
||||||
|
|||||||
Reference in New Issue
Block a user