Compare commits

..

3 Commits

Author SHA1 Message Date
enricobuehler b22d0da75b fix(encode): port the RFI taint sweep to Vulkan Video, close the QSV sweep hole, bounds-check encode feedback
apple / swift (push) Successful in 1m19s
apple / screenshots (push) Successful in 6m24s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 51s
ci / bench (push) Successful in 6m55s
deb / build-publish (push) Successful in 9m12s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
arch / build-publish (push) Successful in 17m2s
docker / deploy-docs (push) Successful in 14s
android / android (push) Successful in 17m59s
deb / build-publish-host (push) Successful in 11m19s
ci / rust (push) Successful in 25m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m54s
windows-host / package (push) Failing after 13m20s
Three defects from the pf-encode sweep, each adjudicated against source.

- Vulkan Video never received fecbec2d's taint sweep (it was carved out one
  commit later). `pick_recovery_slot` accepts any resident slot whose wire is
  below the CURRENT loss start, but "resident and older than this loss" is not
  "the client decoded it": after an earlier loss [a,b] 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 — and those wires stay eligible
  until the 8-slot ring rolls them out. A later loss could therefore anchor on
  one and ship it tagged `recovery_anchor`, which is the client's definitive
  re-anchor signal (punktfunk-core/src/reanchor.rs): the host lifts the client's
  post-loss freeze onto a picture built from a reference it never had. Swept
  before anchor selection, matching AMF/QSV.
  `slot_wire` is blanked and `slot_poc` deliberately is NOT: `slot_poc` feeds
  `build_h265_rps_s0`, which must keep naming every physically-resident DPB
  picture or a conforming decoder evicts them and the anchor then references a
  picture the client already dropped.

- QSV's sweep was incomplete, and in its MODAL case. `ltr_slots` mirrors the
  hardware DPB, but nulling an entry issues no VPL call — the frame stays marked
  long-term until that LongTermIdx is re-marked or an IDR flushes it (amf.rs
  states this verbatim). The rejection loop iterates the post-sweep mirror and
  only rejects `Some` slots, so it silently skipped the single entry the sweep
  exists to distrust, leaving the recovery frame free to predict from it. With
  NUM_LTR_SLOTS=2 the "exactly one slot swept" case is the common one, and the
  two existing tests cover only the both-survive and both-swept cases. Taint is
  now recorded in `ltr_tainted` with the FrameOrder left in place, so anchor
  selection and the queued-force guard skip it while the rejection list still
  names it.

- `read_slot` built a slice from the driver-reported (offset, bytes-written)
  encode feedback with no validation against `bs_size`, so a driver reporting a
  range outside the bitstream buffer produced an out-of-bounds read shipped
  straight onto the wire. Checked in u64 (so the add cannot wrap) before
  `map_memory`, so the error path has no unmap to unwind.

Adds `taint_sweep_excludes_slots_from_an_earlier_loss` covering the two-loss
case the existing single-loss test does not reach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:18:17 +02:00
enricobuehler d398e2296f chore(release): bump workspace version to 0.15.0
apple / swift (push) Successful in 1m16s
audit / bun-audit (push) Successful in 16s
audit / cargo-audit (push) Successful in 2m12s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m1s
ci / bench (push) Successful in 4m54s
release / apple (push) Successful in 10m19s
apple / screenshots (push) Successful in 5m19s
android-screenshots / screenshots (push) Successful in 3m37s
ci / rust (push) Successful in 24m3s
decky / build-publish (push) Successful in 20s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m31s
android / android (push) Successful in 15m3s
deb / build-publish (push) Successful in 10m56s
arch / build-publish (push) Successful in 14m15s
deb / build-publish-host (push) Successful in 10m49s
web-screenshots / screenshots (push) Successful in 3m9s
linux-client-screenshots / screenshots (push) Successful in 7m19s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 9m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m20s
docker / deploy-docs (push) Successful in 24s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
windows-host / package (push) Successful in 16m50s
flatpak / build-publish (push) Successful in 7m18s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 5m2s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 6m10s
MINOR, not patch: 44 commits since v0.14.0 split 21 features / 19 fixes, and
/api/v1 gained the per-scanner library-toggle endpoints (additive, already
regenerated into api/openapi.json by c2bba134). sdk-publish pushes this version
to consumers, so a patch bump would understate a new API surface to anyone
pinning ~0.14.

