feat(host): isolate the zero-copy GPU import in a worker process

The tiled EGL/GL→CUDA import crashed the whole host (SIGSEGV inside
libnvidia-eglcore via cuGraphicsMapResources) when the compositor
invalidated an imported dmabuf mid-map — reproduced on the Bazzite F44
Game→Desktop switch (design/zerocopy-hardening-handoff.md). A driver
SIGSEGV is uncatchable in-process, so the whole EglImporter (tiled
EGL/GL→CUDA and LINEAR Vulkan→CUDA) now runs in a per-capture
`zerocopy-worker` subprocess: dmabuf fds go over a SEQPACKET socketpair
(SCM_RIGHTS, sent once per buffer keyed by dmabuf st_ino; NeedFd resend
self-heals cache desync), frames come back as CUDA-IPC pooled device
buffers (still zero-copy, +one socket RTT/frame). Worker death poisons
the capturer so the existing capture-loss rebuild runs — the host
survives; 3 consecutive deaths latch the GPU import off (CPU/SHM path).
PUNKTFUNK_ZEROCOPY_INPROC=1 keeps the old in-process import for
debugging/A-B.

Also fixed along the way: a failed *tiled* import no longer falls
through to the CPU mmap de-pad (which scrambled tiled bytes; LINEAR
keeps the fallback); Nv12Blit dropped its GL textures while still
CUDA-registered (unregister now runs first); GlBlit had no Drop at all
(GL objects leaked per size change); VkBridge's per-fd src cache is now
invalidated on renegotiation/eviction instead of never.

