Files
punktfunk/crates/pf-zerocopy/src/imp/worker.rs
T
enricobuehler 5e1e64e50b
apple / swift (push) Successful in 1m21s
apple / screenshots (push) Successful in 6m23s
ci / web (push) Successful in 54s
arch / build-publish (push) Successful in 10m52s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 35s
android / android (push) Successful in 13m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 6m8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7m56s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m44s
docker / deploy-docs (push) Successful in 24s
deb / build-publish (push) Successful in 11m52s
ci / rust (push) Successful in 27m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m20s
perf(latency): T2.5b — NV12 compute CSC on the LINEAR/gamescope zero-copy path
design/latency-reduction-2026-07.md T2.5's Linux half: the LINEAR dmabuf path
(gamescope's only offer) fed NVENC RGB, paying its internal RGB->YUV CSC on
the SM the game is saturating — the exact contention §5.A removed everywhere
else. The Vulkan bridge now carries a buffer-to-buffer RGB->NV12 compute
shader (rgb2nv12_buf.comp, BT.709 limited, coefficient-identical to
pf-encode's rgb2yuv.comp; whole-word writes so no 8-bit-storage feature is
needed): import dmabuf -> dispatch CSC into the exportable buffer -> CUDA
de-strides both planes into a pooled two-plane NV12 buffer. PUNKTFUNK_NV12
(default-on) now covers LINEAR; a CSC failure latches RGB for the stream
(mid-frame fallback, no dropped frame); 4:4:4 LINEAR sessions stay RGB (never
silently subsample). New ImportKind::LinearNv12 rides the existing worker IPC
(appended last per the wire-tag rule); cursor stays downstream (blend_nv12).

Validated: .21 clippy -D warnings (pf-zerocopy/pf-capture/host+nvenc) + 17
zero-copy tests. Owed: on-glass gamescope session (visual + dmon sm% check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:14:20 +02:00

484 lines
18 KiB
Rust

//! The isolated zero-copy GPU-import worker (`punktfunk-host zerocopy-worker`; design:
//! [`design/zerocopy-worker-isolation.md`]). It owns the fragile driver stack — the headless
//! EGLDisplay + GL context, the CUDA context, and the Vulkan bridge — so that a driver fault on a
//! producer-invalidated dmabuf (the `cuGraphicsMapResources` SIGSEGV the F44 Game→Desktop switch
//! reproduced) kills THIS process, not the streaming host. The host observes the dead socket,
//! fails the frame cleanly, and its existing capture-loss rebuild takes over.
//!
//! One worker serves one capture (spawned per `pipewire_thread`). It exits on socket EOF — which
//! only happens after the capturer AND every in-flight frame on the host side are gone, so pooled
//! device memory is never freed under a frame the host still reads.
// 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};
use super::egl::{DmabufPlane, EglImporter};
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
use anyhow::{bail, Context, Result};
use std::collections::{HashMap, VecDeque};
use std::io;
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
/// Cap on cached per-key dmabuf fds. PipeWire buffer pools are ≤ ~16 buffers; the cap only
/// matters if a misbehaving producer churns buffers without a renegotiation.
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")
.nth(1)
.map(|s| s.parse())
.transpose()
.context("parse --fd")?
.unwrap_or(3);
// SAFETY: the spawning host `dup2`'d its socketpair end onto exactly this fd number before
// exec (the subcommand's contract) and nothing else in this fresh process owns it, so
// `OwnedFd` takes sole ownership and closes it exactly once at exit.
let sock = unsafe { OwnedFd::from_raw_fd(fd) };
run(sock)
}
/// Bring up the GPU stack, report readiness, and serve until the host goes away.
fn run(sock: OwnedFd) -> Result<()> {
let importer = match EglImporter::new() {
Ok(i) => i,
Err(e) => {
// Init failure is an ANSWER, not a crash: the host falls back to the CPU path,
// exactly like an in-process `EglImporter::new()` failure.
let _ = proto::send(
sock.as_fd(),
&Reply::InitErr {
message: format!("{e:#}"),
},
None,
);
return Ok(());
}
};
proto::send(
sock.as_fd(),
&Reply::Ready {
version: proto::PROTO_VERSION,
},
None,
)
.context("send Ready")?;
tracing::info!(pid = std::process::id(), "zerocopy import worker ready");
let mut backend = EglBackend::new(importer);
serve(&sock, &mut backend)
}
/// What [`serve`] needs from an import implementation — split out so the dispatch loop is
/// unit-testable without a GPU.
pub(crate) trait ImportBackend {
fn modifiers(&mut self, fourcc: u32) -> Vec<u64>;
/// Answers with [`Reply::Frame`] (buffer id + [`BufferDesc`] iff first delivery of that id),
/// [`Reply::NeedFd`] (this side lacks the key's fd — host resends it once), or [`Reply::Err`].
fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply;
fn release(&mut self, id: u32);
fn clear_cache(&mut self);
}
/// The [`Request::Import`] fields, destructured for [`ImportBackend::import`].
pub(crate) struct ImportReq {
pub key: u64,
pub kind: ImportKind,
pub width: u32,
pub height: u32,
pub fourcc: u32,
pub modifier: Option<u64>,
pub offset: u32,
pub stride: u32,
pub has_fd: bool,
}
/// The request loop. Returns `Ok(())` on host EOF (normal end-of-life); any other socket error
/// propagates (the process exits — the host treats it like a death, which it is).
pub(crate) fn serve(sock: &OwnedFd, backend: &mut dyn ImportBackend) -> Result<()> {
let mut buf = Vec::new();
loop {
let (req, fd) = match proto::recv::<Request>(sock.as_fd(), &mut buf) {
Ok(v) => v,
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(()),
Err(e) => return Err(e).context("worker recv"),
};
match req {
Request::Modifiers { fourcc } => {
let reply = Reply::Modifiers {
modifiers: backend.modifiers(fourcc),
};
if send_or_eof(sock, &reply)? {
return Ok(());
}
}
Request::Import {
key,
kind,
width,
height,
fourcc,
modifier,
offset,
stride,
has_fd,
} => {
let req = ImportReq {
key,
kind,
width,
height,
fourcc,
modifier,
offset,
stride,
has_fd,
};
let reply = backend.import(&req, fd);
if send_or_eof(sock, &reply)? {
return Ok(());
}
}
Request::Release { id } => backend.release(id),
Request::ClearCache => backend.clear_cache(),
}
}
}
/// Send a reply; `Ok(true)` means the host is gone (EPIPE) and the loop should end quietly.
fn send_or_eof(sock: &OwnedFd, reply: &Reply) -> Result<bool> {
match proto::send(sock.as_fd(), reply, None) {
Ok(()) => Ok(false),
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(true),
Err(e) => Err(e).context("worker send"),
}
}
/// The real backend: the in-process [`EglImporter`] plus the cross-process bookkeeping —
/// per-key dmabuf fds, in-flight frames (held until `Release`), and stable buffer ids.
struct EglBackend {
importer: EglImporter,
/// The dmabuf fd for each host key (`st_ino`), kept because the tiled path re-imports the fd
/// every frame (`eglCreateImage`) and the LINEAR path caches per fd inside the Vulkan bridge.
fds: HashMap<u64, OwnedFd>,
/// Insertion order of `fds` keys for the LRU cap.
fd_lru: VecDeque<u64>,
/// Frames delivered to the host and not yet released — holding the `DeviceBuffer` is what
/// keeps its device memory alive (pool `Arc`s) while the host encodes from it.
inflight: HashMap<u32, DeviceBuffer>,
/// Buffer id per device allocation. Valid only within one pool generation: pools never free
/// allocations while alive, so a device VA can't repeat until a size change replaces the pool
/// — at which point [`Self::note_dims`] clears this map (ids themselves are never reused;
/// `next_id` only counts up).
ids: HashMap<CUdeviceptr, u32>,
next_id: u32,
/// The (kind, width, height) of the last import — a change means the importer replaced its
/// pool, invalidating the VA→id map (see [`Self::ids`]).
last_shape: Option<(ImportKind, u32, u32)>,
}
impl EglBackend {
fn new(importer: EglImporter) -> EglBackend {
EglBackend {
importer,
fds: HashMap::new(),
fd_lru: VecDeque::new(),
inflight: HashMap::new(),
ids: HashMap::new(),
next_id: 0,
last_shape: None,
}
}
/// Store (or replace) the cached fd for `key`, evicting beyond the cap. A replaced or
/// evicted fd is first forgotten by the Vulkan bridge so its per-fd import can't go stale.
fn store_fd(&mut self, key: u64, fd: OwnedFd) {
if let Some(old) = self.fds.insert(key, fd) {
self.importer.forget_linear_fd(old.as_raw_fd());
self.fd_lru.retain(|k| *k != key);
}
self.fd_lru.push_back(key);
while self.fds.len() > FD_CACHE_CAP {
let Some(oldest) = self.fd_lru.pop_front() else {
break;
};
if let Some(old) = self.fds.remove(&oldest) {
self.importer.forget_linear_fd(old.as_raw_fd());
}
}
}
/// Clear the VA→id map when the importer is about to replace its per-size pool (see
/// [`Self::ids`]).
fn note_dims(&mut self, kind: ImportKind, width: u32, height: u32) {
if self.last_shape != Some((kind, width, height)) {
self.last_shape = Some((kind, width, height));
self.ids.clear();
}
}
}
impl ImportBackend for EglBackend {
fn modifiers(&mut self, fourcc: u32) -> Vec<u64> {
self.importer.supported_modifiers(fourcc)
}
fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply {
if let Some(fd) = fd {
self.store_fd(req.key, fd);
} else if req.has_fd {
return Reply::Err {
message: "Import said has_fd but no fd arrived".into(),
};
}
let Some(raw) = self.fds.get(&req.key).map(|f| f.as_raw_fd()) else {
// We no longer hold this buffer's fd (LRU eviction / cache desync) — ask the host to
// resend it rather than failing the frame.
return Reply::NeedFd;
};
match self.import_inner(req, raw) {
Ok((id, desc)) => Reply::Frame { id, desc },
Err(e) => Reply::Err {
message: format!("{e:#}"),
},
}
}
fn release(&mut self, id: u32) {
if self.inflight.remove(&id).is_none() {
tracing::warn!(id, "release for a frame not in flight (host/worker desync)");
}
}
fn clear_cache(&mut self) {
for (_, fd) in self.fds.drain() {
self.importer.forget_linear_fd(fd.as_raw_fd());
}
self.fd_lru.clear();
self.importer.clear_linear_cache();
}
}
impl EglBackend {
/// The fallible core of [`ImportBackend::import`], once the fd for `req.key` is resolved.
fn import_inner(&mut self, req: &ImportReq, raw: i32) -> Result<(u32, Option<BufferDesc>)> {
let plane = DmabufPlane {
fd: raw,
offset: req.offset,
stride: req.stride,
};
self.note_dims(req.kind, req.width, req.height);
let buf = match req.kind {
ImportKind::Tiled => {
self.importer
.import(&plane, req.width, req.height, req.fourcc, req.modifier)?
}
ImportKind::TiledNv12 => self.importer.import_nv12(
&plane,
req.width,
req.height,
req.fourcc,
req.modifier,
)?,
ImportKind::Tiled444 => self.importer.import_yuv444(
&plane,
req.width,
req.height,
req.fourcc,
req.modifier,
)?,
ImportKind::Linear => self.importer.import_linear(&plane, req.width, req.height)?,
ImportKind::LinearNv12 => self
.importer
.import_linear_nv12(&plane, req.width, req.height)?,
};
// Assign / look up the buffer's id and export its CUDA IPC identity on first delivery.
cuda::make_current()?;
let (id, desc) = match self.ids.get(&buf.ptr) {
Some(&id) => (id, None),
None => {
let id = self.next_id;
self.next_id = self.next_id.wrapping_add(1);
let y_handle = cuda::ipc_export(buf.ptr)?.to_vec();
let uv = match buf.uv {
Some((uv_ptr, uv_pitch)) => {
Some((cuda::ipc_export(uv_ptr)?.to_vec(), uv_pitch))
}
None => None,
};
self.ids.insert(buf.ptr, id);
(
id,
Some(BufferDesc {
width: buf.width,
height: buf.height,
y_handle,
y_pitch: buf.pitch,
uv,
}),
)
}
};
if self.inflight.insert(id, buf).is_some() {
// A pool never hands out a buffer that hasn't been recycled, so a duplicate id means
// corrupted bookkeeping — fail the import rather than alias two frames.
bail!("buffer id {id} already in flight");
}
Ok((id, desc))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
/// Records calls; import behavior is scripted per key.
struct MockBackend {
calls: mpsc::Sender<String>,
next: u32,
}
impl ImportBackend for MockBackend {
fn modifiers(&mut self, fourcc: u32) -> Vec<u64> {
let _ = self.calls.send(format!("modifiers:{fourcc}"));
vec![7, 8, 9]
}
fn import(&mut self, req: &ImportReq, fd: Option<OwnedFd>) -> Reply {
let _ = self.calls.send(format!(
"import:key={} kind={:?} fd={}",
req.key,
req.kind,
fd.is_some()
));
if req.key == 0xbad {
return Reply::Err {
message: "scripted failure".into(),
};
}
if req.key == 0xfeed && !req.has_fd {
return Reply::NeedFd;
}
let id = self.next;
self.next += 1;
let desc = (id == 0).then(|| BufferDesc {
width: req.width,
height: req.height,
y_handle: vec![0u8; 64],
y_pitch: 256,
uv: None,
});
Reply::Frame { id, desc }
}
fn release(&mut self, id: u32) {
let _ = self.calls.send(format!("release:{id}"));
}
fn clear_cache(&mut self) {
let _ = self.calls.send("clear".into());
}
}
fn start_server() -> (
OwnedFd,
mpsc::Receiver<String>,
std::thread::JoinHandle<Result<()>>,
) {
let (host, worker) = proto::socketpair_seqpacket().unwrap();
let (tx, rx) = mpsc::channel();
let join = std::thread::spawn(move || {
let mut backend = MockBackend { calls: tx, next: 0 };
serve(&worker, &mut backend)
});
(host, rx, join)
}
fn import_req(key: u64, has_fd: bool) -> Request {
Request::Import {
key,
kind: ImportKind::Tiled,
width: 64,
height: 64,
fourcc: 1,
modifier: None,
offset: 0,
stride: 256,
has_fd,
}
}
#[test]
fn dispatch_and_eof() {
let (host, rx, join) = start_server();
let mut buf = Vec::new();
proto::send(host.as_fd(), &Request::Modifiers { fourcc: 42 }, None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(
reply,
Reply::Modifiers {
modifiers: vec![7, 8, 9]
}
);
// First import delivers the desc; the second (same mock id sequence continues) doesn't.
proto::send(host.as_fd(), &import_req(1, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
match reply {
Reply::Frame {
id: 0,
desc: Some(_),
} => {}
other => panic!("unexpected reply {other:?}"),
}
proto::send(host.as_fd(), &import_req(1, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(reply, Reply::Frame { id: 1, desc: None });
// A missing worker-side fd is a NeedFd reply (host resends), not a failure.
proto::send(host.as_fd(), &import_req(0xfeed, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(reply, Reply::NeedFd);
// A failed import is an Err reply, not a dead worker.
proto::send(host.as_fd(), &import_req(0xbad, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
match reply {
Reply::Err { message } => assert!(message.contains("scripted failure")),
other => panic!("unexpected reply {other:?}"),
}
// Fire-and-forget ops reach the backend without replies.
proto::send(host.as_fd(), &Request::Release { id: 0 }, None).unwrap();
proto::send(host.as_fd(), &Request::ClearCache, None).unwrap();
// Closing the host end terminates serve() cleanly.
drop(host);
join.join().unwrap().unwrap();
let calls: Vec<String> = rx.iter().collect();
assert_eq!(
calls,
vec![
"modifiers:42",
"import:key=1 kind=Tiled fd=false",
"import:key=1 kind=Tiled fd=false",
"import:key=65261 kind=Tiled fd=false", // 0xfeed
"import:key=2989 kind=Tiled fd=false", // 0xbad
"release:0",
"clear",
]
);
}
}