diff --git a/crates/pf-encode/src/enc/linux/vulkan_video.rs b/crates/pf-encode/src/enc/linux/vulkan_video.rs index 994a92f3..d99c97b4 100644 --- a/crates/pf-encode/src/enc/linux/vulkan_video.rs +++ b/crates/pf-encode/src/enc/linux/vulkan_video.rs @@ -1851,7 +1851,20 @@ impl VulkanVideoEncoder { let f = &self.frames[slot]; let mut fb = [[0u32; 2]; 1]; dev.get_query_pool_results(f.query_pool, 0, &mut fb, vk::QueryResultFlags::WAIT)?; - let (off, len) = (fb[0][0] as usize, fb[0][1] as usize); + // The (offset, bytes-written) pair is driver-reported: validate it against the bitstream + // allocation BEFORE mapping, or the `from_raw_parts` below reads outside the buffer and + // ships whatever it finds straight onto the wire. Checked in u64 so the add cannot wrap, + // and before `map_memory` so there is no unmap to unwind on the error path. + let (off64, len64) = (fb[0][0] as u64, fb[0][1] as u64); + if off64.saturating_add(len64) > self.bs_size { + anyhow::bail!( + "vulkan-encode: driver reported bitstream feedback offset={off64} \ + bytes_written={len64}, outside the {} byte bitstream buffer — the encode likely \ + overflowed its destination range", + self.bs_size + ); + } + let (off, len) = (off64 as usize, len64 as usize); let p = dev.map_memory(f.bs_mem, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *const u8; let prefix: &[u8] = if f.keyframe { @@ -1923,7 +1936,28 @@ impl Encoder for VulkanVideoEncoder { if first_frame < 0 || first_frame > last_frame { return false; } + // Taint sweep BEFORE picking the anchor (the fecbec2d fix AMF and QSV got; this backend was + // carved out one commit later and never received it). "Resident and older than THIS loss" is + // not the same as "the client decoded it": after an earlier loss [a,b] was recovered at wire + // r, everything in [a, r-1] is undecodable at the client — the lost frames plus every frame + // that predicted through the gap. Those wires stay valid anchor candidates here until the + // 8-slot ring rolls them out, so a LATER loss can anchor on one and ship corruption tagged + // `recovery_anchor` — which is the client's definitive re-anchor signal (reanchor.rs), so it + // lifts the post-loss freeze onto a picture built from a reference it never had. + // + // Blank `slot_wire` ONLY. `slot_poc` must keep naming every physically-resident DPB picture + // for `build_h265_rps_s0`, or a conforming decoder evicts them and the anchor references a + // picture the client already dropped. `slot_wire` is the RFI/loss domain; `slot_poc` is the + // reference-delta domain. `prev_slot` and the normal P-frame path are indices, not wires, so + // ordinary prediction is unaffected. + for w in self.slot_wire.iter_mut() { + if *w >= first_frame { + *w = -1; + } + } // Can we anchor a clean P-frame to a resident slot strictly older than the loss? + // (A sweep that empties every candidate yields `None` here and declines the RFI, matching + // `qsv_live_ltr_rfi_taint_sweep_declines`.) match pick_recovery_slot(&self.slot_wire, first_frame) { Some(_) => { self.pending_loss = Some(first_frame); @@ -2697,6 +2731,62 @@ mod tests { assert_eq!(pick_recovery_slot(&[-1; 8], 5), None); } + /// The taint sweep (fecbec2d's fix, ported here): a slot encoded inside an EARLIER, still + /// unrepaired loss window must not become the "known-good" anchor of a LATER loss. Without the + /// sweep, `pick_recovery_slot` accepts it — it is resident and its wire is below the second + /// loss start — and the frame ships tagged `recovery_anchor`, lifting the client's freeze onto + /// a reference it never decoded. + #[test] + fn taint_sweep_excludes_slots_from_an_earlier_loss() { + // Apply the sweep exactly as `invalidate_ref_frames` does. + fn sweep(wires: &mut [i64], loss_first: i64) { + for w in wires.iter_mut() { + if *w >= loss_first { + *w = -1; + } + } + } + + // Slots hold wires 0..7. Loss 1 starts at wire 4, so wires 4..7 are undecodable at the + // client. A second loss report arrives at wire 6 while they are all still resident. + let tainted = [4i64, 5, 6, 7]; + + // WITHOUT the sweep this is the bug: the newest wire below 6 is wire 5 — squarely inside + // loss 1's unrepaired window — and it would be served as the "known-good" anchor. + let unswept = [0i64, 1, 2, 3, 4, 5, 6, 7]; + let picked = pick_recovery_slot(&unswept, 6).expect("unswept picks something"); + assert!( + tainted.contains(&unswept[picked]), + "precondition: without the sweep the anchor comes from the earlier loss window" + ); + + // WITH the sweep, loss 1 blanks 4..7, so loss 2 can only reach genuinely clean wires. + let mut wires = unswept; + sweep(&mut wires, 4); + assert_eq!(wires, [0, 1, 2, 3, -1, -1, -1, -1]); + let picked = pick_recovery_slot(&wires, 6).expect("clean wires remain"); + assert_eq!(picked, 3, "newest clean survivor is wire 3"); + assert!(!tainted.contains(&wires[picked])); + + // Encoding resumes after recovery; wires 8..11 refill the swept slots and are clean. A + // later loss at wire 10 legitimately anchors on wire 9 — the sweep must not over-reject. + wires[4] = 8; + wires[5] = 9; + wires[6] = 10; + wires[7] = 11; + sweep(&mut wires, 10); + assert_eq!( + pick_recovery_slot(&wires, 10), + Some(5), + "wire 9 is post-recovery, clean" + ); + + // A loss covering every live wire leaves nothing clean → decline, caller serves an IDR. + let mut all = [5i64, 6, 7, 8, 9, 10, 11, 12]; + sweep(&mut all, 5); + assert_eq!(pick_recovery_slot(&all, 5), None); + } + /// The full-retention RPS: every resident picture is listed (so the decoder keeps it), the /// setup slot's dying occupant is not, and `used_by_curr_pic` marks exactly the real reference. #[test] diff --git a/crates/pf-encode/src/enc/windows/qsv.rs b/crates/pf-encode/src/enc/windows/qsv.rs index d30c3d3a..6fe26bb7 100644 --- a/crates/pf-encode/src/enc/windows/qsv.rs +++ b/crates/pf-encode/src/enc/windows/qsv.rs @@ -714,7 +714,16 @@ pub struct QsvEncoder { /// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below. ltr_active: bool, /// The wire frame index stored in each LTR slot (`None` = never marked). + /// + /// This mirrors the HARDWARE DPB, so an entry must not be cleared merely because we distrust + /// it: nulling issues no VPL call, and the encoder keeps the frame marked long-term until that + /// `LongTermIdx` is re-marked or an IDR flushes it. Distrust is recorded in `ltr_tainted` + /// instead, so the rejection list can still NAME the entry the hardware is holding. ltr_slots: [Option; NUM_LTR_SLOTS], + /// Per-slot taint from `invalidate_ref_frames`' sweep: the mark is still live in the hardware + /// DPB but was encoded inside the client's corrupt window, so it may not anchor a recovery — + /// it must be REJECTED instead. Cleared wherever the slot is re-marked or the DPB is flushed. + ltr_tainted: [bool; NUM_LTR_SLOTS], next_ltr_slot: usize, ltr_mark_interval: i64, /// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references. @@ -789,6 +798,7 @@ impl QsvEncoder { ir_active: false, ltr_active: false, ltr_slots: [None; NUM_LTR_SLOTS], + ltr_tainted: [false; NUM_LTR_SLOTS], next_ltr_slot: 0, ltr_mark_interval: ltr_mark_interval(fps), pending_force: None, @@ -913,6 +923,7 @@ impl QsvEncoder { self.ltr_active = ltr_active; self.ir_active = ir_active; self.ltr_slots = [None; NUM_LTR_SLOTS]; + self.ltr_tainted = [false; NUM_LTR_SLOTS]; self.next_ltr_slot = 0; self.pending_force = None; self.hdr_applied = self.hdr_meta; @@ -1038,6 +1049,7 @@ impl Encoder for QsvEncoder { // An IDR voids the decoder's reference buffers — drop stale slots and any // queued force; the mark cadence below re-anchors on the IDR itself. self.ltr_slots = [None; NUM_LTR_SLOTS]; + self.ltr_tainted = [false; NUM_LTR_SLOTS]; // the IDR flushed the DPB with them self.next_ltr_slot = 0; self.pending_force = None; } else if self.ltr_test_force_at == Some(cur_idx) { @@ -1053,7 +1065,9 @@ impl Encoder for QsvEncoder { // emptied the slot since the force was queued. An empty slot means there is // nothing clean to re-reference — the frame must ship as a plain P WITHOUT the // `recovery_anchor` tag (the client lifts its post-loss freeze on that tag). - if let Some(idx) = self.ltr_slots[slot] { + // The slot is no longer emptied by the sweep, so test the taint flag too — a + // tainted slot is exactly the "nothing clean to re-reference" case. + if let Some(idx) = self.ltr_slots[slot].filter(|_| !self.ltr_tainted[slot]) { force_ltr = Some((slot, idx)); recovery_anchor = true; } @@ -1061,6 +1075,9 @@ impl Encoder for QsvEncoder { if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) { let slot = self.next_ltr_slot; self.ltr_slots[slot] = Some(cur_idx); + // Re-marking replaces the hardware's LongTermIdx: the tainted frame is gone from + // the DPB and this slot is clean again. + self.ltr_tainted[slot] = false; self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS; mark_slot = Some(slot); } @@ -1323,13 +1340,23 @@ impl Encoder for QsvEncoder { // loss ships corruption as the recovery anchor, and every subsequent mark re-samples // the soup — the sustained-loss field failure where the picture never healed. Dropped // slots stay dropped; the cadence re-marks a clean frame within ~1/4 s. - for marked in self.ltr_slots.iter_mut() { + // + // Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an + // entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it + // made the rejection list below (which iterates the post-sweep mirror and only names `Some` + // slots) silently SKIP the one entry the sweep exists to distrust, so the recovery frame + // could still predict from it. With two slots the "exactly one swept" case is the modal + // one, and it was the broken one. + for (slot, marked) in self.ltr_slots.iter().enumerate() { if marked.is_some_and(|idx| idx >= first) { - *marked = None; + self.ltr_tainted[slot] = true; } } let mut best: Option<(usize, i64)> = None; for (slot, marked) in self.ltr_slots.iter().enumerate() { + if self.ltr_tainted[slot] { + continue; // still in the DPB, but encoded inside the corrupt window + } if let Some(idx) = *marked { if idx < first && best.is_none_or(|(_, b)| idx > b) { best = Some((slot, idx)); @@ -1454,6 +1481,7 @@ impl Encoder for QsvEncoder { self.ltr_active = ltr; self.ir_active = ir; self.ltr_slots = [None; NUM_LTR_SLOTS]; + self.ltr_tainted = [false; NUM_LTR_SLOTS]; self.next_ltr_slot = 0; self.pending_force = None; if let Some(inner) = self.inner.as_mut() {