refactor(encode): AVBufferRef ownership moves into an RAII handle

`CudaHw::new` and `VaapiHw::new` are the same shape — alloc a hwdevice, deref it
to fill fields, init, alloc a frames ctx, deref, init — and both unwound by hand:
`av_buffer_unref` on every failure branch (three in CUDA, two in VAAPI) plus a
hand-written `Drop` repeating the pair. That shape has two failure modes and the
compiler can see neither: add a branch and forget the cleanup (leak), or let two
cleanup paths run (double-unref, an abort inside glibc).

`libav::AvBuffer` owns the ref instead. It null-checks on the way in — the check
each caller open-coded — and unrefs exactly once on drop, so an early `?` releases
whatever was built so far and the failure branches carry no cleanup at all. Both
hand-written `Drop` impls are gone, and `linux/mod.rs` goes from seven
`av_buffer_unref` calls to zero.

The one subtlety, called out at both structs: these two fields must stay declared
frames-BEFORE-device. Fields drop in declaration order, and the code being replaced
deliberately unref'd frames first (a frames ctx holds its own reference on its
device). Refcounting makes either order sound, but a field reorder should not
silently change what ships, so the ordering is load-bearing and commented as such.

Also adds `cuda_hw_alloc_drop_cycles` — the RAII path had NO test coverage: the
NVENC smoke tests take the CPU path and never construct a `CudaHw`, and the VAAPI
twin's tests need AMD/Intel silicon. Looping construct/drop is what catches the
double-unref (abort) and the leak (allocator growth) this refactor is about.