Headline work is the desktop clients drawing level with the Apple revamp: the
Windows client picked up settings parity, a findable console UI in the header,
the shared clipboard (with a per-host toggle), PyroWave decode in the codec
picker, and D3D11VA-first decode + HDR pass-through on Intel; the Linux GTK4
client got the same category-map settings rebuild. Apple landed the intent-based
presenter rebuild and the Dynamic Island redesign. Also: the punktfunk-host
plugins CLI, per-scanner library toggles in the console, and PyroWave raw-dmabuf
zero-copy capture on the Linux NVIDIA host.

Notable fixes: LTR-RFI loss recovery under sustained loss, two encode teardown
memory-safety holes, the audio first-open retry that was leaving sessions
silent, and the GameStream stream-marker announcement on the compat plane.

Every workspace crate is on version.workspace = true, so this stayed a one-line
bump plus the lock sync. (fec-rs, pf-driver-proto, usbip-sim, the Windows driver
crates and pf-vkhdr-layer are deliberately versioned independently and stay put.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:57:58 +02:00
enricobuehler f9668b16a1 fix(encode): NVENC partial-init session leak + three backend-parity gaps
apple / swift (push) Successful in 1m18s
apple / screenshots (push) Successful in 4m33s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
All four found in the pf-encode quality sweep and verified against source.

- NVENC partial-init leak (BOTH platforms, high): `init_session` publishes
  `self.encoder` — and on Windows charges LIVE_SESSION_UNITS — *before* its
  remaining fallible steps (bitstream buffers; on Linux also the input-surface
  alloc and `register_resource`). A failure there left a live session with
  `inited == false`, and every guard on the re-init path keys off `inited`, so
  the next submit skipped teardown and overwrote `self.encoder`: the session
  leaked permanently toward the driver's per-process cap, and its budget units
  never returned, progressively starving parallel-display admission. `teardown`
  already keys off `encoder.is_null()` rather than `inited`, so it cleans up
  exactly this half-built state — it just was never called. Now invoked on the
  `init_session` error path on both platforms.

- `can_encode_10bit` asked the wrong backend (medium): it resolved via
  `linux_auto_is_vaapi`, which ignores `encoder_pref`, while `can_encode_444`
  and `open_video` honour it. On a host that forces a backend (e.g.
  `encoder_pref = "vaapi"` on an NVIDIA box) the probe answered for NVENC while
  the session opened VAAPI, so the negotiated bit depth — and the HDR/SDR colour
  label derived from it — described a backend that never ran. Now uses the same
  `linux_zero_copy_is_vaapi` mirror, and `linux_auto_is_vaapi` carries a warning
  that it resolves the `auto` case only and is not a dispatch mirror.

- Linux software arm ignored SW_BITRATE_CEIL (low): the Windows arm clamped
  openh264 to 100 Mbps, the Linux arm passed the full negotiated rate. The
  constant is now module-scope so both arms share one value.

- QSV/AMF env-parity (low): `PUNKTFUNK_IR_PERIOD_FRAMES` was a no-op on QSV
  despite the comment claiming parity with AMF, and `PUNKTFUNK_NO_QSV_LTR` /
  `PUNKTFUNK_INTRA_REFRESH` had dropped AMF's `trim()` and `yes`/`on` spellings,
  so a value with stray whitespace silently did nothing on Intel while the same
  value worked on AMD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:47:55 +02:00
8 changed files with 222 additions and 44 deletions
Generated
+27 -27
View File
@@ -2159,7 +2159,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.14.0" version = "0.15.0"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2264,7 +2264,7 @@ dependencies = [
[[package]] [[package]]
name = "libvpl-sys" name = "libvpl-sys"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
@@ -2299,7 +2299,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2788,7 +2788,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-capture" name = "pf-capture"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2832,7 +2832,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-clipboard" name = "pf-clipboard"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2850,7 +2850,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-encode" name = "pf-encode"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2894,7 +2894,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2903,7 +2903,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-frame" name = "pf-frame"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@@ -2915,7 +2915,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-gpu" name = "pf-gpu"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-host-config", "pf-host-config",
@@ -2929,11 +2929,11 @@ dependencies = [
[[package]] [[package]]
name = "pf-host-config" name = "pf-host-config"
version = "0.14.0" version = "0.15.0"
[[package]] [[package]]
name = "pf-inject" name = "pf-inject"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2961,14 +2961,14 @@ dependencies = [
[[package]] [[package]]
name = "pf-paths" name = "pf-paths"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"tracing", "tracing",
] ]
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2983,7 +2983,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-vdisplay" name = "pf-vdisplay"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -3013,7 +3013,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-win-display" name = "pf-win-display"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-paths", "pf-paths",
@@ -3025,7 +3025,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-zerocopy" name = "pf-zerocopy"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3221,7 +3221,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3237,7 +3237,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3268,7 +3268,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"ffmpeg-next", "ffmpeg-next",
@@ -3287,7 +3287,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -3318,7 +3318,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3400,7 +3400,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3414,7 +3414,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
@@ -3437,7 +3437,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "pyrowave-sys" name = "pyrowave-sys"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
+1 -1
View File
@@ -48,7 +48,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" } ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package] [workspace.package]
version = "0.14.0" version = "0.15.0"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.14.0" "version": "0.15.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
+12 -1
View File
@@ -1078,7 +1078,18 @@ impl Encoder for NvencCudaEncoder {
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input // 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful. // can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
self.chroma_444 = self.chroma_444 && buf.yuv444; self.chroma_444 = self.chroma_444 && buf.yuv444;
self.init_session()?; // `init_session` publishes `self.encoder` before its remaining fallible steps (bitstream
// buffers, input-surface alloc, `register_resource`), so a failure there leaves a live
// session with `inited == false`. Every guard on the re-init path keys off `inited`, so
// without this the next submit would skip teardown and overwrite `self.encoder`, leaking
// the session and its registered input surfaces permanently. `teardown` keys off
// `encoder.is_null()`, not `inited`, so it cleans up exactly this half-built state.
if let Err(e) = self.init_session() {
// SAFETY: the encode thread owns the session and a failed init leaves nothing
// mid-encode to race with.
unsafe { self.teardown() };
return Err(e);
}
} else { } else {
// Steady state: the copy helpers need the shared context current on this thread. // Steady state: the copy helpers need the shared context current on this thread.
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?; cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
+91 -1
View File
@@ -1851,7 +1851,20 @@ impl VulkanVideoEncoder {
let f = &self.frames[slot]; let f = &self.frames[slot];
let mut fb = [[0u32; 2]; 1]; let mut fb = [[0u32; 2]; 1];
dev.get_query_pool_results(f.query_pool, 0, &mut fb, vk::QueryResultFlags::WAIT)?; 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 = let p =
dev.map_memory(f.bs_mem, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *const u8; dev.map_memory(f.bs_mem, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *const u8;
let prefix: &[u8] = if f.keyframe { let prefix: &[u8] = if f.keyframe {
@@ -1923,7 +1936,28 @@ impl Encoder for VulkanVideoEncoder {
if first_frame < 0 || first_frame > last_frame { if first_frame < 0 || first_frame > last_frame {
return false; 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? // 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) { match pick_recovery_slot(&self.slot_wire, first_frame) {
Some(_) => { Some(_) => {
self.pending_loss = Some(first_frame); self.pending_loss = Some(first_frame);
@@ -2697,6 +2731,62 @@ mod tests {
assert_eq!(pick_recovery_slot(&[-1; 8], 5), None); 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 /// 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. /// setup slot's dying occupant is not, and `used_by_curr_pic` marks exactly the real reference.
#[test] #[test]
+13 -1
View File
@@ -1135,7 +1135,19 @@ impl Encoder for NvencD3d11Encoder {
self.chroma_444 = false; self.chroma_444 = false;
} }
let device = frame.device.clone(); let device = frame.device.clone();
self.init_session(&device)?; // `init_session` publishes `self.encoder` (and charges LIVE_SESSION_UNITS) BEFORE its
// last fallible steps, so a failure there leaves a live session with `inited == false`.
// Every guard on the re-init path keys off `inited`, so without this the next submit
// would skip teardown and overwrite `self.encoder` — leaking the session permanently
// (toward the driver's per-process cap) along with its session-budget units.
// `teardown` keys off `encoder.is_null()`, not `inited`, so it cleans up exactly this
// half-built state and is a no-op when nothing was opened.
if let Err(e) = self.init_session(&device) {
// SAFETY: same contract as the teardown above — the encode thread owns the session,
// and a failed init leaves nothing mid-encode to race with.
unsafe { self.teardown() };
return Err(e);
}
self.init_device = dev_raw; self.init_device = dev_raw;
} }
// The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the // The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the
+49 -7
View File
@@ -146,7 +146,12 @@ const NUM_LTR_SLOTS: usize = 2;
/// `PUNKTFUNK_NO_QSV_LTR` — defeat switch for the LTR-RFI path (parity with /// `PUNKTFUNK_NO_QSV_LTR` — defeat switch for the LTR-RFI path (parity with
/// `PUNKTFUNK_NO_AMF_LTR`); loss recovery then always falls back to IDR. /// `PUNKTFUNK_NO_AMF_LTR`); loss recovery then always falls back to IDR.
fn ltr_disabled() -> bool { fn ltr_disabled() -> bool {
std::env::var("PUNKTFUNK_NO_QSV_LTR").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) // Same accepted spellings as AMF's `ltr_disabled` — this had dropped the `trim()` and the
// `yes`/`on` forms, so a value with stray whitespace (easy to produce with `set VAR=1 `)
// silently left LTR enabled on Intel while the identical value worked on AMD.
std::env::var("PUNKTFUNK_NO_QSV_LTR")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
} }
/// Frames between LTR marks (`PUNKTFUNK_LTR_INTERVAL_FRAMES`, shared with AMF); default ~1/4 s /// Frames between LTR marks (`PUNKTFUNK_LTR_INTERVAL_FRAMES`, shared with AMF); default ~1/4 s
@@ -171,13 +176,22 @@ fn ltr_test_force_at() -> Option<i64> {
/// Mirrors [`super::amf`]'s `PUNKTFUNK_INTRA_REFRESH` opt-in: request the intra-refresh wave /// Mirrors [`super::amf`]'s `PUNKTFUNK_INTRA_REFRESH` opt-in: request the intra-refresh wave
/// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references). /// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references).
fn intra_refresh_requested() -> bool { fn intra_refresh_requested() -> bool {
// Spelling parity with AMF (see `ltr_disabled` above).
std::env::var("PUNKTFUNK_INTRA_REFRESH") std::env::var("PUNKTFUNK_INTRA_REFRESH")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) .map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
} }
/// The wave period in frames (~0.5 s), the same shape as Linux NVENC / AMF. /// The wave period in frames (~0.5 s), `PUNKTFUNK_IR_PERIOD_FRAMES` overrides — the same knob and
/// default as AMF / Linux NVENC. (This claimed parity while ignoring the env var entirely, so the
/// knob silently did nothing on Intel; the clamp is kept because `mfxU16` bounds the field.)
fn intra_refresh_period(fps: u32) -> u16 { fn intra_refresh_period(fps: u32) -> u16 {
(fps / 2).clamp(8, 240) as u16 std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|v| *v >= 2)
.unwrap_or(fps / 2)
.clamp(8, 240) as u16
} }
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
@@ -700,7 +714,16 @@ pub struct QsvEncoder {
/// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below. /// `EncoderCaps::supports_rfi` and all per-frame marking/forcing below.
ltr_active: bool, ltr_active: bool,
/// The wire frame index stored in each LTR slot (`None` = never marked). /// 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<i64>; NUM_LTR_SLOTS], ltr_slots: [Option<i64>; 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, next_ltr_slot: usize,
ltr_mark_interval: i64, ltr_mark_interval: i64,
/// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references. /// Set by `invalidate_ref_frames`: the slot the next submitted frame force-references.
@@ -775,6 +798,7 @@ impl QsvEncoder {
ir_active: false, ir_active: false,
ltr_active: false, ltr_active: false,
ltr_slots: [None; NUM_LTR_SLOTS], ltr_slots: [None; NUM_LTR_SLOTS],
ltr_tainted: [false; NUM_LTR_SLOTS],
next_ltr_slot: 0, next_ltr_slot: 0,
ltr_mark_interval: ltr_mark_interval(fps), ltr_mark_interval: ltr_mark_interval(fps),
pending_force: None, pending_force: None,
@@ -899,6 +923,7 @@ impl QsvEncoder {
self.ltr_active = ltr_active; self.ltr_active = ltr_active;
self.ir_active = ir_active; self.ir_active = ir_active;
self.ltr_slots = [None; NUM_LTR_SLOTS]; self.ltr_slots = [None; NUM_LTR_SLOTS];
self.ltr_tainted = [false; NUM_LTR_SLOTS];
self.next_ltr_slot = 0; self.next_ltr_slot = 0;
self.pending_force = None; self.pending_force = None;
self.hdr_applied = self.hdr_meta; self.hdr_applied = self.hdr_meta;
@@ -1024,6 +1049,7 @@ impl Encoder for QsvEncoder {
// An IDR voids the decoder's reference buffers — drop stale slots and any // 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. // queued force; the mark cadence below re-anchors on the IDR itself.
self.ltr_slots = [None; NUM_LTR_SLOTS]; 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.next_ltr_slot = 0;
self.pending_force = None; self.pending_force = None;
} else if self.ltr_test_force_at == Some(cur_idx) { } else if self.ltr_test_force_at == Some(cur_idx) {
@@ -1039,7 +1065,9 @@ impl Encoder for QsvEncoder {
// emptied the slot since the force was queued. An empty slot means there is // 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 // 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). // `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)); force_ltr = Some((slot, idx));
recovery_anchor = true; recovery_anchor = true;
} }
@@ -1047,6 +1075,9 @@ impl Encoder for QsvEncoder {
if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) { if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
let slot = self.next_ltr_slot; let slot = self.next_ltr_slot;
self.ltr_slots[slot] = Some(cur_idx); 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; self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS;
mark_slot = Some(slot); mark_slot = Some(slot);
} }
@@ -1309,13 +1340,23 @@ impl Encoder for QsvEncoder {
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples // 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 // 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. // 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) { if marked.is_some_and(|idx| idx >= first) {
*marked = None; self.ltr_tainted[slot] = true;
} }
} }
let mut best: Option<(usize, i64)> = None; let mut best: Option<(usize, i64)> = None;
for (slot, marked) in self.ltr_slots.iter().enumerate() { 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 let Some(idx) = *marked {
if idx < first && best.is_none_or(|(_, b)| idx > b) { if idx < first && best.is_none_or(|(_, b)| idx > b) {
best = Some((slot, idx)); best = Some((slot, idx));
@@ -1440,6 +1481,7 @@ impl Encoder for QsvEncoder {
self.ltr_active = ltr; self.ltr_active = ltr;
self.ir_active = ir; self.ir_active = ir;
self.ltr_slots = [None; NUM_LTR_SLOTS]; self.ltr_slots = [None; NUM_LTR_SLOTS];
self.ltr_tainted = [false; NUM_LTR_SLOTS];
self.next_ltr_slot = 0; self.next_ltr_slot = 0;
self.pending_force = None; self.pending_force = None;
if let Some(inner) = self.inner.as_mut() { if let Some(inner) = self.inner.as_mut() {
+28 -5
View File
@@ -223,6 +223,13 @@ impl Encoder for TrackedEncoder {
} }
} }
/// Ceiling applied to the negotiated bitrate before it reaches openh264: software H.264 realistically
/// caps far below the rates a hardware session negotiates, and handing it the full figure just
/// misconfigures its rate control. Module-scope so BOTH software arms share one value — the Linux
/// arm was missing the clamp the Windows arm applied.
#[cfg(any(target_os = "linux", target_os = "windows"))]
const SW_BITRATE_CEIL: u64 = 100_000_000;
/// Open the platform encoder backend. Returns the encoder together with the display label of the /// Open the platform encoder backend. Returns the encoder together with the display label of the
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds /// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
/// the mgmt API's live-session record, and only the open site knows which internal fallback won /// the mgmt API's live-session record, and only the open site knows which internal fallback won
@@ -407,8 +414,14 @@ fn open_video_backend(
); );
} }
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
sw::OpenH264Encoder::open(format, width, height, fps, bitrate_bps) sw::OpenH264Encoder::open(
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software")) format,
width,
height,
fps,
bitrate_bps.min(SW_BITRATE_CEIL),
)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
} }
"auto" | "" => { "auto" | "" => {
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision // A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
@@ -610,8 +623,6 @@ fn open_video_backend(
(build a GPU backend: --features nvenc or amf-qsv, or request H264)" (build a GPU backend: --features nvenc or amf-qsv, or request H264)"
); );
let _ = (bit_depth, chroma); // the software H.264 path is 8-bit 4:2:0 only let _ = (bit_depth, chroma); // the software H.264 path is 8-bit 4:2:0 only
// Software H.264 realistically caps far below the negotiated hardware rates.
const SW_BITRATE_CEIL: u64 = 100_000_000;
sw::OpenH264Encoder::open( sw::OpenH264Encoder::open(
format, format,
width, width,
@@ -784,6 +795,12 @@ fn nvidia_present() -> bool {
/// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still /// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still
/// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise /// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise
/// today's NVIDIA-presence probe, unchanged. /// today's NVIDIA-presence probe, unchanged.
///
/// ⚠ This resolves the **`auto` case only** — it deliberately ignores `encoder_pref`. It is NOT a
/// mirror of [`open_video`]'s dispatch and must not be used to decide which backend a capability
/// probe should ask: use [`linux_zero_copy_is_vaapi`], which layers `encoder_pref` on top of this.
/// (`can_encode_10bit` used this directly and answered for the wrong backend whenever a host
/// forced one.)
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn linux_auto_is_vaapi() -> bool { fn linux_auto_is_vaapi() -> bool {
if let Some(g) = pf_gpu::manual_selection() { if let Some(g) = pf_gpu::manual_selection() {
@@ -997,7 +1014,13 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
// only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode) // only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode)
// is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP // is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP
// honor), since this probe can't know what the compositor will negotiate. // honor), since this probe can't know what the compositor will negotiate.
if linux_auto_is_vaapi() { // Resolve through the SAME helper `can_encode_444` uses (and which mirrors
// `open_video`'s dispatch): `linux_auto_is_vaapi` ignores `encoder_pref`, so on a box
// that forces a backend — e.g. `encoder_pref = "vaapi"` on an NVIDIA host — this probe
// would answer for NVENC while the session actually opens VAAPI, and the negotiated bit
// depth (plus the HDR/SDR colour label derived from it) would describe a backend that
// never runs. That is exactly the dishonesty this probe exists to prevent.
if linux_zero_copy_is_vaapi() {
vaapi::probe_can_encode_10bit(codec) vaapi::probe_can_encode_10bit(codec)
} else { } else {
linux::probe_can_encode_10bit(codec) linux::probe_can_encode_10bit(codec)