Files
punktfunk/crates/punktfunk-host/src/linux/zerocopy/client.rs
T
enricobuehler f68f6bc590
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
fix(linux): zerocopy worker survives the host binary being replaced on disk
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>
2026-07-10 11:34:13 +02:00

712 lines
28 KiB
Rust

//! Host side of the isolated zero-copy GPU import (design:
//! [`design/zerocopy-worker-isolation.md`]): spawns the `zerocopy-worker` subprocess, mirrors the
//! [`super::egl::EglImporter`] entry points over the [`super::proto`] socket, and materializes
//! the worker's pooled CUDA buffers in this process via CUDA IPC (each buffer's handles are
//! opened exactly once and reused as the pool recycles). A worker death — the whole point of the
//! isolation — surfaces as an `Err` with [`RemoteImporter::dead`] set, never as a host fault.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, CUdeviceptr, DeviceBuffer, CU_IPC_HANDLE_SIZE};
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::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, OnceLock};
use std::time::Duration;
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
/// Per-request budget. An import is a few ms of GPU work; if the worker can't answer in this
/// window it is wedged (GPU fault in progress) and gets treated as dead.
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
/// State shared with in-flight frames: the socket (their release messages) and the CUDA IPC
/// mappings (their device pointers). Lives until the LAST in-flight [`DeviceBuffer`] drops, so a
/// mapping is never closed under a frame the encoder still reads — and only then does the socket
/// close, which is what tells an idle worker to exit.
struct Shared {
sock: OwnedFd,
mappings: Mutex<HashMap<u32, Mapping>>,
dead: AtomicBool,
}
/// One pooled worker buffer, opened in this process.
#[derive(Clone, Copy)]
struct Mapping {
y: CUdeviceptr,
y_pitch: usize,
uv: Option<(CUdeviceptr, usize)>,
width: u32,
height: u32,
}
impl Drop for Shared {
fn drop(&mut self) {
// Last reference gone — no DeviceBuffer can still point into these mappings.
for (_, m) in self.mappings.lock().unwrap().drain() {
cuda::ipc_close(m.y);
if let Some((uv, _)) = m.uv {
cuda::ipc_close(uv);
}
}
}
}
/// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket
/// EOF, i.e. after the last in-flight frame drops). Swept on every spawn and every drop so
/// workers don't linger as zombies for more than one capture generation.
static REAPER: Mutex<Vec<Child>> = Mutex::new(Vec::new());
fn sweep_reaper() {
let mut list = REAPER.lock().unwrap();
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 {
shared: Arc<Shared>,
child: Option<Child>,
/// Reused receive scratch buffer (all replies are read by the single capture thread).
rbuf: Vec<u8>,
/// Dmabuf keys (`st_ino`) whose fd the worker already holds — the fd is passed only once.
sent_keys: HashSet<u64>,
}
impl RemoteImporter {
/// 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> {
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).
fn spawn_exe(exe: &Path) -> Result<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
// allowed — `dup2` and `fcntl` both are, and the closure captures only the `Copy` int
// `raw` (no allocation, no locks). `dup2(raw, 3)` installs the socket at the fd number
// 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 {
cmd.pre_exec(move || {
if raw == 3 {
let flags = libc::fcntl(3, libc::F_GETFD);
if flags < 0 || libc::fcntl(3, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 {
return Err(io::Error::last_os_error());
}
} else if libc::dup2(raw, 3) < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let child = cmd.spawn().context("spawn zerocopy-worker")?;
drop(worker_end); // the child holds its own copy now
Self::from_socket(host_end, Some(child))
}
/// Complete the handshake on an already-connected socket (the unit tests drive this against
/// a mock server thread instead of a real subprocess).
fn from_socket(sock: OwnedFd, child: Option<Child>) -> Result<RemoteImporter> {
let mut importer = RemoteImporter {
shared: Arc::new(Shared {
sock,
mappings: Mutex::new(HashMap::new()),
dead: AtomicBool::new(false),
}),
child,
rbuf: Vec::new(),
sent_keys: HashSet::new(),
};
proto::set_recv_timeout(importer.shared.sock.as_fd(), Some(HANDSHAKE_TIMEOUT))?;
let ready = proto::recv::<Reply>(importer.shared.sock.as_fd(), &mut importer.rbuf);
proto::set_recv_timeout(importer.shared.sock.as_fd(), Some(REPLY_TIMEOUT))?;
match ready {
Ok((Reply::Ready { version }, _)) if version == proto::PROTO_VERSION => {
tracing::info!(
pid = importer.child.as_ref().map(|c| c.id()),
"zero-copy GPU import isolated in a worker process"
);
Ok(importer)
}
Ok((Reply::Ready { version }, _)) => {
importer.mark_dead();
bail!(
"zerocopy worker protocol mismatch (worker v{version}, host v{})",
proto::PROTO_VERSION
)
}
Ok((Reply::InitErr { message }, _)) => {
// The worker exits by itself after reporting; not a death, just "no GPU here".
bail!("zerocopy worker init failed: {message}")
}
Ok((other, _)) => {
importer.mark_dead();
bail!("unexpected zerocopy worker handshake: {other:?}")
}
Err(e) => {
importer.mark_dead();
Err(e).context("zerocopy worker handshake (died on startup?)")
}
}
}
/// True once any exchange failed at the transport level — the worker is gone (or wedged) and
/// every further call fails fast. The capture layer poisons its stream on this.
pub fn dead(&self) -> bool {
self.shared.dead.load(Ordering::Relaxed)
}
fn mark_dead(&self) {
self.shared.dead.store(true, Ordering::Relaxed);
}
/// Mirror of [`super::egl::EglImporter::supported_modifiers`] (worker round-trip; empty on
/// any failure, which makes the capture fall back like an importless negotiation).
pub fn supported_modifiers(&mut self, fourcc: u32) -> Vec<u64> {
if self.dead() {
return Vec::new();
}
if let Err(e) = proto::send(
self.shared.sock.as_fd(),
&Request::Modifiers { fourcc },
None,
) {
tracing::warn!(error = %e, "zerocopy worker modifier query failed");
self.mark_dead();
return Vec::new();
}
match proto::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
Ok((Reply::Modifiers { modifiers }, _)) => modifiers,
Ok((other, _)) => {
tracing::warn!(?other, "unexpected zerocopy worker reply to Modifiers");
self.mark_dead();
Vec::new()
}
Err(e) => {
tracing::warn!(error = %e, "zerocopy worker modifier reply failed");
self.mark_dead();
Vec::new()
}
}
}
/// Mirror of [`super::egl::EglImporter::import`] (tiled dmabuf → BGRx CUDA buffer).
pub fn import(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
fourcc: u32,
modifier: Option<u64>,
) -> Result<DeviceBuffer> {
self.import_impl(plane, ImportKind::Tiled, width, height, fourcc, modifier)
}
/// Mirror of [`super::egl::EglImporter::import_nv12`].
pub fn import_nv12(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
fourcc: u32,
modifier: Option<u64>,
) -> Result<DeviceBuffer> {
self.import_impl(
plane,
ImportKind::TiledNv12,
width,
height,
fourcc,
modifier,
)
}
/// Mirror of [`super::egl::EglImporter::import_linear`] (LINEAR dmabuf → Vulkan bridge).
pub fn import_linear(
&mut self,
plane: &DmabufPlane,
width: u32,
height: u32,
) -> Result<DeviceBuffer> {
self.import_impl(plane, ImportKind::Linear, width, height, 0, None)
}
fn import_impl(
&mut self,
plane: &DmabufPlane,
kind: ImportKind,
width: u32,
height: u32,
fourcc: u32,
modifier: Option<u64>,
) -> Result<DeviceBuffer> {
if self.dead() {
bail!("zerocopy worker is dead");
}
let key = dmabuf_key(plane.fd)?;
// One retry: a `NeedFd` reply (the worker's fd cache evicted this key) clears our
// "already sent" note so the second attempt carries the fd again.
let mut attempts = 0;
let reply = loop {
attempts += 1;
let has_fd = self.sent_keys.insert(key);
// SAFETY: `plane.fd` is the dmabuf fd of the PipeWire buffer the capture thread still
// holds for this callback (`consume_frame`'s contract), so it is open and stays open
// for this synchronous call; the `BorrowedFd` never outlives it (used only for the
// `send`).
let pass = has_fd.then(|| unsafe { BorrowedFd::borrow_raw(plane.fd) });
let req = Request::Import {
key,
kind,
width,
height,
fourcc,
modifier,
offset: plane.offset,
stride: plane.stride,
has_fd,
};
if let Err(e) = proto::send(self.shared.sock.as_fd(), &req, pass) {
self.mark_dead();
return Err(e).context("zerocopy worker died (send)");
}
let reply = match proto::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
Ok((reply, _)) => reply,
Err(e) => {
self.mark_dead();
return Err(e).context("zerocopy worker died (no reply)");
}
};
match reply {
Reply::NeedFd if attempts == 1 => {
self.sent_keys.remove(&key);
continue;
}
Reply::NeedFd => {
self.mark_dead();
bail!("zerocopy worker still lacks the fd after a resend (desync)");
}
other => break other,
}
};
match reply {
Reply::Frame { id, desc } => {
if let Some(desc) = desc {
let mapping = open_mapping(&desc).with_context(|| {
// An unopenable mapping poisons every future frame in this buffer —
// treat it as a dead worker so the capture rebuilds cleanly.
self.mark_dead();
format!("open CUDA IPC mapping for worker buffer {id}")
})?;
self.shared.mappings.lock().unwrap().insert(id, mapping);
}
let m = self
.shared
.mappings
.lock()
.unwrap()
.get(&id)
.copied()
.ok_or_else(|| {
self.mark_dead();
anyhow::anyhow!("worker delivered unknown buffer id {id} (desync)")
})?;
let shared = self.shared.clone();
Ok(DeviceBuffer::remote(
m.y,
m.y_pitch,
m.width,
m.height,
m.uv,
Box::new(move || {
// Fire-and-forget recycle; a dead worker just means EPIPE, ignored. The
// captured `shared` Arc is what keeps the mapping + socket alive until
// the last frame drops.
let _ = proto::send(shared.sock.as_fd(), &Request::Release { id }, None);
}),
))
}
Reply::Err { message } => bail!("zerocopy worker import failed: {message}"),
other => {
self.mark_dead();
bail!("unexpected zerocopy worker reply: {other:?}")
}
}
}
/// The PipeWire stream renegotiated — reset both sides' per-buffer caches.
pub fn clear_cache(&mut self) {
self.sent_keys.clear();
if !self.dead() {
if let Err(e) = proto::send(self.shared.sock.as_fd(), &Request::ClearCache, None) {
tracing::warn!(error = %e, "zerocopy worker ClearCache failed");
self.mark_dead();
}
}
}
}
impl Drop for RemoteImporter {
fn drop(&mut self) {
// The worker exits on socket EOF, which happens when the last `Shared` reference (this
// importer, or the final in-flight frame on the encode side) drops. Reap what's already
// gone; park the rest for the next sweep.
if let Some(mut child) = self.child.take() {
if !matches!(child.try_wait(), Ok(Some(_))) {
REAPER.lock().unwrap().push(child);
}
}
sweep_reaper();
}
}
/// Identity of the dma-buf behind `fd`, stable across frames and across `SCM_RIGHTS` re-numbering:
/// every dma-buf gets a unique inode on the kernel's dmabuf pseudo-fs for its lifetime. Used as
/// the worker's fd-cache key so the fd itself is only passed once.
fn dmabuf_key(fd: i32) -> Result<u64> {
// SAFETY: `libc::stat` is plain-old-data for which all-zero is a valid value, so
// `mem::zeroed()` is a sound initializer. `fd` is the caller's live dmabuf fd; `fstat` writes
// into `&mut st`, a live, correctly-sized stack struct that outlives the synchronous call,
// and `st_ino` is read only after the return value is checked.
unsafe {
let mut st: libc::stat = std::mem::zeroed();
if libc::fstat(fd, &mut st) != 0 {
bail!("fstat(dmabuf fd): {}", io::Error::last_os_error());
}
Ok(st.st_ino)
}
}
/// Open a worker buffer's CUDA IPC handles in this process.
fn open_mapping(desc: &BufferDesc) -> Result<Mapping> {
cuda::make_current()?;
let y_handle: [u8; CU_IPC_HANDLE_SIZE] = desc
.y_handle
.as_slice()
.try_into()
.context("worker sent a malformed Y IPC handle")?;
let y = cuda::ipc_open(&y_handle).context("open Y plane IPC handle")?;
let uv = match &desc.uv {
Some((handle, pitch)) => {
let handle: [u8; CU_IPC_HANDLE_SIZE] = handle
.as_slice()
.try_into()
.context("worker sent a malformed UV IPC handle")?;
match cuda::ipc_open(&handle) {
Ok(ptr) => Some((ptr, *pitch)),
Err(e) => {
// Don't leak the Y mapping on a half-open failure.
cuda::ipc_close(y);
return Err(e).context("open UV plane IPC handle");
}
}
}
None => None,
};
Ok(Mapping {
y,
y_pitch: desc.y_pitch,
uv,
width: desc.width,
height: desc.height,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
fn handshake_server(reply: Reply) -> OwnedFd {
let (host, worker) = proto::socketpair_seqpacket().unwrap();
proto::send(worker.as_fd(), &reply, None).unwrap();
// Keep the worker end alive alongside the host end for the test's duration by leaking it
// into the reply thread below? Not needed: the handshake reply is already queued in the
// socket buffer, so the worker end may drop — recv still delivers queued data first.
drop(worker);
host
}
#[test]
fn handshake_ready_and_version_gate() {
let host = handshake_server(Reply::Ready {
version: proto::PROTO_VERSION,
});
let imp = RemoteImporter::from_socket(host, None).unwrap();
assert!(!imp.dead());
let host = handshake_server(Reply::Ready { version: 999 });
assert!(RemoteImporter::from_socket(host, None).is_err());
}
#[test]
fn handshake_init_err() {
let host = handshake_server(Reply::InitErr {
message: "no GPU".into(),
});
let Err(err) = RemoteImporter::from_socket(host, None) else {
panic!("InitErr handshake must fail")
};
assert!(format!("{err:#}").contains("no GPU"), "{err:#}");
}
#[test]
fn handshake_eof_is_an_error() {
let (host, worker) = proto::socketpair_seqpacket().unwrap();
drop(worker);
assert!(RemoteImporter::from_socket(host, None).is_err());
}
#[test]
fn spawning_a_non_worker_fails_cleanly() {
// `true` exits immediately without a handshake → EOF → clean spawn error, the same
// fallback path a GPU-less box takes.
let Err(err) = RemoteImporter::spawn_exe(Path::new("true")) else {
panic!("spawning a non-worker must fail")
};
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();
proto::send(
worker.as_fd(),
&Reply::Ready {
version: proto::PROTO_VERSION,
},
None,
)
.unwrap();
let join = thread::spawn(move || {
let mut buf = Vec::new();
let mut seen = Vec::new();
let mut replies = replies.into_iter();
while let Ok((req, _fd)) = proto::recv::<Request>(worker.as_fd(), &mut buf) {
let needs_reply = matches!(req, Request::Modifiers { .. } | Request::Import { .. });
seen.push(req);
if needs_reply {
match replies.next() {
Some(r) => proto::send(worker.as_fd(), &r, None).unwrap(),
None => break, // close → client sees a dead worker
}
}
}
seen
});
let imp = RemoteImporter::from_socket(host, None).unwrap();
(imp, join)
}
#[test]
fn modifiers_round_trip() {
let (mut imp, join) = scripted_server(vec![Reply::Modifiers {
modifiers: vec![1, 2, 3],
}]);
assert_eq!(imp.supported_modifiers(0x3432_5258), vec![1, 2, 3]);
assert!(!imp.dead());
drop(imp);
let seen = join.join().unwrap();
assert_eq!(
seen,
vec![Request::Modifiers {
fourcc: 0x3432_5258
}]
);
}
#[test]
fn need_fd_triggers_one_resend_with_the_fd() {
let (mut imp, join) = scripted_server(vec![
Reply::Err {
message: "one".into(),
},
Reply::NeedFd,
Reply::Err {
message: "two".into(),
},
]);
let (pr, _pw) = std::io::pipe().unwrap();
let plane = DmabufPlane {
fd: pr.as_fd().as_raw_fd(),
offset: 0,
stride: 256,
};
// First import: first sight of the key → fd rides along; the Err reply keeps the key
// marked as sent (the worker cached the fd before failing).
assert!(imp.import(&plane, 64, 64, 1, Some(2)).is_err());
// Second import: no fd (already sent) → worker answers NeedFd → one retry WITH the fd.
assert!(imp.import(&plane, 64, 64, 1, Some(2)).is_err());
assert!(!imp.dead(), "NeedFd handling must not mark the worker dead");
drop(imp);
let fd_flags: Vec<bool> = join
.join()
.unwrap()
.iter()
.map(|r| match r {
Request::Import { has_fd, .. } => *has_fd,
other => panic!("unexpected request {other:?}"),
})
.collect();
assert_eq!(fd_flags, vec![true, false, true]);
}
#[test]
fn import_error_reply_keeps_worker_alive_and_death_is_detected() {
let (mut imp, join) = scripted_server(vec![Reply::Err {
message: "EGL_BAD_MATCH".into(),
}]);
// Any pipe works as a stand-in fd for key derivation.
let (pr, _pw) = std::io::pipe().unwrap();
let plane = DmabufPlane {
fd: pr.as_fd().as_raw_fd(),
offset: 0,
stride: 256,
};
let Err(err) = imp.import(&plane, 64, 64, 1, Some(2)) else {
panic!("scripted Err reply must fail the import")
};
assert!(format!("{err:#}").contains("EGL_BAD_MATCH"));
assert!(!imp.dead(), "an Err reply must not mark the worker dead");
// The scripted replies are exhausted → the server closes → the next import dies.
let Err(err) = imp.import(&plane, 64, 64, 1, Some(2)) else {
panic!("a closed worker must fail the import")
};
assert!(format!("{err:#}").contains("died"), "{err:#}");
assert!(imp.dead());
drop(imp);
let seen = join.join().unwrap();
// First import carried the fd (first sight of the key); the retry didn't re-send it.
match (&seen[0], &seen[1]) {
(
Request::Import {
has_fd: true,
kind: ImportKind::Tiled,
..
},
Request::Import { has_fd: false, .. },
) => {}
other => panic!("unexpected requests {other:?}"),
}
}
}