fix(zerocopy): the mechanical sweep batch — truthful fence waits, forgiving env flags, no leaked planes

From the pf-zerocopy review sweep (design/pf-zerocopy-sweep-handoff.md), the
compile-verifiable batch:

- dmabuf_fence: the blocking poll's result was discarded — EINTR silently
  skipped the wait (reopening the stale-frame race a SIGCHLD away) and a
  timeout reported as waited. Now EINTR retries with the remaining budget
  and the caller gets Signaled/TimedOut/NoFence, so the one diagnostic
  operators have about implicit fencing stops lying. (C2)
- env flags: PUNKTFUNK_ZEROCOPY=TRUE meant *off* — values are case-folded
  now, and an unrecognised spelling falls back to the flag's default with a
  one-shot warning instead of silently inverting the operator's intent. (C3)
- cuda: alloc_pitched_nv12 leaked the Y plane when the UV allocation failed
  (per-frame under VRAM pressure — the worst possible time to leak); a failed
  async-copy enqueue now drains the stream before returning, so a recycled
  pool buffer can't race an orphaned in-flight copy. (C4, C5)
- worker: --fd is validated (>= 3, fstat + S_ISSOCK) before OwnedFd adoption
  — 'zerocopy-worker --fd -1' was constructing OwnedFd's niche value. (V1)
- docs: all 15 rustdoc warnings fixed, including the link to the renamed
  note_raw_dmabuf_negotiation_failed. (D1)
- CI: a SPIR-V drift gate — the committed .spv blobs are include_bytes!'d and
  rebuilt by hand; the gate diffs disassembly (filtering only the
  shaderc/glslang generator difference) so a forgotten rebuild fails CI
  instead of shipping the old kernel. Both blobs verified in sync. (S1)
- tests: bt709_limited pinned to external BT.709 anchors (it is the sole
  oracle for the GPU colour self-test); blend_geometry gets its first tests —
  empty rect, CURSOR_MAX clamp, per-format group counts, and negative-ox
  floor alignment. (T2, T3)

Verified on .25: clippy -D warnings clean, 27/27 tests, cargo doc 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 12:40:37 +02:00
co-authored by Claude Fable 5
parent c4e80fd455
commit 143a707f76
13 changed files with 329 additions and 66 deletions
+65 -5
View File
@@ -45,7 +45,7 @@ use ash::vk;
pub const CURSOR_MAX: u32 = cuda::CURSOR_MAX;
/// The vendored SPIR-V for `cursor_blend.comp` (beside this file; rebuild with
/// `glslc cursor_blend.comp -o cursor_blend.spv`).
/// `glslangValidator -V cursor_blend.comp -o cursor_blend.spv`; CI gates drift).
const CURSOR_SPV: &[u8] = include_bytes!("cursor_blend.spv");
/// NVENC input-surface layout — selects the spec-constant `MODE` pipeline and the allocation
@@ -84,9 +84,10 @@ impl SlotFormat {
}
/// What the encoder holds per ring slot: the CUDA view it registers with NVENC plus the id it
/// hands back to [`VkSlotBlend::blend`]. The backing Vulkan objects + CUDA mapping live in the
/// [`VkSlotBlend`] (freed by [`free_slots`](VkSlotBlend::free_slots) / drop), so this is Copy —
/// the encoder's ring keeps its existing shape.
/// hands back to [`VkSlotBlend::blend_ref`] / [`VkSlotBlend::blend_ref_ordered`]. The backing
/// Vulkan objects + CUDA mapping live in the [`VkSlotBlend`] (freed by
/// [`free_slots`](VkSlotBlend::free_slots) / drop), so this is Copy — the encoder's ring keeps
/// its existing shape.
#[derive(Clone, Copy)]
pub struct VkSlotRef {
/// Device pointer NVENC registers (CUDA's mapping of the Vulkan memory).
@@ -654,7 +655,7 @@ impl VkSlotBlend {
}
/// Free every allocated slot (encoder teardown, alongside its ring clear). CUDA mappings drop
/// first (field order in [`SlotAlloc`] frees `cuda` via its own `Drop` before we free the VK
/// first (field order in `SlotAlloc` frees `cuda` via its own `Drop` before we free the VK
/// objects explicitly here).
pub fn free_slots(&mut self) {
// Ordered blends return with their work still on the queue — quiesce before freeing the
@@ -1062,3 +1063,62 @@ impl Drop for VkSlotBlend {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn slot() -> VkSlotRef {
VkSlotRef {
ptr: 0,
pitch: 2048,
height: 1080,
id: 0,
}
}
fn geo(fmt: SlotFormat, cw: u32, ch: u32, ox: i32, oy: i32) -> Option<(Push, u32, u32)> {
VkSlotBlend::blend_geometry(&slot(), fmt, 1920, cw, ch, ox, oy)
}
/// An empty (clamped-away) cursor rect dispatches nothing.
#[test]
fn empty_rect_is_none() {
assert!(geo(SlotFormat::Argb, 0, 32, 10, 10).is_none());
assert!(geo(SlotFormat::Nv12, 32, 0, 10, 10).is_none());
}
/// Oversized bitmaps clamp to `CURSOR_MAX` — the push constants must agree with the staging
/// buffer's capacity, or the shader reads past the uploaded bitmap.
#[test]
fn cursor_dims_clamp_to_max() {
let (push, _, _) = geo(SlotFormat::Argb, CURSOR_MAX + 100, CURSOR_MAX + 1, 0, 0).unwrap();
assert_eq!(push.cur_w, CURSOR_MAX);
assert_eq!(push.cur_h, CURSOR_MAX);
}
/// ARGB dispatches per cursor pixel; NV12/YUV444 per word-aligned 4-px span, NV12 walking
/// 2-row blocks and YUV444 single rows. A 32×32 cursor at ox=13: spans cover the aligned
/// x∈[12,48) → 9 spans → 2 groups of 8.
#[test]
fn group_counts_per_format() {
let (_, gx, gy) = geo(SlotFormat::Argb, 32, 32, 13, 0).unwrap();
assert_eq!((gx, gy), (4, 4)); // 32/8 in both axes
let (_, gx, gy) = geo(SlotFormat::Nv12, 32, 32, 13, 0).unwrap();
assert_eq!((gx, gy), (2, 2)); // 9 spans → 2 groups; 16 2-row blocks → 2 groups
let (_, gx, gy) = geo(SlotFormat::Yuv444, 32, 32, 13, 0).unwrap();
assert_eq!((gx, gy), (2, 4)); // 9 spans → 2 groups; 32 rows → 4 groups
}
/// Negative `ox` must anchor spans with FLOOR alignment (`>>` on the signed value), not
/// truncating division: at ox=-5 the aligned start is -8, giving 9 spans over a 32-px cursor
/// (truncation would start at -4 and dispatch only 8 — silently dropping the right edge).
#[test]
fn negative_ox_floor_aligns_the_span_origin() {
let (push, gx, _) = geo(SlotFormat::Nv12, 32, 32, -5, 0).unwrap();
assert_eq!(push.ox, -5, "push constants carry the true origin");
assert_eq!(gx, 2, "9 spans from the floor-aligned start → 2 groups");
}
}