Files
punktfunk/crates/pf-vkdecode/tests/fault_detection.rs
T
enricobuehler 2a57ee36f8 feat(client): M4 — the decoder's own verdict reaches the session
This program exists because a field corruption was architecturally
undetectable through FFmpeg: no decode-status read, no corrupt-frame
flag, errors only as scraped log lines, and no recovery-point signal so
intra-refresh healing was invisible. The native decoder has all of those.
M4 is where they stop being internal.

DecodeHealth counts, per session and without allocating per frame, what
the three answers actually are: damaged (the stream arrived incomplete),
refused (the rung would not decode it at all) and driver-failed (the
hardware says it could not decode what arrived), plus the current and
worst concealment run — the figures that separate one bad AU from a
stream that never came back. They ride the stats line additively, so an
FFmpeg session and a healthy native session emit byte-identical output
to today. The status-query capability is reported too: without it a
clean report cannot be told from an unmeasured one, which is the whole
nb_queries=0 lesson.

The headline is local recovery. Until now the pump could only learn that
intra-refresh healing finished from wire flags the host sends; absent
those it froze until the 500 ms backstop forced an IDR. The parsed
recovery-point SEI now feeds the re-anchor gate directly, so a session
lifts on the picture that is actually clean. Wire semantics are
untouched for every client that never calls it.

Detection now asks for recovery instead of erroring — an integrity
warning ticking the error streak would demote the native rung on exactly
the lossy links it exists to diagnose, where an FFmpeg rung conceals
silently and keeps its job.

Review round 12 found that trade had removed the escape hatch entirely.
Concealment returning Ok(None) reset the demotion streak, and worse: the
driver-verdict ledger is only populated when a frame ships, so under
continuous concealment no verdict was ever read and the erroring arm
could not fire at all. A host framing regression of the 0.23.0
slice-wire class — which does not self-heal, and which a keyframe does
not clear — would have frozen indefinitely with no demotion and a clean
integrity line, where before it demoted to FFmpeg-Vulkan and showed a
picture. Now only an answer that proves the rung works clears the
streak: a shipped frame, or a clean no-frame. Concealment neither ticks
nor clears, so a lossy link still cannot demote a healthy rung while a
driver failure interleaved with concealment reaches the threshold again.

Two more honesty defects from the same round. A rung refusing every AU
reported no integrity line at all — the founding failure mode, wearing
the shape of a clean bill of health; refusals are now counted. And
driver-failed could be non-zero on a device that cannot produce driver
verdicts, because a degraded timeline read looked the same as one; the
attribution is now withheld inside the counter rather than at call
sites, so the self-contradictory line is unrepresentable.

Local recovery also no longer trusts any recovery-point SEI: only one
whose target advances past an outstanding wave counts as a new wave, so
an encoder re-announcing the current wave with a decreasing count — legal,
and what x264 intra-refresh does — cannot lift the freeze early onto a
partially stale picture. Frames buffered across an arm are dropped by
decode order for the same reason.

Fault injection is a first-class tool now (PUNKTFUNK_AU_FAULT, inert
unless set, env read once). Its test replays the vendored vectors
through the real planners and asserts a negative the plan assumed away:
truncation and bit flips are PROVABLY invisible to the parser — Annex-B
carries no NALU length, so a cut slice is just a shorter slice and a
flipped payload byte is syntactically perfect. Only dropped AUs are
parser-detectable; the rest need the driver verdict, which is why the
status query matters. The H.265 leg found a second: three of that
vector's faulted AUs are sub-layer non-reference pictures, so dropping
them damages nothing and silence is correct — the test asserts both
verdicts and guards that neither half goes vacuous.

Per-frame decode latency was deliberately NOT built. Polling answers
only 'complete by now', and the pump polls once per AU, so every sample
would quantise up by as much as a frame interval — 8.3 ms at 120 Hz
against decodes of 0.1-2 ms. Sampling faster needs a spin or a second
thread on a decoder that is deliberately not Sync. A blocking per-frame
wait is the field scar that once capped a stream at 51 fps. The honest
sampled stat stands.

