A damaged AV1 frame stops killing the whole client (and a BOM stops erasing every setting) #97

Merged
enricobuehler merged 3 commits from worktree-rav1d-single-frame-context-abort into main 2026-08-07 16:20:58 +00:00
4 changed files with 344 additions and 52 deletions
+1 -3
View File
@@ -402,9 +402,7 @@ impl ProfilesFile {
/// never an error: nothing about streaming may hinge on this file existing.
pub fn load() -> ProfilesFile {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.map(|p| crate::trust::load_json_or_default(&p))
.unwrap_or_default()
}
+104 -6
View File
@@ -17,6 +17,60 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Read one of this client's JSON config files, or its `Default` — tolerating a byte order
/// mark, and SAYING SO when the file is there but will not parse.
///
/// Both halves are the same bug seen twice.
///
/// **The BOM.** PowerShell's `Set-Content -Encoding UTF8` writes a UTF-8 BOM, and every
/// Windows how-to reaches for it, so `%APPDATA%\punktfunk\client-windows-settings.json`
/// edited from a shell arrives with `EF BB BF` in front of the `{`. `serde_json` rejects
/// that at byte 0 — correctly, JSON has no BOM — and the old
/// `.and_then(|s| from_str(&s).ok())` then turned the whole file into `Default`. Cost an
/// hour on 2026-08-07: a `codec: "av1"` edit was ignored and the client negotiated HEVC,
/// with the file plainly right on screen. So the mark is stripped, which is what every
/// other JSON consumer on Windows does.
///
/// **The silence.** The `.ok()` that hid the BOM hides everything else too: a trailing
/// comma, a truncated write, a hand-edit with a typo. Every one of them presents as "the
/// app forgot all my settings", with nothing anywhere to say why. A parse failure now costs
/// one `warn!` naming the file and serde's own line/column. The RESULT is unchanged —
/// `Default`, never an error — because nothing about streaming may hinge on this file
/// being readable, and refusing to start because a settings file is malformed would be a
/// worse failure than the one being fixed.
///
/// A missing file is not a parse failure and stays silent: that is just first run. Every
/// OTHER read failure is reported, which is not pedantry — `Set-Content -Encoding Unicode`
/// writes UTF-16LE, `read_to_string` rejects it as invalid UTF-8, and that lands in exactly
/// the same "the app forgot my settings, and said nothing" hole the BOM did.
pub(crate) fn load_json_or_default<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return T::default(),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"config file could not be read — every setting in it is being IGNORED \
(a UTF-16 file reads as invalid UTF-8 here; re-save it as UTF-8)"
);
return T::default();
}
};
match serde_json::from_str(raw.strip_prefix('\u{feff}').unwrap_or(&raw)) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"config file did not parse — falling back to defaults for it, and the \
settings in it are being IGNORED (fix or delete the file)"
);
T::default()
}
}
}
pub fn config_dir() -> Result<PathBuf> {
#[cfg(windows)]
{
@@ -358,9 +412,7 @@ impl KnownHosts {
/// read cannot take part in that.
pub fn read() -> KnownHosts {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.map(|p| load_json_or_default(&p))
.unwrap_or_default()
}
@@ -1355,9 +1407,7 @@ impl Settings {
pub fn load() -> Settings {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.map(|p| load_json_or_default(&p))
.unwrap_or_default()
}
@@ -1446,6 +1496,54 @@ mod tests {
std::iter::repeat_n(c, 64).collect()
}
/// **A byte order mark must not silently erase every setting in the file.**
///
/// PowerShell's `Set-Content -Encoding UTF8` writes one, so this is what a settings
/// file edited from a Windows shell actually looks like on disk. `serde_json` refuses
/// `EF BB BF` at byte 0, and the loader used to swallow that refusal and return
/// `Default` — which on 2026-08-07 cost an hour: a `codec: "av1"` edit was ignored and
/// the client negotiated HEVC, with the correct file open on screen. Nothing was
/// logged, because there was nothing in the code to log it.
///
/// Asserts the three cases together, because the middle one is the whole point: a BOM
/// must LOAD, not merely fail loudly.
#[test]
fn a_bom_does_not_turn_a_settings_file_into_defaults() {
let dir = std::env::temp_dir().join(format!(
"pf-client-core-bom-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
let body = r#"{"codec":"av1","bitrate_kbps":42000}"#;
let plain = dir.join("plain.json");
std::fs::write(&plain, body).unwrap();
let s: Settings = load_json_or_default(&plain);
assert_eq!(s.codec, "av1");
assert_eq!(s.bitrate_kbps, 42000);
// The same bytes with a UTF-8 BOM in front must load identically.
let bom = dir.join("bom.json");
std::fs::write(&bom, format!("\u{feff}{body}")).unwrap();
let s: Settings = load_json_or_default(&bom);
assert_eq!(s.codec, "av1", "a BOM must not discard the settings file");
assert_eq!(s.bitrate_kbps, 42000);
// Genuinely broken JSON still falls back to defaults (never an error — nothing
// about streaming may hinge on this file), and a missing file is not a failure
// at all, it is first run.
let broken = dir.join("broken.json");
std::fs::write(&broken, r#"{"codec":"av1",}"#).unwrap();
let d: Settings = load_json_or_default(&broken);
assert_eq!(d.codec, Settings::default().codec);
let gone: Settings = load_json_or_default(&dir.join("nope.json"));
assert_eq!(gone.codec, Settings::default().codec);
let _ = std::fs::remove_dir_all(&dir);
}
/// A settings file predating the touch-input model loads as `trackpad` (the shipped
/// default), and the name round-trips through the enum both ways.
#[test]
+20 -10
View File
@@ -51,7 +51,7 @@
//! | native D3D11VA | [`crate::video_d3d11_native`] | H.264, H.265 | **yes** — frame-hash parity on an RTX 4090 and an AMD iGPU + a 30-minute soak (M5) |
//! | native D3D11VA | | AV1 | **not proven** — it HAS now decoded (4K60, RTX 3500 Ada, 2026-08-07), but with no parity check and no soak it stays out of the admission filter. Its M7 wiring was right all along: what looked like a DXVA reference-mapping bug (`reference picture N holds no DPB slot`, 72 consecutive failures) was the HOST shipping half of every AV1 frame — see `pf_encode`'s `resolve_split_subframe` |
//! | native VAAPI | [`crate::video_vaapi_native`] | H.264, H.265, AV1 | **NO** — has never decoded a frame anywhere (M6/M7; no VAAPI hardware was reachable) |
//! | software | `video_software` | H.264, AV1 | **NO** — openh264 has never run on glass; rav1d decodes 1080p AV1 there but **aborts the process** on 4K (rav1d 1.1.0 panics inside its own error handler, across an `extern "C"` boundary, so nothing can catch it) |
//! | software | `video_software` | H.264, AV1 | **not proven** — openh264 has never run on glass; rav1d HAS now decoded 1080p and 4K60 AV1 there (2026-08-07, .21) and recovers in-session from a mid-stream reference loss, but with no parity check and no soak. Its 4K "abort" was never about 4K: rav1d 1.1.0 kills the process on ANY decode error while it holds a single frame context, so `video_software` opens it with two — see [`crate::video_software`] |
//!
//! The software rung's evidence is recorded for the same reason but does not gate
//! anything: it is the LAST rung, so there is nothing below it to protect.
@@ -1124,16 +1124,22 @@ pub fn native_evidence(rung: NativeRung, wire: u8) -> RungEvidence {
false,
"NEVER decoded a frame on any hardware - no VAAPI device was reachable (M6/M7)",
),
// ⚠ rav1d 1.1.0 ABORTS THE PROCESS on 4K AV1 (2026-08-07, .21): it takes an internal
// error path and then panics inside its own `on_error` (`decode.rs:4997`,
// `Option::unwrap()` on a `None` frame header). The panic crosses the `extern "C"`
// boundary in `dav1d_send_data`, so it is `panic_cannot_unwind` — an abort, which no
// rung demotion or `NoSoftwareRung` refusal can catch. 1080p AV1 decodes fine, and
// libdav1d decodes the same 4K stream 715/715, so this is rav1d's own defect.
// The 4K AV1 abort recorded here on 2026-08-07 is FIXED, and it was never about 4K.
// rav1d 1.1.0 aborts the process on ANY decode error while it holds a single frame
// context: `rav1d_decode_frame_exit` takes `f.frame_hdr`, and the error path then
// re-enters an `on_error` that unwraps it (`decode.rs:4997`), which crosses
// `dav1d_send_data`'s `extern "C"` frame as `panic_cannot_unwind`. 4K was only where
// an error first happened: the CPU rung cannot keep up at 3840x2160, the pump
// flushed its backlog and jumped to live, and the next AU referenced frames nobody
// had decoded. `video_software.rs` now opens rav1d with two frame contexts, which
// takes that `on_error` out of reach; the same damage now surfaces as the `EINVAL`
// the pump answers with a keyframe request. Still NOT `verified`: 4K60 AV1 ran to a
// clean exit on .21 and recovered from the damage in-session, but openh264 has
// still never run on glass and neither leg has a soak or a parity check.
(NativeRung::Software, CODEC_H264 | CODEC_AV1) => (
false,
"openh264 has never run on glass; rav1d decodes 1080p AV1 there but ABORTS the \
process on 4K (rav1d 1.1.0 panics in its own error handler) (M8)",
"openh264 has never run on glass; rav1d decodes 1080p and 4K60 AV1 there and \
survives a mid-stream reference loss, but has no parity check or soak (M8)",
),
_ => (false, "no hardware run recorded for this rung and codec"),
};
@@ -3221,7 +3227,11 @@ mod tests {
CODEC_H264,
"openh264 never ran on glass",
),
(NativeRung::Software, CODEC_AV1, "rav1d never ran on glass"),
(
NativeRung::Software,
CODEC_AV1,
"rav1d has decoded on glass but has no parity check and no soak",
),
] {
assert!(
!native_evidence(rung, codec).verified,
+219 -33
View File
@@ -42,12 +42,9 @@
//!
//! Threading: openh264's `num_threads` is documented upstream as "will probably just
//! segfault", so this stays single-threaded — the old libavcodec rung's slice threading has
//! no equivalent here. rav1d gets the machine's cores: `max_frame_delay = 1` is the knob
//! that removes frame delay (dav1d's `get_num_threads` then computes `n_fc = min(1, n_tc)`,
//! so exactly one frame is ever in flight whatever `n_threads` says), and `n_threads`
//! drives the INTRA-frame tile/row workers, which cost no latency at all. Pinning it to 1
//! bought nothing and gave the rung reached only because the GPU already failed a single
//! core to decode 4K with.
//! no equivalent here. rav1d gets the machine's cores, and **at least two frame contexts**;
//! [`Av1Software::new`] carries the whole argument, because "at least two" is not a
//! performance choice but the difference between an error and `abort()`.
use crate::video::{CpuPlanarFrame, RungLoss};
use crate::video_color::ColorDesc;
@@ -385,11 +382,20 @@ use rav1d::include::dav1d::dav1d::{Dav1dContext, Dav1dSettings};
use rav1d::include::dav1d::headers::{Dav1dSequenceHeader, DAV1D_PIXEL_LAYOUT_I420};
use rav1d::include::dav1d::picture::Dav1dPicture;
use rav1d::src::lib::{
dav1d_close, dav1d_data_create, dav1d_data_unref, dav1d_default_settings, dav1d_get_picture,
dav1d_open, dav1d_parse_sequence_header, dav1d_picture_unref, dav1d_send_data,
dav1d_close, dav1d_data_create, dav1d_data_unref, dav1d_default_settings,
dav1d_get_frame_delay, dav1d_get_picture, dav1d_open, dav1d_parse_sequence_header,
dav1d_picture_unref, dav1d_send_data,
};
use std::ptr::NonNull;
/// Frame contexts rav1d must end up with — see [`Av1Software::new`] for why ONE is fatal.
///
/// rav1d derives its count as `n_fc = min(max_frame_delay, n_threads)` (`get_num_threads`),
/// so this is a floor on BOTH settings, not just on the delay. Two, not more: every extra
/// context is another 4K working set, and the drain in [`Av1Software::decode`] takes the
/// frame back out in the same call, so there is nothing to buy above the minimum.
const AV1_MIN_FRAME_CONTEXTS: i32 = 2;
struct Av1Software {
/// `None` only between `Drop` taking it and the close returning — every other
/// observer sees a live context.
@@ -438,29 +444,27 @@ unsafe impl Send for Av1Software {}
impl Av1Software {
fn new() -> Result<Av1Software> {
let mut settings = std::mem::MaybeUninit::<Dav1dSettings>::uninit();
// SAFETY: `dav1d_default_settings` fully initializes the `Dav1dSettings` behind
// the pointer it is given; the storage is a live local that outlives the call.
let mut settings = unsafe {
dav1d_default_settings(NonNull::new_unchecked(settings.as_mut_ptr()));
settings.assume_init()
};
// No frame delay: a punktfunk stream is zero-reorder and real-time, so the
// throughput a FRAME-threaded decoder buys costs exactly the latency this client
// spends the rest of its budget defending. `max_frame_delay = 1` is the knob that
// says so — dav1d's `get_num_threads` derives `n_fc = min(max_frame_delay, n_tc)`,
// so one frame stays in flight no matter how many threads exist. Same reasoning
// as the old libavcodec rung's `FF_THREAD_SLICE` + `AV_CODEC_FLAG_LOW_DELAY`, and
// `n_threads` is that rung's SLICE half: intra-frame tile/row workers, which add
// no delay. Capped at 8 — this is the rung reached because the GPU already
// failed, and it should not also take the machine over.
settings.max_frame_delay = 1;
settings.n_threads = std::thread::available_parallelism()
.map(|n| n.get().clamp(1, 8))
.unwrap_or(1) as i32;
// Film grain synthesis is a post-process the hosts never signal and nobody can
// afford on the rung that exists because the GPU already failed.
settings.apply_grain = 0;
let mut settings = av1_settings();
// Ask rav1d itself what those settings actually bought, and refuse to open a
// decoder that would abort the process on its first bad AU.
//
// `dav1d_get_frame_delay` IS `get_num_threads`' `n_fc`, so this is not a restatement
// of [`av1_settings`]' arithmetic — it is rav1d's own answer, and it stays right if
// rav1d's derivation ever changes. It is here because the alternative failure mode
// is uniquely bad: a settings edit that quietly reinstates `n_fc = 1` costs nothing
// at build time, nothing in the tests, nothing on a clean link, and then kills the
// whole client the first time a frame arrives damaged. A refusal is a `bail!` the
// session reports and recovers from; the thing it replaces is `abort()`.
let delay = frame_delay(&mut settings);
if delay < AV1_MIN_FRAME_CONTEXTS {
bail!(
"rav1d would run with {delay} frame context(s) (n_threads={}, \
max_frame_delay={}); anything below {AV1_MIN_FRAME_CONTEXTS} takes the \
single-frame-context path, which ABORTS the process on any decode error",
settings.n_threads,
settings.max_frame_delay,
);
}
let mut ctx: Option<Dav1dContext> = None;
// SAFETY: both pointers are live locals for the duration of the call, which is
// dav1d_open's whole contract: it reads `settings` and writes the context out.
@@ -475,7 +479,79 @@ impl Av1Software {
}
Ok(Av1Software { ctx })
}
}
/// The settings the AV1 CPU leg opens rav1d with. Its own function so the invariant the
/// rung's life depends on — `n_fc >= AV1_MIN_FRAME_CONTEXTS` — is testable without opening
/// a decoder.
fn av1_settings() -> Dav1dSettings {
let mut settings = std::mem::MaybeUninit::<Dav1dSettings>::uninit();
// SAFETY: `dav1d_default_settings` fully initializes the `Dav1dSettings` behind
// the pointer it is given; the storage is a live local that outlives the call.
let mut settings = unsafe {
dav1d_default_settings(NonNull::new_unchecked(settings.as_mut_ptr()));
settings.assume_init()
};
// ⚠⚠⚠ TWO frame contexts, not one, and this is a CORRECTNESS setting.
//
// rav1d 1.1.0 (and upstream `main` as of 2026-08-07) aborts the process on ANY decode
// error whenever it is configured with a single frame context. The path, read out of
// `rav1d/src/decode.rs`:
//
// * `rav1d_submit_frame`'s `c.fc.len() == 1` branch calls `rav1d_decode_frame`
// inline, which ALWAYS finishes in `rav1d_decode_frame_exit` — and that does an
// unconditional `mem::take(&mut f.frame_hdr)` (`decode.rs:4873`).
// * If the decode returned `Err`, the same branch then re-enters the local
// `on_error`, whose first act is `f.frame_hdr.as_ref().unwrap()`
// (`decode.rs:4997`) — on the `None` the teardown above just left.
// * That panic unwinds into `dav1d_send_data`, which is `extern "C"`, so it is
// `panic_cannot_unwind` → `abort()`. No `catch_unwind` at our call site, no rung
// demotion and no `NoSoftwareRung` refusal can catch an abort.
//
// The `c.fc.len() > 1` branch never calls `rav1d_decode_frame`, so it never reaches
// that `on_error` at all: it hands the frame to `rav1d_task_frame_init` and errors come
// back through `cached_error`/`task_thread.retval` as ordinary `EINVAL`s — which the
// pump already answers with a keyframe request.
//
// Measured on .21 (2026-08-07) against a captured 4K60 AV1 stream that the client had
// itself made undecodable by flushing its backlog and jumping to live, so the next AU
// referenced frames nobody had decoded (libdav1d gives the same verdict on the same
// capture: 13 frames, then "Invalid data"):
//
// n_threads=8 max_frame_delay=1 → n_fc=1 → ABORT
// n_threads=1 max_frame_delay=1 → n_fc=1 → ABORT
// n_threads=1 max_frame_delay=2 → n_fc=1 → ABORT ← the one that proves the rule
// n_threads=8 max_frame_delay=2 → n_fc=2 → 13 pictures, EINVAL, survives
// n_threads=8 max_frame_delay=0 → n_fc=3 → survives
//
// The third row is why `n_threads` has a floor of two and not one: `n_fc` is
// `min(max_frame_delay, n_threads)`, so a single thread silently puts the whole thing
// back on the aborting path. Pinning threads to 1 was also tested as the suspected
// trigger and is NOT one — the tile workers are innocent, the single frame context is
// the whole defect.
//
// The frame of latency this would normally cost is bought back in `decode`, which
// drains the in-flight frame in the same call instead of pipelining it — see there.
//
// Reported upstream as memorysafety/rav1d#1497, with the one-line fix
// (`is_some_and` for the `unwrap`) and a reproducer that needs no capture: any AV1
// stream with one temporal unit removed from the middle. If a release ever carries
// that fix, THIS floor is still the right default — it is what makes a decoder error
// an error — but the `bail!` in `Av1Software::new` could then relax.
settings.max_frame_delay = AV1_MIN_FRAME_CONTEXTS;
// `n_threads` drives the INTRA-frame tile/row workers, which add no delay, and now also
// floors `n_fc`. Capped at 8 — this is the rung reached because the GPU already failed,
// and it should not also take the machine over.
settings.n_threads = std::thread::available_parallelism()
.map(|n| n.get().clamp(AV1_MIN_FRAME_CONTEXTS as usize, 8))
.unwrap_or(AV1_MIN_FRAME_CONTEXTS as usize) as i32;
// Film grain synthesis is a post-process the hosts never signal and nobody can
// afford on the rung that exists because the GPU already failed.
settings.apply_grain = 0;
settings
}
impl Av1Software {
fn decode(&mut self, au: &[u8], color: &mut ColorDesc) -> Result<Option<CpuPlanarFrame>> {
let ctx = self.ctx.context("rav1d context closed")?;
if au.is_empty() {
@@ -536,16 +612,52 @@ impl Av1Software {
}
bail!("rav1d send_data: {}", r.0);
}
// The AU is fully inside the decoder — stop feeding and go and drain it.
if sent && data.0.sz == 0 {
break;
}
match self.take_picture(ctx, color)? {
Some(f) => out = Some(f),
// Nothing more to take and the AU is fully consumed — done.
None if sent && data.0.sz == 0 => break,
// Nothing to take and the decoder still would not accept the rest: it
// has neither produced nor consumed, which is a wedge, not back-pressure.
None if !sent => bail!("rav1d: decoder accepted no data and produced no picture"),
None => {}
}
}
// Now drain — and drain PAST the first `EAGAIN`, which is the whole trick that
// makes two frame contexts cost no latency.
//
// `rav1d_get_picture` only reaches its blocking `drain_picture` on a call whose own
// `drain` flag is ALREADY set, and that flag is set by the PREVIOUS `get_picture`
// and cleared by every `send_data` that carried bytes. So the first `EAGAIN` after
// a send does not mean "no picture for this AU" — it means "ask again", and the
// frame this AU coded comes out of the SECOND call, which waits for the tile
// workers instead of leaving the frame in flight. Stopping at the first `None` is
// what a single-frame-context reading of dav1d's API teaches, and with `n_fc = 2`
// it silently puts the pipeline two frames behind.
//
// Measured on .21 (2026-08-07), 4K60 AV1, 14 temporal units, `n_fc = 2`:
// stopping at the first `None` → units 0 and 1 produce NOTHING, then one frame
// per unit: a standing two-frame delay
// draining past it (this loop) → one frame per unit from unit 0, and 20-42 ms
// per unit against 21-53 ms at `n_fc = 1`
// i.e. it is not a trade at all — same cadence as the aborting configuration, and
// slightly faster, because the tile workers overlap the drain.
//
// Terminating: every `Some` consumes one picture and a temporal unit codes finitely
// many, and rav1d opens each frame context `finished = true` (`lib.rs:232`), so the
// drain walk over an idle context returns rather than waiting for a frame nobody
// submitted.
let mut idle = 0;
while idle < 2 {
match self.take_picture(ctx, color)? {
Some(f) => {
out = Some(f);
idle = 0;
}
None => idle += 1,
}
}
Ok(out)
}
@@ -721,6 +833,21 @@ fn dav1d_errno(r: rav1d::Dav1dResult) -> Option<i32> {
(r.0 < 0).then_some(-r.0)
}
/// How many frame contexts (`n_fc`) rav1d will actually run with, given these settings.
///
/// rav1d's own `get_num_threads` answer rather than a copy of its arithmetic, so the floor
/// [`Av1Software::new`] enforces cannot drift away from the thing it is protecting against.
/// A negative return (settings rav1d would refuse outright) collapses to 0, which the
/// caller's floor check then rejects — the right answer for that case too.
fn frame_delay(settings: &mut Dav1dSettings) -> i32 {
// SAFETY: `settings` is a live local for the duration of the call, which is this
// function's whole contract — it `ptr::read`s the struct and writes nothing back. The
// read is a bitwise copy of a plain-data struct (`Rav1dSettings` has no `Drop`), so it
// cannot release anything the caller still owns.
let r = unsafe { dav1d_get_frame_delay(NonNull::new(settings as *mut Dav1dSettings)) };
r.0.max(0)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1039,6 +1166,58 @@ mod tests {
.is_some());
}
/// **The rung must never open rav1d with one frame context.**
///
/// With `n_fc == 1`, rav1d 1.1.0 `abort()`s the whole client on ANY decode error:
/// `rav1d_submit_frame`'s single-frame-context branch runs `rav1d_decode_frame`, whose
/// `rav1d_decode_frame_exit` unconditionally takes `f.frame_hdr`, and then — only if
/// the decode failed — calls an `on_error` that opens with
/// `f.frame_hdr.as_ref().unwrap()`. The panic crosses `dav1d_send_data`'s `extern "C"`
/// frame as `panic_cannot_unwind`, so nothing in this crate can catch it: not the
/// pump's survivable-error arm, not a rung demotion, not a `NoSoftwareRung` refusal.
/// Reproduced on .21 on 2026-08-07 with a 4K60 AV1 capture, twice.
///
/// The assertion is rav1d's OWN `n_fc` (`dav1d_get_frame_delay` is literally
/// `get_num_threads`' answer), not a re-derivation of it here, so it also holds if
/// rav1d changes how the number is computed.
#[test]
fn the_av1_rung_never_opens_rav1d_with_a_single_frame_context() {
let mut s = av1_settings();
let n_fc = frame_delay(&mut s);
assert!(
n_fc >= AV1_MIN_FRAME_CONTEXTS,
"rav1d would run with n_fc={n_fc} (n_threads={}, max_frame_delay={}) — one \
frame context ABORTS the process on the first damaged AU",
s.n_threads,
s.max_frame_delay,
);
// ...and the constructor refuses rather than opening such a decoder, so a machine
// whose settings somehow land there loses the rung instead of the process.
assert!(Av1Software::new().is_ok());
}
/// ⚠ The trap that makes the setting above look like a one-liner when it is two.
///
/// `n_fc = min(max_frame_delay, n_threads)`, so `max_frame_delay = 2` on its own is NOT
/// enough: one decode thread drags `n_fc` back to 1 and reinstates the abort. Measured
/// on .21 — `n_threads=1, max_frame_delay=2` aborted on the same capture that
/// `n_threads=8, max_frame_delay=2` survives — which is also the datapoint that rules
/// out "the tile workers are the trigger": fewer threads made it worse, not better.
///
/// Asserted against rav1d's own arithmetic so it cannot rot into folklore.
#[test]
fn one_decode_thread_would_put_the_rung_back_on_the_aborting_path() {
let mut one_thread = av1_settings();
one_thread.n_threads = 1;
assert_eq!(
frame_delay(&mut one_thread),
1,
"n_fc is min(max_frame_delay, n_threads) — this is why n_threads has a floor"
);
// And the floor in `av1_settings` is what keeps the shipping config off it.
assert!(av1_settings().n_threads >= AV1_MIN_FRAME_CONTEXTS);
}
/// The AV1 leg decodes a real stream and reports the sequence header's own colour.
/// Fixture: the vendored cros-codecs AV1 vector (IVF), whose first temporal unit is a
/// key frame — enough to prove the rav1d FFI (open → send → get → unref → close),
@@ -1076,6 +1255,13 @@ mod tests {
units > 100,
"expected the full 25 fps vector, got {units} units"
);
// ⚠ This equality is also the LATENCY guard for the two-frame-context config.
// rav1d with `n_fc = 2` pipelines by default: `dav1d_get_picture` answers `EAGAIN`
// once after each send and only reaches its blocking drain on the call after that.
// A `decode` that stopped at the first `EAGAIN` would still decode every frame
// eventually — it would just hand each one back two AUs late, and `frames` would
// come up exactly `n_fc` short of `units` here. So this line is what says the drain
// loop still returns THIS AU's picture in THIS call.
assert_eq!(frames, units, "every temporal unit here shows a picture");
let f = first.expect("no AV1 frame decoded");
assert_eq!((f.width, f.height), (320, 240));