test(encode): a QSV construct/drop smoke test, and AvFilterGraph goes Linux-only

Two loose ends ahead of running this on real Intel silicon.

`zerocopy_qsv_alloc_drop_cycles` covers the one ownership question in the crate that
was genuinely ambiguous. `ZeroCopyInner::open` builds a `D3d11Hw` and then DERIVES a
QSV device + frames ctx from it, and the tuple it used to return handed those two
derived pointers out twice — once as the encoder's args, once as the pair moved into
`Self`. Free for raw pointers, two owners for `AvBuffer`. Looping construct/drop is
what separates the outcomes: a double-unref aborts in the CRT, a missed one leaks an
Intel device per session. Nothing else reaches this code — `zerocopy_enabled`
defaults QSV OFF, and the native VPL backend supersedes this whole file unless
`PUNKTFUNK_QSV_FFMPEG=1` — so the test calls `open` directly and sidesteps both
gates, the same shape as `cuda_hw_alloc_drop_cycles`.

`AvFilterGraph` is now `#[cfg(target_os = "linux")]`. The Windows gate had been
reporting `struct AvFilterGraph is never constructed` since a960dff8 and I had been
filtering it out of my own log greps (searching for "never read", which does not
match "never constructed"). It is true: the VAAPI dmabuf path is the only
filter-graph user, and the AMF/QSV backends build no graph. Cfg'd out rather than
`allow`ed, so it cannot outlive its last caller unnoticed.

Verified. Windows .133: `cargo test -p pf-encode --features amf-qsv --no-run` at
EXITCODE=0 with the AvFilterGraph warnings gone (107 -> 105), and the test binary
lists `ffmpeg_win::tests::zerocopy_qsv_alloc_drop_cycles`. Linux .21: workspace
check exit 0 / zero errors, pf-encode 33 passed / 0 failed, pf-client-core 34 passed
/ 0 failed, and zero dead-code warnings there (AvFilterGraph is live on Linux).

NOTE for running it: the test binary needs `C:\Users\Public\ffmpeg\bin` on PATH at
RUNTIME. `FFMPEG_DIR` is build-time only, and without the DLLs the harness exits
silently with no output at all — which reads exactly like a test that does not exist.
This commit is contained in:
2026-07-28 21:31:41 +02:00
parent e60fee7da6
commit 564797a12a
2 changed files with 59 additions and 0 deletions
+8
View File
@@ -78,8 +78,15 @@ impl Drop for AvBuffer {
/// of eight failure branches — a four-line cleanup block copied eight times, once inside a macro. /// of eight failure branches — a four-line cleanup block copied eight times, once inside a macro.
/// Freeing the graph is the same ownership question as unref'ing a buffer, so it gets the same /// Freeing the graph is the same ownership question as unref'ing a buffer, so it gets the same
/// answer. /// answer.
///
/// Linux-only: the VAAPI dmabuf path is the sole filter-graph user in this crate. The Windows
/// AMF/QSV backends feed the encoder directly and build no graph, so on Windows this type would be
/// dead code — cfg'd out rather than `allow`ed, because "nothing here uses it" is the honest
/// statement and an `allow` would keep it compiling after it stopped being true anywhere.
#[cfg(target_os = "linux")]
pub(crate) struct AvFilterGraph(*mut ffi::AVFilterGraph); pub(crate) struct AvFilterGraph(*mut ffi::AVFilterGraph);
#[cfg(target_os = "linux")]
impl AvFilterGraph { impl AvFilterGraph {
/// Allocate a filter graph, rejecting the null `avfilter_graph_alloc` returns on OOM. /// Allocate a filter graph, rejecting the null `avfilter_graph_alloc` returns on OOM.
/// ///
@@ -99,6 +106,7 @@ impl AvFilterGraph {
} }
} }
#[cfg(target_os = "linux")]
impl Drop for AvFilterGraph { impl Drop for AvFilterGraph {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `self.0` is the non-null graph `alloc` took ownership of, and this type is its // SAFETY: `self.0` is the non-null graph `alloc` took ownership of, and this type is its
@@ -1443,6 +1443,57 @@ impl Encoder for FfmpegWinEncoder {
mod tests { mod tests {
use super::*; use super::*;
/// Construct/drop `ZeroCopyInner` on the QSV path, repeatedly, on real Intel silicon.
///
/// This is the one path in the crate where ownership was genuinely ambiguous: `open` builds a
/// `D3d11Hw` (D3D11VA device + frames pair) and then DERIVES a QSV device + frames ctx from it,
/// and the tuple it used to return handed those two derived pointers out **twice** — once as
/// the encoder's `dev_ref`/`frames_ref`, once as the pair moved into `Self`. Harmless while they
/// were raw pointers; two owners once they are `AvBuffer`. Looping construct/drop is what tells
/// the difference apart: a double-unref aborts inside the CRT, a missed one leaks an Intel
/// device per session.
///
/// Nothing else reaches it. `zerocopy_enabled` defaults QSV **off**, so the normal encode path
/// never builds one on Intel, and the native VPL backend (`enc/windows/qsv.rs`) supersedes this
/// whole file unless `PUNKTFUNK_QSV_FFMPEG=1`. Calling `open` directly sidesteps both gates so
/// the ownership itself is what gets exercised.
///
/// `#[ignore]`d (needs a real Intel QSV device):
/// `cargo test -p pf-encode --features amf-qsv zerocopy_qsv_alloc_drop_cycles -- --ignored --nocapture`
#[test]
#[ignore = "needs a real Intel QSV device (run on an Intel host, not the build box)"]
fn zerocopy_qsv_alloc_drop_cycles() {
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
// SAFETY: `CreateDXGIFactory1`/`EnumAdapters1` are the documented enumeration pair and are
// checked here; `make_device` requires a live `IDXGIAdapter1`, which `adapter` is for the
// duration of the call (it outlives it as a local). The returned device is the sole owner of
// its COM object and outlives every `ZeroCopyInner` built from it below.
let (device, _ctx) = unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("CreateDXGIFactory1");
let adapter = factory.EnumAdapters1(0).expect("EnumAdapters1(0)");
pf_frame::dxgi::make_device(&adapter).expect("D3D11 device on adapter 0")
};
for i in 0..8 {
let zc = ZeroCopyInner::open(
WinVendor::Qsv,
Codec::H264,
PixelFormat::Bgrx,
640,
480,
30,
8_000_000,
8,
&device,
)
.unwrap_or_else(|e| panic!("ZeroCopyInner::open(QSV) failed on iteration {i}: {e:#}"));
// The QSV arm must have derived BOTH halves — an `Option` that came back `None` here
// would mean the AMF branch was taken and the derived-pair ownership never ran.
assert!(zc.qsv_frames.is_some(), "QSV path derived no frames ctx");
assert!(zc.qsv_device.is_some(), "QSV path derived no device");
}
eprintln!("8 ZeroCopyInner(QSV) alloc/drop cycles completed without abort");
}
/// Zero-copy default matrix: the operator override wins in both directions; unset resolves /// Zero-copy default matrix: the operator override wins in both directions; unset resolves
/// AMF on (on-glass validated) and QSV off (opt-in until validated on Intel glass — the /// AMF on (on-glass validated) and QSV off (opt-in until validated on Intel glass — the
/// probe-never-assume rule). /// probe-never-assume rule).