Also fixed, pre-existing: the re-anchor gate re-armed on every damaged
AU, so sustained damage permanently zeroed the mark count — meaning the
wire's two-mark rule could never complete on exactly the lossy links it
was written for.

Field note recorded while wiring this: intra_refresh_recovery is set by
exactly one encoder backend (Linux libav-NVENC under
PUNKTFUNK_INTRA_REFRESH). AMF and QSV run a wave with no wire mark, and
AMF emits no recovery-point SEI either, so AMD/Windows intra-refresh
sessions still have no clean recovery point by either route.

Gates: fmt clean; container clippy -D warnings zero across
pf-client-core + pf-presenter + pf-vkdecode + punktfunk-core; tests
69/131/129/354/41 plus 5 fault-detection green; cargo check --workspace
clean.
2026-08-06 04:30:40 +02:00

448 lines
19 KiB
Rust

//! Fault injection proves detection — M4's exit criterion, on the CPU.
//!
//! The native-decode program exists because a field corruption was
//! ARCHITECTURALLY undetectable: FFmpeg's Vulkan decoder creates no status queries,
//! never sets `AV_FRAME_FLAG_CORRUPT`, and reports trouble only as log lines. The
//! native lane now has the signals. This harness fires them on purpose, so
//! "detection works" is a test result rather than a belief.
//!
//! It drives the REAL detection path minus the GPU: [`pf_vkdecode::AuFault`] — the
//! very injector the client's `PUNKTFUNK_AU_FAULT` knob arms — damages a real
//! 250-AU host-shaped stream, the damaged AUs go through the planner exactly as
//! `VkH264Decoder::decode` / `VkH265Decoder::decode` feed it, and the verdict is
//! read with [`pf_vkdecode::is_integrity_warning`] /
//! [`pf_vkdecode::is_integrity_warning_h265`] — the same predicates the client
//! conceals on. Nothing is mocked; what is absent is only the Vulkan submission
//! below the planner, which cannot change a plan's warnings.
//!
//! **Both codecs**, because they detect a lost AU through genuinely different
//! predicates and a harness covering one proves nothing about the other. H.264 has
//! a `frame_num` gap — an explicit, cheap counter break that fires whether or not
//! the missing picture is ever referenced. HEVC has no analogue at all (POC is
//! coded per picture and simply jumps), so its entire drop detection rests on the
//! RPS resolving a reference the DPB does not hold: `MissingReference` or nothing.
//! HEVC ships in this milestone, so it is tested here on the same terms.
//!
//! What it therefore proves, and — just as deliberately — what it proves is
//! IMPOSSIBLE here:
//!
//! * **Proved**: a dropped AU is DETECTED as *integrity* damage within a bounded
//! number of AUs, on both codecs — the class that makes the client release the
//! picture unshown and ask for a re-anchor. That is M4's exit criterion for the
//! parser-visible half.
//! * **Proved**: the clean stream produces NO integrity warning anywhere, so the
//! detector cannot be passing by firing on everything (the failure mode that
//! would cost a keyframe round trip per second on healthy links).
//! * **Proved impossible**: truncation and payload flips are INVISIBLE to the
//! parser. Annex-B carries no NALU length, so a cut slice is just a shorter
//! slice, and a flipped payload byte is syntactically perfect. Both decode to a
//! wrong picture with nothing in the bitstream to object to. The only detector
//! left is the driver's per-op `RESULT_STATUS` verdict, which needs real hardware
//! — the GPU smoke/parity tests' ground. These assertions are the negative space
//! that justifies the status ring existing at all, and the reason a session on a
//! driver without `queryResultStatusSupport` must be reported as unmeasured
//! rather than clean.
//! * **Proved impossible, and correctly so**: a dropped SUB-LAYER NON-REFERENCE
//! picture is invisible to the planner on either codec, because nothing ever
//! references it. That is not a hole in detection — no later picture is damaged
//! — it is a missing OUTPUT frame, which is the wire's frame-index gap detector's
//! job (`punktfunk_core::reanchor::index_gap`), not the bitstream's. The HEVC
//! vector below contains both kinds and the test asserts both verdicts, so the
//! distinction cannot quietly become "HEVC misses drops".
use pf_bitstream::h264::H264Planner;
use pf_bitstream::h265::H265Planner;
use pf_vkdecode::{
is_integrity_warning, is_integrity_warning_h265, AuFault, FaultAction, FaultMode,
};
use std::io::Cursor;
/// The same vendored vectors the WP-A conversion tests and the GPU tests use: 250
/// AUs of real encoder output each, an IDR then P-frames — the punktfunk
/// envelope's shape, in both codecs.
const TEST_25FPS_H264: &[u8] = include_bytes!(
"../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264"
);
const TEST_25FPS_H265: &[u8] = include_bytes!(
"../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265"
);
/// Test-only H.264 AU splitter, mirroring pf-bitstream's (`#[cfg(test)]`-private
/// there) and the GPU tests'.
fn split_h264(stream: &[u8]) -> Vec<&[u8]> {
use cros_codecs::codec::h264::parser::{Nalu, NaluType};
let mut aus = Vec::new();
let mut cursor = Cursor::new(stream);
let mut au_start = 0usize;
let mut au_has_slice = false;
while let Ok(nalu) = Nalu::next(&mut cursor) {
let nalu_offset = cursor.position() as usize;
let start = nalu_offset - nalu.offset;
let is_slice = matches!(nalu.header.type_, NaluType::Slice | NaluType::SliceIdr);
let first_mb_zero = is_slice && stream.get(nalu_offset + 1).is_some_and(|b| b & 0x80 != 0);
if au_has_slice && (!is_slice || first_mb_zero) {
aus.push(&stream[au_start..start]);
au_start = start;
au_has_slice = false;
}
au_has_slice |= is_slice;
}
aus.push(&stream[au_start..]);
aus
}
/// The H.265 twin, mirroring `pic_h265`'s test splitter: a new AU starts at a
/// non-VCL NALU following slices, or at a slice segment whose
/// `first_slice_segment_in_pic_flag` is set (the first bit of the byte after the
/// 2-byte NAL header) when the current AU already has slices.
fn split_h265(stream: &[u8]) -> Vec<&[u8]> {
use cros_codecs::codec::h265::parser::Nalu;
let mut aus = Vec::new();
let mut cursor = Cursor::new(stream);
let mut au_start = 0usize;
let mut au_has_slice = false;
while let Ok(nalu) = Nalu::next(&mut cursor) {
let header_start = cursor.position() as usize;
let start = header_start - nalu.offset;
let is_slice = (nalu.header.type_ as u32) < 32;
let first_slice_flag =
is_slice && stream.get(header_start + 2).is_some_and(|b| b & 0x80 != 0);
if au_has_slice && (!is_slice || first_slice_flag) {
aus.push(&stream[au_start..start]);
au_start = start;
au_has_slice = false;
}
au_has_slice |= is_slice;
}
aus.push(&stream[au_start..]);
aus
}
/// What the client would do with each AU, condensed to the one bit that matters:
/// did THIS AU make the client conceal and ask for recovery?
///
/// Mirrors `video_vk_native::NativeVulkanDecoder::decode`'s CPU half exactly — a
/// plan that fails outright is trouble, and a plan whose warnings include an
/// integrity warning is damage. (The third source, a driver `Failed` verdict on a
/// prior frame, has no CPU analogue and is the GPU tests' business.)
fn damaged_h264(planner: &mut H264Planner, au: &[u8]) -> bool {
match planner.plan_au(au) {
Ok(plan) => plan.warnings.iter().any(is_integrity_warning),
// A refusal is the loudest possible detection: the client turns it into an
// Err, releases nothing, and asks. Counting it as detected is what the
// client does.
Err(_) => true,
}
}
/// The H.265 twin — with the one arm that is NOT damage.
///
/// `RaslSkipped` is the spec's own answer (8.1.3 NOTE) for a leading picture whose
/// references precede an open-GOP join: `VkH265Decoder::decode` turns it into
/// `Ok(None)` and clears the warning ledger, so the client neither drops a frame
/// nor asks for anything. Counting it here would let this harness "detect" a fault
/// through a path production treats as a non-event.
fn damaged_h265(planner: &mut H265Planner, au: &[u8]) -> bool {
use pf_bitstream::h265::PlanError;
match planner.plan_au(au) {
Ok(plan) => plan.warnings.iter().any(is_integrity_warning_h265),
Err(PlanError::RaslSkipped { .. }) => false,
Err(_) => true,
}
}
/// Replay a vector with `fault` armed, returning for each AU index whether the
/// client would have flagged damage — plus how many AUs were actually faulted, so
/// a test can prove the injector fired at all rather than passing vacuously.
///
/// Generic over the codec through the split + verdict pair, so both legs replay
/// through ONE driver: the injector, the AU cadence and the "a dropped AU has no
/// verdict of its own" rule are codec-independent, and forking them per codec is
/// how the two legs would quietly stop testing the same thing.
fn replay<'a>(
stream: &'a [u8],
split: fn(&'a [u8]) -> Vec<&'a [u8]>,
mut verdict: impl FnMut(&[u8]) -> bool,
fault: Option<AuFault>,
) -> (Vec<bool>, usize) {
let aus = split(stream);
let mut fault = fault;
let mut flags = Vec::with_capacity(aus.len());
let mut faulted = 0usize;
for au in aus {
let action = match &mut fault {
Some(f) => f.apply(au),
None => FaultAction::Pass,
};
match action {
FaultAction::Pass => flags.push(verdict(au)),
FaultAction::Drop => {
faulted += 1;
// The AU never reaches the decoder — exactly what the client does
// for a dropped AU, and exactly what the network does for a lost
// one. No verdict for this index.
flags.push(false);
}
FaultAction::Corrupt(bytes) => {
faulted += 1;
flags.push(verdict(&bytes));
}
}
}
(flags, faulted)
}
fn replay_h264(fault: Option<AuFault>) -> (Vec<bool>, usize) {
let mut planner = H264Planner::new();
replay(
TEST_25FPS_H264,
split_h264,
move |au| damaged_h264(&mut planner, au),
fault,
)
}
fn replay_h265(fault: Option<AuFault>) -> (Vec<bool>, usize) {
let mut planner = H265Planner::new();
replay(
TEST_25FPS_H265,
split_h265,
move |au| damaged_h265(&mut planner, au),
fault,
)
}
/// How many AUs after the fault detection is allowed to take on H.264. ONE: a
/// dropped reference is visible to the planner on the very next AU that references
/// it. The bound is stated as a number rather than "eventually" because
/// "eventually" is what the 500 ms freeze backstop already provides — the whole
/// point of local detection is that it is immediate.
const DETECT_WITHIN: usize = 1;
/// The fault period the tests replay at: five faults over the 250-AU vector, far
/// enough apart that each one's detection is unambiguously about ITS fault.
const PERIOD: u32 = 50;
/// The 0-based indices [`AuFault`] faults at `PERIOD`, over `total` AUs. The
/// injector counts the AUs it is OFFERED, 1-based, so the first fault lands on
/// index `PERIOD - 1` — which is also why any period above 1 leaves a session's
/// opening parameter sets and IDR untouched.
fn fault_indices(total: usize) -> Vec<usize> {
(PERIOD as usize - 1..total)
.step_by(PERIOD as usize)
.collect()
}
/// Every faulted AU is followed within [`DETECT_WITHIN`] AUs by a damage verdict.
/// Returns how many faults actually had a successor window to check, so the caller
/// can refuse a vacuous pass.
fn assert_detected_after_each_fault(flags: &[bool], what: &str) -> usize {
let mut checked = 0usize;
for dropped in fault_indices(flags.len()) {
// The last AU of the vector has no successor to detect on — the stream
// simply ends there. Skipping it is honest; asserting on it would be a
// statement about the fixture's length, not about detection.
let Some(window) = flags.get(dropped + 1..(dropped + 1 + DETECT_WITHIN).min(flags.len()))
else {
continue;
};
if window.is_empty() {
continue;
}
checked += 1;
assert!(
window.iter().any(|&d| d),
"{what}: the AU(s) after dropped AU {dropped} must read as damaged — \
the reference it needs was never decoded"
);
}
checked
}
/// The indices that read as damaged — the message a failing assertion needs.
fn flagged(flags: &[bool]) -> Vec<usize> {
flags
.iter()
.enumerate()
.filter(|(_, &d)| d)
.map(|(i, _)| i)
.collect()
}
/// The control: a healthy stream must produce NO damage verdict anywhere, on
/// either codec. Without this the fault tests below prove nothing — a detector
/// that fires on every AU would pass them and cost a keyframe round trip per
/// second in the field.
#[test]
fn a_clean_stream_never_reads_as_damaged() {
for (codec, (flags, faulted)) in [("h264", replay_h264(None)), ("h265", replay_h265(None))] {
assert_eq!(faulted, 0, "{codec}: no fault armed");
assert_eq!(flags.len(), 250, "{codec}: the whole vector replayed");
assert!(
flagged(&flags).is_empty(),
"{codec}: the clean vector must plan without a single integrity \
warning — flagged AUs: {:?}",
flagged(&flags)
);
}
}
/// A DROPPED access unit — the everyday network-loss shape — is detected on the
/// next AU, because that AU references a picture the DPB never received. This is
/// the exit criterion's first half: deliberately corrupted input, detection within
/// a bounded number of frames.
#[test]
fn a_dropped_access_unit_is_detected_on_the_very_next_one() {
let (flags, faulted) = replay_h264(Some(AuFault::new(FaultMode::Drop, PERIOD)));
assert!(faulted >= 4, "the injector fired ({faulted} AUs dropped)");
let checked = assert_detected_after_each_fault(&flags, "h264");
assert!(
checked >= 4,
"{checked} drops actually had a successor to check"
);
}
/// The H.265 leg of the same criterion, and the reason it is a separate test
/// rather than a loop over both codecs: HEVC detects a dropped AU through a
/// DIFFERENT predicate, and it has a class of AU whose loss is legitimately
/// invisible.
///
/// There is no `frame_num` gap to notice — POC is coded per picture and a jump in
/// it is legal — so everything rests on the RPS asking for a picture the DPB does
/// not hold (`MissingReference`). If that ever stopped firing, HEVC would lose
/// drop detection entirely while the H.264 leg above stayed green.
///
/// And the RPS can only speak for pictures something REFERENCES. A sub-layer
/// non-reference picture (`TRAIL_N` and friends — 3 of the 5 faults this vector
/// takes) is referenced by nothing, so its loss damages no later picture and the
/// planner is right to stay silent: it is a missing output frame, caught by the
/// wire's frame-index gap, not by the bitstream. Asserting BOTH verdicts is what
/// stops that correct silence from being mistaken for a detection hole — or a
/// detection hole from hiding behind it.
#[test]
fn a_dropped_hevc_reference_picture_is_detected_through_the_rps() {
// Which AUs carry a picture something can reference? Read off a CLEAN replay,
// so the classification is the stream's own and not this test's guess.
let mut planner = H265Planner::new();
let referenced: Vec<bool> = split_h265(TEST_25FPS_H265)
.into_iter()
.map(|au| {
planner
.plan_au(au)
.map(|plan| !plan.picture.nalu_type.is_slnr())
.unwrap_or(false)
})
.collect();
let (flags, faulted) = replay_h265(Some(AuFault::new(FaultMode::Drop, PERIOD)));
assert!(faulted >= 4, "the injector fired ({faulted} AUs dropped)");
let (mut checked_refs, mut checked_slnr) = (0usize, 0usize);
for dropped in fault_indices(flags.len()) {
let Some(window) = flags.get(dropped + 1..(dropped + 1 + DETECT_WITHIN).min(flags.len()))
else {
continue;
};
if window.is_empty() {
continue;
}
if referenced[dropped] {
checked_refs += 1;
assert!(
window.iter().any(|&d| d),
"h265: the AU after dropped REFERENCE picture {dropped} must read \
as damaged — its RPS names a picture the DPB never received"
);
} else {
checked_slnr += 1;
assert!(
!window.iter().any(|&d| d),
"h265: dropping sub-layer non-reference picture {dropped} damages \
nothing — if the planner starts complaining here it is reporting \
damage that did not happen, and every such report costs a frame \
and a keyframe round trip"
);
}
}
assert!(
checked_refs >= 2 && checked_slnr >= 2,
"the vector must exercise BOTH classes ({checked_refs} reference drops, \
{checked_slnr} non-reference drops) or this test proves only half of what \
it claims"
);
}
/// A TRUNCATED access unit is NOT parser-visible, and the assertion is that it
/// stays that way — on both codecs.
///
/// Annex-B has no NALU length field: a slice cut at a byte boundary is
/// indistinguishable from a shorter slice. Its header parses, the picture plans,
/// it enters the DPB, and every later AU resolves its reference against an entry
/// that exists — so nothing in the syntax is ever wrong. (pf-bitstream's
/// `TruncatedAu` warning is a narrower thing entirely: a NALU whose HEADER is
/// malformed with real data still behind it.) The hardware, meanwhile, is handed a
/// slice whose bitstream ends mid-picture, which is a decode error it can report —
/// so this mode is how a lab run fires the driver's `RESULT_STATUS` detector
/// deterministically, and it is useless without one.
#[test]
fn a_truncated_access_unit_is_invisible_to_the_parser_and_needs_the_driver_verdict() {
for (codec, (flags, faulted)) in [
(
"h264",
replay_h264(Some(AuFault::new(FaultMode::Truncate, PERIOD))),
),
(
"h265",
replay_h265(Some(AuFault::new(FaultMode::Truncate, PERIOD))),
),
] {
assert!(
faulted >= 4,
"{codec}: the injector fired ({faulted} AUs truncated)"
);
assert!(
flagged(&flags).is_empty(),
"{codec}: a mid-slice cut carries no syntax error — if this starts \
firing, the cut is landing on a NALU header and the mode has stopped \
exercising the driver-only path it exists for (flagged AUs: {:?})",
flagged(&flags)
);
}
}
/// The mode that shows what the DRIVER's status query is for: a byte flipped deep
/// in a slice payload leaves a bitstream that parses perfectly, resolves every
/// reference, and decodes to a wrong picture. The planner is silent — as it should
/// be, since nothing about the syntax is wrong — and on the FFmpeg rungs that
/// silence is the end of the story (`nb_queries = 0`, no `AV_FRAME_FLAG_CORRUPT`).
/// This is the Xbox Ally X class exactly, on both codecs.
#[test]
fn a_payload_bit_flip_is_invisible_to_the_parser_which_is_why_the_status_query_exists() {
for (codec, (flags, faulted)) in [
(
"h264",
replay_h264(Some(AuFault::new(FaultMode::Flip, PERIOD))),
),
(
"h265",
replay_h265(Some(AuFault::new(FaultMode::Flip, PERIOD))),
),
] {
assert!(
faulted >= 4,
"{codec}: the injector fired ({faulted} AUs flipped)"
);
assert!(
flagged(&flags).is_empty(),
"{codec}: a payload flip must not be parser-visible — if this ever \
starts firing, the flip is landing in syntax rather than payload and \
the mode has stopped testing what it claims (flagged AUs: {:?})",
flagged(&flags)
);
}
}