Files
punktfunk/crates/pf-vkdecode/tests/fault_detection.rs
T
enricobuehler f373dffb5e chore: migrate the main workspace and pf-vkhdr-layer to edition 2024 (WP20)
The safety half of the rust-safety programme's §8.4: `std::env::set_var`/`remove_var` are
`unsafe fn` in edition 2024, converting the class of bug the programme found the hard way
(the 972af299 environ data race lived in a file with ZERO occurrences of the word
`unsafe`) from invisible to counted and compiler-enforced.

Manifests: [workspace.package] edition 2021→2024, rust-version 1.82→1.85 (the pinned
toolchain is 1.96.0, so no toolchain bump — only the declared floor rises); the 13 crates
pinning `edition = "2021"` literally now inherit it (Trap 1: the root bump alone reaches
only `edition.workspace = true` crates and would have left pf-encode/pf-capture/pf-inject
et al. on 2021 while reading as complete); pf-driver-proto's stale rust-version 1.82 pin
now inherits; pf-vkhdr-layer (a separate workspace, inherits nothing) bumped to 2024. The
four vendored crates (fec-rs, cros-codecs, usbip-sim, the patched ndk) stay on 2021
deliberately — upstream code stays pristine. The excluded usbip-poc standalone PoC is
untouched.

Mechanical, done textually across ALL cfg branches so no platform's half is left behind
(Trap 3 — 44% of the host's unsafe is Windows-only and a one-platform `cargo fix` misses
it): 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 in abi.rs); 12 bare extern blocks →
`unsafe extern`; `gen` is a reserved keyword, so pf-vdisplay's generation stamps
(registry.rs, windows/manager.rs) and the WinUI shell's animation counters rename
gen → generation (internal identifiers only, no serde/wire surface); two
match-ergonomics patterns take the compiler's suggested reference form.

env mutation: every `set_var`/`remove_var` site (20 files) now sits in an `unsafe` block
whose SAFETY comment states the real serialization argument (pf-vdisplay's ENV_LOCK,
CONFIG_DIR_TEST_LOCK, ART_ROOTS_LOCK, vkdecode's gpu_lock, the `--test-threads=1`
contracts of the hardware spikes, or single-threaded startup). Two genuine hazards
surfaced en route — exactly the WP3b-class finds this migration exists to make visible —
and are fixed here:
- windows/service.rs spawned the network-profile warner thread BEFORE `load_host_env()`,
  so a child-spawning thread (child spawn snapshots the env block) was live while
  `set_var` ran in a loop; the load now precedes the spawn.
- pf-console-ui's `fake_home()` re-set HOME outside its OnceLock on EVERY call, so two
  parallel tests could race the write; the set now happens exactly once inside
  `get_or_init`.

cbindgen (Trap 2): 0.29.4 parses `#[unsafe(no_mangle)]` — verified empirically; the
header regenerates byte-identical. The ci.yml drift check could never catch "failed to
regenerate" (build.rs demotes a cbindgen failure to a warning and writes nothing, leaving
the checked-in header untouched and the diff clean), so the step now first asserts the
"punktfunk-core: wrote" line and the absence of "cbindgen failed" (sh -e safe: no `!`
pipeline, no tee-masked exit).

rustfmt: style_edition pinned to 2021 at the root — edition 2024 would otherwise flip the
style edition and reformat ~370 untouched files inside this same commit, burying the
migration diff. The drivers workspace pins its already-current 2024 style. Adopting the
2024 style tree-wide is its own future one-line-plus-reformat commit.

Census: the primary metric moves UP BY DESIGN — 2435 → 2453 operations, unsafe blocks
1534 → 1577, and env_set_var is now a counted category (45 ops). The newly counted env
sites are a truer number, not a regression; baseline snapshot saved as punktfunk-planning
design/rust-safety-census-baseline-2026-08-12-edition-2024.txt. Gate C's env ratchet is
now compiler-enforced (the hygiene-script header says so); the two shrunk file counts
(nvenc_cuda 49→2 via the test helpers, shell/tests 2→1) are lowered in the same commit
per the gate's own rule.

Drop order (the semantic change most likely to bite this codebase): the migration lint
`-W tail-expr-drop-order` reports zero findings on the macOS-visible halves of
pf-encode / pf-zerocopy / pf-capture / pf-frame; the Linux and Windows halves run the
same lint on the gate boxes. The four #[ignore]d alloc/drop-cycle tests on the hardware
boxes remain owed, as before this change.
2026-08-12 16:12:35 +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)
);
}
}