Verified on Nobara (RTX 5070 Ti): `cargo check -p pf-encode --all-targets` clean at
exit 0, `cargo test -p pf-encode` 33 passed / 0 failed, and
`cuda_hw_alloc_drop_cycles` passes against a real CUDA device — eight
construct/drop cycles, no abort. `VaapiHw` is compile-verified only; it is the
identical shape but no AMD/Intel box was reachable to run its two ignored tests.
This commit is contained in:
2026-07-28 21:31:41 +02:00
parent 5219107177
commit 7adc1db672
3 changed files with 130 additions and 71 deletions
+46
View File
@@ -26,6 +26,52 @@ pub(crate) fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
ffi::AVPixelFormat::from(p) ffi::AVPixelFormat::from(p)
} }
/// An owned `AVBufferRef` — unref'd exactly once, when it drops.
///
/// The hwdevice/hwframes constructors used to unref by hand on *every* failure branch (three in the
/// CUDA path, two in VAAPI) and then once more in a hand-written `Drop`. That shape leaks the moment
/// somebody adds a branch and forgets the cleanup, and double-unrefs the moment two of them run —
/// and neither mistake is visible at the call site or catchable by the compiler. Ownership lives in
/// this type instead: an early `?` drops whatever was built so far, in reverse construction order,
/// with no cleanup code at the call site at all.
///
/// **Drop order** matters to the callers holding two of these. A frames context internally holds its
/// own reference on its device, and the code this replaced deliberately unref'd frames *before*
/// device. Rust drops struct fields in DECLARATION order, so a struct holding both must declare
/// frames before device to keep that. Refcounting makes either order sound in principle —
/// the device cannot die while a frames ctx still references it — but the observable order is kept
/// exactly as it shipped rather than quietly inverted by a field reorder.
pub(crate) struct AvBuffer(*mut ffi::AVBufferRef);
impl AvBuffer {
/// Take ownership of a freshly-created `AVBufferRef`, rejecting the null that an ffmpeg
/// allocator returns on failure (so the `is_null` check every caller used to open-code happens
/// once, here).
///
/// # Safety
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
/// nothing else may unref it.
pub(crate) unsafe fn from_raw(p: *mut ffi::AVBufferRef) -> Option<Self> {
(!p.is_null()).then_some(AvBuffer(p))
}
/// The borrowed pointer, for the ffmpeg calls that read a ref without consuming it. Borrowed
/// only — the `AvBuffer` stays the owner, so callers must not unref what this returns.
pub(crate) fn as_ptr(&self) -> *mut ffi::AVBufferRef {
self.0
}
}
impl Drop for AvBuffer {
fn drop(&mut self) {
// SAFETY: `self.0` is the non-null ref `from_raw` took ownership of, and this type is its
// sole owner (it is neither `Clone` nor `Copy`, and `as_ptr` only lends), so this runs
// exactly once for that reference. `av_buffer_unref` drops the one reference and nulls the
// pointer through the `&mut`.
unsafe { ffi::av_buffer_unref(&mut self.0) };
}
}
/// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can /// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can
/// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse /// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse
/// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop. /// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop.
+58 -37
View File
@@ -22,7 +22,8 @@ use std::os::raw::c_int;
use std::ptr; use std::ptr;
use super::libav::{ use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT, apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_ITU709,
SWS_POINT,
}; };
use ffmpeg::ffi; // = ffmpeg_sys_next use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -60,8 +61,11 @@ struct AVCUDADeviceContext {
/// CUDA hardware-frame contexts that wrap our shared `CUcontext`, so `hevc_nvenc` reads the /// CUDA hardware-frame contexts that wrap our shared `CUcontext`, so `hevc_nvenc` reads the
/// imported device buffer directly. Owns two `AVBufferRef`s, unref'd on drop. /// imported device buffer directly. Owns two `AVBufferRef`s, unref'd on drop.
struct CudaHw { struct CudaHw {
device_ref: *mut ffi::AVBufferRef, // Declared frames-BEFORE-device on purpose: these drop in declaration order, and that
frames_ref: *mut ffi::AVBufferRef, // reproduces exactly what the hand-written `Drop` this replaced did (the frames ctx holds its
// own reference on the device). Do not reorder these two fields.
frames_ref: AvBuffer,
device_ref: AvBuffer,
} }
impl CudaHw { impl CudaHw {
@@ -72,57 +76,42 @@ impl CudaHw {
/// (`nvenc_open_einval`), and a hwdevice/hwframes EINVAL is a config error no bitrate can /// (`nvenc_open_einval`), and a hwdevice/hwframes EINVAL is a config error no bitrate can
/// fix — enrolling it would burn ~10 doomed encoder opens before surfacing the real failure. /// fix — enrolling it would burn ~10 doomed encoder opens before surfacing the real failure.
unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> { unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> {
let mut device_ref = ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA); // Each `?`/`bail!` below drops whatever has been built so far — `AvBuffer`'s `Drop` is the
if device_ref.is_null() { // single unref path, so the failure branches carry no cleanup of their own.
bail!("av_hwdevice_ctx_alloc(CUDA) failed"); let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
} ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
let dev_ctx = (*device_ref).data as *mut ffi::AVHWDeviceContext; ))
.context("av_hwdevice_ctx_alloc(CUDA) failed")?;
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
let cu = (*dev_ctx).hwctx as *mut AVCUDADeviceContext; let cu = (*dev_ctx).hwctx as *mut AVCUDADeviceContext;
(*cu).cuda_ctx = cu_ctx; // share the importer's context (*cu).cuda_ctx = cu_ctx; // share the importer's context
let r = ffi::av_hwdevice_ctx_init(device_ref); let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
if r < 0 { if r < 0 {
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwdevice_ctx_init failed ({r})"); bail!("av_hwdevice_ctx_init failed ({r})");
} }
let mut frames_ref = ffi::av_hwframe_ctx_alloc(device_ref); let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
if frames_ref.is_null() { .context("av_hwframe_ctx_alloc failed")?;
ffi::av_buffer_unref(&mut device_ref); let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
bail!("av_hwframe_ctx_alloc failed");
}
let fc = (*frames_ref).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_CUDA; (*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
(*fc).sw_format = pixel_to_av(sw_format); (*fc).sw_format = pixel_to_av(sw_format);
(*fc).width = w as c_int; (*fc).width = w as c_int;
(*fc).height = h as c_int; (*fc).height = h as c_int;
(*fc).initial_pool_size = 0; // we supply the device pointers (*fc).initial_pool_size = 0; // we supply the device pointers
let r = ffi::av_hwframe_ctx_init(frames_ref); let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 { if r < 0 {
ffi::av_buffer_unref(&mut frames_ref);
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_init failed ({r})"); bail!("av_hwframe_ctx_init failed ({r})");
} }
Ok(CudaHw { Ok(CudaHw {
device_ref,
frames_ref, frames_ref,
device_ref,
}) })
} }
} }
impl Drop for CudaHw { // No `Drop` for `CudaHw`: each `AvBuffer` field unrefs itself, in declaration order (frames, then
fn drop(&mut self) { // device — see the field comment). The hand-written unref pair this replaced had to be kept in sync
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `CudaHw::new` created // with every failure branch in `new`; now there is exactly one unref path and it cannot be skipped.
// (it bails before returning `Self` if either alloc fails, so a live `CudaHw` always holds
// both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`. This
// `Drop` runs exactly once and `CudaHw` owns these refs exclusively → no double-free /
// use-after-free. Frames are unref'd before the device (the frames ctx internally refs the
// device; refcounted, so the order is sound regardless).
unsafe {
ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref);
}
}
}
/// Map a captured layout to the NVENC input pixel format, and whether a 3→4 byte expand is /// Map a captured layout to the NVENC input pixel format, and whether a 3→4 byte expand is
/// needed (packed RGB/BGR have no padding byte; the NVENC `*0` formats do). /// needed (packed RGB/BGR have no padding byte; the NVENC `*0` formats do).
@@ -421,8 +410,8 @@ impl NvencEncoder {
unsafe { unsafe {
let raw = video.as_mut_ptr(); let raw = video.as_mut_ptr();
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA; (*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
(*raw).hw_device_ctx = ffi::av_buffer_ref(hw.device_ref); (*raw).hw_device_ctx = ffi::av_buffer_ref(hw.device_ref.as_ptr());
(*raw).hw_frames_ctx = ffi::av_buffer_ref(hw.frames_ref); (*raw).hw_frames_ctx = ffi::av_buffer_ref(hw.frames_ref.as_ptr());
} }
Some(hw) Some(hw)
} else { } else {
@@ -847,7 +836,8 @@ impl NvencEncoder {
.cuda .cuda
.as_ref() .as_ref()
.context("CUDA hw context missing (encoder opened in CPU mode)")? .context("CUDA hw context missing (encoder opened in CPU mode)")?
.frames_ref; .frames_ref
.as_ptr();
// The device→device copy below uses our shared context directly; make it current on the // The device→device copy below uses our shared context directly; make it current on the
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine). // encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
pf_zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?; pf_zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
@@ -1055,6 +1045,37 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
.is_ok() .is_ok()
} }
#[cfg(test)]
mod cuda_hw_tests {
use super::*;
/// `CudaHw` owns its two `AVBufferRef`s through `AvBuffer`, so *construct and drop* is the
/// entire contract: a missed unref leaks, a doubled one aborts inside glibc. Nothing else in
/// the suite covers it — the NVENC smoke tests take the CPU path and never build one, and the
/// VAAPI twin's tests need AMD/Intel silicon. Looping the cycle is the point: a double-unref
/// shows up as an abort, and a leak shows as the allocator growing across iterations.
///
/// `#[ignore]`d (needs a real CUDA device):
/// `cargo test -p pf-encode cuda_hw_alloc_drop_cycles -- --ignored --nocapture`
#[test]
#[ignore = "needs a real CUDA device (run on an NVIDIA host, not the build box)"]
fn cuda_hw_alloc_drop_cycles() {
ffmpeg::init().expect("libav init");
let cu_ctx = pf_zerocopy::cuda::context().expect("shared CUDA context");
for i in 0..8 {
// SAFETY: `CudaHw::new` requires libav initialized (asserted above) and a valid
// `CUcontext` — `cu_ctx` is the live shared context from `pf_zerocopy`. NV12 at
// 640x480 are a valid format and positive dims. The handle drops at the end of each
// iteration, which is precisely the unref path under test.
let hw = unsafe { CudaHw::new(cu_ctx.cast(), Pixel::NV12, 640, 480) }
.unwrap_or_else(|e| panic!("CudaHw::new failed on iteration {i}: {e:#}"));
assert!(!hw.device_ref.as_ptr().is_null(), "device ref went null");
assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
}
eprintln!("8 CudaHw alloc/drop cycles completed without abort");
}
}
#[cfg(test)] #[cfg(test)]
mod hdr_tests { mod hdr_tests {
use super::*; use super::*;
+26 -34
View File
@@ -36,7 +36,8 @@ use std::ptr;
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use super::libav::{ use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT, apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_ITU709,
SWS_POINT,
}; };
use ffmpeg::ffi; // = ffmpeg_sys_next use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -396,8 +397,8 @@ pub fn probe_can_encode(codec: Codec) -> bool {
480, 480,
30, 30,
2_000_000, 2_000_000,
hw.device_ref, hw.device_ref.as_ptr(),
hw.frames_ref, hw.frames_ref.as_ptr(),
false, false,
) )
.is_ok(), .is_ok(),
@@ -435,8 +436,8 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
480, 480,
30, 30,
2_000_000, 2_000_000,
hw.device_ref, hw.device_ref.as_ptr(),
hw.frames_ref, hw.frames_ref.as_ptr(),
true, true,
) )
.is_ok(), .is_ok(),
@@ -463,8 +464,11 @@ pub fn probe_can_encode_444(_codec: Codec) -> bool {
/// VAAPI device + NV12 frames pool (the encoder's input surfaces for the CPU path). /// VAAPI device + NV12 frames pool (the encoder's input surfaces for the CPU path).
struct VaapiHw { struct VaapiHw {
device_ref: *mut ffi::AVBufferRef, // Declared frames-BEFORE-device on purpose: these drop in declaration order, which reproduces
frames_ref: *mut ffi::AVBufferRef, // exactly what the hand-written `Drop` this replaced did (the frames ctx holds its own
// reference on the device). Do not reorder these two fields.
frames_ref: AvBuffer,
device_ref: AvBuffer,
} }
impl VaapiHw { impl VaapiHw {
@@ -481,44 +485,32 @@ impl VaapiHw {
if r < 0 { if r < 0 {
bail!("no VAAPI device ({:?}): {}", node, ffmpeg::Error::from(r)); bail!("no VAAPI device ({:?}): {}", node, ffmpeg::Error::from(r));
} }
let mut frames_ref = ffi::av_hwframe_ctx_alloc(device_ref); // `av_hwdevice_ctx_create` wrote an owned ref into `device_ref`; take ownership of it here so
if frames_ref.is_null() { // every `bail!` below drops it (and the frames ref, once built) without cleanup of its own.
ffi::av_buffer_unref(&mut device_ref); let device_ref = AvBuffer::from_raw(device_ref)
bail!("av_hwframe_ctx_alloc(VAAPI) failed"); .context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
} let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
let fc = (*frames_ref).data as *mut ffi::AVHWFramesContext; .context("av_hwframe_ctx_alloc(VAAPI) failed")?;
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI; (*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*fc).sw_format = sw_format; (*fc).sw_format = sw_format;
(*fc).width = w as c_int; (*fc).width = w as c_int;
(*fc).height = h as c_int; (*fc).height = h as c_int;
(*fc).initial_pool_size = pool; (*fc).initial_pool_size = pool;
let r = ffi::av_hwframe_ctx_init(frames_ref); let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
if r < 0 { if r < 0 {
ffi::av_buffer_unref(&mut frames_ref);
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})"); bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
} }
Ok(VaapiHw { Ok(VaapiHw {
device_ref,
frames_ref, frames_ref,
device_ref,
}) })
} }
} }
impl Drop for VaapiHw { // No `Drop` for `VaapiHw`: each `AvBuffer` field unrefs itself, in declaration order (frames, then
fn drop(&mut self) { // device — see the field comment). The hand-written unref pair this replaced had to stay in sync
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `VaapiHw::new` // with every failure branch in `new`; now there is one unref path and it cannot be skipped.
// created (it bails before constructing `Self` if either alloc fails, so a live `VaapiHw`
// always holds both). `av_buffer_unref` drops one reference and nulls the pointer through the
// `&mut`. This `Drop` runs exactly once and `VaapiHw` owns these refs exclusively, so there
// is no double-free / use-after-free. Frames are unref'd before the device because the frames
// ctx internally holds a ref on the device (refcounted, so the order is sound either way).
unsafe {
ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref);
}
}
}
struct CpuInner { struct CpuInner {
enc: encoder::video::Encoder, enc: encoder::video::Encoder,
@@ -567,8 +559,8 @@ impl CpuInner {
height, height,
fps, fps,
bitrate_bps, bitrate_bps,
hw.device_ref, hw.device_ref.as_ptr(),
hw.frames_ref, hw.frames_ref.as_ptr(),
ten_bit, ten_bit,
)? )?
}; };
@@ -698,7 +690,7 @@ impl CpuInner {
if hwf.is_null() { if hwf.is_null() {
bail!("av_frame_alloc(hw) failed"); bail!("av_frame_alloc(hw) failed");
} }
if ffi::av_hwframe_get_buffer(self.hw.frames_ref, hwf, 0) < 0 { if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf, 0) < 0 {
ffi::av_frame_free(&mut hwf); ffi::av_frame_free(&mut hwf);
bail!("av_hwframe_get_buffer(VAAPI) failed"); bail!("av_hwframe_get_buffer(VAAPI) failed");
} }