# 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 1–2 **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.