Design: design/zerocopy-worker-isolation.md. Unit tests: 14 new
(protocol fd-passing, worker dispatch, client handshake/death/NeedFd,
death latch). On-glass validated on the RTX 5070 Ti/GNOME box (.21):
the worker path streams at p50 1.30 ms (NV12, 1800 frames 0-mismatched,
parity with the in-process path), and a kill -9 of the worker
mid-stream is survived by the host and recovered — poison -> capture
lost, rebuilding pipeline in place -> a fresh worker in ~185 ms ->
streaming resumes (2385 frames, 0 mismatched). A real KWin
compositor-crash repro is still pending (a worker kill -9 is strictly
harsher, so it corroborates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 07:13:39 +00:00
parent 07d46f865c
commit 077d8f85ca
11 changed files with 2166 additions and 67 deletions
+163
View File
@@ -0,0 +1,163 @@
# Zero-copy capture hardening — GPU-import worker isolation
> **Status: IMPLEMENTED + on-glass validated (2026-07-06).** This is the implementation
> plan + decision record for the crash described in
> [`zerocopy-hardening-handoff.md`](zerocopy-hardening-handoff.md) (host SIGSEGV inside
> `libnvidia-eglcore` via `cuGraphicsMapResources` when the compositor invalidated an imported
> dmabuf mid-map, observed on the Bazzite F44 Game→Desktop switch). Validated on the RTX 5070 Ti /
> GNOME box (.21): the isolated worker carries frames at **p50 1.30 ms** end-to-end (NV12, 1800
> frames 0-mismatched), and a `kill -9` of the worker mid-stream is survived by the host and
> recovered — poison → `capture lost — rebuilding pipeline in place` → a fresh worker in **~185 ms**
> → streaming resumes (2385 frames, 0 mismatched, one 33 ms blip at the rebuild seam). See §6.
## 1. The decision: isolate, don't (only) prevent
The handoff's §9 framed two directions — *prevent the stale resource* vs *isolate the crash*.
The audit (§3 below) shows our per-frame lifetime discipline is already correct: the `EGLImage`
is created and destroyed strictly inside the PipeWire `on_process` callback while the buffer is
held (not requeued), and the CUDA-registered textures are **our own GL render targets**, never
wrappers around producer buffers. The invalidation that crashed the host is **external**
a compositor crash (or GPU channel wreckage from the surrounding plasmashell/Xwayland core dumps)
yanked the dmabuf's GPU-side state while the driver executed our in-flight GL sampling + CUDA map.
No in-process ordering fix can close that race, and a driver SIGSEGV is not catchable.
So the fix is **process isolation**: the entire `EglImporter` (tiled dmabuf → EGL/GL → CUDA *and*
LINEAR dmabuf → Vulkan bridge → CUDA) moves into a small per-capture **worker subprocess**. If the
driver faults, the *worker* dies; the host observes a dead socket, fails the frame/capture cleanly,
and the existing capture-loss rebuild path (`gamestream/stream.rs`, `punktfunk1.rs`) takes over —
which is exactly what already happens today on the safe SHM path when a compositor goes away.
What is deliberately **not** isolated:
- **SHM/CPU capture** — no GPU import, nothing to contain.
- **VAAPI passthrough** (AMD/Intel) — capture only `dup`s the dmabuf fd; the GPU import happens in
the encoder (Mesa VA, which reports errors rather than faulting; no observed crashes). Out of
scope here.
- **NVENC itself** — libavcodec/NVENC surface errors as return codes; if the GPU is globally
wedged the encoder errors and the session rebuilds. Isolating encode would mean shipping a
session-wide media-pipeline process, far beyond this fix.
## 2. Architecture
```
host process worker process (punktfunk-host zerocopy-worker)
──────────── ───────────────────────────────────────────────
PipeWire on_process EGLDisplay + GL ctx + CUDA ctx + VkBridge
│ dmabuf fd (held, fence-waited) │
├── IMPORT{key,geometry} + fd ──────────────▶│ eglCreateImage → GL blit/NV12 convert
│ (SCM_RIGHTS, first sight per key) │ → cuGraphicsMapResources → copy → unmap
│ │ → pooled CUDA buffer (cuMemAllocPitch)
│◀────────── FRAME{id [, ipc desc]} ─────────┤ exported ONCE via cuIpcGetMemHandle
│ host opens the IPC handle once, │
│ wraps it as DeviceBuffer │
▼ │
encode thread (NVENC) reads the device ptr │ keeps the DeviceBuffer in-flight
│ DeviceBuffer drop │
└── RELEASE{id} ────────────────────────────▶│ returns the buffer to its pool
```
- **Transport**: a `socketpair(AF_UNIX, SOCK_SEQPACKET)` created before spawn; the child end is
`dup2`'d to fd 3 (`zerocopy-worker --fd 3`). SEQPACKET gives reliable, ordered, message-framed
delivery; dmabuf fds ride as `SCM_RIGHTS`. Messages are small serde_json bodies (~200 B/frame;
negligible at 240 fps).
- **Frame data never crosses the socket.** The worker's `BufferPool` allocations are exported once
each via `cuIpcGetMemHandle`; the host `cuIpcOpenMemHandle`s each exactly once (cached by buffer
id) and reuses the mapping as the pool recycles. Per frame the reply is just `{id}` — the copy
was already synced (`copy_blocking`) worker-side before the reply, so the host reads complete
pixels. The result is the same zero-CPU-touch path as before, plus one socket RTT (~tens of µs).
- **fd caching**: the host keys each PipeWire buffer by its dmabuf `st_ino` (unique per dma-buf
object) and sends the fd only on first sight; the worker keeps the received dup (tiled: for the
per-frame `eglCreateImage`; LINEAR: for the Vulkan `src_cache`). A format renegotiation
(`param_changed`) sends `CLEAR_CACHE`, dropping both sides' caches — this also fixes the
pre-existing LINEAR-path bug where `VkBridge::src_cache` was keyed by raw fd number and never
invalidated across pool recycles (§3, trigger b). Cache desync is self-healing: a worker that no
longer holds a key's fd (LRU eviction) answers `NeedFd` and the host retries once with the fd.
- **Lifetimes**: the worker holds each exported frame as a real `DeviceBuffer` in an in-flight map
until `RELEASE{id}` arrives, so the existing pool `Arc` machinery keeps device memory alive
across pool replacement while the host still reads it. Host-side, every remote `DeviceBuffer`
holds an `Arc` of the client's shared state (socket + IPC-mapping cache), so mappings are closed
only after the last in-flight frame drops.
- **Worker lifetime**: one worker per capture (per `pipewire_thread`), spawned from
`/proc/self/exe`. It exits on socket EOF; the host reaps children via a global sweep list (no
zombies). Host death ⇒ EOF ⇒ worker exit.
### Failure semantics (the point of the exercise)
| event | behavior |
|---|---|
| worker init fails (no GPU, EGL error) | handshake reports `init_err` → capture falls back to the CPU/SHM offer, same as `EglImporter::new()` failure today |
| driver SIGSEGV in the worker (the observed crash) | socket EOF → import fails with a *dead-worker* error → the capturer is **poisoned**`next_frame`/`try_latest` return an error → the session's capture-loss rebuild runs (new capturer, new worker). **The host process survives.** |
| tiled import fails but worker alive (e.g. `EGL_BAD_MATCH` on one frame) | frame dropped; after 3 consecutive failures the capturer poisons → rebuild. It must **never** fall through to the CPU mmap path — mmap of a *tiled* dmabuf de-pads scrambled bytes (a pre-existing fallback bug; the CPU fallback was only ever correct for LINEAR). |
| LINEAR import fails | unchanged: fall back to the CPU mmap path in-stream (a LINEAR dmabuf is mappable), degraded not dead |
| repeated worker deaths | a process-wide latch (`note_gpu_import_death`, 3 consecutive deaths without a successful import between them) disables the GPU importer for the rest of the process — rebuilds renegotiate the SHM offer. Stops a wedged GPU stack from crash-looping the worker while still streaming (CPU path). A successful import resets the streak. |
### Escape hatch
`PUNKTFUNK_ZEROCOPY_INPROC=1` keeps the importer in-process (the pre-isolation behavior) for
debugging and A/B latency comparison. Default is the worker.
## 3. Audit answers for handoff §5 (which triggers are actually reachable)
- **Compositor crash / restart** — reachable (observed). Contained by the worker.
- **PipeWire buffer-pool recycle / renegotiation**:
- *Tiled EGL path*: **not reachable in code** — the `EGLImage` lives strictly inside
`on_process` while the buffer is held; the CUDA registrations wrap our own persistent GL
textures, not producer buffers.
- *LINEAR Vulkan path*: **reachable**`VkBridge::src_cache` keyed by raw fd, never
invalidated: a pool teardown + fd-number reuse could serve a stale imported buffer (wrong
frame or driver fault), and old entries leaked. Fixed by st_ino keys + `CLEAR_CACHE` on
renegotiation + an LRU cap.
- **Virtual-output teardown / mode change racing an in-flight map** — same class as compositor
crash (external invalidation, another thread); contained by the worker.
- **Output removal** — ditto.
## 4. In-process lifetime fixes (also shipped, they harden the worker itself)
- `Nv12Blit::drop` deleted its GL textures **before** the struct fields dropped, i.e. while
`y_tex`/`uv_tex` were still CUDA-registered. Now `RegisteredTexture::release()` runs first
(unregister → delete), removing a driver-state hazard of exactly the class that crashed.
- `GlBlit` had **no** `Drop` — its GL program/VAO/FBO/textures leaked on every size change and on
importer teardown. Now mirrors `Nv12Blit` (release registrations, then delete GL objects).
## 5. Residual risks, accepted
- A worker death while the encode thread still holds an IPC-mapped frame: the exporting process is
gone; the host-side mapping stays open until the `DeviceBuffer` drops. CUDA surfaces this as a
copy error at worst (encode error → session rebuild), not a host fault.
- The VAAPI encoder's in-host VA dmabuf import (Mesa) keeps its current exposure; no NVIDIA-class
faults observed there.
- `cuIpcOpenMemHandle` requires same-device, different-process — both hold by construction.
## 6. Validation
- **GPU-less (CI / dev VM)**: protocol unit tests (framing, fd round-trip over a socketpair,
error propagation, dead-worker detection against a mock server, latch behavior); worker-spawn
failure path (spawning a non-worker exe ⇒ clean fallback).
- **On-glass (NVIDIA RTX 5070 Ti + GNOME/Mutter, .21, 2026-07-06)** — steps 12 **PASSED**:
1. streamed `PUNKTFUNK_ZEROCOPY=1` through the worker (`zerocopy import worker ready`
`zero-copy GPU import isolated in a worker process``dmabuf imported to CUDA … nv12=true`),
end-to-end **p50 1.30 ms** (1800 frames, 0 mismatched) — parity with the pre-isolation path;
2. `kill -9` the worker mid-stream → host **survived**; the next import logged
`tiled GPU import lost — failing this capture for rebuild … Broken pipe … dead=true`, then
`capture lost — rebuilding pipeline in place, rebuild=1`, a **fresh worker (new pid) in
~185 ms**, and streaming resumed (2385 frames, 0 mismatched; single 33 ms frame at the seam).
The `worker-ready` count was 2 (original + rebuild), confirming the respawn.
Still pending: 3. a real compositor kill/restart mid-stream on a KWin box (the exact original
trigger — a `kill -9` of the worker is a strictly harsher event, so this is corroboration not a
gap); 4. `nv12-selftest` (in-process path untouched). *Note: on a static virtual desktop the
dead-worker detection only fires once a new frame triggers an import — realistic (a running game
produces continuous frames) but it means an idle desktop can sit poisoned-but-quiet briefly.*
## 7. Files
- `crates/punktfunk-host/src/linux/zerocopy/proto.rs` — message types + SEQPACKET/SCM_RIGHTS I/O.
- `crates/punktfunk-host/src/linux/zerocopy/worker.rs` — worker main loop (`zerocopy-worker`),
backend trait (testable), EGL/CUDA backend.
- `crates/punktfunk-host/src/linux/zerocopy/client.rs``RemoteImporter` (spawn, handshake, IPC
mapping cache, release plumbing, reaping) + the `Importer` enum (Remote | InProc).
- `crates/punktfunk-host/src/linux/zerocopy/cuda.rs` — CUDA IPC entry points; remote-release
`DeviceBuffer`s.
- `crates/punktfunk-host/src/linux/zerocopy/egl.rs` — teardown-order fixes (§4).
- `crates/punktfunk-host/src/capture/linux/mod.rs``Importer` wiring, tiled-failure poisoning,
death latch, `CLEAR_CACHE` on renegotiation.
- `crates/punktfunk-host/src/main.rs` — the hidden `zerocopy-worker` subcommand.