forked from unom/punktfunk
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef0af3b558 | ||
|
|
535e95c4c0 | ||
|
|
28b6633058 | ||
|
|
c58217e403 | ||
|
|
23f9b1130e | ||
|
|
c2c71f0ac5 | ||
|
|
6a506a8fa9 | ||
|
|
712ee935d6 | ||
|
|
a2aa0a5f97 | ||
|
|
d6b9862f1e | ||
|
|
f4e39a442b | ||
|
|
a0577cb86e | ||
|
|
2a62fe7857 | ||
|
|
66ba61b12c | ||
|
|
5002849737 | ||
|
|
9a59504ba4 | ||
|
|
e8c306b9c0 | ||
|
|
c3b57438e1 | ||
|
|
e20b614059 | ||
|
|
6eb89b3f34 | ||
|
|
bbd26ea82c | ||
|
|
549fdf238b | ||
|
|
3ea411fa39 | ||
|
|
3b2fcd076d | ||
|
|
d67ab9ede4 | ||
|
|
abec2a1457 | ||
|
|
5f097d530d | ||
|
|
2bfd1cd2d5 | ||
|
|
dfebb9dfbb | ||
|
|
f675b3710e | ||
|
|
23fa03b051 | ||
|
|
6e4638dab5 | ||
|
|
0c2ac333ae |
@@ -15,6 +15,17 @@
|
||||
# fails if any crate carries a license outside the allowlist — the regression
|
||||
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
|
||||
# nothing scans it — see the CRA roadmap.)
|
||||
# * miri → NON-BLOCKING interpretation of the few FFI-free leaf crates, one of them
|
||||
# cross-compiled to MSVC layout. Not a supply-chain scan; it lives here because
|
||||
# audit.yml already has exactly the shape it needs (weekly cron,
|
||||
# workflow_dispatch, the rust-ci container, the same cache pattern) and because
|
||||
# ci.yml runs on every push against a fleet where 37 of 46 jobs contend for
|
||||
# ubuntu-24.04. See the `miri:` job below for what it does and does not buy.
|
||||
# * c-abi-asan → NON-BLOCKING ASAN+LSAN run of the C ABI harness (tests/c/run.sh under
|
||||
# PF_SAN=address): both sides of the abi.rs boundary instrumented at once, and
|
||||
# the only automated check on its Box::into_raw/from_raw leak contract. Same
|
||||
# here-not-ci.yml reasoning as miri — plus -Zbuild-std defeats sccache, so it
|
||||
# must not ride the per-push leg.
|
||||
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
|
||||
# change, and on demand.
|
||||
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
|
||||
@@ -44,6 +55,13 @@ on:
|
||||
- 'about.toml'
|
||||
- '.gitea/workflows/audit.yml'
|
||||
workflow_dispatch:
|
||||
# NOTE on the `paths:` list above and the `miri:` job: `crates/pf-driver-proto/**` is deliberately
|
||||
# NOT listed, even though that crate is what the Miri job exists to watch. `paths:` is a
|
||||
# WORKFLOW-level filter — adding it would fire all six jobs (three bun trees, pnpm, cargo-audit,
|
||||
# the license gate) on every driver-proto edit, onto a fleet where 37 of 46 jobs contend for
|
||||
# ubuntu-24.04, to run one 2-minute job. Weekly cron + workflow_dispatch is the day-one cadence;
|
||||
# revisit once the job has a green history, and if you do, prefer moving miri to its own workflow
|
||||
# file over widening this filter.
|
||||
|
||||
jobs:
|
||||
cargo-audit:
|
||||
@@ -177,3 +195,254 @@ jobs:
|
||||
command -v cargo-about >/dev/null 2>&1 || cargo install --locked cargo-about --version 0.9.1 --features cli
|
||||
cargo about generate about.hbs --fail -o /dev/null
|
||||
cargo about generate -m packaging/windows/drivers/Cargo.toml -c about.toml about.hbs --fail -o /dev/null
|
||||
|
||||
# ── Miri ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# WHAT THIS BUYS, precisely — one thing, and it is worth having:
|
||||
# It interprets `pf-driver-proto` CROSS-COMPILED TO `x86_64-pc-windows-msvc`, on a Linux
|
||||
# runner, with no Windows box anywhere in the loop. That crate is `#![forbid(unsafe_code)]`
|
||||
# and is path-dep'd by BOTH the main workspace and the driver workspace, so it is the layout
|
||||
# oracle for every frame and IOCTL crossing that boundary — and drift there is silent
|
||||
# corruption, not a compile error. Nothing else in CI checks it at MSVC layout.
|
||||
# On the first run ever performed against this repo it found a real defect: a layout test
|
||||
# reading an align-8 struct out of an align-1 stack buffer, which had passed on every machine
|
||||
# and every CI leg since it was written because a stack `[u8; 40]` usually lands 8-aligned.
|
||||
#
|
||||
# WHAT IT DOES NOT BUY — do not let anyone report this as unsafe coverage, and do not publish a
|
||||
# "Miri coverage" percentage; it would be noise. Miri can execute on the order of 2% of the
|
||||
# host's unsafe. It cannot run ash, windows-rs, ffmpeg, CUDA or the WDK, and in those crates
|
||||
# the unsafe *is* the foreign call, so there is nothing for an interpreter to execute. This
|
||||
# job is a targeted instrument for three leaf surfaces, not a safety net.
|
||||
#
|
||||
# NON-BLOCKING, deliberately, and via a step-level `||` — NOT job-level `continue-on-error`,
|
||||
# which act_runner does not reliably honor (same reasoning as docs-site-audit above; a red job
|
||||
# here would take the whole run red). Flip to blocking only after several weeks of green
|
||||
# establish the nightly-drift rate.
|
||||
#
|
||||
# Do NOT add crates here because they merely compile under Miri. Add them because they contain
|
||||
# pure-Rust unsafe or a layout contract worth interpreting. Explicitly excluded:
|
||||
# * pf-bitstream — its compile did not finish in 27 min at 2.1 GB RSS, and it is
|
||||
# `forbid(unsafe_code)`, so there is nothing to find. Do not re-add it.
|
||||
# * pf-update-check — ring; every FFI crate — dies on the first foreign call. Structural.
|
||||
# * punktfunk-core in bulk — `-- fec packet crypto` selects 63 tests and was killed at a
|
||||
# 25-minute cap with not one test reported complete. Only the narrow
|
||||
# `fec::gf8` selection below is affordable, and it was timed before it
|
||||
# was committed. Do not widen this filter without timing the result.
|
||||
#
|
||||
# MEASURED, not estimated — 192.168.1.25 (Ubuntu, 8 cores), on the DATED toolchain this job
|
||||
# actually installs, with a COLD target dir and a COLD sysroot cache (so each step's figure
|
||||
# includes building the Miri sysroot it needs) and a warm cargo registry. Every step below has
|
||||
# been run start to finish; nothing here is extrapolated:
|
||||
# step A 21 + 12 + 4 pass 43 s
|
||||
# step B 21 pass 26 s
|
||||
# step C 2 pass 63 s
|
||||
# TOTAL 132 s cold. Interpretation itself is ~10 s of that; the rest is compiling, plus ~38 s
|
||||
# of one-time sysroot builds (21 s host + 17 s MSVC) that the cache below then carries.
|
||||
# Warm, the three steps are ~6 s / ~3 s / ~10 s. `timeout-minutes: 30` is therefore vast
|
||||
# headroom, kept deliberately so a first fully-uncached run — which additionally downloads a
|
||||
# ~400 MB toolchain and the registry — cannot trip it.
|
||||
# If you add a step, MEASURE IT FIRST. The estimate this job replaced said "under 15 s across
|
||||
# all four steps" and was extrapolated from a partial run; the real punktfunk-core figure was
|
||||
# >25 min. Extrapolation is exactly how that happened.
|
||||
miri:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# A DATED nightly, bumped deliberately — exactly like rust-toolchain.toml, and for the same
|
||||
# reason. The cache keys below carry this value, so bumping it self-invalidates them.
|
||||
# ⚠ `nightly-<date>` names the day rustup PUBLISHED the build, and that build is compiled
|
||||
# from the PREVIOUS day's commit. This pin therefore resolves to
|
||||
# `rustc 1.99.0-nightly (969b803cb 2026-08-09)` [verified by installing it], NOT the
|
||||
# `12c36e253 2026-08-10` that the rust-safety programme doc's §7 table cites — that figure
|
||||
# came from the ROLLING `nightly` channel and was mislabelled as the dated one. Harmless,
|
||||
# but do not "fix" the date to chase that hash: all three steps below were re-run and are
|
||||
# green on the dated toolchain this job actually installs.
|
||||
MIRI_TOOLCHAIN: nightly-2026-08-10
|
||||
# A GUARD, not a fix for a present problem: audit.yml sets no sccache — only ci.yml does, at
|
||||
# workflow level (ci.yml:27). `cargo-miri` REPLACES rustc and cannot be wrapped; it prints
|
||||
# "Ignoring `RUSTC_WRAPPER` environment variable, Miri does not support wrapping" and
|
||||
# carries on [verified]. This keeps a future workflow-level sccache from becoming a puzzle.
|
||||
RUSTC_WRAPPER: ""
|
||||
# -Zmiri-disable-isolation: pf-gpu's tests mkdir, and Miri aborts them without it [verified].
|
||||
# -Zmiri-symbolic-alignment-check: the whole point — it refuses to let an accidentally
|
||||
# favourable stack slot stand in for an alignment guarantee. This is the flag that caught
|
||||
# the pf-driver-proto defect.
|
||||
# NOTE the absence of -Zmiri-ignore-leaks. Miri leak-checks by DEFAULT, and that is the one
|
||||
# leak-detection capability it offers here. None of the crates below leaks, so the job is
|
||||
# green. The tree does contain DELIBERATE leaks (pf-umdf-util/src/section.rs `ViewCell`,
|
||||
# gamepad_raii.rs leak-on-timeout) — when coverage ever reaches them, annotate those two
|
||||
# sites; do not blanket-disable the check.
|
||||
MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-symbolic-alignment-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Two caches, split on purpose so a Cargo.lock change does not re-download a ~400 MB
|
||||
# toolchain. Both use their OWN `miri-` key prefix — never a shared one.
|
||||
# The Miri sysroot is per-toolchain and per-target (two are built here: host + MSVC), so it
|
||||
# belongs with the toolchain, not with the lockfile.
|
||||
- name: cache the nightly toolchain + Miri sysroots
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/rustup/toolchains/${{ env.MIRI_TOOLCHAIN }}-x86_64-unknown-linux-gnu
|
||||
~/.cache/miri
|
||||
key: miri-toolchain-v1-${{ env.MIRI_TOOLCHAIN }}
|
||||
- name: cache the cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/cargo/registry
|
||||
key: miri-registry-v1-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: miri-registry-v1-
|
||||
|
||||
# The image needs no change for this: ci/rust-ci.Dockerfile:51-54 installs via rustup and
|
||||
# `chmod -R a+w`s both RUSTUP_HOME and CARGO_HOME, so a job can add a toolchain at runtime.
|
||||
# `rust-src` is required — cargo-miri builds its sysroot from source, per target.
|
||||
#
|
||||
# This does NOT disturb the 1.96.0 pin: `cargo +<toolchain>` overrides rust-toolchain.toml
|
||||
# for that single invocation only, so `cargo fmt` / `clippy` keep resolving 1.96.0 and the
|
||||
# fmt-parity contract in CLAUDE.md is untouched. The two echo lines below keep that claim
|
||||
# honest in the log. They are deliberately NOT `rustup show active-toolchain`: that command
|
||||
# RESOLVES the toolchain file and would install the whole 1.96.0 toolchain just to print a
|
||||
# line, in a job where every cargo call is `+$MIRI_TOOLCHAIN` and 1.96.0 is never needed.
|
||||
# Deliberately NOT `rustup override set` — that writes persistent per-directory state into
|
||||
# the runner's rustup config, which leaks into unrelated later jobs on a self-hosted fleet.
|
||||
# Deliberately NOT a second rust-toolchain.toml in a subdirectory — that would apply to
|
||||
# every cargo invocation under that subtree including fmt, which is the drift the root pin
|
||||
# exists to prevent.
|
||||
- name: install the pinned nightly + miri
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
rustup toolchain install "$MIRI_TOOLCHAIN" \
|
||||
--profile minimal \
|
||||
--component miri,rust-src \
|
||||
--target x86_64-pc-windows-msvc
|
||||
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
|
||||
cargo +"$MIRI_TOOLCHAIN" --version
|
||||
|
||||
# A run that reports `0 passed` is a selection that matched nothing, not a success — that
|
||||
# exact mistake has already cost one round-trip here. So each step below checks a zero exit
|
||||
# AND that at least one target reported a non-zero pass count, which is what catches a
|
||||
# crate rename or a `--` filter that stops matching. (Each step legitimately prints several
|
||||
# `0 passed` lines too — the empty bin/doctest targets — so the check is "at least one
|
||||
# non-zero", not "no zeroes".) Expected counts at the time of writing: 21 + 12 + 4.
|
||||
- name: miri — FFI-free leaf crates (native)
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=1
|
||||
cargo +"$MIRI_TOOLCHAIN" miri test \
|
||||
-p pf-driver-proto -p pf-host-config -p pf-gpu 2>&1 | tee /tmp/miri-native.log || ok=0
|
||||
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-native.log || ok=0
|
||||
[ "$ok" = 1 ] || echo "::warning::miri (FFI-free leaf crates, native) did not pass — non-blocking; see punktfunk-planning design/rust-safety-programme.md §7"
|
||||
|
||||
# THE step that justifies the job: pf-driver-proto at MSVC layout, on Linux, no Windows box.
|
||||
# Expected: 21 passed. If this one ever goes red, treat it as a layout-contract break
|
||||
# between the host and driver workspaces until proven otherwise.
|
||||
- name: miri — pf-driver-proto at x86_64-pc-windows-msvc layout
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=1
|
||||
cargo +"$MIRI_TOOLCHAIN" miri test \
|
||||
-p pf-driver-proto --target x86_64-pc-windows-msvc 2>&1 | tee /tmp/miri-msvc.log || ok=0
|
||||
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-msvc.log || ok=0
|
||||
[ "$ok" = 1 ] || echo "::warning::miri (pf-driver-proto @ MSVC layout) did not pass — non-blocking, but this is the layout oracle for every frame and IOCTL; see design/rust-safety-programme.md §7"
|
||||
|
||||
# fec-rs dispatches its GF(2^8) multiply through RUNTIME `is_x86_feature_detected!`. Under
|
||||
# Miri that detection reports the COMPILE-TIME target features, so WITHOUT these RUSTFLAGS
|
||||
# the step silently interprets the scalar fallback and is worthless. Verified both ways on
|
||||
# 192.168.1.25: bare, `avx2=false ssse3=false`; with the flags, `avx2=true ssse3=true` and
|
||||
# `_mm256_shuffle_epi8` genuinely executes under the interpreter. GFNI stays false either
|
||||
# way — Miri does not implement it — so the gfni branch is simply not covered here.
|
||||
#
|
||||
# ⚠ x86_64 ONLY, and it must stay that way. A RUSTFLAGS env var OVERRIDES config rustflags
|
||||
# ENTIRELY (.cargo/config.toml:11-13 says so), and that config carries `--cfg aes_armv8` /
|
||||
# `--cfg polyval_armv8` for aarch64 — worth a measured ~3x decrypt-throughput cliff if
|
||||
# dropped. Harmless here because this job pins ubuntu-24.04/x86_64; fatal on mac-mini-1.
|
||||
# Narrow selection is mandatory, not an optimisation: see the punktfunk-core note above.
|
||||
- name: miri — punktfunk-core fec::gf8, taking the real AVX2/SSSE3 branches
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+avx2,+ssse3
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=1
|
||||
cargo +"$MIRI_TOOLCHAIN" miri test \
|
||||
-p punktfunk-core --lib -- fec::gf8 2>&1 | tee /tmp/miri-gf8.log || ok=0
|
||||
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-gf8.log || ok=0
|
||||
[ "$ok" = 1 ] || echo "::warning::miri (punktfunk-core fec::gf8, AVX2/SSSE3) did not pass — non-blocking; see design/rust-safety-programme.md §7"
|
||||
|
||||
# ASAN + LSAN over the C ABI harness — §6.1 of design/rust-safety-programme.md, its rank-1
|
||||
# tooling item. crates/punktfunk-core/tests/c/run.sh already proves the staticlib links and
|
||||
# round-trips 4 frames byte-exact from C on every push (ci.yml); PF_SAN=address rebuilds BOTH
|
||||
# sides instrumented — the staticlib on nightly with -Zsanitizer/-Zbuild-std (std itself
|
||||
# included), the harness with clang -fsanitize — so ASAN sees the seam a Rust-only tool cannot,
|
||||
# and LSAN (detect_leaks=1, the script's default) becomes the one automated check on abi.rs's
|
||||
# Box::into_raw/from_raw leak contract.
|
||||
# Proven to fail on 192.168.1.25: deleting a single punktfunk_session_free() from harness.c
|
||||
# makes LSAN report the ~308 Rust-side allocations behind the handle and run.sh exit 1.
|
||||
# What it does NOT see: the invalid-InputKind-discriminant UB at abi.rs (that needs the
|
||||
# validator, tracked in §5 of the programme doc), and nothing GPU/Windows — this is the
|
||||
# default-feature (quic-less, opus-less) core only.
|
||||
c-abi-asan:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# The SAME dated pin as the miri job above, deliberately — one nightly date to bump for
|
||||
# both jobs (they have no toolchain interaction; sharing the date just halves the chores).
|
||||
SAN_TOOLCHAIN: nightly-2026-08-10
|
||||
# Same guard as the miri job: audit.yml sets no sccache today, and -Zbuild-std could not
|
||||
# use it anyway. Keeps a future workflow-level sccache from becoming a puzzle.
|
||||
RUSTC_WRAPPER: ""
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Own `san-` key prefixes — never shared with the miri caches, per the cache-poisoning
|
||||
# note there (and so an incomplete save from one job can never starve the other).
|
||||
- name: cache the nightly toolchain
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/rustup/toolchains/${{ env.SAN_TOOLCHAIN }}-x86_64-unknown-linux-gnu
|
||||
key: san-toolchain-v1-${{ env.SAN_TOOLCHAIN }}
|
||||
- name: cache the cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/cargo/registry
|
||||
key: san-registry-v1-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: san-registry-v1-
|
||||
|
||||
# rust-src is required: -Zbuild-std compiles std from source so it is instrumented too —
|
||||
# without that, LSAN cannot attribute allocations made inside std (Vec, Box, HashMap).
|
||||
- name: install the pinned nightly + rust-src
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
rustup toolchain install "$SAN_TOOLCHAIN" --profile minimal --component rust-src
|
||||
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
|
||||
cargo +"$SAN_TOOLCHAIN" --version
|
||||
|
||||
# The image installs clang but Ubuntu does not always pull the compiler-rt sanitizer
|
||||
# runtime with it (verified absent on a stock 26.04 box). Probe with an actual ASAN link
|
||||
# and self-heal via apt if it fails — container jobs on this fleet run as root (the
|
||||
# bun-audit job's apt-get above relies on the same fact).
|
||||
- name: ensure clang's ASAN runtime
|
||||
run: |
|
||||
if ! echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe 2>/dev/null; then
|
||||
apt-get update && apt-get install -y --no-install-recommends "libclang-rt-$(clang -dumpversion | cut -d. -f1)-dev"
|
||||
echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe
|
||||
fi
|
||||
|
||||
# run.sh handles everything behind PF_SAN (nightly build, target path, clang flags,
|
||||
# ASAN_OPTIONS=detect_leaks=1) and exits non-zero on any report. The grep is the
|
||||
# proved-it-ran guard, same reasoning as the miri steps: a script change that silently
|
||||
# skips the harness must not read as green. run.sh expects bash and PATH cargo — both true
|
||||
# in this container. PF_SAN_TOOLCHAIN pins the script's `cargo +<toolchain>` to the dated
|
||||
# nightly installed above — without it the script would ask for the ROLLING `nightly`
|
||||
# channel, which this job deliberately does not install.
|
||||
- name: C ABI harness under ASAN+LSAN
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=1
|
||||
PF_SAN=address PF_SAN_TOOLCHAIN="$SAN_TOOLCHAIN" \
|
||||
bash crates/punktfunk-core/tests/c/run.sh 2>&1 | tee /tmp/asan-harness.log || ok=0
|
||||
grep -q 'PASS: 4 frames round-tripped byte-exact' /tmp/asan-harness.log || ok=0
|
||||
[ "$ok" = 1 ] || echo "::warning::c-abi-asan did not pass — non-blocking on day one; see design/rust-safety-programme.md §6.1. An LSAN report here means the abi.rs into_raw/from_raw contract broke."
|
||||
|
||||
@@ -111,6 +111,13 @@ jobs:
|
||||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
|
||||
# rust-safety WP2c: three textual gates for classes no lint covers — unsafe fn markers
|
||||
# carrying no contract, panic across an extern boundary (an abort since 1.81), and
|
||||
# process-global safe APIs (env::set_var & co, count-ratcheted). Pure grep/awk, no cargo.
|
||||
# Both failure modes were demonstrated before this became blocking (planted instances).
|
||||
- name: Unsafe-hygiene grep gates
|
||||
run: sh scripts/ci/check-unsafe-hygiene.sh
|
||||
|
||||
- name: Clippy (deny warnings)
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
@@ -139,8 +146,8 @@ jobs:
|
||||
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
|
||||
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
|
||||
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
|
||||
# `cargo build`, where warnings are not errors, so pf-encode's own
|
||||
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
|
||||
# `cargo build`, where warnings are not errors, so the `undocumented_unsafe_blocks` deny
|
||||
# (now hoisted into [workspace.lints]) — pf-encode's stated unsafe-proof gate —
|
||||
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
|
||||
# `default = ["pyrowave"]`, so the steps above already cover it.)
|
||||
#
|
||||
|
||||
@@ -159,9 +159,10 @@ jobs:
|
||||
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
|
||||
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
|
||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||
# toolchain-only probe crate and is excluded.)
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. wdk-probe is a
|
||||
# toolchain-only probe crate, but it holds real DDI slot-dispatch unsafe (iddcx_rt.rs), so it
|
||||
# runs the same gates.
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay -p wdk-probe --all-targets -- -D warnings
|
||||
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
|
||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||
|
||||
@@ -184,6 +184,20 @@ but latches nothing; only the full-length attempts that follow hand down negotia
|
||||
classification is a pure function with tests
|
||||
(`pf_capture::linux::first_frame_timeout_tests`).
|
||||
|
||||
### Windows host — an idle box can sleep again (virtual-mic stream idle-stop)
|
||||
|
||||
🛑 **Installing the host blocked system sleep forever, client connected or not.** The
|
||||
host-lifetime mic pump kept a WASAPI render stream RUNNING on the virtual-mic device
|
||||
(typically the Steam Streaming Microphone), writing silence 24/7 — and any running stream makes
|
||||
the Windows audio stack hold a kernel power request ("An audio stream is currently in use" in
|
||||
`powercfg /requests`, attributed to that device) that vetoes sleep. The render loop now stops
|
||||
the stream (`IAudioClient::Stop`; the client stays initialized and the mic *endpoint* keeps
|
||||
existing for apps to bind) after 10 s of silence-only output and resumes on the next mic frame
|
||||
within one device period — below the jitter buffer's prime depth, so nothing is audible.
|
||||
Streaming sessions still hold the box awake through their own `PowerRequest` assertions, as
|
||||
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
|
||||
third-party virtual audio driver misbehaves while its render side is paused.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
Generated
+1
@@ -1116,6 +1116,7 @@ dependencies = [
|
||||
name = "display-disturb"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
|
||||
+11
@@ -101,6 +101,17 @@ repository = "https://git.unom.io/unom/punktfunk"
|
||||
[workspace.lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
|
||||
# The companion lint: every `unsafe {}` / `unsafe impl` carries a `// SAFETY:` proof. Hoisted here
|
||||
# from ~85 per-file `#![deny(...)]` attributes so a NEW crate (or a new module in an old one) is
|
||||
# covered on creation rather than on remembering — the per-file form left pf-vkhdr-layer,
|
||||
# wdk-probe, and half of pf-clipboard uncovered for months. NOTE: this table reaches only crates
|
||||
# with `[lints] workspace = true`; `packaging/windows/drivers` and `packaging/windows/pf-vkhdr-layer`
|
||||
# are SEPARATE workspaces and restate it (any "workspace-wide" claim must be made three times or it
|
||||
# is false). Of the members, only the two vendored snapshots (pf-bitstream/vendor/cros-codecs,
|
||||
# punktfunk-host/vendor/usbip-sim) stay out, deliberately — upstream code stays pristine.
|
||||
[workspace.lints.clippy]
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
|
||||
+5
-1
@@ -53,7 +53,7 @@
|
||||
"clients"
|
||||
],
|
||||
"summary": "Unpair a client",
|
||||
"description": "Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Removing the last pairing also closes the GameStream ENet control port\n(UDP 47999), which is only bound while at least one pairing exists. Caveat: the nvhttp TLS\nlayer does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.",
|
||||
"description": "Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Revocation is complete: a LIVE GameStream session owned by this certificate is\nended (the client gets the standard TERMINATION+disconnect), and removing the last pairing\nalso closes the ENet control port (UDP 47999), which is only bound while at least one pairing\nexists. The nvhttp TLS layer still completes a handshake with any well-formed client cert BY\nDESIGN (authorization is per-request via the paired-fingerprint check) — an unpaired client\nthat reconnects is rejected at every post-pair endpoint.",
|
||||
"operationId": "unpairClient",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4753,6 +4753,10 @@
|
||||
"type": "boolean",
|
||||
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
|
||||
},
|
||||
"edid_lock": {
|
||||
"type": "boolean",
|
||||
"description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off."
|
||||
},
|
||||
"game_session": {
|
||||
"$ref": "#/components/schemas/GameSession",
|
||||
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
||||
|
||||
@@ -43,10 +43,12 @@ struct OutputReady {
|
||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||
enum DecodeEvent {
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||
Au(Frame, bool),
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
|
||||
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
|
||||
/// reassembler's later `frames_dropped` climb for the loss (the feeder already fired the RFI
|
||||
/// request).
|
||||
Au(Frame, u32),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
@@ -603,7 +605,11 @@ fn feeder_loop(
|
||||
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
|
||||
// counting AUs.
|
||||
let au_first = frame.part.is_none_or(|p| p.first);
|
||||
let gap = au_first && client.note_frame_index(frame.frame_index);
|
||||
let gap = if au_first {
|
||||
client.note_frame_index(frame.frame_index)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
@@ -691,9 +697,12 @@ fn dispatch_event(
|
||||
match ev {
|
||||
DecodeEvent::Au(f, gap) => {
|
||||
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||
if gap {
|
||||
gate.arm(Instant::now());
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo). Credited arm: the gap width
|
||||
// pre-covers the reassembler's ~120 ms-later `frames_dropped` climb for the same loss,
|
||||
// so a fast RFI anchor that heals in between isn't re-frozen by it (the double-arm
|
||||
// race — see `ReanchorGate::arm_expecting_drops`).
|
||||
if gap > 0 {
|
||||
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
|
||||
}
|
||||
// One entry per AU (parts share the pts): the completing delivery carries it.
|
||||
if f.complete {
|
||||
|
||||
@@ -222,8 +222,13 @@ pub(super) fn run_sync(
|
||||
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||
if client.note_frame_index(frame.frame_index) {
|
||||
gate.arm(Instant::now());
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// `frames_dropped` climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race — see
|
||||
// `ReanchorGate::arm_expecting_drops`).
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
if gap > 0 {
|
||||
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
|
||||
}
|
||||
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||
|
||||
@@ -9,7 +9,7 @@ use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
||||
use super::{hex32, jni_guard, lock_recover, parse_hex32, SessionHandle};
|
||||
|
||||
/// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared)
|
||||
/// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old
|
||||
@@ -41,7 +41,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastErr
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
) -> jni::sys::jstring {
|
||||
let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap());
|
||||
let token = std::mem::take(&mut *lock_recover(&LAST_ERROR));
|
||||
match env.new_string(token) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
|
||||
@@ -45,6 +45,15 @@ pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
|
||||
})
|
||||
}
|
||||
|
||||
/// Poison-recovering lock for the JNI entry points that are NOT behind [`jni_guard`]: a
|
||||
/// `.lock().unwrap()` there turns a poisoned mutex into a panic across the `extern "system"`
|
||||
/// boundary — an abort of the whole app on Rust ≥ 1.81 (the panic-in-extern grep gate's class).
|
||||
/// The slots behind these mutexes are plane-thread handles and last-value caches; whatever a
|
||||
/// poisoned writer left is still valid to inspect or replace.
|
||||
pub(crate) fn lock_recover<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
|
||||
pub(crate) struct SessionHandle {
|
||||
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
|
||||
|
||||
@@ -8,7 +8,7 @@ use jni::objects::JString;
|
||||
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
|
||||
use jni::JNIEnv;
|
||||
|
||||
use super::{jni_guard, SessionHandle};
|
||||
use super::{jni_guard, lock_recover, SessionHandle};
|
||||
|
||||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
|
||||
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
|
||||
@@ -48,7 +48,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
.filter(|s| !s.is_empty());
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = h.video.lock().unwrap();
|
||||
let mut guard = lock_recover(&h.video);
|
||||
if guard.is_some() {
|
||||
return; // already streaming
|
||||
}
|
||||
@@ -222,7 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
if h.video.lock().unwrap().is_none() {
|
||||
if lock_recover(&h.video).is_none() {
|
||||
return std::ptr::null_mut(); // not streaming → no stats
|
||||
}
|
||||
let snap = h
|
||||
@@ -385,7 +385,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = h.audio.lock().unwrap();
|
||||
let mut guard = lock_recover(&h.audio);
|
||||
if guard.is_some() {
|
||||
return; // already playing
|
||||
}
|
||||
@@ -434,7 +434,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = h.mic.lock().unwrap();
|
||||
let mut guard = lock_recover(&h.mic);
|
||||
if let Some(m) = guard.as_ref() {
|
||||
return m.session_id(); // already capturing — same stream, same session
|
||||
}
|
||||
@@ -516,7 +516,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
*lock_recover(&h.pad_audio) = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
@@ -629,6 +629,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
jboolean::from(h.mic.lock().unwrap().is_some())
|
||||
jboolean::from(lock_recover(&h.mic).is_some())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -774,12 +774,22 @@ public final class PunktfunkConnection {
|
||||
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
|
||||
/// after close.
|
||||
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
|
||||
noteFrameIndexGapWidth(frameIndex) > 0
|
||||
}
|
||||
|
||||
/// Like `noteFrameIndexGap`, but reports the gap's WIDTH — how many frames this arrival revealed
|
||||
/// as missing (0 = none). The post-loss re-anchor gate arms with the width
|
||||
/// (`ReanchorGate.arm(expectingDrops:)`) so the reassembler's later `framesDropped` climb for
|
||||
/// the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm race).
|
||||
/// Same core side effect as `noteFrameIndex` (the throttled RFI request); call it for every
|
||||
/// received AU. Returns 0 after close.
|
||||
public func noteFrameIndexGapWidth(_ frameIndex: UInt32) -> UInt32 {
|
||||
abiLock.lock()
|
||||
defer { abiLock.unlock() }
|
||||
guard let h = handle, !closeRequested else { return false }
|
||||
var gap = false
|
||||
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
|
||||
return gap
|
||||
guard let h = handle, !closeRequested else { return 0 }
|
||||
var width: UInt32 = 0
|
||||
_ = punktfunk_connection_note_frame_index_ex(h, frameIndex, &width)
|
||||
return width
|
||||
}
|
||||
|
||||
/// Cumulative access units the host→client reassembler dropped as unrecoverable (FEC couldn't
|
||||
|
||||
@@ -55,6 +55,16 @@ final class ReanchorGate: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// `arm()` for a loss detected as a frame-index gap of a known width
|
||||
/// (`PunktfunkConnection.noteFrameIndexGapWidth`). Pre-credits the reassembler's later
|
||||
/// `framesDropped` climb for the same lost frames, so `poll` doesn't re-freeze a stream an
|
||||
/// RFI anchor already healed (the double-arm race — the Rust gate's docs tell the story).
|
||||
func arm(expectingDrops: UInt64) {
|
||||
lock.lock()
|
||||
punktfunk_reanchor_gate_arm_expecting_drops(ptr, expectingDrops)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
|
||||
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
|
||||
/// `decoderKeyframe: false` — VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
|
||||
|
||||
@@ -57,6 +57,8 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
|
||||
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
|
||||
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
|
||||
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
|
||||
/// Pump-side events (loss recovery, format seeding) — the stage-2 sibling of StreamPump's log.
|
||||
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "pump")
|
||||
|
||||
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
|
||||
/// user's presentation intent (design/apple-presentation-rebuild.md — the 2026-07 rebuild that
|
||||
@@ -921,7 +923,11 @@ public final class Stage2Pipeline {
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze —
|
||||
// the following concealed frames are withheld until a clean re-anchor.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race).
|
||||
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
|
||||
if gapWidth > 0 { reanchorGate.arm(expectingDrops: UInt64(gapWidth)) }
|
||||
onFrame?(au)
|
||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||
format = f // refreshed on every IDR (mode changes included)
|
||||
@@ -932,6 +938,21 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if format == nil {
|
||||
// No decodable format yet: the opening IDR's parameter sets never
|
||||
// arrived (or never parsed), and under the host's infinite GOP nothing
|
||||
// re-delivers them unless we ASK. Without this the guard below drops
|
||||
// every AU silently, forever — the field "black stream, zero recovery
|
||||
// requests" state (2026-08-12): the host streams perfectly, the client
|
||||
// shows nothing and says nothing. awaitingIDR routes through the same
|
||||
// 100 ms-throttled recovery.request() at the top of the loop.
|
||||
if !awaitingIDR {
|
||||
pumpLog.warning(
|
||||
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
|
||||
)
|
||||
}
|
||||
awaitingIDR = true
|
||||
}
|
||||
guard let f = format, !token.isStopped else { return true }
|
||||
if decoder.decode(au: au, format: f) {
|
||||
decodeFailRun = 0
|
||||
|
||||
@@ -100,7 +100,11 @@ final class StreamPump {
|
||||
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
|
||||
// recovery above stays the backstop for when the recovery frame itself is lost.
|
||||
// The same gap is the earliest, most precise signal to ARM the display freeze.
|
||||
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// framesDropped climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race).
|
||||
let gapWidth = connection.noteFrameIndexGapWidth(au.frameIndex)
|
||||
if gapWidth > 0 { gate.arm(expectingDrops: UInt64(gapWidth)) }
|
||||
onFrame?(au)
|
||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||
if let f = idrFormat {
|
||||
@@ -116,6 +120,21 @@ final class StreamPump {
|
||||
}
|
||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||
}
|
||||
if format == nil {
|
||||
// No decodable format yet: the opening IDR's parameter sets never
|
||||
// arrived (or never parsed), and under the host's infinite GOP nothing
|
||||
// re-delivers them unless we ASK. Without this the format guard below
|
||||
// drops every AU silently, forever — the field "black stream, zero
|
||||
// recovery requests" state (2026-08-12). awaitingIDR routes through the
|
||||
// same 100 ms-throttled recovery.request() at the top of the loop.
|
||||
if !awaitingIDR {
|
||||
awaitingSince = Date()
|
||||
pumpLog.warning(
|
||||
"video: received AUs but no decodable format (missing/unparsed parameter sets) — requesting an IDR until one seeds it"
|
||||
)
|
||||
}
|
||||
awaitingIDR = true
|
||||
}
|
||||
let failed = layer.status == .failed
|
||||
if failed {
|
||||
// Decode wedged hard (the cold-first-connect case — a lost/corrupt opening
|
||||
|
||||
@@ -176,7 +176,13 @@ unsafe extern "system" fn wnd_proc(
|
||||
let slice = unsafe { std::slice::from_raw_parts(cds.lpData as *const u16, len) };
|
||||
let url = String::from_utf16_lossy(slice);
|
||||
tracing::debug!(%url, "link from another instance");
|
||||
INBOX.lock().unwrap().push(url);
|
||||
// Poison-recover, never unwrap: a panic out of a window procedure is an abort since
|
||||
// Rust 1.81, and the inbox is a plain Vec that stays valid whatever a poisoned
|
||||
// writer left behind.
|
||||
INBOX
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push(url);
|
||||
return LRESULT(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
//! (measure the path: probe burst → goodput / loss / recommended bitrate)
|
||||
|
||||
// Unsafe-proof program: every `unsafe {}` in this client carries a `// SAFETY:` proof.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// Link as a GUI (windows) subsystem binary so the default windowed launch (MSIX / double-click)
|
||||
// does NOT pop a console window. The CLI paths (--headless/--discover) reattach to the launching
|
||||
// terminal's console at startup (see main), so their output is still visible when run from a shell.
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
#![allow(non_snake_case)]
|
||||
// Bindgen output for a C API: u128 layout warnings and the like are upstream's concern.
|
||||
#![allow(improper_ctypes)]
|
||||
// The workspace-wide undocumented_unsafe_blocks deny cannot apply to GENERATED code: bindgen
|
||||
// emits `unsafe {}` in layout tests/accessors and nobody hand-writes proofs into OUT_DIR. This
|
||||
// crate is bindings-only by charter (the safe wrapper lives with the consumer), so the allow is
|
||||
// crate-wide; the hand-written link-sanity test below still carries its proof by convention.
|
||||
#![allow(clippy::undocumented_unsafe_blocks)]
|
||||
// Generated code — clippy findings in it (missing safety docs on generated unsafe fns, style
|
||||
// nits across 14k lines) are bindgen's shape, not ours; the safe wrapper in pf-encode is the
|
||||
// linted surface.
|
||||
@@ -27,6 +32,8 @@ mod tests {
|
||||
/// implementations — that's fine, MFXLoad itself must still succeed).
|
||||
#[test]
|
||||
fn dispatcher_links_and_loads() {
|
||||
// SAFETY: MFXLoad allocates the dispatcher's loader context (documented to work with no
|
||||
// driver present) and MFXUnload frees that same non-null handle; nothing else is touched.
|
||||
unsafe {
|
||||
let loader = MFXLoad();
|
||||
assert!(!loader.is_null(), "MFXLoad returned NULL");
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
|
||||
//! orchestrator).
|
||||
|
||||
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
|
||||
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted the crate's hardest FFI
|
||||
// (the ring/slot construction, the channel broker, every D3D converter ctor) from the deny above.
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use anyhow::Result;
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
// The Linux capturer reaches `DmabufFrame` through `super::`; `CursorOverlay` it names directly as
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
// Every `unsafe` block in this module TREE carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||
// program). This file itself has none — the FFI lives in the child modules declared at the bottom
|
||||
// (`pipewire`, `pw_cursor`, `pw_pods`, `portal`, `xfixes_cursor`), which this inner attribute covers.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
//! `crate::dxgi::*` path keeps resolving. DXGI Desktop Duplication has been removed; this
|
||||
//! module contains no capturer.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget};
|
||||
|
||||
// The P010 colour self-test (sweep Phase 5.5) — the `hdr-p010-selftest` subcommand, its f64
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
//! [`pf_driver_proto`] (which OWNS the contract, with `const` size asserts) — both sides `use` it, so
|
||||
//! drift is a compile error rather than a "must match" comment.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::dxgi::{
|
||||
make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, HdrRgb10Converter, PyroFrameShare,
|
||||
VideoConverter, WinCaptureTarget,
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
//! capturer): duplicates the unnamed shared header / ring / event handles into the driver's WUDFHost
|
||||
//! and delivers them as bare handle values over the SYSTEM-only control device.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
//! [`pf_frame::CursorOverlay`] the Linux portal path produces — everything downstream (the
|
||||
//! cursor forwarder, the wire, the client renderer) is shared.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
use pf_driver_proto::cursor::{
|
||||
CursorShm, CURSOR_MAGIC, CURSOR_SHAPE_BYTES, CURSOR_SHAPE_MAX, CURSOR_SHAPE_OFFSET,
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
//! alpha-blended quad (the GDI poller's full-fidelity shape at its polled position), entirely
|
||||
//! GPU-side on the capture device, before the normal conversion runs from the scratch.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
use windows::core::s;
|
||||
use windows::Win32::Graphics::Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
//! `winsta0\default` (the service supervisor retargets the token — `windows/service.rs`
|
||||
//! `spawn_host`), so the poller thread sees the session's cursor directly; no helper process.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Off-thread display-descriptor polling (plan §W4, carved out of the IDD-push capturer): the
|
||||
//! live HDR state + active resolution of the virtual target, sampled off the capture loop via CCD.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The display descriptor the capture loop follows: live HDR state + active resolution of the
|
||||
|
||||
@@ -33,9 +33,6 @@
|
||||
//! The session's `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land
|
||||
//! AFTER that stall's report line — the next report (and the metronomic tally) still carries it.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
//! ([`acquire`]), refcounted across parallel capturers; probes sample at 20 Hz or slower and cost
|
||||
//! microseconds each, so the engine is invisible next to a streaming session.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Capture-stall detection (plan §W4, carved out of the IDD-push capturer): flags multi-hundred-ms
|
||||
//! holes in DWM frame delivery that open while the desktop was actively composing.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
|
||||
@@ -536,14 +533,47 @@ impl StallWatch {
|
||||
suspects)"
|
||||
);
|
||||
} else {
|
||||
// The two REALTIME GPU-priority opt-ins, as configured in THIS process's
|
||||
// environment (machine env; the WUDFHost driver process resolves the PFVD pair
|
||||
// the same way, so this read mirrors what the driver decided — modulo a machine
|
||||
// env edited after either process started, which a restart heals). The RX 9070
|
||||
// XT field A/B (2026-08-12) convicted EXACTLY this warning's signature twice
|
||||
// over: the driver's swap-chain REALTIME raise beat at ~1.8 s, the host
|
||||
// auto-gate's REALTIME upgrade at ~3.6 s — so a log carrying this warning must
|
||||
// say whether either lever is engaged before anyone chases display hardware.
|
||||
let rt_gpu_driver = if std::env::var_os("PFVD_NO_RT_GPU").is_some() {
|
||||
"off (PFVD_NO_RT_GPU)"
|
||||
} else {
|
||||
match std::env::var_os("PFVD_RT_GPU") {
|
||||
None => "off (default)",
|
||||
Some(v) if v.eq_ignore_ascii_case("thread") => "gpu-thread (+7)",
|
||||
Some(_) => "REALTIME (PFVD_RT_GPU)",
|
||||
}
|
||||
};
|
||||
let rt_gpu_host = match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
|
||||
.ok()
|
||||
.as_deref()
|
||||
{
|
||||
Some("off") => "off",
|
||||
Some("normal") => "normal",
|
||||
Some("realtime") => "REALTIME (pinned)",
|
||||
Some("auto") => "auto (gated REALTIME upgrade)",
|
||||
_ => "high (default)",
|
||||
};
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
rt_gpu_driver,
|
||||
rt_gpu_host,
|
||||
verdicts = %verdict_tally,
|
||||
classes = %class_tally,
|
||||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||||
the disturbance is BELOW Windows. FIRST: if rt_gpu_driver or \
|
||||
rt_gpu_host shows a REALTIME opt-in, clear it (unset PFVD_RT_GPU / \
|
||||
set PUNKTFUNK_GPU_PRIORITY_CLASS=high) — a punktfunk process holding \
|
||||
REALTIME GPU priority is the field-proven amplifier of exactly this \
|
||||
signature on AMD. Otherwise: the GPU driver servicing a \
|
||||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
// proof of why it is sound. This crate held ~91 unsafe items with NO enforcement while every
|
||||
// other subsystem crate denied it — the decoders' `unsafe impl Send`s had a one-line aside
|
||||
// instead of an argument precisely because nothing required one.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod au_dump;
|
||||
|
||||
@@ -885,7 +885,12 @@ fn pump(
|
||||
Some(exp) => {
|
||||
if let Some(gap) = index_gap(exp, frame.frame_index) {
|
||||
let now = Instant::now();
|
||||
gate.arm(now);
|
||||
// Credited arm: the reassembler books these same lost frames into
|
||||
// `frames_dropped` up to ~120 ms from now; the credit keeps that
|
||||
// delayed climb from re-freezing a stream the RFI anchor healed in
|
||||
// between (the double-arm race — see
|
||||
// `ReanchorGate::arm_expecting_drops`).
|
||||
gate.arm_expecting_drops(now, u64::from(gap));
|
||||
next_expected_index = Some(frame.frame_index.wrapping_add(1));
|
||||
// The gap carries the PRECISE lost range — [first missing, newest
|
||||
// received - 1] — so this is the one recovery signal that can drive true
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
//! (`PostMessage` is the documented thread-safe way to poke a message loop). Per-window state hangs
|
||||
//! off `GWLP_USERDATA`, so multiple concurrent sessions each get their own window + state.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; the deny enforcing it sits at
|
||||
// the crate root (lib.rs), covering every backend.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
//! [`spawn_decline_loop`] — so its control loop compiles unchanged on every host platform; the
|
||||
//! platform split lives entirely behind [`start`].
|
||||
|
||||
// Unsafe-proof program: every `unsafe` block in any backend carries a `// SAFETY:` proof,
|
||||
// enforced workspace-wide by `[workspace.lints]` — a new backend under `host/` is covered on
|
||||
// creation.
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
//! capture hint, start banner.
|
||||
|
||||
// Unsafe-proof program: every `unsafe {}` in the Skia/Vulkan overlay carries a `// SAFETY:` proof.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod anim;
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
//! ([`dxva::as_bytes`] / [`dxva::slice_bytes`]), fenced behind a sealed trait
|
||||
//! that only this crate's `#[repr(C)]` PODs implement, and carrying a written
|
||||
//! proof — enforced:
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
pub mod config;
|
||||
pub mod descriptors;
|
||||
|
||||
@@ -48,6 +48,8 @@ impl AvBuffer {
|
||||
/// allocator returns on failure (so the `is_null` check every caller used to open-code happens
|
||||
/// once, here).
|
||||
///
|
||||
// unsafe-fn-no-op-ok: contract-deferring constructor (`Vec::set_len` shape) — the body is
|
||||
// safe; the ownership transfer promised here is what Drop/as_ptr later rely on.
|
||||
/// # Safety
|
||||
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
|
||||
/// nothing else may unref it.
|
||||
@@ -117,6 +119,88 @@ impl Drop for AvFilterGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/// An owned `AVFrame`, freed exactly once when it drops.
|
||||
///
|
||||
/// The house pattern (`AvBuffer` above): `alloc` rejects the allocator's null once, `as_ptr`
|
||||
/// lends, `Drop` frees, no `Clone`. Before this type existed the crate held 8 `av_frame_alloc`
|
||||
/// sites matched by 22 hand-placed `av_frame_free`s — an ownership contract upheld by nobody,
|
||||
/// and broken in practice: the Windows zero-copy submit path leaked the frame AND a pooled
|
||||
/// hwframe surface on three `?` exits, under a comment asserting the opposite (fixed in the
|
||||
/// same change that introduced this type).
|
||||
///
|
||||
/// Why not ffmpeg-next's own RAII frame (`frame::Video::empty()`, already used as `VideoFrame`
|
||||
/// in the Linux NVENC path): `Frame::empty()` does not null-check — on allocator failure it
|
||||
/// wraps null and the next field write through it is UB — whereas every open-coded site here
|
||||
/// null-checked. This type keeps that: `alloc` returns `Option`, mirroring
|
||||
/// `AvFilterGraph::alloc`.
|
||||
pub(crate) struct AvFrame(std::ptr::NonNull<ffi::AVFrame>);
|
||||
|
||||
impl AvFrame {
|
||||
/// Allocate a frame, rejecting the null `av_frame_alloc` returns on OOM.
|
||||
///
|
||||
/// Safe: the call takes no arguments and has no precondition a caller could violate — the
|
||||
/// only contract is what happens to the result, and that is exactly what this type owns.
|
||||
pub(crate) fn alloc() -> Option<Self> {
|
||||
// SAFETY: parameterless allocator; it returns either a fresh, uniquely-owned frame whose
|
||||
// ownership passes to the value returned here, or null (rejected by NonNull::new).
|
||||
std::ptr::NonNull::new(unsafe { ffi::av_frame_alloc() }).map(AvFrame)
|
||||
}
|
||||
|
||||
/// The borrowed pointer, for the ffmpeg calls that fill or read the frame without taking
|
||||
/// ownership of it. Borrowed only — the `AvFrame` stays the owner, so callers must not free
|
||||
/// or move-from what this returns.
|
||||
pub(crate) fn as_ptr(&self) -> *mut ffi::AVFrame {
|
||||
self.0.as_ptr()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvFrame {
|
||||
fn drop(&mut self) {
|
||||
let mut p = self.0.as_ptr();
|
||||
// SAFETY: `p` is the non-null frame `alloc` took ownership of, and this type is its
|
||||
// sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs exactly
|
||||
// once. `av_frame_free` unrefs any buffers the frame holds (returning pooled hwframe
|
||||
// surfaces to their pool) and frees the frame; it nulls only the local copy.
|
||||
unsafe { ffi::av_frame_free(&mut p) };
|
||||
}
|
||||
}
|
||||
|
||||
/// An owned swscale context, freed exactly once when it drops.
|
||||
///
|
||||
/// Same ownership question as the frame above — `sws_getContext` at 3 sites was matched by 5
|
||||
/// hand-placed `sws_freeContext`s, two of them inside hand-written `Drop` impls whose real job
|
||||
/// this type absorbs.
|
||||
pub(crate) struct AvSwsContext(std::ptr::NonNull<ffi::SwsContext>);
|
||||
|
||||
impl AvSwsContext {
|
||||
/// Take ownership of a freshly-created `SwsContext`, rejecting the null `sws_getContext`
|
||||
/// returns on failure (unsupported conversion or OOM).
|
||||
///
|
||||
// unsafe-fn-no-op-ok: contract-deferring constructor (`Vec::set_len` shape) — the body is
|
||||
// safe; the ownership transfer promised here is what Drop/as_ptr later rely on.
|
||||
/// # Safety
|
||||
/// `p` must be null, or a live `SwsContext` whose ownership passes to the returned value —
|
||||
/// nothing else may free it.
|
||||
pub(crate) unsafe fn from_raw(p: *mut ffi::SwsContext) -> Option<Self> {
|
||||
std::ptr::NonNull::new(p).map(AvSwsContext)
|
||||
}
|
||||
|
||||
/// The borrowed pointer, for `sws_scale` calls. Borrowed only — the `AvSwsContext` stays
|
||||
/// the owner.
|
||||
pub(crate) fn as_ptr(&self) -> *mut ffi::SwsContext {
|
||||
self.0.as_ptr()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvSwsContext {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the non-null context `from_raw` took ownership of, and this type
|
||||
// is its sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs
|
||||
// exactly once.
|
||||
unsafe { ffi::sws_freeContext(self.0.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
/// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can
|
||||
/// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse
|
||||
/// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop.
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
|
||||
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
|
||||
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
@@ -26,8 +24,8 @@ use std::os::raw::c_int;
|
||||
use std::ptr;
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_ITU709,
|
||||
SWS_POINT,
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
|
||||
SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
@@ -193,6 +191,17 @@ struct OpenArgs {
|
||||
}
|
||||
|
||||
pub struct NvencEncoder {
|
||||
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced ran before any field
|
||||
// drop, freeing `sws_csc` ahead of `enc`/`frame`/`cuda` — and this path runs on every
|
||||
// stall-watchdog recovery via `*self = fresh` in `reset`. Declaration order is what
|
||||
// preserves that sequence now (drop order follows declaration; an offset_of assert cannot
|
||||
// pin it — repr(Rust) may lay memory out in any order).
|
||||
/// CPU CSC paths only: swscale context converting the captured packed source into
|
||||
/// [`Self::frame`] — RGB/BGR → planar YUV444P for a 4:4:4 session (`hevc_nvenc` only emits
|
||||
/// 4:4:4 from a YUV444 *input*; RGB-in is always 4:2:0), or X2RGB10/X2BGR10 → P010 (BT.2020
|
||||
/// limited) for an HDR session. `None` on the plain RGB paths AND on the zero-copy paths (the
|
||||
/// worker's GPU convert delivers ready CUDA frames).
|
||||
sws_csc: Option<AvSwsContext>,
|
||||
enc: encoder::video::Encoder,
|
||||
/// Reusable 4-bpp CPU input frame (CPU path only; `None` for the zero-copy/CUDA path).
|
||||
/// Mutating it in place across frames is sound only because the encoder is opened with
|
||||
@@ -201,12 +210,6 @@ pub struct NvencEncoder {
|
||||
frame: Option<VideoFrame>,
|
||||
/// Zero-copy path: CUDA hwdevice/hwframes contexts (the encoder takes `AV_PIX_FMT_CUDA`).
|
||||
cuda: Option<CudaHw>,
|
||||
/// CPU CSC paths only: swscale context converting the captured packed source into
|
||||
/// [`Self::frame`] — RGB/BGR → planar YUV444P for a 4:4:4 session (`hevc_nvenc` only emits
|
||||
/// 4:4:4 from a YUV444 *input*; RGB-in is always 4:2:0), or X2RGB10/X2BGR10 → P010 (BT.2020
|
||||
/// limited) for an HDR session. `None` on the plain RGB paths AND on the zero-copy paths (the
|
||||
/// worker's GPU convert delivers ready CUDA frames). Freed in `Drop`.
|
||||
sws_csc: Option<*mut ffi::SwsContext>,
|
||||
/// This session opened as full-chroma 4:4:4 (FREXT) — via either input path.
|
||||
want_444: bool,
|
||||
src_format: PixelFormat,
|
||||
@@ -228,7 +231,7 @@ pub struct NvencEncoder {
|
||||
args: OpenArgs,
|
||||
}
|
||||
|
||||
// `CudaHw` holds raw `AVBufferRef`s and `sws_csc` a raw `SwsContext`; the encoder lives on a single
|
||||
// `CudaHw` holds raw `AVBufferRef`s and `sws_csc` an owned `SwsContext`; the encoder lives on a single
|
||||
// thread. The CPU encoder is already `Send` via ffmpeg-next; assert it for the raw fields too.
|
||||
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
|
||||
// holding raw `AVBufferRef`s and an optional raw `SwsContext`, none of which are `Send` by default.
|
||||
@@ -610,14 +613,13 @@ impl NvencEncoder {
|
||||
);
|
||||
}
|
||||
|
||||
// Built HERE, below the fallible encoder open, NOT above it. `sws_getContext` returns a raw
|
||||
// pointer whose only free is `Drop for NvencEncoder` — and `Drop` needs a CONSTRUCTED
|
||||
// `Self`, which does not exist on `open`'s early returns (the intra-refresh-unsupported
|
||||
// retry, which recurses into `Self::open`, and the plain error return). Creating the
|
||||
// context above them leaked one per failed attempt, and `open_nvenc_probed`'s EINVAL
|
||||
// bitrate ladder calls `open` up to ~10 times, so a host stepping its bitrate down leaked a
|
||||
// context per step. Nothing between here and the `Ok(NvencEncoder { … })` below can return,
|
||||
// so this placement makes the leak unrepresentable rather than merely unlikely.
|
||||
// Built HERE, below the fallible encoder open, NOT above it — historically because the
|
||||
// context's only free was `Drop for NvencEncoder`, which needs a CONSTRUCTED `Self` that
|
||||
// does not exist on `open`'s early returns; creating it above them leaked one per failed
|
||||
// attempt, and `open_nvenc_probed`'s EINVAL bitrate ladder calls `open` up to ~10 times.
|
||||
// The owned `AvSwsContext` now frees itself on any exit, but the placement stays: it
|
||||
// documents the dependency on the post-open `nvenc_pixel`, and there is no reason to
|
||||
// build a context an early return would just throw away.
|
||||
// CPU CSC paths: build the packed-RGB → planar swscale (no rescale) into the encoder's
|
||||
// input frame. THREE users: 4:4:4 (RGB→YUV444P, BT.709, range per the flag), HDR
|
||||
// (X2RGB10/X2BGR10→P010, BT.2020 limited — the PQ transfer is per-channel and rides
|
||||
@@ -642,10 +644,10 @@ impl NvencEncoder {
|
||||
// formats. Both dims are the encoder's positive `width`/`height` as `c_int`; `src_av` is a
|
||||
// valid `AVPixelFormat` (from the `sws_src_pixel`-validated packed-RGB source), the dst is
|
||||
// YUV444P (4:4:4) or P010LE (HDR). The trailing filter/param pointers are null = "use
|
||||
// defaults" (documented as accepted). No Rust memory is borrowed; the returned pointer is
|
||||
// null-checked below.
|
||||
// defaults" (documented as accepted). No Rust memory is borrowed; ownership of the
|
||||
// returned context passes to the `AvSwsContext` (null rejected by `from_raw`).
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
AvSwsContext::from_raw(ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
@@ -656,11 +658,11 @@ impl NvencEncoder {
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
))
|
||||
};
|
||||
if sws.is_null() {
|
||||
let Some(sws) = sws else {
|
||||
bail!("sws_getContext(RGB→{nvenc_pixel:?}) failed");
|
||||
}
|
||||
};
|
||||
// Colour math applies to the CSC users ONLY. The expand is a pure byte shuffle —
|
||||
// packed 3-bpp RGB/BGR to the same channels in 4 bytes, `nvenc_pixel` being `rgb0`/
|
||||
// `bgr0` — and NVENC does the RGB→YUV itself downstream. Handing it a matrix + range
|
||||
@@ -680,7 +682,16 @@ impl NvencEncoder {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
let dst_range = i32::from(full_range_444);
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, dst_range, 0, 1 << 16, 1 << 16);
|
||||
ffi::sws_setColorspaceDetails(
|
||||
sws.as_ptr(),
|
||||
cs,
|
||||
1,
|
||||
cs,
|
||||
dst_range,
|
||||
0,
|
||||
1 << 16,
|
||||
1 << 16,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(sws)
|
||||
@@ -694,10 +705,10 @@ impl NvencEncoder {
|
||||
Some(VideoFrame::new(nvenc_pixel, width, height))
|
||||
};
|
||||
Ok(NvencEncoder {
|
||||
sws_csc,
|
||||
enc,
|
||||
frame,
|
||||
cuda: cuda_hw,
|
||||
sws_csc,
|
||||
want_444,
|
||||
src_format: format,
|
||||
width,
|
||||
@@ -840,7 +851,7 @@ impl NvencEncoder {
|
||||
// three CSC users (see `open`): 4:4:4 → planar YUV444P, HDR → P010, and the packed 3-bpp
|
||||
// expand → `rgb0`/`bgr0`. The remaining branch below is the 4-bpp source, which needs no
|
||||
// conversion at all — just a row copy honouring the destination stride.
|
||||
if let Some(sws) = self.sws_csc {
|
||||
if let Some(sws) = self.sws_csc.as_ref().map(AvSwsContext::as_ptr) {
|
||||
let frame = self
|
||||
.frame
|
||||
.as_mut()
|
||||
@@ -929,27 +940,23 @@ impl NvencEncoder {
|
||||
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via
|
||||
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
|
||||
// (`make_current()?`), the precondition for the device-pointer copies below.
|
||||
// * `av_frame_alloc` → `f` (null-checked). `av_hwframe_get_buffer(frames_ref, f, 0)` fills `f`
|
||||
// with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`); on
|
||||
// failure we free `f` and bail.
|
||||
// * For NV12 we read `(*f).data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
|
||||
// `data[0]`/`linesize[0]` — in-struct fields of the non-null `f`, valid for the surface dims
|
||||
// ffmpeg allocated — and pass them to the cuda copy helpers, which device→device copy `buf`
|
||||
// (the imported `DeviceBuffer`, owned by the caller and live for this call) into the surface.
|
||||
// * On copy error we free `f` and return. Otherwise we write `pts`/`pict_type` through `f` and
|
||||
// `avcodec_send_frame` it into the live owned `self.enc` context (which takes its own ref of
|
||||
// the pooled surface), then free our `f` ref exactly once. Single-threaded encoder → no race.
|
||||
// * `f` is an owned `AvFrame` — every exit below (bail, copy error, success) drops it
|
||||
// exactly once, releasing its ref on the pooled surface. `av_hwframe_get_buffer` fills
|
||||
// it with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`).
|
||||
// * For NV12 we read `data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
|
||||
// `data[0]`/`linesize[0]` — in-struct fields of the live frame, valid for the surface
|
||||
// dims ffmpeg allocated — and pass them to the cuda copy helpers, which device→device
|
||||
// copy `buf` (the imported `DeviceBuffer`, owned by the caller and live for this call)
|
||||
// into the surface.
|
||||
// * `avcodec_send_frame` takes its own ref of the pooled surface, so the drop afterwards
|
||||
// is the sole owning free. Single-threaded encoder → no race.
|
||||
unsafe {
|
||||
let mut f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
bail!("av_frame_alloc failed");
|
||||
}
|
||||
let f = AvFrame::alloc().context("av_frame_alloc failed")?;
|
||||
// Pooled CUDA surface: sets format, width/height, data[0]/linesize[0], buf[0] and
|
||||
// hw_frames_ctx. Reused across frames (the pool recycles), keeping NVENC's
|
||||
// registration cache warm.
|
||||
let r = ffi::av_hwframe_get_buffer(frames_ref, f, 0);
|
||||
let r = ffi::av_hwframe_get_buffer(frames_ref, f.as_ptr(), 0);
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut f);
|
||||
bail!("av_hwframe_get_buffer(CUDA) failed ({r})");
|
||||
}
|
||||
// NV12 surfaces are two-plane (Y in data[0], interleaved UV in data[1]); YUV444
|
||||
@@ -960,41 +967,36 @@ impl NvencEncoder {
|
||||
let copy_res = if buf.yuv444 {
|
||||
let dsts = core::array::from_fn(|i| {
|
||||
(
|
||||
(*f).data[i] as pf_zerocopy::cuda::CUdeviceptr,
|
||||
(*f).linesize[i] as usize,
|
||||
(*f.as_ptr()).data[i] as pf_zerocopy::cuda::CUdeviceptr,
|
||||
(*f.as_ptr()).linesize[i] as usize,
|
||||
)
|
||||
});
|
||||
pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts, true)
|
||||
} else if self.want_444 {
|
||||
ffi::av_frame_free(&mut f);
|
||||
bail!(
|
||||
"4:4:4 session but the zero-copy frame is not YUV444 (LINEAR/gamescope \
|
||||
capture has no GPU 4:4:4 convert) — unset PUNKTFUNK_ZEROCOPY to use the \
|
||||
CPU 4:4:4 path on this compositor"
|
||||
);
|
||||
} else if buf.is_nv12() {
|
||||
let y_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let y_pitch = (*f).linesize[0] as usize;
|
||||
let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let uv_pitch = (*f).linesize[1] as usize;
|
||||
let y_ptr = (*f.as_ptr()).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let y_pitch = (*f.as_ptr()).linesize[0] as usize;
|
||||
let uv_ptr = (*f.as_ptr()).data[1] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let uv_pitch = (*f.as_ptr()).linesize[1] as usize;
|
||||
pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch, true)
|
||||
} else {
|
||||
let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let dst_pitch = (*f).linesize[0] as usize;
|
||||
let dst_ptr = (*f.as_ptr()).data[0] as pf_zerocopy::cuda::CUdeviceptr;
|
||||
let dst_pitch = (*f.as_ptr()).linesize[0] as usize;
|
||||
pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch, true)
|
||||
};
|
||||
if let Err(e) = copy_res {
|
||||
ffi::av_frame_free(&mut f);
|
||||
return Err(e).context("copy imported buffer into NVENC surface");
|
||||
}
|
||||
(*f).pts = pts;
|
||||
(*f).pict_type = if idr {
|
||||
copy_res.context("copy imported buffer into NVENC surface")?;
|
||||
(*f.as_ptr()).pts = pts;
|
||||
(*f.as_ptr()).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), f);
|
||||
ffi::av_frame_free(&mut f);
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), f.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame(CUDA) failed ({r})");
|
||||
}
|
||||
@@ -1003,16 +1005,9 @@ impl NvencEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NvencEncoder {
|
||||
fn drop(&mut self) {
|
||||
if let Some(sws) = self.sws_csc.take() {
|
||||
// SAFETY: `sws` is the non-null `SwsContext` allocated by `sws_getContext` in `open` and
|
||||
// owned exclusively by this encoder (taken out of the field so it can't be freed twice).
|
||||
// `sws_freeContext` frees it; nothing else references it after this single-threaded drop.
|
||||
unsafe { ffi::sws_freeContext(sws) };
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `NvencEncoder`: `sws_csc` (`Option<AvSwsContext>`) frees itself, and as field #1
|
||||
// it does so ahead of `enc`/`frame`/`cuda` — the same sequence the hand-written `Drop` performed
|
||||
// (see the field-order note on the struct).
|
||||
|
||||
/// Serialises the save → `AV_LOG_FATAL` → restore window that every capability probe opens around
|
||||
/// an encoder open it *expects* to fail.
|
||||
|
||||
@@ -63,8 +63,6 @@
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, cached_split_verdict, codec_guid,
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
|
||||
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
|
||||
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
@@ -36,8 +34,8 @@ use std::ptr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, PollOutcome,
|
||||
SWS_CS_ITU709, SWS_POINT,
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, AvFrame,
|
||||
AvSwsContext, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
@@ -546,8 +544,13 @@ impl VaapiHw {
|
||||
struct CpuInner {
|
||||
enc: encoder::video::Encoder,
|
||||
hw: VaapiHw,
|
||||
sws: *mut ffi::SwsContext,
|
||||
nv12: *mut ffi::AVFrame, // reusable software NV12 staging frame (swscale dst → upload src)
|
||||
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced freed `nv12` BEFORE
|
||||
// `sws` — the reverse of the old declaration order — and field-DECLARATION order is what
|
||||
// preserves that now (drop order follows declaration; an offset_of assert cannot pin it,
|
||||
// repr(Rust) may lay memory out in any order).
|
||||
/// Reusable software NV12/P010 staging frame (swscale dst → upload src).
|
||||
nv12: AvFrame,
|
||||
sws: AvSwsContext,
|
||||
src_format: PixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -602,10 +605,10 @@ impl CpuInner {
|
||||
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
|
||||
// `src_pixel`), the dst is NV12/P010. The three trailing pointers (srcFilter, dstFilter,
|
||||
// param) are explicitly null = "use defaults", which the API documents as accepted. No Rust
|
||||
// memory is borrowed — only by-value ints/enums — and the returned pointer is null-checked
|
||||
// just below.
|
||||
// memory is borrowed — only by-value ints/enums — and ownership of the returned context
|
||||
// passes to the `AvSwsContext` (null rejected by `from_raw`).
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
AvSwsContext::from_raw(ffi::sws_getContext(
|
||||
width as c_int,
|
||||
height as c_int,
|
||||
src_av,
|
||||
@@ -616,16 +619,15 @@ impl CpuInner {
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
)
|
||||
))
|
||||
};
|
||||
if sws.is_null() {
|
||||
let Some(sws) = sws else {
|
||||
bail!(
|
||||
"sws_getContext(RGB→{})",
|
||||
if ten_bit { "P010" } else { "NV12" }
|
||||
);
|
||||
}
|
||||
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
|
||||
// check immediately preceding returned false). The coefficient table from
|
||||
};
|
||||
// SAFETY: `sws` is the live owned context from above. The coefficient table from
|
||||
// `sws_getCoefficients` (ITU-709, or BT.2020 NCL for the HDR path — matching the VUI) is a
|
||||
// libswscale static const valid for the whole process, reused here for both the inverse
|
||||
// (src) and forward (dst) matrices. `sws_setColorspaceDetails` only reads those tables and
|
||||
@@ -637,32 +639,22 @@ impl CpuInner {
|
||||
} else {
|
||||
SWS_CS_ITU709
|
||||
});
|
||||
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
|
||||
ffi::sws_setColorspaceDetails(sws.as_ptr(), cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
|
||||
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
|
||||
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
|
||||
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
|
||||
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
|
||||
// NV12/P010 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a
|
||||
// unique fresh pointer, so none of these writes alias anything.
|
||||
let nv12 = unsafe {
|
||||
let f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
ffi::sws_freeContext(sws);
|
||||
bail!("av_frame_alloc(staging) failed");
|
||||
}
|
||||
(*f).format = staging_av as c_int;
|
||||
(*f).width = width as c_int;
|
||||
(*f).height = height as c_int;
|
||||
if ffi::av_frame_get_buffer(f, 0) < 0 {
|
||||
let mut f = f;
|
||||
ffi::av_frame_free(&mut f);
|
||||
ffi::sws_freeContext(sws);
|
||||
let nv12 = AvFrame::alloc().context("av_frame_alloc(staging) failed")?;
|
||||
// SAFETY: writing the plain `format`/`width`/`height` fields through the owned frame's
|
||||
// pointer stays inside its allocation (sole owner, not yet shared).
|
||||
// `av_frame_get_buffer` allocates backing storage for those dims/format; on failure the
|
||||
// owned `nv12` (and the `sws` above it) simply drop — the hand-written unwind this
|
||||
// replaced had to free both by hand on every branch.
|
||||
unsafe {
|
||||
(*nv12.as_ptr()).format = staging_av as c_int;
|
||||
(*nv12.as_ptr()).width = width as c_int;
|
||||
(*nv12.as_ptr()).height = height as c_int;
|
||||
if ffi::av_frame_get_buffer(nv12.as_ptr(), 0) < 0 {
|
||||
bail!("av_frame_get_buffer(staging) failed");
|
||||
}
|
||||
f
|
||||
};
|
||||
}
|
||||
tracing::info!(
|
||||
encoder = codec.vaapi_name(),
|
||||
"VAAPI encode active ({width}x{height}@{fps}, CPU→{} upload path)",
|
||||
@@ -671,8 +663,8 @@ impl CpuInner {
|
||||
Ok(CpuInner {
|
||||
enc,
|
||||
hw,
|
||||
sws,
|
||||
nv12,
|
||||
sws,
|
||||
src_format: format,
|
||||
width,
|
||||
height,
|
||||
@@ -693,49 +685,43 @@ impl CpuInner {
|
||||
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from
|
||||
// `src_data[0] = bytes.as_ptr()` (the other planes null/0 — packed RGB is single-plane), all
|
||||
// in bounds; `bytes`, `src_data`, `src_stride` are live locals for this synchronous call.
|
||||
// `self.sws` is the non-null context built in `open`; it writes into `self.nv12` (a non-null
|
||||
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
|
||||
// `av_frame_alloc` (null-checked) yields a fresh `hwf`; `av_hwframe_get_buffer` pulls a pooled
|
||||
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads
|
||||
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write
|
||||
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live
|
||||
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once.
|
||||
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race.
|
||||
// `self.sws` is the owned context built in `open`; it writes into `self.nv12` (an owned
|
||||
// frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
|
||||
// `hwf` is an owned `AvFrame` — every exit below drops it exactly once, releasing its ref
|
||||
// on the pooled VAAPI surface. `av_hwframe_get_buffer` pulls that surface from the live
|
||||
// non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads the staged NV12 into
|
||||
// it. `avcodec_send_frame` takes its own ref, so the drop afterwards is the sole owning
|
||||
// free. The encoder runs only on this thread (see `unsafe impl Send`), so no
|
||||
// aliasing/data race.
|
||||
unsafe {
|
||||
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
|
||||
if ffi::sws_scale(
|
||||
self.sws,
|
||||
self.sws.as_ptr(),
|
||||
src_data.as_ptr(),
|
||||
src_stride.as_ptr(),
|
||||
0,
|
||||
h as c_int,
|
||||
(*self.nv12).data.as_ptr(),
|
||||
(*self.nv12).linesize.as_ptr(),
|
||||
(*self.nv12.as_ptr()).data.as_ptr(),
|
||||
(*self.nv12.as_ptr()).linesize.as_ptr(),
|
||||
) < 0
|
||||
{
|
||||
bail!("sws_scale RGB→NV12 failed");
|
||||
}
|
||||
let mut hwf = ffi::av_frame_alloc();
|
||||
if hwf.is_null() {
|
||||
bail!("av_frame_alloc(hw) failed");
|
||||
}
|
||||
if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf, 0) < 0 {
|
||||
ffi::av_frame_free(&mut hwf);
|
||||
let hwf = AvFrame::alloc().context("av_frame_alloc(hw) failed")?;
|
||||
if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf.as_ptr(), 0) < 0 {
|
||||
bail!("av_hwframe_get_buffer(VAAPI) failed");
|
||||
}
|
||||
if ffi::av_hwframe_transfer_data(hwf, self.nv12, 0) < 0 {
|
||||
ffi::av_frame_free(&mut hwf);
|
||||
if ffi::av_hwframe_transfer_data(hwf.as_ptr(), self.nv12.as_ptr(), 0) < 0 {
|
||||
bail!("av_hwframe_transfer_data(→VAAPI) failed");
|
||||
}
|
||||
(*hwf).pts = pts;
|
||||
(*hwf).pict_type = if idr {
|
||||
(*hwf.as_ptr()).pts = pts;
|
||||
(*hwf.as_ptr()).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf);
|
||||
ffi::av_frame_free(&mut hwf);
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame(VAAPI) failed ({r})");
|
||||
}
|
||||
@@ -744,24 +730,10 @@ impl CpuInner {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CpuInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each
|
||||
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double
|
||||
// free). `CpuInner` owns both exclusively and `Drop` runs once. `av_frame_free` takes `&mut`
|
||||
// and nulls the pointer. `self.enc`/`self.hw` are freed afterward by their own `Drop` impls;
|
||||
// the encoder holds its own `av_buffer_ref`'d device/frames copies, so field-drop order is
|
||||
// irrelevant to soundness.
|
||||
unsafe {
|
||||
if !self.nv12.is_null() {
|
||||
ffi::av_frame_free(&mut self.nv12);
|
||||
}
|
||||
if !self.sws.is_null() {
|
||||
ffi::sws_freeContext(self.sws);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `CpuInner`: `nv12` (`AvFrame`) and `sws` (`AvSwsContext`) free themselves, in
|
||||
// field-declaration order — the same nv12-then-sws sequence the hand-written `Drop` performed
|
||||
// (see the field-order note on the struct). The encoder holds its own `av_buffer_ref`'d
|
||||
// device/frames copies, so their order against `enc`/`hw` is irrelevant to soundness.
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Zero-copy dmabuf path: DRM-PRIME → hwmap(vaapi) → scale_vaapi(nv12) filter graph → encode.
|
||||
@@ -1043,16 +1015,20 @@ impl DmabufInner {
|
||||
// whole synchronous `submit`; we describe one object/layer/plane from its
|
||||
// fourcc/modifier/offset/stride and its `lseek`-queried size. `libc::lseek` on that live
|
||||
// fd only reads the description's size and returns it (or -1); it touches no Rust memory.
|
||||
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
|
||||
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
|
||||
// * `drm`/`nv12` are owned `AvFrame`s — every exit drops each exactly once (the
|
||||
// hand-placed frees this replaced were branch-clean, but only by inspection). We set
|
||||
// `drm`'s scalar fields and `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref
|
||||
// of the live owned ctx).
|
||||
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
|
||||
// av_buffer_create(.., free_desc, ..)` registers a destructor that reclaims it exactly once
|
||||
// when the buffer's refcount hits zero — matched alloc/free, no leak/double-free.
|
||||
// * `av_buffersrc_add_frame_flags(self.src, drm, KEEP_REF)` pushes a ref into the live
|
||||
// buffersrc; KEEP_REF keeps our own `drm` ref, which we then `av_frame_free`. We pull the
|
||||
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the
|
||||
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned
|
||||
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race.
|
||||
// buffersrc; KEEP_REF keeps our own `drm` ref, dropped explicitly right after the push
|
||||
// (the same point the hand-written free sat, kept so the descriptor's release timing
|
||||
// across the pull does not change). We pull the converted surface with
|
||||
// `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the dmabuf (owned by
|
||||
// the caller) is read while still valid. `nv12` is sent into the live owned `self.enc`
|
||||
// (takes its own ref) and dropped. Single-threaded encoder → no race.
|
||||
unsafe {
|
||||
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
|
||||
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
|
||||
@@ -1077,21 +1053,18 @@ impl DmabufInner {
|
||||
desc.layers[0].planes[0].offset = dmabuf.offset as isize;
|
||||
desc.layers[0].planes[0].pitch = dmabuf.stride as isize;
|
||||
|
||||
let mut drm = ffi::av_frame_alloc();
|
||||
if drm.is_null() {
|
||||
bail!("av_frame_alloc(drm) failed");
|
||||
}
|
||||
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
|
||||
(*drm).width = self.width as c_int;
|
||||
(*drm).height = self.height as c_int;
|
||||
let drm = AvFrame::alloc().context("av_frame_alloc(drm) failed")?;
|
||||
(*drm.as_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
|
||||
(*drm.as_ptr()).width = self.width as c_int;
|
||||
(*drm.as_ptr()).height = self.height as c_int;
|
||||
// The dmabuf is the compositor's rendered desktop: full-range RGB. Tag the frame so
|
||||
// the VPP's colour negotiation sees the real input instead of "unspecified" (an
|
||||
// untagged input lets the driver pick its own default for the RGB→NV12 conversion —
|
||||
// Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
|
||||
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
|
||||
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
|
||||
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr());
|
||||
(*drm).data[0] = Box::into_raw(desc) as *mut u8;
|
||||
(*drm.as_ptr()).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
|
||||
(*drm.as_ptr()).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
|
||||
(*drm.as_ptr()).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr());
|
||||
(*drm.as_ptr()).data[0] = Box::into_raw(desc) as *mut u8;
|
||||
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
|
||||
// which outlives this call — the graph reads the surface before submit returns).
|
||||
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
|
||||
@@ -1102,8 +1075,8 @@ impl DmabufInner {
|
||||
// reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
|
||||
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
|
||||
}
|
||||
(*drm).buf[0] = ffi::av_buffer_create(
|
||||
(*drm).data[0],
|
||||
(*drm.as_ptr()).buf[0] = ffi::av_buffer_create(
|
||||
(*drm.as_ptr()).data[0],
|
||||
std::mem::size_of::<ffi::AVDRMFrameDescriptor>(),
|
||||
Some(free_desc),
|
||||
ptr::null_mut(),
|
||||
@@ -1113,45 +1086,40 @@ impl DmabufInner {
|
||||
// Push through hwmap → scale_vaapi; pull the NV12 surface back out.
|
||||
let r = ffi::av_buffersrc_add_frame_flags(
|
||||
self.src,
|
||||
drm,
|
||||
drm.as_ptr(),
|
||||
ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int,
|
||||
);
|
||||
ffi::av_frame_free(&mut drm);
|
||||
// These two stages ARE the import: the push hands libav our DRM-PRIME descriptor, and
|
||||
// the pull is where `hwmap` actually maps it into a VA surface (and `scale_vaapi` runs
|
||||
// the CSC). A failure here means this driver would not take this compositor's dmabuf —
|
||||
// which no encoder rebuild can fix — so tell the process-wide latch, and capture
|
||||
// negotiates CPU frames from the next session on. `avcodec_send_frame` below is
|
||||
// deliberately NOT counted: that one is the encoder stalling, which the in-place
|
||||
// rebuild above us exists to recover, and disabling zero-copy over it would be a
|
||||
// permanent penalty for a transient fault.
|
||||
drop(drm); // release our ref where the hand-written free sat (see the SAFETY note)
|
||||
// These two stages ARE the import: the push hands libav our DRM-PRIME descriptor, and
|
||||
// the pull is where `hwmap` actually maps it into a VA surface (and `scale_vaapi` runs
|
||||
// the CSC). A failure here means this driver would not take this compositor's dmabuf —
|
||||
// which no encoder rebuild can fix — so tell the process-wide latch, and capture
|
||||
// negotiates CPU frames from the next session on. `avcodec_send_frame` below is
|
||||
// deliberately NOT counted: that one is the encoder stalling, which the in-place
|
||||
// rebuild above us exists to recover, and disabling zero-copy over it would be a
|
||||
// permanent penalty for a transient fault.
|
||||
if r < 0 {
|
||||
let e = format!("av_buffersrc_add_frame failed ({r})");
|
||||
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
|
||||
bail!("{e}");
|
||||
}
|
||||
t_push = t0.elapsed();
|
||||
let mut nv12 = ffi::av_frame_alloc();
|
||||
if nv12.is_null() {
|
||||
bail!("av_frame_alloc(nv12) failed");
|
||||
}
|
||||
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
|
||||
let nv12 = AvFrame::alloc().context("av_frame_alloc(nv12) failed")?;
|
||||
let r = ffi::av_buffersink_get_frame(self.sink, nv12.as_ptr());
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut nv12);
|
||||
let e = format!("av_buffersink_get_frame failed ({r})");
|
||||
pf_zerocopy::note_raw_dmabuf_import_failure(&e);
|
||||
bail!("{e}");
|
||||
}
|
||||
pf_zerocopy::note_raw_dmabuf_import_ok();
|
||||
t_pull = t0.elapsed() - t_push;
|
||||
(*nv12).pts = pts;
|
||||
(*nv12).pict_type = if idr {
|
||||
(*nv12.as_ptr()).pts = pts;
|
||||
(*nv12.as_ptr()).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12);
|
||||
ffi::av_frame_free(&mut nv12);
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame(VAAPI) failed ({r})");
|
||||
}
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
//! worker caches it, so the steady state passes **zero** descriptors (the PipeWire pool recycles a
|
||||
//! small buffer set).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pf_frame::{CapturedFrame, CursorOverlay, DmabufFrame, FramePayload, PixelFormat};
|
||||
use pf_zerocopy::ipc;
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
//! `libloading`), the device binding (D3D11 vs CUDA), input-surface registration, and the
|
||||
//! Windows-only async retrieve — stay in their backends. Sibling of [`super::nvenc_status`].
|
||||
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table calls almost line for line; narrowing it
|
||||
// would add one `unsafe {}` plus one SAFETY comment per call that could only restate the signature.
|
||||
// Clearing this file means DELETING the markers that carry no caller contract, not wrapping the
|
||||
// calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// UNSAFE-LINT EXEMPTION REMOVED — the old fence rationale ("raw nvEncodeAPI entry-table calls
|
||||
// almost line for line") was false for this file: it makes ZERO FFI calls. Its unsafe surface is
|
||||
// C-union access whose soundness hangs entirely on which codec arm is active, and the 4:4:4 note
|
||||
// below records the shipped bug (hevcConfig bytes stamped onto an AV1 config) that per-operation
|
||||
// visibility makes findable. So this file runs the strictest discipline in the crate: every
|
||||
// union READ, borrow, or bitfield-setter call sits in its own `unsafe {}` block naming the codec
|
||||
// guard it relies on. (Plain union-arm field WRITES are safe by language rule — writing an arm
|
||||
// cannot itself be UB; the hazard is the mismatched read — so those stay bare, guarded by the
|
||||
// same codec matches.)
|
||||
#![deny(clippy::multiple_unsafe_ops_per_block)]
|
||||
|
||||
use super::Codec;
|
||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
||||
@@ -694,10 +698,9 @@ mod tests {
|
||||
};
|
||||
assert_eq!(cfg.profileGUID, nv::NV_ENC_HEVC_PROFILE_FREXT_GUID);
|
||||
// SAFETY: an HEVC session's union arm is `hevcConfig` — the one this path wrote.
|
||||
unsafe {
|
||||
assert_eq!(cfg.encodeCodecConfig.hevcConfig.chromaFormatIDC(), 3);
|
||||
assert_eq!(cfg.encodeCodecConfig.hevcConfig.pixelBitDepthMinus8(), 2);
|
||||
}
|
||||
unsafe { assert_eq!(cfg.encodeCodecConfig.hevcConfig.chromaFormatIDC(), 3) };
|
||||
// SAFETY: same HEVC arm as above.
|
||||
unsafe { assert_eq!(cfg.encodeCodecConfig.hevcConfig.pixelBitDepthMinus8(), 2) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1210,6 +1213,8 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
||||
// are the only accepted config). H.264 has no tier. Level 0 = autoselect for HEVC.
|
||||
match c.codec {
|
||||
Codec::H265 => {
|
||||
// Plain union-arm writes are safe by language rule (the hazard is a mismatched
|
||||
// READ later); the match on `c.codec` keeps the arm honest.
|
||||
cfg.encodeCodecConfig.hevcConfig.tier = 1;
|
||||
cfg.encodeCodecConfig.hevcConfig.level = 0;
|
||||
}
|
||||
@@ -1264,21 +1269,29 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
||||
}
|
||||
if want_444 && c.codec == Codec::H265 {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
|
||||
// SAFETY: HEVC session (guarded by `c.codec == Codec::H265` on this branch), so
|
||||
// `hevcConfig` is the active arm.
|
||||
unsafe { cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3) };
|
||||
if c.bit_depth == 10 {
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2); // Main 4:4:4 10
|
||||
// SAFETY: same HEVC arm, same branch guard. (Main 4:4:4 10)
|
||||
unsafe { cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2) };
|
||||
}
|
||||
} else if c.bit_depth == 10 {
|
||||
match c.codec {
|
||||
Codec::H265 => {
|
||||
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
|
||||
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
|
||||
// SAFETY: HEVC session (matched on `c.codec`), so `hevcConfig` is the active arm.
|
||||
unsafe { cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2) };
|
||||
}
|
||||
Codec::Av1 => {
|
||||
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
|
||||
cfg.encodeCodecConfig
|
||||
.av1Config
|
||||
.set_inputPixelBitDepthMinus8(c.av1_input_depth_minus8);
|
||||
// SAFETY: AV1 session (matched on `c.codec`), so `av1Config` is the active arm.
|
||||
unsafe { cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2) };
|
||||
// SAFETY: same AV1 arm, same match guard.
|
||||
unsafe {
|
||||
cfg.encodeCodecConfig
|
||||
.av1Config
|
||||
.set_inputPixelBitDepthMinus8(c.av1_input_depth_minus8)
|
||||
};
|
||||
}
|
||||
Codec::H264 => {} // no 10-bit H.264 encode on NVENC — negotiation never asks
|
||||
Codec::PyroWave => unreachable!("PyroWave never opens the direct-NVENC backend"),
|
||||
@@ -1306,7 +1319,9 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
||||
};
|
||||
match c.codec {
|
||||
Codec::H265 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
|
||||
// SAFETY: HEVC session (matched on `c.codec`), so `hevcConfig` is the active
|
||||
// arm; the borrow is dropped before any other union access.
|
||||
let vui = unsafe { &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters };
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
@@ -1315,7 +1330,9 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::H264 => {
|
||||
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
|
||||
// SAFETY: H.264 session (matched on `c.codec`), so `h264Config` is the active
|
||||
// arm; the borrow is dropped before any other union access.
|
||||
let vui = unsafe { &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters };
|
||||
vui.videoSignalTypePresentFlag = 1;
|
||||
vui.videoFullRangeFlag = 0;
|
||||
vui.colourDescriptionPresentFlag = 1;
|
||||
@@ -1324,7 +1341,9 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo
|
||||
vui.colourMatrix = mat;
|
||||
}
|
||||
Codec::Av1 => {
|
||||
let av1 = &mut cfg.encodeCodecConfig.av1Config;
|
||||
// SAFETY: AV1 session (matched on `c.codec`), so `av1Config` is the active arm;
|
||||
// the borrow is dropped before any other union access.
|
||||
let av1 = unsafe { &mut cfg.encodeCodecConfig.av1Config };
|
||||
av1.colorPrimaries = prim;
|
||||
av1.transferCharacteristics = trc;
|
||||
av1.matrixCoefficients = mat;
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
//! defaulting to BT.709 limited — true of every punktfunk client (`csc_rows` falls back to 709 on
|
||||
//! "unspecified"), but NOT of vendor TV decoders, which guess colorimetry from RESOLUTION: an LG
|
||||
//! webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly washed out.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{EncodedFrame, Encoder};
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
|
||||
@@ -49,8 +49,6 @@
|
||||
// restate the signature. Clearing this file means DELETING the markers that carry no caller
|
||||
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
|
||||
@@ -37,8 +37,6 @@
|
||||
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
||||
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
|
||||
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
@@ -61,8 +59,8 @@ use windows::Win32::Graphics::Dxgi::Common::{
|
||||
};
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_BT2020,
|
||||
SWS_CS_ITU709, SWS_POINT,
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
|
||||
SWS_CS_BT2020, SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
@@ -499,10 +497,14 @@ fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
|
||||
|
||||
struct SystemInner {
|
||||
enc: encoder::video::Encoder,
|
||||
// FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced freed `sw_frame`
|
||||
// before `sws`, and field-DECLARATION order is what preserves that now (an offset_of assert
|
||||
// cannot pin this — repr(Rust) may reorder memory independently of declaration order, and
|
||||
// drop order follows declaration).
|
||||
/// Reusable software NV12/P010 frame: swscale dst / readback dst, and the `send_frame` src.
|
||||
sw_frame: *mut ffi::AVFrame,
|
||||
/// swscale ctx for the BGRA→NV12 fallback (built lazily; null for the YUV-readback path).
|
||||
sws: *mut ffi::SwsContext,
|
||||
sw_frame: AvFrame,
|
||||
/// swscale ctx for the BGRA→NV12 fallback (built lazily; `None` for the YUV-readback path).
|
||||
sws: Option<AvSwsContext>,
|
||||
/// CPU-readable staging texture for the D3D11 readback (built lazily on the captured device).
|
||||
staging: Option<ID3D11Texture2D>,
|
||||
ctx: Option<ID3D11DeviceContext>,
|
||||
@@ -549,26 +551,18 @@ impl SystemInner {
|
||||
ptr::null_mut(),
|
||||
)?
|
||||
};
|
||||
// SAFETY: `av_frame_alloc` returns a freshly-allocated, uniquely-owned `AVFrame` (null-checked
|
||||
// before any deref); writing `format`/`width`/`height` through `*f` stays inside that
|
||||
// allocation. `av_frame_get_buffer(f, 0)` allocates the backing planes — on failure we
|
||||
// `av_frame_free` the sole owner (no double-free) and bail; on success the raw `f` is moved into
|
||||
// `self.sw_frame` and freed exactly once in `Drop`.
|
||||
let sw_frame = unsafe {
|
||||
let f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
bail!("av_frame_alloc(sw) failed");
|
||||
}
|
||||
(*f).format = sw_av as c_int;
|
||||
(*f).width = width as c_int;
|
||||
(*f).height = height as c_int;
|
||||
if ffi::av_frame_get_buffer(f, 0) < 0 {
|
||||
let mut f = f;
|
||||
ffi::av_frame_free(&mut f);
|
||||
let sw_frame = AvFrame::alloc().context("av_frame_alloc(sw) failed")?;
|
||||
// SAFETY: writing `format`/`width`/`height` through the owned frame's pointer stays inside
|
||||
// its allocation. `av_frame_get_buffer` allocates the backing planes — on failure the
|
||||
// owned `sw_frame` simply drops (freed once, by the wrapper).
|
||||
unsafe {
|
||||
(*sw_frame.as_ptr()).format = sw_av as c_int;
|
||||
(*sw_frame.as_ptr()).width = width as c_int;
|
||||
(*sw_frame.as_ptr()).height = height as c_int;
|
||||
if ffi::av_frame_get_buffer(sw_frame.as_ptr(), 0) < 0 {
|
||||
bail!("av_frame_get_buffer(sw) failed");
|
||||
}
|
||||
f
|
||||
};
|
||||
}
|
||||
tracing::info!(
|
||||
encoder = vendor.encoder_name(codec),
|
||||
"{} encode active ({width}x{height}@{fps}, system-memory {} path)",
|
||||
@@ -578,7 +572,7 @@ impl SystemInner {
|
||||
Ok(SystemInner {
|
||||
enc,
|
||||
sw_frame,
|
||||
sws: ptr::null_mut(),
|
||||
sws: None,
|
||||
staging: None,
|
||||
ctx: None,
|
||||
format,
|
||||
@@ -634,13 +628,13 @@ impl SystemInner {
|
||||
// frame and `self.enc`'s own context, both live for the call and neither retained by libav
|
||||
// (it references the frame's buffers itself).
|
||||
unsafe {
|
||||
(*self.sw_frame).pts = pts;
|
||||
(*self.sw_frame).pict_type = if idr {
|
||||
(*self.sw_frame.as_ptr()).pts = pts;
|
||||
(*self.sw_frame.as_ptr()).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame);
|
||||
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
|
||||
}
|
||||
@@ -705,10 +699,10 @@ impl SystemInner {
|
||||
let total = pitch.saturating_mul(h + h.div_ceil(2));
|
||||
let mapped = std::slice::from_raw_parts(base, total);
|
||||
let chroma_off = pitch * h;
|
||||
let y_dst = (*self.sw_frame).data[0];
|
||||
let y_stride = (*self.sw_frame).linesize[0] as usize;
|
||||
let uv_dst = (*self.sw_frame).data[1];
|
||||
let uv_stride = (*self.sw_frame).linesize[1] as usize;
|
||||
let y_dst = (*self.sw_frame.as_ptr()).data[0];
|
||||
let y_stride = (*self.sw_frame.as_ptr()).linesize[0] as usize;
|
||||
let uv_dst = (*self.sw_frame.as_ptr()).data[1];
|
||||
let uv_stride = (*self.sw_frame.as_ptr()).linesize[1] as usize;
|
||||
for y in 0..h {
|
||||
let s = &mapped[y * pitch..y * pitch + row_bytes];
|
||||
ptr::copy_nonoverlapping(s.as_ptr(), y_dst.add(y * y_stride), row_bytes);
|
||||
@@ -748,7 +742,7 @@ impl SystemInner {
|
||||
let pitch = map.RowPitch as usize;
|
||||
let h = self.height as usize;
|
||||
let base = map.pData as *const u8;
|
||||
self.ensure_sws(
|
||||
let sws = self.ensure_sws(
|
||||
pixel_to_av(Pixel::BGRA),
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
SWS_CS_ITU709,
|
||||
@@ -756,13 +750,13 @@ impl SystemInner {
|
||||
let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
|
||||
let r = ffi::sws_scale(
|
||||
self.sws,
|
||||
sws,
|
||||
src_data.as_ptr(),
|
||||
src_stride.as_ptr(),
|
||||
0,
|
||||
h as c_int,
|
||||
(*self.sw_frame).data.as_ptr(),
|
||||
(*self.sw_frame).linesize.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).data.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
|
||||
);
|
||||
ctx.Unmap(&staging, 0);
|
||||
if r < 0 {
|
||||
@@ -798,7 +792,7 @@ impl SystemInner {
|
||||
let h = self.height as usize;
|
||||
let base = map.pData as *const u8;
|
||||
// RGB(BT.2020 PQ) → YUV(BT.2020 PQ): a matrix-only repack (same PQ transfer), full→limited.
|
||||
self.ensure_sws(
|
||||
let sws = self.ensure_sws(
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_X2BGR10LE,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_P010LE,
|
||||
SWS_CS_BT2020,
|
||||
@@ -806,13 +800,13 @@ impl SystemInner {
|
||||
let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
|
||||
let r = ffi::sws_scale(
|
||||
self.sws,
|
||||
sws,
|
||||
src_data.as_ptr(),
|
||||
src_stride.as_ptr(),
|
||||
0,
|
||||
h as c_int,
|
||||
(*self.sw_frame).data.as_ptr(),
|
||||
(*self.sw_frame).linesize.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).data.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
|
||||
);
|
||||
ctx.Unmap(&staging, 0);
|
||||
if r < 0 {
|
||||
@@ -844,7 +838,7 @@ impl SystemInner {
|
||||
// `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned
|
||||
// `sw_frame`. `send` then hands `sw_frame` to the encoder.
|
||||
unsafe {
|
||||
self.ensure_sws(
|
||||
let sws = self.ensure_sws(
|
||||
pixel_to_av(sws_src(format)?),
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
SWS_CS_ITU709,
|
||||
@@ -852,13 +846,13 @@ impl SystemInner {
|
||||
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
|
||||
if ffi::sws_scale(
|
||||
self.sws,
|
||||
sws,
|
||||
src_data.as_ptr(),
|
||||
src_stride.as_ptr(),
|
||||
0,
|
||||
h as c_int,
|
||||
(*self.sw_frame).data.as_ptr(),
|
||||
(*self.sw_frame).linesize.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).data.as_ptr(),
|
||||
(*self.sw_frame.as_ptr()).linesize.as_ptr(),
|
||||
) < 0
|
||||
{
|
||||
bail!("sws_scale RGB→NV12 failed");
|
||||
@@ -872,23 +866,24 @@ impl SystemInner {
|
||||
/// 10-bit RGB10→P010 BT.2020), so caching a single context is sound.
|
||||
///
|
||||
/// Safe: every argument is a plain libav enum/int, and the context it caches belongs to `self`
|
||||
/// (freed once in `Drop`).
|
||||
/// (an owned `AvSwsContext`, freed by its own drop). Returns the borrowed pointer for the
|
||||
/// caller's `sws_scale` — borrowed only, `self.sws` stays the owner.
|
||||
fn ensure_sws(
|
||||
&mut self,
|
||||
src_av: ffi::AVPixelFormat,
|
||||
dst_av: ffi::AVPixelFormat,
|
||||
cs: c_int,
|
||||
) -> Result<()> {
|
||||
if !self.sws.is_null() {
|
||||
return Ok(());
|
||||
) -> Result<*mut ffi::SwsContext> {
|
||||
if let Some(sws) = &self.sws {
|
||||
return Ok(sws.as_ptr());
|
||||
}
|
||||
// SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params"
|
||||
// null trio, and returns an owned context or null — which is checked before use, so
|
||||
// `sws_setColorspaceDetails` and the store below only ever see a live one.
|
||||
// `sws_getCoefficients` returns a pointer into libav's own static tables, valid for the
|
||||
// process, and the call only reads it.
|
||||
// null trio, and returns an owned context or null — `from_raw` rejects the null, so
|
||||
// `sws_setColorspaceDetails` only ever sees a live one, and ownership passes to the
|
||||
// `AvSwsContext`. `sws_getCoefficients` returns a pointer into libav's own static tables,
|
||||
// valid for the process, and the call only reads it.
|
||||
let sws = unsafe {
|
||||
let sws = ffi::sws_getContext(
|
||||
let raw = ffi::sws_getContext(
|
||||
self.width as c_int,
|
||||
self.height as c_int,
|
||||
src_av,
|
||||
@@ -900,36 +895,22 @@ impl SystemInner {
|
||||
ptr::null_mut(),
|
||||
ptr::null(),
|
||||
);
|
||||
if sws.is_null() {
|
||||
let Some(owned) = AvSwsContext::from_raw(raw) else {
|
||||
bail!("sws_getContext(RGB→YUV) failed");
|
||||
}
|
||||
};
|
||||
// Source full-range RGB → destination limited-range YUV (matches the limited-range VUI
|
||||
// we signal). For RGB input the src coefficient table is unused; pass dst for both.
|
||||
let coeff = ffi::sws_getCoefficients(cs);
|
||||
ffi::sws_setColorspaceDetails(sws, coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
|
||||
sws
|
||||
ffi::sws_setColorspaceDetails(owned.as_ptr(), coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
|
||||
owned
|
||||
};
|
||||
self.sws = sws;
|
||||
Ok(())
|
||||
Ok(self.sws.insert(sws).as_ptr())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SystemInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `sw_frame` is the `AVFrame` allocated in `open` (or null) — `av_frame_free` drops it
|
||||
// once and nulls the pointer through the `&mut`; `sws` is the cached `SwsContext` (or null) —
|
||||
// `sws_freeContext` frees it once. This `Drop` runs exactly once and `SystemInner` owns both
|
||||
// exclusively, so there is no double-free or use-after-free.
|
||||
unsafe {
|
||||
if !self.sw_frame.is_null() {
|
||||
ffi::av_frame_free(&mut self.sw_frame);
|
||||
}
|
||||
if !self.sws.is_null() {
|
||||
ffi::sws_freeContext(self.sws);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `SystemInner`: `sw_frame` (`AvFrame`) and `sws` (`Option<AvSwsContext>`) free
|
||||
// themselves, in field-declaration order — the same sw_frame-then-sws sequence the hand-written
|
||||
// `Drop` performed, pinned by the offset_of assert at the struct.
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Zero-copy D3D11 path (the AMF default; QSV opt-in — see `zerocopy_enabled`): share the capture
|
||||
@@ -1214,32 +1195,29 @@ impl ZeroCopyInner {
|
||||
}
|
||||
|
||||
fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
|
||||
// SAFETY: `d3d = av_frame_alloc()` is a fresh owned frame (null-checked) and is `av_frame_free`d
|
||||
// exactly once on every path below. `av_hwframe_get_buffer` fills it from the pool — on failure
|
||||
// we free it and bail. `(*d3d).data[0]` is the pool's texture-array and `data[1]` the array
|
||||
// index; `from_raw_borrowed` borrows that `ID3D11Texture2D` WITHOUT taking ownership (no Release
|
||||
// — the frame owns it) and is null-checked. `src` (the captured texture) and `dst` (the pooled
|
||||
// slice) live on the SAME D3D11 device wrapped by `self.hw`, and the caller guarantees
|
||||
// `captured.format == pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, ..,
|
||||
// src, 0, ..)` on the single-threaded immediate context `self.ctx` is a valid same-format GPU
|
||||
// copy. For QSV the mapped `qsv` frame is a fresh owned frame whose `hw_frames_ctx` takes an
|
||||
// `av_buffer_ref` of `self.qsv_frames`; it is `av_frame_free`d (releasing that ref) on both the
|
||||
// map-failure and success paths. `avcodec_send_frame` only internally refs the input frame, so
|
||||
// the `av_frame_free(d3d)`/`av_frame_free(qsv)` afterwards are the sole owning frees — no leak,
|
||||
// no double-free, no use-after-free.
|
||||
// SAFETY: `d3d`/`qsv` are owned `AvFrame`s, so EVERY exit — including the three `?` exits
|
||||
// between the pool pull and the send, which as hand-placed frees previously leaked the
|
||||
// frame plus one of the POOL-sized hwframe surfaces per failure (eight failures wedged
|
||||
// the encoder permanently) — unrefs the pooled surface back to the pool. `(*d3d).data[0]`
|
||||
// is the pool's texture-array and `data[1]` the array index; `from_raw_borrowed` borrows
|
||||
// that `ID3D11Texture2D` WITHOUT taking ownership (no Release — the frame owns it) and is
|
||||
// null-checked. `src` (the captured texture) and `dst` (the pooled slice) live on the
|
||||
// SAME D3D11 device wrapped by `self.hw`, and the caller guarantees `captured.format ==
|
||||
// pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, .., src, 0, ..)`
|
||||
// on the single-threaded immediate context `self.ctx` is a valid same-format GPU copy.
|
||||
// For QSV the mapped `qsv` frame's `hw_frames_ctx` takes an `av_buffer_ref` of
|
||||
// `self.qsv_frames`; its drop at the end of the arm releases that ref at the same point
|
||||
// the hand-written free did. `avcodec_send_frame` only internally refs the input frame,
|
||||
// so the drops are the sole owning frees — no leak, no double-free, no use-after-free.
|
||||
unsafe {
|
||||
// Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice.
|
||||
let mut d3d = ffi::av_frame_alloc();
|
||||
if d3d.is_null() {
|
||||
bail!("av_frame_alloc(d3d11) failed");
|
||||
}
|
||||
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d, 0);
|
||||
let d3d = AvFrame::alloc().context("av_frame_alloc(d3d11) failed")?;
|
||||
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d.as_ptr(), 0);
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("av_hwframe_get_buffer(D3D11) failed ({r})");
|
||||
}
|
||||
let dst_ptr = (*d3d).data[0] as *mut c_void;
|
||||
let dst_index = (*d3d).data[1] as usize as u32;
|
||||
let dst_ptr = (*d3d.as_ptr()).data[0] as *mut c_void;
|
||||
let dst_index = (*d3d.as_ptr()).data[1] as usize as u32;
|
||||
let dst_tex = ID3D11Texture2D::from_raw_borrowed(&dst_ptr)
|
||||
.ok_or_else(|| anyhow!("pooled D3D11 frame has null texture"))?;
|
||||
// GPU-local copy of the captured slice into the pooled array slice (like NVENC's CUDA
|
||||
@@ -1249,58 +1227,50 @@ impl ZeroCopyInner {
|
||||
self.ctx
|
||||
.CopySubresourceRegion(&dst, dst_index, 0, 0, 0, &src, 0, None);
|
||||
|
||||
(*d3d).pts = pts;
|
||||
(*d3d).pict_type = if idr {
|
||||
(*d3d.as_ptr()).pts = pts;
|
||||
(*d3d.as_ptr()).pict_type = if idr {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||||
} else {
|
||||
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
|
||||
};
|
||||
|
||||
let send = match self.vendor {
|
||||
WinVendor::Amf => ffi::avcodec_send_frame(self.enc.as_mut_ptr(), d3d),
|
||||
WinVendor::Amf => ffi::avcodec_send_frame(self.enc.as_mut_ptr(), d3d.as_ptr()),
|
||||
WinVendor::Qsv => {
|
||||
// Map the D3D11 frame to a QSV surface (1:1, no copy), then send the mapped frame.
|
||||
let mut qsv = ffi::av_frame_alloc();
|
||||
if qsv.is_null() {
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("av_frame_alloc(qsv) failed");
|
||||
}
|
||||
let qsv = AvFrame::alloc().context("av_frame_alloc(qsv) failed")?;
|
||||
// Always `Some` on this arm — `open` fills the pair for `WinVendor::Qsv` and
|
||||
// leaves it `None` only for AMF — but say so with a bail rather than an unwrap,
|
||||
// matching the null check above it. The `Option` is what the raw pointer's
|
||||
// "null means AMF" convention was already encoding.
|
||||
let Some(qsv_frames) = self.qsv_frames.as_ref() else {
|
||||
ffi::av_frame_free(&mut qsv);
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("QSV send path without a derived QSV frames context");
|
||||
};
|
||||
(*qsv).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
|
||||
(*qsv).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
|
||||
(*qsv.as_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
|
||||
(*qsv.as_ptr()).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
|
||||
// The map flags are a bindgen enum (no BitOr) — cast each to int before OR-ing.
|
||||
let r = ffi::av_hwframe_map(
|
||||
qsv,
|
||||
d3d,
|
||||
qsv.as_ptr(),
|
||||
d3d.as_ptr(),
|
||||
ffi::AV_HWFRAME_MAP_DIRECT as c_int | ffi::AV_HWFRAME_MAP_READ as c_int,
|
||||
);
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut qsv);
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("av_hwframe_map(D3D11→QSV) failed ({r})");
|
||||
}
|
||||
(*qsv).pts = pts;
|
||||
(*qsv).pict_type = (*d3d).pict_type;
|
||||
let s = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv);
|
||||
ffi::av_frame_free(&mut qsv);
|
||||
s
|
||||
(*qsv.as_ptr()).pts = pts;
|
||||
(*qsv.as_ptr()).pict_type = (*d3d.as_ptr()).pict_type;
|
||||
ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv.as_ptr())
|
||||
// `qsv` drops here — releasing the mapped frame and its frames-ctx ref at the
|
||||
// same point the hand-written `av_frame_free(&mut qsv)` did.
|
||||
}
|
||||
};
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
if send < 0 {
|
||||
bail!(
|
||||
"avcodec_send_frame({}) failed ({send})",
|
||||
self.vendor.label()
|
||||
);
|
||||
}
|
||||
// `d3d` drops here (and on every early exit above), returning the pooled surface.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -39,8 +39,6 @@
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::nvenc_core::{
|
||||
apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
|
||||
|
||||
@@ -37,9 +37,6 @@
|
||||
//! it stays behind the same gate and falls back to IDR wherever the driver declines. 4:4:4 stays
|
||||
//! `false` until probed on real hardware (design §8.6).
|
||||
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use libvpl_sys as vpl;
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
// `#[cfg(test)]` instead.
|
||||
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||
// program). As a parent module this also covers the child modules (windows/linux backends).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::Result;
|
||||
use pf_frame::{CapturedFrame, PixelFormat};
|
||||
|
||||
+42
-27
@@ -7,9 +7,6 @@
|
||||
//! The win32u GPU-preference hook, the HDR/video-engine converters, and the self-tests stay in the
|
||||
//! capture crate — they are capture mechanics, not shared identity.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::Interface;
|
||||
use windows::Win32::Foundation::{HMODULE, LUID};
|
||||
@@ -158,18 +155,26 @@ enum PrioMode {
|
||||
Off,
|
||||
/// A fixed class the operator pinned (`normal`=2 / `high`=4 / `realtime`=5).
|
||||
Static(i32),
|
||||
/// The default: HIGH immediately, then upgrade to REALTIME when it is safe — HAGS off, or
|
||||
/// Opt-in (`auto`): HIGH immediately, then upgrade to REALTIME when it is safe — HAGS off, or
|
||||
/// HAGS on with comfortable VRAM headroom (with a monitor that downgrades the moment VRAM
|
||||
/// tightens). REALTIME is the proven ceiling-raiser (it is how our brief encode preempts a
|
||||
/// saturating game), but REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC
|
||||
/// hang — the gate takes the win everywhere it cannot hit the hazard.
|
||||
/// tightens). REALTIME is the T2.3 ceiling-raiser (a higher-priority context preempts at
|
||||
/// pixel granularity), but it carries TWO field-proven hazards: REALTIME + NVIDIA + HAGS +
|
||||
/// near-full VRAM is a documented NVENC hang (the VRAM gate covers that one), and on AMD the
|
||||
/// upgrade itself produced a metronomic content-starving stall class (~3.6 s period, RX 9070
|
||||
/// XT, 2026-08-12 A/B: pinning `high` removed it) that no VRAM gate can see — which is why
|
||||
/// `auto` is no longer the default.
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Resolve `PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default **auto**).
|
||||
/// Resolve `PUNKTFUNK_GPU_PRIORITY_CLASS` (`off|normal|high|realtime|auto`, default **high**).
|
||||
/// D3DKMT_SCHEDULINGPRIORITYCLASS: IDLE 0, BELOW_NORMAL 1, NORMAL 2, ABOVE_NORMAL 3, HIGH 4,
|
||||
/// REALTIME 5. `realtime` pins REALTIME statically (no gate — the operator owns the hazard);
|
||||
/// `high` restores the pre-T2.3 static default.
|
||||
/// `auto` is the T2.3 gated-REALTIME mode, opt-in since the 2026-08-12 field A/B convicted the
|
||||
/// REALTIME upgrade of its own metronomic stall class on AMD (see [`PrioMode::Auto`]) — HIGH is
|
||||
/// the Sunshine/Apollo-parity lever that delivered the original decisive win, and the default
|
||||
/// must not hold REALTIME anywhere (the same inversion as the vdisplay driver's `PFVD_RT_GPU`
|
||||
/// ladder, which fixed the faster ~1.8 s metronome the same day). Unrecognized values read as
|
||||
/// the default, not as `auto` — a typo must not opt a box into the hazard.
|
||||
fn configured_gpu_priority_mode() -> PrioMode {
|
||||
match std::env::var("PUNKTFUNK_GPU_PRIORITY_CLASS")
|
||||
.ok()
|
||||
@@ -177,9 +182,10 @@ fn configured_gpu_priority_mode() -> PrioMode {
|
||||
{
|
||||
Some("off") => PrioMode::Off,
|
||||
Some("normal") => PrioMode::Static(2),
|
||||
Some("high") => PrioMode::Static(4),
|
||||
Some("realtime") => PrioMode::Static(5),
|
||||
_ => PrioMode::Auto,
|
||||
Some("auto") => PrioMode::Auto,
|
||||
// `high`, unset, and anything unrecognized all land on the HIGH default.
|
||||
_ => PrioMode::Static(4),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,14 +284,17 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
|
||||
/// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but
|
||||
/// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling
|
||||
/// priority class (the strong cross-process lever — far more effective than `SetGPUThreadPriority`
|
||||
/// alone, which we measured as no help) lets our brief encode preempt the game. Default is the
|
||||
/// T2.3 `auto` mode: HIGH immediately here, then [`auto_priority_gate`] upgrades to REALTIME
|
||||
/// where the NVIDIA+HAGS+full-VRAM NVENC-hang hazard cannot bite (and a monitor downgrades when
|
||||
/// it could). Runs once per process; best-effort.
|
||||
/// `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime|auto` (default auto; `high` = the
|
||||
/// pre-gate static behavior; `realtime` = pinned, operator owns the hazard). Best-effort:
|
||||
/// silently no-ops under a UAC-filtered token (the process will not hold SE_INC_BASE_PRIORITY,
|
||||
/// so the D3DKMT call is a no-op).
|
||||
/// alone, which we measured as no help) lets our brief encode preempt the game. Default is a
|
||||
/// static HIGH — the class that delivered that win. The T2.3 `auto` mode (HIGH here, then
|
||||
/// [`auto_priority_gate`] upgrades to REALTIME behind the NVENC-hang VRAM gate) is opt-in since
|
||||
/// the 2026-08-12 field A/B: on AMD the REALTIME upgrade generated its own metronomic
|
||||
/// content-starving stall class (~3.6 s period) that the VRAM gate cannot see, and pinning HIGH
|
||||
/// removed it. Runs once per process; best-effort.
|
||||
/// `PUNKTFUNK_GPU_PRIORITY_CLASS = off|normal|high|realtime|auto` (default high; `auto` = the
|
||||
/// gated-REALTIME upgrade, operator opts into the AMD stall hazard for the extra ceiling;
|
||||
/// `realtime` = pinned, operator owns every hazard). Best-effort: silently no-ops under a
|
||||
/// UAC-filtered token (the process will not hold SE_INC_BASE_PRIORITY, so the D3DKMT call is a
|
||||
/// no-op).
|
||||
fn elevate_process_gpu_priority() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
@@ -319,17 +328,23 @@ fn elevate_process_gpu_priority() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- REALTIME auto-gate (gpu-contention §5.C / latency plan T2.3) --------------------------------
|
||||
// --- REALTIME auto-gate (gpu-contention §5.C / latency plan T2.3) — OPT-IN since 2026-08-12 ------
|
||||
//
|
||||
// REALTIME GPU scheduling priority is the genuine cross-process ceiling-raiser under a saturating
|
||||
// game (a higher-priority context preempts at pixel granularity — the Async-TimeWarp mechanism),
|
||||
// and our SYSTEM service uniquely holds the SE_INC_BASE_PRIORITY it needs. The one documented
|
||||
// hazard: REALTIME + NVIDIA + HAGS-on + near-full VRAM can hang NVENC. So: probe HAGS once via
|
||||
// D3DKMT; HAGS off ⇒ REALTIME unconditionally; HAGS on ⇒ REALTIME gated on LOCAL-segment VRAM
|
||||
// headroom, with a monitor thread that downgrades to HIGH the moment usage crosses
|
||||
// [`VRAM_DOWNGRADE_PCT`] of the OS budget and restores REALTIME after it has stayed under
|
||||
// [`VRAM_RESTORE_PCT`] for [`VRAM_RESTORE_TICKS`] consecutive polls (hysteresis against flapping
|
||||
// on the boundary of the hazard window).
|
||||
// and our SYSTEM service uniquely holds the SE_INC_BASE_PRIORITY it needs. Two field-proven
|
||||
// hazards bound it. (1) REALTIME + NVIDIA + HAGS-on + near-full VRAM can hang NVENC — the VRAM
|
||||
// gate below exists for that one: probe HAGS once via D3DKMT; HAGS off ⇒ REALTIME
|
||||
// unconditionally; HAGS on ⇒ REALTIME gated on LOCAL-segment VRAM headroom, with a monitor
|
||||
// thread that downgrades to HIGH the moment usage crosses [`VRAM_DOWNGRADE_PCT`] of the OS
|
||||
// budget and restores REALTIME after it has stayed under [`VRAM_RESTORE_PCT`] for
|
||||
// [`VRAM_RESTORE_TICKS`] consecutive polls (hysteresis against flapping on the boundary of the
|
||||
// hazard window). (2) On AMD (RX 9070 XT A/B), a punktfunk process holding REALTIME generated a
|
||||
// metronomic content-starving stall class — every ~3.6 s ALL processes' presents paused
|
||||
// 150–800 ms with the GPU responsive — that no VRAM gate can see, and the vdisplay driver's
|
||||
// REALTIME swap-chain raise produced the same pathology on its own ~1.8 s beat. That second
|
||||
// hazard is why the whole gate now runs only under an explicit `auto`, and the default stays a
|
||||
// static HIGH.
|
||||
|
||||
/// Downgrade REALTIME→HIGH when local VRAM usage exceeds this share of the OS budget.
|
||||
const VRAM_DOWNGRADE_PCT: u64 = 92;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! tuning), and — on Windows — [`dxgi`] (the capture identity + D3D11 device creation).
|
||||
|
||||
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
pub mod hdr;
|
||||
pub mod metronome;
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
|
||||
//! See `design/host-latency-plan.md` Tier 3A.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
//! can't deschedule them; the native, GameStream, and direct-NVENC send threads all reach this the
|
||||
//! same way (`pf_frame::thread_qos::boost_thread_priority`).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
/// Raise the current thread's OS scheduling priority so a CPU-heavy game can't deschedule our
|
||||
/// capture/encode/send threads. This matters even though our GPU work is already HIGH priority: the
|
||||
/// GPU scheduler can only favour commands we've actually SUBMITTED, so if a normal-priority thread is
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
//! live session actually encodes on, for the console's "in use" display.
|
||||
|
||||
// Unsafe-proof program: every `unsafe {}` in this leaf carries a `// SAFETY:` proof.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
|
||||
//! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use crate::pad_slots::PadSlots;
|
||||
use anyhow::{bail, Result};
|
||||
use punktfunk_core::input::{gamepad, GamepadFrame, MAX_PADS};
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
//! output's logical rectangle — the same shape the libei backend uses with its EI region.
|
||||
|
||||
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
//! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier
|
||||
//! state so the compositor resolves shifted keysyms correctly.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use punktfunk_core::input::InputKind;
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
//! with its position (never at a stale point), tip edges get their own DOWN/UP frames, and a
|
||||
//! range-leave is a final frame without `INRANGE`.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::quic::{
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
//! user's, and any layout re-reads a *position* as a *character* — on a German host that is
|
||||
//! exactly the y↔z swap / ü-on-ö scramble.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::mem::size_of;
|
||||
|
||||
@@ -14,13 +14,6 @@
|
||||
|
||||
// Scaffold: trait methods + per-OS backends are defined ahead of the target that uses them.
|
||||
#![allow(dead_code)]
|
||||
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// …and its companion: without this, an `unsafe fn` body needs no blocks, so an unproven FFI call
|
||||
// could hide inside one and still satisfy the deny above. The workspace keeps
|
||||
// `unsafe_op_in_unsafe_fn` at `warn` while the encoder backends are cleared; this crate is at zero.
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
//! the decode chain there is Vulkan → D3D11VA → software.
|
||||
|
||||
// Unsafe-proof program: every `unsafe {}` in this crate carries a `// SAFETY:` proof.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
// THE VULKAN CONTRACT, stated once - most `// SAFETY:` proofs in this crate are an instance of it.
|
||||
//
|
||||
|
||||
@@ -18,3 +18,6 @@ path = "src/main.rs"
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -39,13 +39,6 @@
|
||||
// honest. (Was a bare crate-wide allow whose "scaffold, defined ahead of the target that uses them"
|
||||
// rationale had stopped being true.)
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
|
||||
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted this crate's hardest
|
||||
// FFI from the deny above — every IOCTL wrapper, and `restore_displays_ccd`, the call the whole
|
||||
// Windows teardown path depends on to give the operator their physical panels back.
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use anyhow::Result;
|
||||
pub use punktfunk_core::Mode;
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
//! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside
|
||||
//! the KWin session's environment.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
//! each output's name / enabled / priority / current-mode size, then build a
|
||||
//! `kde_output_configuration_v2` and `apply()` it, waiting for `applied` / `failed`.
|
||||
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -267,6 +267,16 @@ pub struct DisplayPolicy {
|
||||
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
|
||||
#[serde(default)]
|
||||
pub pnp_disable_monitors: bool,
|
||||
/// **EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the
|
||||
/// software equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at
|
||||
/// the first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its
|
||||
/// live-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks
|
||||
/// on its next start. Targets the standby-sink stall class at its SOURCE: with emulation
|
||||
/// pinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD
|
||||
/// driver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like
|
||||
/// `game_session`); `#[serde(default)]` = off.
|
||||
#[serde(default)]
|
||||
pub edid_lock: bool,
|
||||
/// **Mirror a physical monitor instead of creating a virtual display**: the connector name
|
||||
/// (`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.
|
||||
///
|
||||
@@ -318,6 +328,7 @@ impl Default for DisplayPolicy {
|
||||
game_session: GameSession::default(),
|
||||
ddc_power_off: false,
|
||||
pnp_disable_monitors: false,
|
||||
edid_lock: false,
|
||||
capture_monitor: None,
|
||||
}
|
||||
}
|
||||
@@ -454,6 +465,7 @@ impl EffectivePolicy {
|
||||
game_session: GameSession,
|
||||
ddc_power_off: bool,
|
||||
pnp_disable_monitors: bool,
|
||||
edid_lock: bool,
|
||||
capture_monitor: Option<String>,
|
||||
) -> DisplayPolicy {
|
||||
DisplayPolicy {
|
||||
@@ -474,6 +486,7 @@ impl EffectivePolicy {
|
||||
game_session,
|
||||
ddc_power_off,
|
||||
pnp_disable_monitors,
|
||||
edid_lock,
|
||||
capture_monitor,
|
||||
}
|
||||
}
|
||||
@@ -739,6 +752,13 @@ impl DisplayPolicyStore {
|
||||
self.get().pnp_disable_monitors
|
||||
}
|
||||
|
||||
/// The experimental AMD connector-EDID-emulation axis — orthogonal to the preset (like
|
||||
/// [`Self::game_session`]), read directly off the stored policy (default off when
|
||||
/// unconfigured).
|
||||
pub fn edid_lock(&self) -> bool {
|
||||
self.get().edid_lock
|
||||
}
|
||||
|
||||
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
||||
/// write succeeds, so a full disk can't leave memory and file disagreeing — and the whole
|
||||
/// transaction runs under [`Self::write`], so neither can two concurrent PUTs.
|
||||
@@ -1318,13 +1338,16 @@ mod tests {
|
||||
GameSession::Dedicated,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Some("DP-2".into()),
|
||||
);
|
||||
// The orthogonal axes (game-session, DDC power-off, PnP disable, capture-monitor pin) are
|
||||
// preserved through the transform — arranging displays must not clear an unrelated setting.
|
||||
// The orthogonal axes (game-session, DDC power-off, PnP disable, EDID lock,
|
||||
// capture-monitor pin) are preserved through the transform — arranging displays must not
|
||||
// clear an unrelated setting.
|
||||
assert_eq!(p.game_session, GameSession::Dedicated);
|
||||
assert!(p.ddc_power_off);
|
||||
assert!(p.pnp_disable_monitors);
|
||||
assert!(p.edid_lock);
|
||||
assert_eq!(p.capture_monitor.as_deref(), Some("DP-2"));
|
||||
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
||||
assert_eq!(p.preset, Preset::Custom);
|
||||
@@ -1405,7 +1428,7 @@ mod tests {
|
||||
let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
12,
|
||||
13,
|
||||
"a display-policy axis was added or removed: {keys:?} — wire it into the mgmt PUT's \
|
||||
per-axis merge (and into `EffectivePolicy` if it is a behavior axis) before bumping this"
|
||||
);
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
//! its `Drop` releases the refcount (a *stale* lease — its monitor was preempted + recreated under it —
|
||||
//! is a no-op, so it can never tear down the live monitor).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
@@ -181,6 +178,10 @@ struct GroupState {
|
||||
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled at
|
||||
/// the group's first isolate — last-member teardown re-enables them BEFORE the CCD restore.
|
||||
pnp_disabled: Vec<String>,
|
||||
/// Whether the EXPERIMENTAL `edid_lock` axis pinned AMD connector emulation at the group's
|
||||
/// first isolate (`pf_win_display::adl_emul::lock_for_stream`) — last-member teardown owes the
|
||||
/// unlock (pinned emulation outlives the process, so a crash journal backs this flag up).
|
||||
edid_locked: bool,
|
||||
/// Whether `ccd_saved` was captured by an EXCLUSIVE isolate (vs `Primary`, which also
|
||||
/// snapshots but deliberately keeps the physical displays active) — gates the re-assert
|
||||
/// watchdog, which must never "fix" a Primary group's lit panels. Cleared with the restore.
|
||||
@@ -1403,6 +1404,18 @@ impl VirtualDisplayManager {
|
||||
if crate::policy::prefs().ddc_power_off() {
|
||||
inner.group.ddc_panels_off = crate::ddc::panel_off_except(n);
|
||||
}
|
||||
// EXPERIMENTAL `edid_lock` policy axis (AMD only): pin connector EDID
|
||||
// emulation BEFORE the isolate deactivates the physicals — an awake
|
||||
// sink still answers the live-EDID read the lock pins (asleep sinks
|
||||
// fall back to the driver's stored emulation data). With emulation at
|
||||
// ADL_EMUL_MODE_ALWAYS the KMD stops servicing the sleeping sink's
|
||||
// HPD/DDC/link — the standby-sink stall class at its source
|
||||
// (rationale + crash journal in `pf_win_display::adl_emul`). First
|
||||
// member only, like the DDC leg: the connectors are host-wide.
|
||||
if crate::policy::prefs().edid_lock() {
|
||||
inner.group.edid_locked =
|
||||
pf_win_display::adl_emul::lock_for_stream();
|
||||
}
|
||||
inner.group.ccd_saved = isolate_displays_ccd_seam(&keep);
|
||||
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
|
||||
// additionally disable the deactivated monitors' PnP devnodes (persistent
|
||||
@@ -1961,6 +1974,15 @@ impl VirtualDisplayManager {
|
||||
);
|
||||
inner.group.ddc_panels_off = 0;
|
||||
}
|
||||
// EXPERIMENTAL `edid_lock` unlock. AFTER the CCD restore + DDC wake: the re-activated
|
||||
// physical paths do not depend on it (the pinned emulation IS the real monitor's
|
||||
// EDID), and unlocking last keeps the driver from re-probing the sinks mid-restore.
|
||||
// OUTSIDE the `ccd_saved` gate for the same reason as the DDC wake above — the lock
|
||||
// was applied BEFORE the isolate, whose snapshot capture can have failed.
|
||||
if inner.group.edid_locked {
|
||||
pf_win_display::adl_emul::unlock_after_stream();
|
||||
inner.group.edid_locked = false;
|
||||
}
|
||||
} else {
|
||||
match shrink_action(inner.group.ccd_exclusive, inner.group.ccd_saved.is_some()) {
|
||||
// Re-issue the isolate over the shrunk set (defensive — the departing monitor's
|
||||
|
||||
@@ -81,6 +81,8 @@ pub(crate) trait VdisplayDriver: Send + Sync {
|
||||
/// The monitor is NOT departed; the caller CCD-forces the freshly-advertised mode afterwards.
|
||||
/// The default errs so a backend without support routes to the re-arrival fallback.
|
||||
///
|
||||
// unsafe-fn-no-op-ok: trait method — the "dev is live" contract binds every impl; this
|
||||
// default body is a stub that bails.
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn update_modes(&self, dev: HANDLE, key: &MonitorKey, mode: Mode) -> Result<()> {
|
||||
@@ -114,6 +116,7 @@ mod tests {
|
||||
fn open(&self, _reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
|
||||
anyhow::bail!("fake driver has no control device")
|
||||
}
|
||||
// unsafe-fn-no-op-ok: signature mandated by the trait; test stub.
|
||||
unsafe fn add_monitor(
|
||||
&self,
|
||||
_dev: HANDLE,
|
||||
@@ -125,9 +128,11 @@ mod tests {
|
||||
) -> Result<AddedMonitor> {
|
||||
anyhow::bail!("fake driver adds no monitors")
|
||||
}
|
||||
// unsafe-fn-no-op-ok: signature mandated by the trait; test stub.
|
||||
unsafe fn remove_monitor(&self, _dev: HANDLE, _key: &MonitorKey) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
// unsafe-fn-no-op-ok: signature mandated by the trait; test stub.
|
||||
unsafe fn ping(&self, _dev: HANDLE) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
//! Only the driver-specific bits (GUID, IOCTL codes, request/reply structs, the version handshake) are
|
||||
//! here, per `pf_driver_proto`.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
|
||||
@@ -112,10 +112,9 @@
|
||||
//! Unsafe posture: unlike pf-bitstream (which forbids unsafe outright), this crate
|
||||
//! cannot — the `ash::vk::native` bindgen structs are zero-initialized the way the
|
||||
//! encode side does it (`pf-encode/src/enc/linux/vk_build.rs`), and the GPU half is
|
||||
//! Vulkan FFI. Every unsafe block therefore carries a written `// SAFETY:` proof,
|
||||
//! enforced (and unlike the encoder there is NO file-level
|
||||
//! `unsafe_op_in_unsafe_fn` exemption — every operation is individually fenced):
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
//! Vulkan FFI. Every unsafe block therefore carries a written `// SAFETY:` proof — enforced by
|
||||
//! the workspace `[workspace.lints]` tables, and (unlike the encoder) with NO file-level
|
||||
//! `unsafe_op_in_unsafe_fn` exemption: every operation is individually fenced.
|
||||
|
||||
pub mod caps;
|
||||
pub mod caps_av1;
|
||||
|
||||
@@ -88,8 +88,6 @@
|
||||
//! the readback geometry (row pitch / crop) or intra decode; mismatches that
|
||||
//! only appear on later frames point at inter prediction / DPB management.
|
||||
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
mod common;
|
||||
|
||||
use ash::vk;
|
||||
|
||||
@@ -39,8 +39,6 @@
|
||||
//! so releases pass `false`), soak, and both vendors' DPB arrangements at once
|
||||
//! (each box exercises only its own).
|
||||
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
mod common;
|
||||
|
||||
use ash::vk;
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
//! AMD ADL connector/EDID emulation — the shared implementation behind the `display-disturb
|
||||
//! adl-emul` probe AND the `edid_lock` display-policy axis (the software equivalent of an
|
||||
//! HPD-holding dummy plug).
|
||||
//!
|
||||
//! Three field cases (ASUS VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI — all
|
||||
//! RX 9070 XT hosts) share one mechanism: a connected-but-asleep sink whose standby HPD/DDC/link
|
||||
//! servicing the KMD performs below every OS lever (CCD deactivation, devnode disable and CRU
|
||||
//! EDID overrides are confirmed no-ops — `design/vdisplay-disturbance-immunity.md` §2a/§3). The
|
||||
//! one software lever that can stop the servicing at its SOURCE is the driver's own connector
|
||||
//! emulation: pin the live EDID with `ADL2_Adapter_ConnectionData_Set`, then
|
||||
//! `ADL2_Adapter_EmulationMode_Set(ADL_EMUL_MODE_ALWAYS)` so the driver stops caring what the
|
||||
//! physical pins report.
|
||||
//!
|
||||
//! [`run`] performs one action across every AMD adapter's connectors and returns the per-op
|
||||
//! [`OpRecord`]s — the probe tool prints them as bench lines, the host tracing-logs them. The
|
||||
//! `edid_lock` axis drives [`lock_for_stream`]/[`unlock_after_stream`] at the Exclusive isolate
|
||||
//! (`pf-vdisplay`'s Windows manager), with a crash journal ([`startup_recover`]) because pinned
|
||||
//! emulation persists across host restarts — and can persist across REBOOTS, so every lock ships
|
||||
//! with its unlock (driver reinstall = the escape hatch of last resort).
|
||||
//!
|
||||
//! Everything is best-effort by design: no AMD driver (`atiadlxx.dll` absent) means the axis is
|
||||
//! inert, and each rc is preserved so a field log answers the consumer-vs-Pro gating question
|
||||
//! (`ADL_ERR_NOT_SUPPORTED(-8)` vs `ADL_OK`).
|
||||
|
||||
// FFI mirrors of ADL's C structs — keep AMD's field names verbatim so the header diff is
|
||||
// mechanical.
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::time::Instant;
|
||||
|
||||
use windows::core::{s, PCSTR};
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||
|
||||
// ---- ADL constants (adl_defines.h, GPUOpen display-library) ----
|
||||
|
||||
const ADL_OK: i32 = 0;
|
||||
const ADL_MAX_PATH: usize = 256;
|
||||
const ADL_MAX_DISPLAY_EDID_DATA_SIZE: usize = 1024;
|
||||
const ADL_MAX_RAD_LINK_COUNT: usize = 15;
|
||||
|
||||
const ADL_EMUL_MODE_OFF: i32 = 0;
|
||||
const ADL_EMUL_MODE_ALWAYS: i32 = 3;
|
||||
|
||||
const ADL_QUERY_REAL_DATA: i32 = 0;
|
||||
const ADL_QUERY_EMULATED_DATA: i32 = 1;
|
||||
|
||||
const ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED: i32 = 0x1;
|
||||
const ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT: i32 = 0x2;
|
||||
const ADL_EMUL_STATUS_EMULATED_DEVICE_USED: i32 = 0x4;
|
||||
|
||||
const AMD_VENDOR_ID: i32 = 1002;
|
||||
|
||||
/// Decode the rc values a field log will actually contain (adl_defines.h) — `-8` vs `-1` is the
|
||||
/// whole consumer-vs-Pro question, so spell them out.
|
||||
pub fn rc_str(rc: i32) -> &'static str {
|
||||
match rc {
|
||||
0 => "ADL_OK",
|
||||
1..=4 => "ADL_OK_(warning-class)",
|
||||
-1 => "ADL_ERR",
|
||||
-2 => "ADL_ERR_NOT_INIT",
|
||||
-3 => "ADL_ERR_INVALID_PARAM",
|
||||
-5 => "ADL_ERR_INVALID_ADL_IDX",
|
||||
-8 => "ADL_ERR_NOT_SUPPORTED",
|
||||
-9 => "ADL_ERR_NULL_POINTER",
|
||||
-10 => "ADL_ERR_DISABLED_ADAPTER",
|
||||
-22 => "ADL_ERR_CALL_TO_INCOMPATIABLE_DRIVER",
|
||||
-23 => "ADL_ERR_NO_ADMINISTRATOR_PRIVILEGES",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_type_str(t: i32) -> &'static str {
|
||||
match t {
|
||||
1 => "VGA",
|
||||
2 => "DVI-D",
|
||||
3 => "DVI-I",
|
||||
8 => "HDMI-A",
|
||||
9 => "HDMI-B",
|
||||
10 => "DP",
|
||||
11 => "eDP",
|
||||
12 => "miniDP",
|
||||
13 => "VIRTUAL",
|
||||
14 => "USB-C",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ADL structs (adl_structures.h, verbatim layouts) ----
|
||||
|
||||
#[repr(C)]
|
||||
struct AdapterInfo {
|
||||
iSize: i32,
|
||||
iAdapterIndex: i32,
|
||||
strUDID: [u8; ADL_MAX_PATH],
|
||||
iBusNumber: i32,
|
||||
iDeviceNumber: i32,
|
||||
iFunctionNumber: i32,
|
||||
iVendorID: i32,
|
||||
strAdapterName: [u8; ADL_MAX_PATH],
|
||||
strDisplayName: [u8; ADL_MAX_PATH],
|
||||
iPresent: i32,
|
||||
// _WIN32 tail — this tool only builds for Windows.
|
||||
iExist: i32,
|
||||
strDriverPath: [u8; ADL_MAX_PATH],
|
||||
strDriverPathExt: [u8; ADL_MAX_PATH],
|
||||
strPNPString: [u8; ADL_MAX_PATH],
|
||||
iOSDisplayIndex: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLMSTRad {
|
||||
iLinkNumber: i32,
|
||||
rad: [u8; ADL_MAX_RAD_LINK_COUNT],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLDevicePort {
|
||||
iConnectorIndex: i32,
|
||||
aMSTRad: ADLMSTRad,
|
||||
}
|
||||
|
||||
impl ADLDevicePort {
|
||||
/// A non-MST port at `connector` (MST RAD all-zero = "DP root / non-DP ignored" per header).
|
||||
fn root(connector: i32) -> Self {
|
||||
Self {
|
||||
iConnectorIndex: connector,
|
||||
aMSTRad: ADLMSTRad {
|
||||
iLinkNumber: 0,
|
||||
rad: [0; ADL_MAX_RAD_LINK_COUNT],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLConnectionProperties {
|
||||
iValidProperties: i32,
|
||||
iBitrate: i32,
|
||||
iNumberOfLanes: i32,
|
||||
iColorDepth: i32,
|
||||
iStereo3DCaps: i32,
|
||||
iOutputBandwidth: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLConnectionData {
|
||||
iConnectionType: i32,
|
||||
aConnectionProperties: ADLConnectionProperties,
|
||||
iNumberofPorts: i32,
|
||||
iActiveConnections: i32,
|
||||
iDataSize: i32,
|
||||
EdidData: [u8; ADL_MAX_DISPLAY_EDID_DATA_SIZE],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct ADLConnectionState {
|
||||
iEmulationStatus: i32,
|
||||
iEmulationMode: i32,
|
||||
iDisplayIndex: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct ADLConnectorInfo {
|
||||
iConnectorIndex: i32,
|
||||
iConnectorId: i32,
|
||||
iSlotIndex: i32,
|
||||
iType: i32,
|
||||
iOffset: i32,
|
||||
iLength: i32,
|
||||
}
|
||||
|
||||
// ---- dynamic binding (atiadlxx.dll ships with every AMD driver; absent elsewhere) ----
|
||||
|
||||
type AdlContext = *mut c_void;
|
||||
type MallocCb = unsafe extern "C" fn(i32) -> *mut c_void;
|
||||
|
||||
type FnMainCreate = unsafe extern "C" fn(MallocCb, i32, *mut AdlContext) -> i32;
|
||||
type FnMainDestroy = unsafe extern "C" fn(AdlContext) -> i32;
|
||||
type FnNumAdapters = unsafe extern "C" fn(AdlContext, *mut i32) -> i32;
|
||||
type FnAdapterInfoGet = unsafe extern "C" fn(AdlContext, *mut AdapterInfo, i32) -> i32;
|
||||
type FnEdidMgmtCaps = unsafe extern "C" fn(AdlContext, i32, *mut i32) -> i32;
|
||||
type FnBoardLayoutGet = unsafe extern "C" fn(
|
||||
AdlContext,
|
||||
i32,
|
||||
*mut i32,
|
||||
*mut i32,
|
||||
*mut *mut c_void,
|
||||
*mut i32,
|
||||
*mut *mut ADLConnectorInfo,
|
||||
) -> i32;
|
||||
type FnConnStateGet =
|
||||
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, *mut ADLConnectionState) -> i32;
|
||||
type FnConnDataGet =
|
||||
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32, *mut ADLConnectionData) -> i32;
|
||||
type FnConnDataSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, ADLConnectionData) -> i32;
|
||||
type FnConnDataRemove = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort) -> i32;
|
||||
type FnEmulModeSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32) -> i32;
|
||||
|
||||
struct Adl {
|
||||
create: FnMainCreate,
|
||||
destroy: FnMainDestroy,
|
||||
num_adapters: FnNumAdapters,
|
||||
adapter_info: FnAdapterInfoGet,
|
||||
edid_caps: FnEdidMgmtCaps,
|
||||
board_layout: FnBoardLayoutGet,
|
||||
conn_state: FnConnStateGet,
|
||||
conn_data_get: FnConnDataGet,
|
||||
conn_data_set: FnConnDataSet,
|
||||
conn_data_remove: FnConnDataRemove,
|
||||
emul_mode_set: FnEmulModeSet,
|
||||
}
|
||||
|
||||
/// ADL's application-provided allocator: it hands buffers (board-layout arrays) back through
|
||||
/// out-pointers and expects the app to own them.
|
||||
unsafe extern "C" fn adl_malloc(size: i32) -> *mut c_void {
|
||||
let size = size.max(1) as usize;
|
||||
// SAFETY: non-zero size with a fixed valid alignment; the resulting buffers are deliberately
|
||||
// never freed — ADL's contract wants an ADL_Main_Memory_Free symmetry, and leaking the <1 KiB
|
||||
// of board-layout arrays in a one-shot probe is simpler than proving allocator parity.
|
||||
unsafe {
|
||||
std::alloc::alloc(std::alloc::Layout::from_size_align(size, 16).expect("tiny ADL alloc"))
|
||||
as *mut c_void
|
||||
}
|
||||
}
|
||||
|
||||
impl Adl {
|
||||
fn load() -> Option<Self> {
|
||||
// SAFETY: plain LoadLibrary of the AMD-driver-installed ADL runtime by its well-known
|
||||
// name; a foreign-DLL search-path attack would require writing to System32.
|
||||
let lib: HMODULE = unsafe { LoadLibraryA(s!("atiadlxx.dll")) }.ok()?;
|
||||
// One unsafe helper: resolve `name` or bail. Every Fn* type above matches the ADL
|
||||
// header's C signature (x64 has a single calling convention, so `extern "C"` is exact).
|
||||
unsafe fn sym<T: Copy>(lib: HMODULE, name: PCSTR) -> Option<T> {
|
||||
debug_assert_eq!(std::mem::size_of::<T>(), std::mem::size_of::<usize>());
|
||||
// SAFETY: caller passes a fn-pointer type T of pointer size (asserted above);
|
||||
// GetProcAddress yields the export's address or None.
|
||||
let f = unsafe { GetProcAddress(lib, name) }?;
|
||||
// SAFETY: reinterpreting one non-null fn pointer as the export's true C signature.
|
||||
Some(unsafe { std::mem::transmute_copy::<_, T>(&f) })
|
||||
}
|
||||
// SAFETY: `lib` is the live module handle from the successful load above.
|
||||
unsafe {
|
||||
Some(Self {
|
||||
create: sym(lib, s!("ADL2_Main_Control_Create"))?,
|
||||
destroy: sym(lib, s!("ADL2_Main_Control_Destroy"))?,
|
||||
num_adapters: sym(lib, s!("ADL2_Adapter_NumberOfAdapters_Get"))?,
|
||||
adapter_info: sym(lib, s!("ADL2_Adapter_AdapterInfo_Get"))?,
|
||||
edid_caps: sym(lib, s!("ADL2_Adapter_EDIDManagement_Caps"))?,
|
||||
board_layout: sym(lib, s!("ADL2_Adapter_BoardLayout_Get"))?,
|
||||
conn_state: sym(lib, s!("ADL2_Adapter_ConnectionState_Get"))?,
|
||||
conn_data_get: sym(lib, s!("ADL2_Adapter_ConnectionData_Get"))?,
|
||||
conn_data_set: sym(lib, s!("ADL2_Adapter_ConnectionData_Set"))?,
|
||||
conn_data_remove: sym(lib, s!("ADL2_Adapter_ConnectionData_Remove"))?,
|
||||
emul_mode_set: sym(lib, s!("ADL2_Adapter_EmulationMode_Set"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the library surface ----
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EmulAction {
|
||||
/// Read-only: caps + layout + per-connector state. Always safe.
|
||||
Probe,
|
||||
/// Pin live EDID + `ADL_EMUL_MODE_ALWAYS` on occupied (or named) connectors.
|
||||
Lock,
|
||||
/// `ADL_EMUL_MODE_OFF` + remove pinned EDID on all (or named) connectors.
|
||||
Unlock,
|
||||
}
|
||||
|
||||
/// One ADL call's outcome — op name, target, duration, rc (decoded via [`rc_str`]) and the
|
||||
/// op-specific fields. The probe tool prints these as its bench correlation lines; the host
|
||||
/// tracing-logs them. The rc IS the deliverable of a field run.
|
||||
pub struct OpRecord {
|
||||
pub op: &'static str,
|
||||
pub target: String,
|
||||
pub took_ms: u128,
|
||||
pub rc: i32,
|
||||
pub extra: String,
|
||||
}
|
||||
|
||||
impl OpRecord {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.rc == ADL_OK
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OpRecord {
|
||||
/// The bench line minus the caller's epoch prefix: `op target took_ms ok rc=N(STR) extra`.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let sep = if self.extra.is_empty() { "" } else { " " };
|
||||
write!(
|
||||
f,
|
||||
"{} {} took_ms={} ok={} rc={}({}){sep}{}",
|
||||
self.op,
|
||||
self.target,
|
||||
self.took_ms,
|
||||
self.ok(),
|
||||
self.rc,
|
||||
rc_str(self.rc),
|
||||
self.extra
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a [`run`] ended: the AMD runtime was absent entirely, died at init (nothing was touched),
|
||||
/// or walked the connectors (each op's rc in the records — a NOT_SUPPORTED driver still `Done`s).
|
||||
pub enum RunOutcome {
|
||||
/// `atiadlxx.dll` not loadable (or an export missing) — not an AMD driver install; the
|
||||
/// emulation lever does not exist on this box.
|
||||
NoAdl,
|
||||
/// `ADL2_Main_Control_Create` / adapter enumeration failed — records hold the failing rc.
|
||||
InitFailed(Vec<OpRecord>),
|
||||
/// The connector walk ran; every op's outcome is in the records.
|
||||
Done(Vec<OpRecord>),
|
||||
}
|
||||
|
||||
impl RunOutcome {
|
||||
pub fn records(&self) -> &[OpRecord] {
|
||||
match self {
|
||||
RunOutcome::NoAdl => &[],
|
||||
RunOutcome::InitFailed(r) | RunOutcome::Done(r) => r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn c_str(buf: &[u8]) -> String {
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..len]).into_owned()
|
||||
}
|
||||
|
||||
/// Perform `action` on every AMD adapter's connectors (or only `connector_filter`), returning the
|
||||
/// per-op records. Read-only for [`EmulAction::Probe`]; [`EmulAction::Lock`] pins occupied
|
||||
/// connectors only (unless the filter names one), matching an HPD dummy on the cables that exist.
|
||||
pub fn run(action: EmulAction, connector_filter: Option<i32>) -> RunOutcome {
|
||||
let mut recs: Vec<OpRecord> = Vec::new();
|
||||
let mut rec = |op: &'static str, target: &str, took_ms: u128, rc: i32, extra: String| {
|
||||
recs.push(OpRecord {
|
||||
op,
|
||||
target: target.to_owned(),
|
||||
took_ms,
|
||||
rc,
|
||||
extra,
|
||||
});
|
||||
};
|
||||
|
||||
let Some(adl) = Adl::load() else {
|
||||
return RunOutcome::NoAdl;
|
||||
};
|
||||
|
||||
let mut ctx: AdlContext = std::ptr::null_mut();
|
||||
let t = Instant::now();
|
||||
// SAFETY: documented init call — our allocator callback, iEnumConnectedAdapters=0 (ALL
|
||||
// adapters: an exclusively-isolated streaming host may report no "connected" display on the
|
||||
// physical GPU), and a valid out-slot for the context.
|
||||
let rc = unsafe { (adl.create)(adl_malloc, 0, &mut ctx) };
|
||||
rec(
|
||||
"adl-init",
|
||||
"atiadlxx",
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
if rc != ADL_OK {
|
||||
return RunOutcome::InitFailed(recs);
|
||||
}
|
||||
|
||||
let mut count = 0i32;
|
||||
// SAFETY: live context; valid out-param.
|
||||
let rc = unsafe { (adl.num_adapters)(ctx, &mut count) };
|
||||
if rc != ADL_OK || count <= 0 {
|
||||
rec("adl-num-adapters", "all", 0, rc, format!("count={count}"));
|
||||
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
|
||||
let _ = unsafe { (adl.destroy)(ctx) };
|
||||
return RunOutcome::InitFailed(recs);
|
||||
}
|
||||
let mut infos: Vec<AdapterInfo> = (0..count)
|
||||
.map(|_| {
|
||||
// SAFETY: AdapterInfo is plain ints + byte arrays — the all-zero pattern is valid,
|
||||
// and ADL fills the array in place.
|
||||
let mut a: AdapterInfo = unsafe { std::mem::zeroed() };
|
||||
a.iSize = std::mem::size_of::<AdapterInfo>() as i32;
|
||||
a
|
||||
})
|
||||
.collect();
|
||||
let bytes = std::mem::size_of_val(infos.as_slice()) as i32;
|
||||
// SAFETY: caller-allocated array of exactly `count` stamped entries, byte size passed as the
|
||||
// API's iInputSize contract requires.
|
||||
let rc = unsafe { (adl.adapter_info)(ctx, infos.as_mut_ptr(), bytes) };
|
||||
rec("adl-adapters", "all", 0, rc, format!("count={count}"));
|
||||
if rc != ADL_OK {
|
||||
// SAFETY: as above — context teardown, nothing ADL-owned used afterwards.
|
||||
let _ = unsafe { (adl.destroy)(ctx) };
|
||||
return RunOutcome::InitFailed(recs);
|
||||
}
|
||||
|
||||
// One GPU surfaces as many logical adapters — probe each bus once, AMD-present only.
|
||||
let mut seen_buses: Vec<i32> = Vec::new();
|
||||
for info in &infos {
|
||||
if info.iPresent == 0
|
||||
|| info.iVendorID != AMD_VENDOR_ID
|
||||
|| seen_buses.contains(&info.iBusNumber)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
seen_buses.push(info.iBusNumber);
|
||||
let idx = info.iAdapterIndex;
|
||||
let name = c_str(&info.strAdapterName);
|
||||
let target = format!("adapter{idx}[{}]", name.trim());
|
||||
|
||||
let mut supported = 0i32;
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context, adapter index from this enumeration, valid out-param.
|
||||
let rc = unsafe { (adl.edid_caps)(ctx, idx, &mut supported) };
|
||||
rec(
|
||||
"adl-edid-caps",
|
||||
&target,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!("supported={supported}"),
|
||||
);
|
||||
|
||||
let (mut valid, mut n_slots, mut n_conn) = (0i32, 0i32, 0i32);
|
||||
let mut slots: *mut c_void = std::ptr::null_mut();
|
||||
let mut connectors: *mut ADLConnectorInfo = std::ptr::null_mut();
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context + adapter index; out-pointers valid; ADL allocates the two arrays
|
||||
// through `adl_malloc` (deliberately leaked, see there).
|
||||
let rc = unsafe {
|
||||
(adl.board_layout)(
|
||||
ctx,
|
||||
idx,
|
||||
&mut valid,
|
||||
&mut n_slots,
|
||||
&mut slots,
|
||||
&mut n_conn,
|
||||
&mut connectors,
|
||||
)
|
||||
};
|
||||
rec(
|
||||
"adl-board-layout",
|
||||
&target,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!("connectors={n_conn} valid_flags={valid:#x}"),
|
||||
);
|
||||
let connector_list: &[ADLConnectorInfo] =
|
||||
if rc == ADL_OK && !connectors.is_null() && n_conn > 0 {
|
||||
// SAFETY: ADL just filled `connectors` with `n_conn` entries via our allocator; the
|
||||
// (leaked) buffer outlives this borrow.
|
||||
unsafe { std::slice::from_raw_parts(connectors, n_conn as usize) }
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
|
||||
for c in connector_list {
|
||||
if connector_filter.is_some_and(|want| want != c.iConnectorIndex) {
|
||||
continue;
|
||||
}
|
||||
let port = ADLDevicePort::root(c.iConnectorIndex);
|
||||
let ctarget = format!(
|
||||
"adapter{idx}.connector{}[{}]",
|
||||
c.iConnectorIndex,
|
||||
connector_type_str(c.iType)
|
||||
);
|
||||
|
||||
let mut state = ADLConnectionState::default();
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context; port is a by-value POD naming a connector this adapter just
|
||||
// enumerated; valid out-param.
|
||||
let rc = unsafe { (adl.conn_state)(ctx, idx, port, &mut state) };
|
||||
let real = state.iEmulationStatus & ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED != 0;
|
||||
rec(
|
||||
"adl-conn-state",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!(
|
||||
"status={:#x} real_connected={} emulated_present={} emulated_used={} mode={} display={}",
|
||||
state.iEmulationStatus,
|
||||
real,
|
||||
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT != 0,
|
||||
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_USED != 0,
|
||||
state.iEmulationMode,
|
||||
state.iDisplayIndex,
|
||||
),
|
||||
);
|
||||
if rc != ADL_OK {
|
||||
continue;
|
||||
}
|
||||
|
||||
match action {
|
||||
EmulAction::Probe => {
|
||||
// SAFETY: ADLConnectionData is plain ints + a byte array; all-zero is valid
|
||||
// and ADL overwrites it.
|
||||
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port as above; REAL query fills `data` in place.
|
||||
let rc = unsafe {
|
||||
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
|
||||
};
|
||||
rec(
|
||||
"adl-conn-data",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!(
|
||||
"type={} edid_bytes={}",
|
||||
data.iConnectionType, data.iDataSize
|
||||
),
|
||||
);
|
||||
}
|
||||
EmulAction::Lock => {
|
||||
if !real && connector_filter.is_none() {
|
||||
continue; // nothing to pin — and pinning an EMPTY connector is a different experiment
|
||||
}
|
||||
// SAFETY: as in Probe — zeroed then driver-filled.
|
||||
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; REAL query first — we pin exactly what the
|
||||
// sink reports today, so the emulated display IS the user's monitor.
|
||||
let mut rc = unsafe {
|
||||
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
|
||||
};
|
||||
if rc != ADL_OK {
|
||||
// Asleep sinks can refuse a live EDID read — fall back to whatever the
|
||||
// driver already has as emulation data (Radeon-Pro-UI parity).
|
||||
// SAFETY: same contract, emulated-data query.
|
||||
rc = unsafe {
|
||||
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_EMULATED_DATA, &mut data)
|
||||
};
|
||||
}
|
||||
rec(
|
||||
"adl-lock-read",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
format!(
|
||||
"type={} edid_bytes={}",
|
||||
data.iConnectionType, data.iDataSize
|
||||
),
|
||||
);
|
||||
if rc != ADL_OK || data.iDataSize <= 0 {
|
||||
continue;
|
||||
}
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; `data` passed by value per the ADL signature.
|
||||
let rc = unsafe { (adl.conn_data_set)(ctx, idx, port, data) };
|
||||
rec(
|
||||
"adl-lock-set",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; mode constant from the header.
|
||||
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_ALWAYS) };
|
||||
rec(
|
||||
"adl-lock-mode-always",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
}
|
||||
EmulAction::Unlock => {
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; mode constant from the header.
|
||||
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_OFF) };
|
||||
rec(
|
||||
"adl-unlock-mode-off",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
let t = Instant::now();
|
||||
// SAFETY: live context/port; removes emulation data set earlier (harmless
|
||||
// where none exists — the rc says so).
|
||||
let rc = unsafe { (adl.conn_data_remove)(ctx, idx, port) };
|
||||
rec(
|
||||
"adl-unlock-remove",
|
||||
&ctarget,
|
||||
t.elapsed().as_millis(),
|
||||
rc,
|
||||
String::new(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
|
||||
let _ = unsafe { (adl.destroy)(ctx) };
|
||||
RunOutcome::Done(recs)
|
||||
}
|
||||
|
||||
// ---- the `edid_lock` display-policy axis (host-side) ----
|
||||
|
||||
/// The crash-recovery journal: a marker that a lock was applied and not yet unlocked. Pinned
|
||||
/// emulation outlives the process (and can outlive a reboot), so a host that died mid-stream
|
||||
/// must unlock on its next start ([`startup_recover`]).
|
||||
fn journal_path() -> std::path::PathBuf {
|
||||
pf_paths::config_dir().join("edid-lock-active.json")
|
||||
}
|
||||
|
||||
fn tracing_log(prefix: &str, outcome: &RunOutcome) {
|
||||
match outcome {
|
||||
RunOutcome::NoAdl => tracing::info!(
|
||||
"{prefix}: atiadlxx.dll not loadable — not an AMD driver install; the ADL \
|
||||
emulation lever does not exist on this box"
|
||||
),
|
||||
RunOutcome::InitFailed(recs) | RunOutcome::Done(recs) => {
|
||||
for r in recs {
|
||||
if r.ok() {
|
||||
tracing::info!("{prefix}: {r}");
|
||||
} else {
|
||||
tracing::warn!("{prefix}: {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the `edid_lock` axis at stream bring-up: pin the live EDID + `ADL_EMUL_MODE_ALWAYS` on
|
||||
/// every occupied AMD connector (the software HPD dummy), journaling first so a crash still
|
||||
/// unlocks on the next host start. Returns whether a later [`unlock_after_stream`] is owed —
|
||||
/// true whenever the ADL runtime exists, because even a partially-failed lock may have pinned
|
||||
/// some connectors (each rc is in the log).
|
||||
pub fn lock_for_stream() -> bool {
|
||||
// Journal BEFORE touching the driver: a crash between the first `ConnectionData_Set` and the
|
||||
// journal write would otherwise leave pinned connectors with no startup unlock owed.
|
||||
if let Err(e) = std::fs::write(journal_path(), b"{\"locked\":true}") {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"edid_lock: crash journal write failed — continuing (the feature degrades to \
|
||||
no-crash-journal)"
|
||||
);
|
||||
}
|
||||
let outcome = run(EmulAction::Lock, None);
|
||||
tracing_log("edid_lock", &outcome);
|
||||
if matches!(outcome, RunOutcome::NoAdl) {
|
||||
tracing::info!("edid_lock: enabled but this is not an AMD driver install — axis inert");
|
||||
let _ = std::fs::remove_file(journal_path());
|
||||
return false;
|
||||
}
|
||||
let locked = outcome
|
||||
.records()
|
||||
.iter()
|
||||
.filter(|r| r.op == "adl-lock-mode-always" && r.ok())
|
||||
.count();
|
||||
tracing::info!(
|
||||
connectors = locked,
|
||||
"edid_lock: connector emulation pinned (software HPD dummy) — unlocked at stream teardown"
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Undo [`lock_for_stream`] at teardown: `ADL_EMUL_MODE_OFF` + remove the pinned EDID on every
|
||||
/// AMD connector, then clear the crash journal. Idempotent and harmless where nothing is pinned.
|
||||
pub fn unlock_after_stream() {
|
||||
let outcome = run(EmulAction::Unlock, None);
|
||||
tracing_log("edid_lock", &outcome);
|
||||
let _ = std::fs::remove_file(journal_path());
|
||||
}
|
||||
|
||||
/// Host-startup crash recovery: a previous host that died holding the lock left connector
|
||||
/// emulation pinned (it persists past the process — and can persist past a reboot). If the
|
||||
/// journal marker exists, unlock everything and clear it — before any new session touches the
|
||||
/// topology, mirroring `monitor_devnode::startup_recover`.
|
||||
pub fn startup_recover() {
|
||||
if !journal_path().exists() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
"edid_lock: a previous host left connector emulation pinned (crash/kill) — unlocking"
|
||||
);
|
||||
unlock_after_stream();
|
||||
}
|
||||
@@ -28,9 +28,6 @@
|
||||
//! suspects — without ever touching the CCD lock itself (the display-config lock is exactly what
|
||||
//! stalls during churn; the capture thread must never block on it).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Mutex, Once, OnceLock};
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
// `win_display` has denied both unsafe-proof lints since its CCD helpers stopped being `unsafe fn`;
|
||||
// hoist that to the crate root so the smaller modules (`input_desktop`, `monitor_devnode`,
|
||||
// `display_events`) and any future one are covered by default rather than by remembering to opt in.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod adl_emul;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod display_events;
|
||||
/// Bind display-config writes to the input desktop so a UAC / lock screen can't refuse them.
|
||||
|
||||
@@ -217,18 +217,32 @@ fn journal_and_disable(targets: Vec<(String, String)>) -> Vec<String> {
|
||||
disabled
|
||||
}
|
||||
|
||||
/// Re-enable `ids` (teardown / recovery) and clear them from the journal.
|
||||
/// Re-enable `ids` (teardown / recovery) and clear the ones that actually re-enabled from the
|
||||
/// journal. A FAILED re-enable must keep its journal entry: it is the only record that the
|
||||
/// devnode is still disabled, and the next host start's [`startup_recover`] is the only thing
|
||||
/// left that will retry it. (The old behavior cleared every requested id unconditionally — a
|
||||
/// mid-life re-enable failure erased its own crash-recovery entry, leaving the operator's
|
||||
/// monitor invisible to Windows AND to every display listing until they re-enabled it by hand
|
||||
/// in Device Manager: the "my displays are gone until I restart everything" field class.)
|
||||
pub fn enable_instances(ids: &[String]) -> u32 {
|
||||
let mut ok = 0u32;
|
||||
let mut reenabled: Vec<&String> = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
if set_devnode(id, false) {
|
||||
tracing::info!(id, "PnP-disable: monitor devnode re-enabled");
|
||||
reenabled.push(id);
|
||||
ok += 1;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
id,
|
||||
"PnP-disable: monitor devnode re-enable FAILED — keeping its crash-journal \
|
||||
entry so the next host start retries (until then this monitor stays disabled)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let journal: Vec<String> = read_journal()
|
||||
.into_iter()
|
||||
.filter(|j| !ids.contains(j))
|
||||
.filter(|j| !reenabled.contains(&j))
|
||||
.collect();
|
||||
write_journal(&journal);
|
||||
ok
|
||||
|
||||
@@ -8,13 +8,6 @@
|
||||
//! them, which let the SudoVDA backend be dropped without losing them (audit §9 / Goal 2 — done). The
|
||||
//! plan's `windows/display_ccd.rs`. Extracted verbatim from the former SudoVDA backend before its removal.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
|
||||
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted every CCD/GDI helper
|
||||
// below — including `restore_displays_ccd`, the call pf-vdisplay's teardown path depends on to give
|
||||
// the operator their physical panels back.
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
// The CCD/GDI helpers below are SAFE fns. They were `unsafe fn` for a decade of habit rather than a
|
||||
// memory-safety obligation: every one takes `Copy` scalars or borrowed Rust data, returns owned
|
||||
// values, and discharges its own FFI preconditions internally (`retry_set_display_config` even binds
|
||||
@@ -1969,29 +1962,173 @@ pub fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
isolate_journal::clear();
|
||||
}
|
||||
|
||||
/// Every display target that still EXISTS right now — `(adapter LUID low, high, target id)` keys
|
||||
/// from a full `QDC_ALL_PATHS` sweep, counting a target present when the OS says a monitor is
|
||||
/// attached (`targetAvailable`) OR an active path drives it (the flag reads FALSE transiently
|
||||
/// right after a removal — same rule as [`target_inventory`]). `None` when the CCD query itself
|
||||
/// fails, so the caller can fall back to trusting its snapshot verbatim.
|
||||
fn available_target_keys() -> Option<Vec<(u32, i32, u32)>> {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
// SAFETY: the CCD contract at the top of this file — `&mut np`/`&mut nm` are live locals the
|
||||
// OS fills with the counts it wants for these flags.
|
||||
if unsafe { GetDisplayConfigBufferSizes(QDC_ALL_PATHS, &mut np, &mut nm) }.is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
// SAFETY: the CCD contract — `paths`/`modes` were just allocated with exactly `np`/`nm`
|
||||
// elements from the sizing call above, and are handed over with those same counts.
|
||||
if unsafe {
|
||||
QueryDisplayConfig(
|
||||
QDC_ALL_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
paths.truncate(np as usize);
|
||||
let mut keys: Vec<(u32, i32, u32)> = Vec::new();
|
||||
for p in &paths {
|
||||
let t = &p.targetInfo;
|
||||
let key = (t.adapterId.LowPart, t.adapterId.HighPart, t.id);
|
||||
let present = t.targetAvailable.as_bool() || p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0;
|
||||
if present && !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
Some(keys)
|
||||
}
|
||||
|
||||
/// Drop every snapshot path whose TARGET no longer exists (`avail` — the live
|
||||
/// [`available_target_keys`] sweep) and rebuild the mode table with only the entries the
|
||||
/// survivors reference, remapping their `modeInfoIdx` slots. Both halves matter:
|
||||
/// `SetDisplayConfig(SDC_USE_SUPPLIED_DISPLAY_CONFIG)` validates the WHOLE submission, so one
|
||||
/// stale path — or one orphaned mode entry left behind by a dropped path — fails the entire
|
||||
/// restore with 0x57 ERROR_INVALID_PARAMETER. Returns `(paths, modes, dropped_path_count)`;
|
||||
/// pure over its inputs so the remap arithmetic is unit-testable without a live CCD.
|
||||
fn prune_saved_config_for_targets(
|
||||
paths: &[DISPLAYCONFIG_PATH_INFO],
|
||||
modes: &[DISPLAYCONFIG_MODE_INFO],
|
||||
avail: &[(u32, i32, u32)],
|
||||
) -> (
|
||||
Vec<DISPLAYCONFIG_PATH_INFO>,
|
||||
Vec<DISPLAYCONFIG_MODE_INFO>,
|
||||
usize,
|
||||
) {
|
||||
let mut kept: Vec<DISPLAYCONFIG_PATH_INFO> = Vec::with_capacity(paths.len());
|
||||
let mut new_modes: Vec<DISPLAYCONFIG_MODE_INFO> = Vec::with_capacity(modes.len());
|
||||
// old mode index → new mode index, memoized: clone configs legitimately share a source mode
|
||||
// entry between paths, and it must land in the rebuilt table exactly once.
|
||||
let mut remap: Vec<Option<u32>> = vec![None; modes.len()];
|
||||
let take =
|
||||
|idx: u32, new_modes: &mut Vec<DISPLAYCONFIG_MODE_INFO>, remap: &mut Vec<Option<u32>>| {
|
||||
if idx == DISPLAYCONFIG_PATH_MODE_IDX_INVALID {
|
||||
return DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
|
||||
}
|
||||
match modes.get(idx as usize) {
|
||||
// An out-of-range index could never have applied — un-pin the mode rather than
|
||||
// shipping a table the whole submission fails on.
|
||||
None => DISPLAYCONFIG_PATH_MODE_IDX_INVALID,
|
||||
Some(m) => match remap[idx as usize] {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let n = new_modes.len() as u32;
|
||||
new_modes.push(*m);
|
||||
remap[idx as usize] = Some(n);
|
||||
n
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
let mut dropped = 0usize;
|
||||
for p in paths {
|
||||
let t = &p.targetInfo;
|
||||
if !avail.contains(&(t.adapterId.LowPart, t.adapterId.HighPart, t.id)) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
let mut p = *p;
|
||||
// SAFETY: POD union reads (CCD header contract) — `modeInfoIdx` overlays a same-sized
|
||||
// bitfield struct, both valid for every bit pattern; used only as bounds-checked indices.
|
||||
let (src_idx, tgt_idx) = unsafe {
|
||||
(
|
||||
p.sourceInfo.Anonymous.modeInfoIdx,
|
||||
p.targetInfo.Anonymous.modeInfoIdx,
|
||||
)
|
||||
};
|
||||
p.sourceInfo.Anonymous.modeInfoIdx = take(src_idx, &mut new_modes, &mut remap);
|
||||
p.targetInfo.Anonymous.modeInfoIdx = take(tgt_idx, &mut new_modes, &mut remap);
|
||||
kept.push(p);
|
||||
}
|
||||
(kept, new_modes, dropped)
|
||||
}
|
||||
|
||||
fn restore_displays_ccd_inner(saved: &SavedConfig) {
|
||||
let (paths, modes) = saved;
|
||||
if paths.is_empty() {
|
||||
let (saved_paths, saved_modes) = saved;
|
||||
if saved_paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: the CCD contract at the top of this file — the path/mode arrays go over as
|
||||
// slices, so pointer and length cannot disagree, and both outlive this synchronous
|
||||
// call. `retry_set_display_config` binds it to the input desktop, which is the one
|
||||
// precondition a caller of this global-state write could otherwise get wrong.
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| unsafe {
|
||||
SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||
)
|
||||
});
|
||||
if rc == 0 {
|
||||
tracing::info!("display isolate (CCD): restored original topology");
|
||||
} else {
|
||||
// Prune the snapshot against what is STILL ATTACHED before replaying it. A monitor unplugged
|
||||
// mid-session leaves the snapshot referencing an absent target, and SetDisplayConfig rejects
|
||||
// the WHOLE array with 0x57 ERROR_INVALID_PARAMETER — nothing restores, the desk stays dark,
|
||||
// and the next session snapshots that wreckage (the poisoned-snapshot chain's first link;
|
||||
// field 2026-08-12: rc=0x57 across a mid-session unplug, then sessions flipping between
|
||||
// black/working at random). Dropping the stale paths lets the surviving displays restore
|
||||
// normally; when NOTHING survives there is nothing to replay and the dark-desk backstop
|
||||
// below is the whole answer.
|
||||
let (kept, pruned_modes, dropped);
|
||||
let (paths, modes): (&Vec<_>, &Vec<_>) = match available_target_keys() {
|
||||
Some(avail) => {
|
||||
(kept, pruned_modes, dropped) =
|
||||
prune_saved_config_for_targets(saved_paths, saved_modes, &avail);
|
||||
if dropped > 0 {
|
||||
tracing::warn!(
|
||||
dropped,
|
||||
kept = kept.len(),
|
||||
"display isolate (CCD): snapshot references target(s) that are no longer \
|
||||
attached (unplugged mid-session?) — pruned them so the survivors can restore \
|
||||
(a verbatim replay fails whole with rc=0x57)"
|
||||
);
|
||||
}
|
||||
(&kept, &pruned_modes)
|
||||
}
|
||||
// The availability query itself failed — replay verbatim, exactly the old behavior.
|
||||
None => (saved_paths, saved_modes),
|
||||
};
|
||||
let mut apply_rc = 0i32; // 0 also when the replay was skipped (nothing left to apply)
|
||||
if paths.is_empty() {
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): topology restore failed rc={rc:#x}{} — physical displays may be left deactivated",
|
||||
sdc_access_denied_hint(rc)
|
||||
"display isolate (CCD): nothing from the topology snapshot is still attached — \
|
||||
skipping the replay (the dark-desk backstop decides what lights up)"
|
||||
);
|
||||
} else {
|
||||
// SAFETY: the CCD contract at the top of this file — the path/mode arrays go over as
|
||||
// slices, so pointer and length cannot disagree, and both outlive this synchronous
|
||||
// call. `retry_set_display_config` binds it to the input desktop, which is the one
|
||||
// precondition a caller of this global-state write could otherwise get wrong.
|
||||
let rc = crate::input_desktop::retry_set_display_config(|| unsafe {
|
||||
SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||
)
|
||||
});
|
||||
apply_rc = rc;
|
||||
if rc == 0 {
|
||||
tracing::info!("display isolate (CCD): restored original topology");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): topology restore failed rc={rc:#x}{} — physical displays may be left deactivated",
|
||||
sdc_access_denied_hint(rc)
|
||||
);
|
||||
}
|
||||
}
|
||||
// GUARANTEE the desk is never left all-dark. The saved config can be unappliable (field
|
||||
// rc=0x64a ERROR_BAD_CONFIGURATION: it pinned a virtual target incarnation that was since
|
||||
@@ -2027,7 +2164,7 @@ fn restore_displays_ccd_inner(saved: &SavedConfig) {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
|
||||
"display isolate (CCD): no external physical display active after the restore (rc={apply_rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
|
||||
);
|
||||
force_extend_topology();
|
||||
// Measure what the force achieved: a sink still dark AFTER the EXTEND preset can never
|
||||
@@ -2135,3 +2272,124 @@ mod live_tests {
|
||||
tracing::info!("live CCD query: {n} active display path(s)");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prune_saved_config_tests {
|
||||
//! The snapshot-prune remap arithmetic (`prune_saved_config_for_targets`) — pure over its
|
||||
//! inputs, so the 0x57-poisoned-restore fix is testable without a live CCD: a stale target's
|
||||
//! path must vanish, its modes must not orphan (an orphaned entry fails the whole
|
||||
//! SetDisplayConfig exactly like the stale path did), and clone-shared modes must land once.
|
||||
use super::*;
|
||||
|
||||
fn path(
|
||||
luid_low: u32,
|
||||
target_id: u32,
|
||||
src_mode: u32,
|
||||
tgt_mode: u32,
|
||||
) -> DISPLAYCONFIG_PATH_INFO {
|
||||
let mut p = DISPLAYCONFIG_PATH_INFO::default();
|
||||
p.targetInfo.adapterId.LowPart = luid_low;
|
||||
p.targetInfo.id = target_id;
|
||||
p.sourceInfo.adapterId.LowPart = luid_low;
|
||||
p.sourceInfo.Anonymous.modeInfoIdx = src_mode;
|
||||
p.targetInfo.Anonymous.modeInfoIdx = tgt_mode;
|
||||
p
|
||||
}
|
||||
|
||||
fn mode(marker: u32) -> DISPLAYCONFIG_MODE_INFO {
|
||||
DISPLAYCONFIG_MODE_INFO {
|
||||
id: marker,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn indices(p: &DISPLAYCONFIG_PATH_INFO) -> (u32, u32) {
|
||||
// SAFETY: POD union reads — `modeInfoIdx` overlays a same-sized bitfield struct, both
|
||||
// valid for every bit pattern (the same contract the production reads rely on).
|
||||
unsafe {
|
||||
(
|
||||
p.sourceInfo.Anonymous.modeInfoIdx,
|
||||
p.targetInfo.Anonymous.modeInfoIdx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_attached_survives_with_dense_indices() {
|
||||
let paths = vec![path(1, 100, 0, 1), path(1, 200, 2, 3)];
|
||||
let modes = vec![mode(10), mode(11), mode(12), mode(13)];
|
||||
let avail = vec![(1, 0, 100), (1, 0, 200)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert_eq!(new_modes.len(), 4);
|
||||
assert_eq!(indices(&kept[0]), (0, 1));
|
||||
assert_eq!(indices(&kept[1]), (2, 3));
|
||||
assert_eq!(new_modes[3].id, 13, "mode entries follow their paths");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gone_target_drops_its_path_and_modes() {
|
||||
// Target 200 was unplugged mid-session (the field rc=0x57 case): its path AND its two
|
||||
// mode entries must vanish, and the survivor's indices must be remapped dense.
|
||||
let paths = vec![path(1, 100, 0, 1), path(1, 200, 2, 3)];
|
||||
let modes = vec![mode(10), mode(11), mode(12), mode(13)];
|
||||
let avail = vec![(1, 0, 100)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 1);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].targetInfo.id, 100);
|
||||
assert_eq!(
|
||||
new_modes.len(),
|
||||
2,
|
||||
"the dropped path's modes must not orphan"
|
||||
);
|
||||
assert_eq!((new_modes[0].id, new_modes[1].id), (10, 11));
|
||||
assert_eq!(indices(&kept[0]), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clone_shared_source_mode_lands_exactly_once() {
|
||||
// Clone configs share one source mode entry between paths — the rebuilt table must
|
||||
// contain it once, referenced by both survivors.
|
||||
let paths = vec![path(1, 100, 0, 1), path(1, 200, 0, 2)];
|
||||
let modes = vec![mode(10), mode(11), mode(12)];
|
||||
let avail = vec![(1, 0, 100), (1, 0, 200)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(new_modes.len(), 3);
|
||||
let (a_src, _) = indices(&kept[0]);
|
||||
let (b_src, _) = indices(&kept[1]);
|
||||
assert_eq!(a_src, b_src, "shared source mode keeps one table entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpinned_and_corrupt_indices_stay_unpinned() {
|
||||
// The INVALID sentinel must pass through, and an out-of-range index (a corrupt snapshot)
|
||||
// must degrade to unpinned rather than shipping a table the whole apply fails on.
|
||||
let paths = vec![path(1, 100, DISPLAYCONFIG_PATH_MODE_IDX_INVALID, 99)];
|
||||
let modes = vec![mode(10)];
|
||||
let avail = vec![(1, 0, 100)];
|
||||
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!(dropped, 0);
|
||||
assert!(new_modes.is_empty());
|
||||
assert_eq!(
|
||||
indices(&kept[0]),
|
||||
(
|
||||
DISPLAYCONFIG_PATH_MODE_IDX_INVALID,
|
||||
DISPLAYCONFIG_PATH_MODE_IDX_INVALID
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_adapters_do_not_alias_the_same_target_id() {
|
||||
// Target ids are only unique per adapter LUID — a survivor on adapter 2 must not keep a
|
||||
// stale path alive on adapter 1 just because the ids match.
|
||||
let paths = vec![path(1, 100, 0, 1)];
|
||||
let modes = vec![mode(10), mode(11)];
|
||||
let avail = vec![(2, 0, 100)];
|
||||
let (kept, _, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
|
||||
assert_eq!((kept.len(), dropped), (0, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
//! wait, no harm, and `WaitOutcome::NoFence` tells us the driver doesn't fence (so zero-copy
|
||||
//! would still race).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::os::fd::RawFd;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
//! A worker death — the whole point of the isolation — surfaces as an `Err` with
|
||||
//! [`RemoteImporter::dead`] set, never as a host fault.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::cuda::{self, CUdeviceptr, DeviceBuffer, CU_IPC_HANDLE_SIZE};
|
||||
use super::egl::DmabufPlane;
|
||||
use super::ipc;
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
//! driver — see [`super::egl`].)
|
||||
|
||||
#![allow(non_camel_case_types, non_snake_case)]
|
||||
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use std::os::raw::{c_uint, c_void};
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
//! and drive this layer.
|
||||
|
||||
#![allow(non_camel_case_types, non_snake_case)]
|
||||
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use std::os::raw::{c_int, c_uint, c_void};
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
//! owned [`DeviceBuffer`] so the dmabuf can be returned to the compositor immediately.
|
||||
|
||||
#![allow(non_upper_case_globals)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::cuda::{self, DeviceBuffer};
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
//! [`super`].
|
||||
|
||||
#![allow(non_upper_case_globals)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{bail, ensure, Result};
|
||||
use std::os::raw::{c_int, c_void};
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
//! inode with `punktfunk-host`, because a shared inode shares the file capability — so it passes
|
||||
//! its own resolved path to [`spawn_worker`] instead.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use std::fs::File;
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
//! Falls back cleanly: if bring-up fails the encoder allocates plain CUDA surfaces and composite
|
||||
//! mode degrades to no cursor (warned once) — never a failed session.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::cuda::{self, CUdeviceptr};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use ash::vk;
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
//! a stream's life). Falls back cleanly: any init/import error disables the importer and the
|
||||
//! CPU mmap path takes over.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::cuda::{self, DeviceBuffer};
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use ash::vk;
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
//! only happens after the capturer AND every in-flight frame on the host side are gone, so pooled
|
||||
//! device memory is never freed under a frame the host still reads.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::cuda::{self, CUdeviceptr, DeviceBuffer};
|
||||
use super::egl::{DmabufPlane, EglImporter};
|
||||
use super::ipc;
|
||||
|
||||
@@ -8,13 +8,9 @@
|
||||
//! consumes the shared frame vocabulary, which sits ABOVE this crate (this crate provides the
|
||||
//! `DeviceBuffer` that vocabulary's `FramePayload::Cuda` owns).
|
||||
|
||||
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof. Each
|
||||
// file keeps its own `#![deny(...)]` too; this crate-root deny is the catch-all gate.
|
||||
// `unsafe_op_in_unsafe_fn` closes the gap the clippy lint leaves: operations inside an
|
||||
// `unsafe fn` body are not "unsafe blocks", so without it ~45 functions' worth of raw driver
|
||||
// calls sat OUTSIDE the invariant this crate advertises.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` carries a `// SAFETY:` proof, and
|
||||
// `unsafe fn` bodies need explicit blocks (~45 functions' worth of raw driver calls used to sit
|
||||
// outside that invariant). Both lints are enforced by the workspace `[workspace.lints]` tables.
|
||||
|
||||
/// Wait for a dmabuf's implicit read-ready fence (`DMA_BUF_IOCTL_EXPORT_SYNC_FILE` + poll).
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -53,6 +53,19 @@ use std::os::raw::c_char;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::ptr;
|
||||
|
||||
/// Poison-recovering lock for the C ABI surface. `.lock().unwrap()` inside an `extern "C"` fn
|
||||
/// turns a poisoned mutex (some other thread panicked mid-write) into a panic across the C
|
||||
/// boundary — an abort since Rust 1.81, exactly the class the panic-in-extern grep gate exists
|
||||
/// for. The slots behind these mutexes are plain last-value caches (frame/audio/cursor/clip), so
|
||||
/// whatever a poisoned writer left behind is still structurally valid data to overwrite or hand
|
||||
/// out; recovering the guard is strictly better than aborting the embedding application.
|
||||
/// (`quic`-gated with its only callers, the `punktfunk_connection_*` entry points — a
|
||||
/// `default-features = false` consumer like the tray would otherwise see dead code.)
|
||||
#[cfg(feature = "quic")]
|
||||
fn lock_recover<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Opaque session handle. Pointer-only from C.
|
||||
pub struct PunktfunkSession {
|
||||
inner: Session,
|
||||
@@ -471,8 +484,7 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
|
||||
}
|
||||
match s.inner.poll_frame() {
|
||||
Ok(frame) => {
|
||||
s.last_frame = Some(frame);
|
||||
let f = s.last_frame.as_ref().unwrap();
|
||||
let f = s.last_frame.insert(frame);
|
||||
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
|
||||
// matching `#[repr(C)]` type, written once by value.
|
||||
unsafe {
|
||||
@@ -494,8 +506,10 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
|
||||
|
||||
/// Client: serialize and send one input event to the host.
|
||||
///
|
||||
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` is a valid client handle; `ev` points to a valid [`InputEvent`].
|
||||
/// `s` is a valid client handle; `ev` points to a readable `InputEvent`-sized allocation.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_send_input(
|
||||
s: *mut PunktfunkSession,
|
||||
@@ -509,12 +523,11 @@ pub unsafe extern "C" fn punktfunk_send_input(
|
||||
Some(s) => s,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let ev = match unsafe { ev.as_ref() } {
|
||||
Some(e) => e,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
// SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
|
||||
// for the one field where a reference formed too early would be UB instead.
|
||||
let ev = match unsafe { read_input_event(ev) } {
|
||||
Ok(e) => e,
|
||||
Err(status) => return status,
|
||||
};
|
||||
match s.inner.send_input(ev) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
@@ -523,6 +536,31 @@ pub unsafe extern "C" fn punktfunk_send_input(
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate caller memory as an [`InputEvent`] WITHOUT forming the reference first.
|
||||
///
|
||||
/// `InputEvent.kind` is a `#[repr(u8)]` enum with 16 valid discriminants, and a C embedder
|
||||
/// writing `ev->kind = 42` is not a decodable error once `&InputEvent` exists — forming the
|
||||
/// reference IS the UB, by the language's validity rule. So the tag is read as a raw byte and
|
||||
/// validated through the same `InputKind::from_u8` the wire path uses (`input.rs::decode`),
|
||||
/// and the typed reference comes into existence only afterwards. Every other field is a plain
|
||||
/// integer (or the `[u8; 3]` pad), valid for any bit pattern.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ev` is null (reported as a status) or readable for `size_of::<InputEvent>()` bytes.
|
||||
unsafe fn read_input_event<'a>(ev: *const InputEvent) -> Result<&'a InputEvent, PunktfunkStatus> {
|
||||
if ev.is_null() {
|
||||
return Err(PunktfunkStatus::NullPointer);
|
||||
}
|
||||
// SAFETY: non-null per the check above, readable per this fn's contract; a one-byte read
|
||||
// at offset 0 (the `kind` tag — repr(C) puts it first) cannot itself be UB for any value.
|
||||
if crate::input::InputKind::from_u8(unsafe { ev.cast::<u8>().read() }).is_none() {
|
||||
return Err(PunktfunkStatus::InvalidArg);
|
||||
}
|
||||
// SAFETY: non-null, readable, and the discriminant byte was just validated — every field
|
||||
// of the repr(C) struct now holds a valid bit pattern for its type.
|
||||
Ok(unsafe { &*ev })
|
||||
}
|
||||
|
||||
/// Register the host-side input callback (pass a NULL fn pointer to clear). The callback
|
||||
/// fires from within [`punktfunk_host_poll_input`], on the calling thread.
|
||||
///
|
||||
@@ -2249,9 +2287,8 @@ pub unsafe extern "C" fn punktfunk_connection_next_au(
|
||||
.next_frame(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(frame) => {
|
||||
let mut slot = c.last.lock().unwrap();
|
||||
*slot = Some(frame);
|
||||
let f = slot.as_ref().unwrap();
|
||||
let mut slot = lock_recover(&c.last);
|
||||
let f = slot.insert(frame);
|
||||
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
|
||||
// matching `#[repr(C)]` type, written once by value.
|
||||
unsafe {
|
||||
@@ -2314,9 +2351,8 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio(
|
||||
.next_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(pkt) => {
|
||||
let mut slot = c.last_audio.lock().unwrap();
|
||||
*slot = Some(pkt);
|
||||
let p = slot.as_ref().unwrap();
|
||||
let mut slot = lock_recover(&c.last_audio);
|
||||
let p = slot.insert(pkt);
|
||||
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
|
||||
// matching `#[repr(C)]` type, written once by value.
|
||||
unsafe {
|
||||
@@ -2467,7 +2503,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
Ok(pkt) => pkt,
|
||||
Err(e) => return e.status(),
|
||||
};
|
||||
let mut state = c.audio_pcm.lock().unwrap();
|
||||
let mut state = lock_recover(&c.audio_pcm);
|
||||
match state.decode_packet(&pkt.data, pkt.seq, channels) {
|
||||
// Nothing to hand out this call: a DTX silence marker with no loss owed before it.
|
||||
Ok(0) => PunktfunkStatus::NoFrame,
|
||||
@@ -3072,9 +3108,8 @@ pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape(
|
||||
.next_cursor_shape(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(shape) => {
|
||||
let mut slot = c.last_cursor_shape.lock().unwrap();
|
||||
*slot = Some(shape);
|
||||
let sh = slot.as_ref().unwrap();
|
||||
let mut slot = lock_recover(&c.last_cursor_shape);
|
||||
let sh = slot.insert(shape);
|
||||
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
|
||||
// matching `#[repr(C)]` type, written once by value.
|
||||
unsafe {
|
||||
@@ -3363,8 +3398,10 @@ pub unsafe extern "C" fn punktfunk_connection_shard_payload(
|
||||
|
||||
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||
///
|
||||
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `ev` points to a valid [`InputEvent`].
|
||||
/// `c` is a valid connection handle; `ev` points to a readable `InputEvent`-sized allocation.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_send_input(
|
||||
@@ -3379,12 +3416,11 @@ pub unsafe extern "C" fn punktfunk_connection_send_input(
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let ev = match unsafe { ev.as_ref() } {
|
||||
Some(e) => e,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
// SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
|
||||
// for the one field where a reference formed too early would be UB instead.
|
||||
let ev = match unsafe { read_input_event(ev) } {
|
||||
Ok(e) => e,
|
||||
Err(status) => return status,
|
||||
};
|
||||
match c.inner.send_input(ev) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
@@ -4053,7 +4089,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
|
||||
.next_clip(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(ev) => {
|
||||
let mut slot = c.last_clip.lock().unwrap();
|
||||
let mut slot = lock_recover(&c.last_clip);
|
||||
let out_ev = build_clip_event(ev, &mut slot);
|
||||
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
|
||||
// written once by value.
|
||||
@@ -4065,7 +4101,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
|
||||
// traffic is sporadic, so without this a one-off 50 MiB paste stays resident
|
||||
// for the rest of the session (there is no other release entry point). The
|
||||
// borrow contract already says `out` data is valid only until the next call.
|
||||
*c.last_clip.lock().unwrap() = None;
|
||||
*lock_recover(&c.last_clip) = None;
|
||||
e.status()
|
||||
}
|
||||
}
|
||||
@@ -4337,7 +4373,41 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
|
||||
if !gap_out.is_null() {
|
||||
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
|
||||
// written once by value.
|
||||
unsafe { *gap_out = gap };
|
||||
unsafe { *gap_out = gap > 0 };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// [`punktfunk_connection_note_frame_index`] with the gap WIDTH instead of a yes/no: writes to
|
||||
/// `gap_width_out` how many frames this arrival revealed as missing (0 = contiguous/straggler).
|
||||
/// A client with a post-loss display freeze passes the width to
|
||||
/// [`punktfunk_reanchor_gate_arm_expecting_drops`] so the reassembler's later `frames_dropped`
|
||||
/// climb for the SAME loss cannot re-freeze a stream an RFI anchor already healed (the double-arm
|
||||
/// race — see the gate function's doc).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_note_frame_index_ex(
|
||||
c: *const PunktfunkConnection,
|
||||
frame_index: u32,
|
||||
gap_width_out: *mut u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
|
||||
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
|
||||
// here handles.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let gap = c.inner.note_frame_index(frame_index);
|
||||
if !gap_width_out.is_null() {
|
||||
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
|
||||
// written once by value.
|
||||
unsafe { *gap_width_out = gap };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
@@ -4688,6 +4758,31 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
|
||||
});
|
||||
}
|
||||
|
||||
/// [`punktfunk_reanchor_gate_arm`] for a loss detected as a **frame-index gap**, where the caller
|
||||
/// knows how many frames the gap skipped ([`punktfunk_connection_note_frame_index_ex`]). On top of
|
||||
/// arming, the gate pre-credits the reassembler's `frames_dropped` climb those same lost frames
|
||||
/// will produce up to ~120 ms later, so [`punktfunk_reanchor_gate_poll`] does not treat that
|
||||
/// delayed bookkeeping as a SECOND loss — without the credit, a fast LTR-RFI anchor lifts the
|
||||
/// freeze between the two signals and the stale climb re-freezes a healed stream (the double-arm
|
||||
/// race). Use the plain arm for non-gap loss signals (decoder wedge/demotion). NULL is a no-op.
|
||||
///
|
||||
/// # Safety
|
||||
/// `g` is a valid gate handle.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
|
||||
g: *mut ReanchorGate,
|
||||
expected_drops: u64,
|
||||
) {
|
||||
guard_void(|| {
|
||||
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
|
||||
// not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match` here
|
||||
// handles.
|
||||
if let Some(g) = unsafe { g.as_mut() } {
|
||||
g.arm_expecting_drops(std::time::Instant::now(), expected_drops);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fold one decoded frame and write to `out_present` whether to display it (`true`) or withhold it as
|
||||
/// a post-loss concealment (`false`). `flags` is the AU's `user_flags` word ([`PunktfunkFrame::flags`]):
|
||||
/// the gate reads `FLAG_SOF` (the host's IDR marker), `USER_FLAG_RECOVERY_ANCHOR` and
|
||||
@@ -4809,6 +4904,30 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A C embedder writing `ev->kind = 42` must come back as a status code, not UB. The test
|
||||
/// stages the event in `MaybeUninit` storage so no `&InputEvent` to an invalid value ever
|
||||
/// exists on the test's own side either.
|
||||
#[test]
|
||||
fn read_input_event_rejects_null_and_bad_discriminant() {
|
||||
// SAFETY: null is the documented reported-not-UB case.
|
||||
let null_result = unsafe { read_input_event(std::ptr::null()) };
|
||||
assert_eq!(null_result.unwrap_err(), PunktfunkStatus::NullPointer);
|
||||
|
||||
let mut slot = core::mem::MaybeUninit::<InputEvent>::zeroed();
|
||||
let p = slot.as_mut_ptr();
|
||||
// SAFETY: writing one byte at offset 0 of aligned, sized storage.
|
||||
unsafe { p.cast::<u8>().write(42) };
|
||||
// SAFETY: `p` is aligned and readable for the full struct.
|
||||
let bad_tag = unsafe { read_input_event(p) };
|
||||
assert_eq!(bad_tag.unwrap_err(), PunktfunkStatus::InvalidArg);
|
||||
|
||||
// SAFETY: as above; tag 0 (KeyDown) + zeroed fields is a fully valid event.
|
||||
unsafe { p.cast::<u8>().write(0) };
|
||||
// SAFETY: as above.
|
||||
let ev = unsafe { read_input_event(p) }.expect("valid tag must pass");
|
||||
assert_eq!(ev.kind, crate::input::InputKind::KeyDown);
|
||||
}
|
||||
|
||||
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
|
||||
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
|
||||
/// packing idiom — no struct growth, so the size guard above stays at 19).
|
||||
|
||||
@@ -881,14 +881,20 @@ impl NativeClient {
|
||||
///
|
||||
/// Call it for EVERY received frame; it is cheap and idempotent, and the
|
||||
/// [`frames_dropped`](Self::frames_dropped)-driven [`request_keyframe`](Self::request_keyframe)
|
||||
/// loop stays the backstop for when the recovery frame itself is lost. Returns `true` when a
|
||||
/// forward gap was detected on this call (whether or not the RFI was throttled), so a client with
|
||||
/// a post-loss display freeze can (re-)arm it on the same signal.
|
||||
/// loop stays the backstop for when the recovery frame itself is lost. Returns the gap WIDTH —
|
||||
/// how many frames this arrival revealed as missing, `0` when none (contiguous or straggler),
|
||||
/// whether or not the RFI was throttled — so a client with a post-loss display freeze can
|
||||
/// (re-)arm it on the same signal AND pre-credit the reassembler's later `frames_dropped` climb
|
||||
/// for the same loss ([`ReanchorGate::arm_expecting_drops`] — without the credit, a fast
|
||||
/// LTR-RFI anchor lifts the freeze before the climb books the loss, and the stale climb then
|
||||
/// re-freezes the healed stream).
|
||||
///
|
||||
/// This centralizes the loss-range detection so every embedder gets identical behavior. (The
|
||||
/// in-process Vulkan session pump keeps its own copy because it gates a display freeze on the same
|
||||
/// signal and shares one throttle across RFI + keyframe requests.)
|
||||
pub fn note_frame_index(&self, frame_index: u32) -> bool {
|
||||
///
|
||||
/// [`ReanchorGate::arm_expecting_drops`]: crate::reanchor::ReanchorGate::arm_expecting_drops
|
||||
pub fn note_frame_index(&self, frame_index: u32) -> u32 {
|
||||
// Decide (and update state) under the lock; fire the request after releasing it.
|
||||
let (gap, ask) = self
|
||||
.rfi
|
||||
|
||||
@@ -32,14 +32,16 @@ pub(crate) enum RecoveryAsk {
|
||||
impl RfiRecovery {
|
||||
/// Pure decision behind [`NativeClient::note_frame_index`]: fold one received `frame_index` (in
|
||||
/// receive order) observed at `now`, advancing the expectation and returning `(gap, ask)`.
|
||||
/// `gap` is whether this frame revealed a forward gap (the embedder arms its post-loss display
|
||||
/// freeze on it); `ask` is the (throttled) recovery request to fire — an RFI naming the exact
|
||||
/// lost span, or a keyframe when the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is
|
||||
/// hopeless there: no encoder holds references that old, and a huge jump is more likely a
|
||||
/// resync — e.g. the first real AU after an old host's speed test — than a real loss). Split
|
||||
/// out from the connection so the wrapping arithmetic + [`RFI_THROTTLE`] are unit-testable
|
||||
/// without a live session (see the tests below).
|
||||
pub(crate) fn observe(&mut self, frame_index: u32, now: Instant) -> (bool, RecoveryAsk) {
|
||||
/// `gap` is how many frames this arrival revealed as missing — 0 for contiguous/straggler; the
|
||||
/// embedder arms its post-loss display freeze on a non-zero gap, and the WIDTH lets it
|
||||
/// pre-credit the reassembler's later `frames_dropped` climb for the same loss
|
||||
/// ([`crate::reanchor::ReanchorGate::arm_expecting_drops`] — the double-arm race). `ask` is the
|
||||
/// (throttled) recovery request to fire — an RFI naming the exact lost span, or a keyframe when
|
||||
/// the span exceeds [`crate::packet::RFI_MAX_RANGE`] (RFI is hopeless there: no encoder holds
|
||||
/// references that old, and a huge jump is more likely a resync — e.g. the first real AU after
|
||||
/// an old host's speed test — than a real loss). Split out from the connection so the wrapping
|
||||
/// arithmetic + [`RFI_THROTTLE`] are unit-testable without a live session (see the tests below).
|
||||
pub(crate) fn observe(&mut self, frame_index: u32, now: Instant) -> (u32, RecoveryAsk) {
|
||||
match self.next_expected {
|
||||
Some(exp) => {
|
||||
// Wrapping split at the half-space: a small positive delta is a forward gap
|
||||
@@ -47,10 +49,11 @@ impl RfiRecovery {
|
||||
let ahead = frame_index.wrapping_sub(exp);
|
||||
if ahead == 0 {
|
||||
self.next_expected = Some(frame_index.wrapping_add(1)); // contiguous
|
||||
(false, RecoveryAsk::None)
|
||||
(0, RecoveryAsk::None)
|
||||
} else if ahead < u32::MAX / 2 {
|
||||
// Forward gap: [exp, frame_index-1] lost. Advance past this frame so the same
|
||||
// gap isn't re-detected, then fire a throttled recovery ask for the lost range.
|
||||
// Forward gap: [exp, frame_index-1] lost (`ahead` frames). Advance past this
|
||||
// frame so the same gap isn't re-detected, then fire a throttled recovery ask
|
||||
// for the lost range.
|
||||
self.next_expected = Some(frame_index.wrapping_add(1));
|
||||
let send = self
|
||||
.last_req
|
||||
@@ -65,15 +68,15 @@ impl RfiRecovery {
|
||||
} else {
|
||||
RecoveryAsk::Rfi(exp, frame_index.wrapping_sub(1))
|
||||
};
|
||||
(true, ask)
|
||||
(ahead, ask)
|
||||
} else {
|
||||
// Straggler behind the delivery point — leave the expectation.
|
||||
(false, RecoveryAsk::None)
|
||||
(0, RecoveryAsk::None)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.next_expected = Some(frame_index.wrapping_add(1));
|
||||
(false, RecoveryAsk::None)
|
||||
(0, RecoveryAsk::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +99,7 @@ mod rfi_recovery_tests {
|
||||
fn first_frame_arms_without_a_gap() {
|
||||
let mut r = RfiRecovery::default();
|
||||
// The opening frame only seeds the expectation — there is no prior frame to be missing.
|
||||
assert_eq!(r.observe(100, base()), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(100, base()), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(101));
|
||||
}
|
||||
|
||||
@@ -105,9 +108,9 @@ mod rfi_recovery_tests {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(100, t);
|
||||
assert_eq!(r.observe(101, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(102, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(101, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(102, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(103, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(104));
|
||||
}
|
||||
|
||||
@@ -117,7 +120,7 @@ mod rfi_recovery_tests {
|
||||
let t = base();
|
||||
r.observe(100, t); // expecting 101 next
|
||||
// 101..=104 were lost; 105 arrived. The RFI must name exactly the missing span.
|
||||
assert_eq!(r.observe(105, t), (true, RecoveryAsk::Rfi(101, 104)));
|
||||
assert_eq!(r.observe(105, t), (4, RecoveryAsk::Rfi(101, 104)));
|
||||
// The expectation advances past the delivered frame so the same gap can't re-fire.
|
||||
assert_eq!(r.next_expected, Some(106));
|
||||
}
|
||||
@@ -128,7 +131,7 @@ mod rfi_recovery_tests {
|
||||
let t = base();
|
||||
r.observe(100, t);
|
||||
// Exactly one frame (101) lost → range is the single index [101, 101].
|
||||
assert_eq!(r.observe(102, t), (true, RecoveryAsk::Rfi(101, 101)));
|
||||
assert_eq!(r.observe(102, t), (1, RecoveryAsk::Rfi(101, 101)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -137,16 +140,16 @@ mod rfi_recovery_tests {
|
||||
let t0 = base();
|
||||
r.observe(100, t0);
|
||||
// First gap fires the request and stamps the throttle.
|
||||
assert_eq!(r.observe(105, t0), (true, RecoveryAsk::Rfi(101, 104)));
|
||||
assert_eq!(r.observe(105, t0), (4, RecoveryAsk::Rfi(101, 104)));
|
||||
// A second gap 50 ms later is still a gap, but the request is throttled away.
|
||||
assert_eq!(
|
||||
r.observe(110, t0 + Duration::from_millis(50)),
|
||||
(true, RecoveryAsk::None)
|
||||
(4, RecoveryAsk::None)
|
||||
);
|
||||
// Past the window, the request re-opens for the still-accurate lost span.
|
||||
assert_eq!(
|
||||
r.observe(120, t0 + RFI_THROTTLE + Duration::from_millis(1)),
|
||||
(true, RecoveryAsk::Rfi(111, 119))
|
||||
(9, RecoveryAsk::Rfi(111, 119))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,7 +161,7 @@ mod rfi_recovery_tests {
|
||||
r.observe(105, t); // expecting 106 next
|
||||
// A reordered late arrival (103, well behind 106) is neither a gap nor a request, and it
|
||||
// must not rewind the expectation — otherwise the next in-order frame would false-gap.
|
||||
assert_eq!(r.observe(103, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(103, t), (0, RecoveryAsk::None));
|
||||
assert_eq!(r.next_expected, Some(106));
|
||||
}
|
||||
|
||||
@@ -167,9 +170,9 @@ mod rfi_recovery_tests {
|
||||
let mut r = RfiRecovery::default();
|
||||
let t = base();
|
||||
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
|
||||
assert_eq!(r.observe(u32::MAX, t), (false, RecoveryAsk::None)); // contiguous, wraps to 0
|
||||
assert_eq!(r.observe(u32::MAX, t), (0, RecoveryAsk::None)); // contiguous, wraps to 0
|
||||
assert_eq!(r.next_expected, Some(0));
|
||||
assert_eq!(r.observe(0, t), (false, RecoveryAsk::None)); // still contiguous across the wrap
|
||||
assert_eq!(r.observe(0, t), (0, RecoveryAsk::None)); // still contiguous across the wrap
|
||||
assert_eq!(r.next_expected, Some(1));
|
||||
}
|
||||
|
||||
@@ -179,7 +182,7 @@ mod rfi_recovery_tests {
|
||||
let t = base();
|
||||
r.observe(u32::MAX - 1, t); // expecting u32::MAX next
|
||||
// u32::MAX was lost and 1 arrived → the lost span wraps: [u32::MAX, 0].
|
||||
assert_eq!(r.observe(1, t), (true, RecoveryAsk::Rfi(u32::MAX, 0)));
|
||||
assert_eq!(r.observe(1, t), (2, RecoveryAsk::Rfi(u32::MAX, 0)));
|
||||
assert_eq!(r.next_expected, Some(2));
|
||||
}
|
||||
|
||||
@@ -192,14 +195,14 @@ mod rfi_recovery_tests {
|
||||
// reference exists for an RFI, and the jump may be a phantom (an old host's
|
||||
// speed-test burst consuming video indexes) — ask for the IDR resync instead.
|
||||
let jump = 100 + crate::packet::RFI_MAX_RANGE + 2;
|
||||
assert_eq!(r.observe(jump, t), (true, RecoveryAsk::Keyframe));
|
||||
assert_eq!(r.observe(jump, t), (jump - 101, RecoveryAsk::Keyframe));
|
||||
// The expectation still advances past the delivered frame (no re-fire on the next one).
|
||||
assert_eq!(r.next_expected, Some(jump + 1));
|
||||
assert_eq!(r.observe(jump + 1, t), (false, RecoveryAsk::None));
|
||||
assert_eq!(r.observe(jump + 1, t), (0, RecoveryAsk::None));
|
||||
// A huge gap consumes the shared throttle too — an immediate follow-up gap stays quiet.
|
||||
assert_eq!(
|
||||
r.observe(jump + 10, t + Duration::from_millis(1)),
|
||||
(true, RecoveryAsk::None)
|
||||
(8, RecoveryAsk::None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
// `qos_windows`) — sendmmsg/recvmsg_x/USO/qWAVE move caller-owned buffers, nothing more.
|
||||
// A new module parsing wire data may NOT add a carve-out.
|
||||
#![deny(unsafe_code)]
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
#![forbid(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
pub mod abi;
|
||||
|
||||
@@ -64,6 +64,26 @@ pub const REANCHOR_MARKS_TO_LIFT: u32 = 2;
|
||||
/// floor fires, so a real stall still recovers.
|
||||
pub const RECOVERY_MARK_PATIENCE: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// How long a frame-index-gap arm's expected `frames_dropped` climb stays pre-credited in
|
||||
/// [`ReanchorGate::poll`]. One loss arms the gate through TWO signals: the frame-index gap the
|
||||
/// instant the AU after the loss is delivered ([`ReanchorGate::arm_expecting_drops`]), and the
|
||||
/// reassembler's `frames_dropped` climb once the lost frame ages out of its loss window (~120 ms
|
||||
/// later, and only when at least one of its packets arrived). Without the credit, a *fast* recovery
|
||||
/// — an LTR-RFI anchor typically lands within ~60 ms — lifts the freeze between the two signals,
|
||||
/// and the stale climb then re-freezes a stream that is already bit-exact healed; the host swallows
|
||||
/// the resulting keyframe request as an echo of the very RFI that healed it, so the picture stays
|
||||
/// frozen until the [`REANCHOR_FREEZE_MAX`] overdue re-ask extracts a full IDR (the field
|
||||
/// "H265 freezes on every loss, AV1 fine" signature — the slower IDR path usually lands after the
|
||||
/// climb and dodged the race).
|
||||
///
|
||||
/// Sized to cover the reassembler's 120 ms loss window plus delivery jitter with a wide margin,
|
||||
/// while staying short enough that a leftover credit (a straggler that filled the gap late, so no
|
||||
/// climb ever came; or a whole-frame vanish the reassembler never saw a packet of) cannot mask a
|
||||
/// genuinely unrelated future climb for long. A masked climb is also never silent in practice:
|
||||
/// every unrecoverable loss reveals itself as a frame-index gap on the next delivered frame, which
|
||||
/// re-arms (and re-credits) through [`ReanchorGate::arm_expecting_drops`] on its own.
|
||||
pub const DROP_CREDIT_WINDOW: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// Frames skipped when `got` arrives while `expected` was the next index, or `None` if `got` is
|
||||
/// contiguous (`== expected`) or a straggler we have already passed. Frame indices are u32 counters
|
||||
/// that wrap, so the "ahead" test is a wrapping subtraction split at the half-space: a small positive
|
||||
@@ -185,6 +205,14 @@ pub struct ReanchorGate {
|
||||
/// a client stamps the decoder's decode-order watermark whenever this counter moves and
|
||||
/// discards the local recovery of anything older. Every other client ignores it.
|
||||
arms: u64,
|
||||
/// `frames_dropped` climb still expected from losses that already armed via a frame-index gap
|
||||
/// ([`Self::arm_expecting_drops`]). [`Self::poll`] consumes climbs against this before treating
|
||||
/// them as a NEW loss, so the reassembler's delayed bookkeeping of a gap-armed (and possibly
|
||||
/// already anchor-healed) loss cannot re-freeze the stream — see [`DROP_CREDIT_WINDOW`].
|
||||
drop_credit: u64,
|
||||
/// When the outstanding [`Self::drop_credit`] lapses ([`DROP_CREDIT_WINDOW`] after the latest
|
||||
/// credited arm). `None` when no credit is outstanding.
|
||||
drop_credit_expiry: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ReanchorGate {
|
||||
@@ -199,6 +227,8 @@ impl ReanchorGate {
|
||||
last_dropped: frames_dropped,
|
||||
local_sei_since_arm: false,
|
||||
arms: 0,
|
||||
drop_credit: 0,
|
||||
drop_credit_expiry: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +260,20 @@ impl ReanchorGate {
|
||||
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
}
|
||||
|
||||
/// [`arm`](Self::arm) for a loss detected as a **frame-index gap**, where the caller knows how
|
||||
/// many frames the gap skipped. On top of arming, it pre-credits the reassembler's
|
||||
/// `frames_dropped` climb those same lost frames will produce up to ~120 ms later (its
|
||||
/// loss-window age-out), so [`poll`](Self::poll) does not treat that delayed bookkeeping as a
|
||||
/// SECOND loss. Without the credit a fast LTR-RFI anchor lifts the freeze between the two
|
||||
/// signals and the stale climb re-freezes a healed stream — the double-arm race
|
||||
/// ([`DROP_CREDIT_WINDOW`] tells the whole story). Use plain [`arm`](Self::arm) for every
|
||||
/// non-gap loss signal (decoder wedge/demotion), which has no climb to credit.
|
||||
pub fn arm_expecting_drops(&mut self, now: Instant, expected_drops: u64) {
|
||||
self.arm(now);
|
||||
self.drop_credit = self.drop_credit.saturating_add(expected_drops);
|
||||
self.drop_credit_expiry = Some(now + DROP_CREDIT_WINDOW);
|
||||
}
|
||||
|
||||
/// Fold the client's OWN recovery-point observation for one decoded frame, BEFORE handing that
|
||||
/// frame to [`on_decoded`](Self::on_decoded). Returns `true` when it lifted the freeze.
|
||||
///
|
||||
@@ -333,16 +377,36 @@ impl ReanchorGate {
|
||||
}
|
||||
|
||||
/// Periodic fold of the session's `frames_dropped` counter plus the overdue backstop. Returns
|
||||
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed (a
|
||||
/// fresh unrecoverable loss — arm the freeze) or the freeze has held a full [`REANCHOR_FREEZE_MAX`]
|
||||
/// window with no re-anchor (re-ask and keep holding — NEVER resume to the concealed picture; a
|
||||
/// genuinely dead stream is the QUIC idle-timeout watchdog's job, not the gate's).
|
||||
/// `true` when the client should (throttled) request a keyframe: either the drop count climbed by
|
||||
/// more than the outstanding gap-arm credit (a fresh unrecoverable loss — arm the freeze) or the
|
||||
/// freeze has held a full [`REANCHOR_FREEZE_MAX`] window with no re-anchor (re-ask and keep
|
||||
/// holding — NEVER resume to the concealed picture; a genuinely dead stream is the QUIC
|
||||
/// idle-timeout watchdog's job, not the gate's).
|
||||
///
|
||||
/// A climb covered by [`arm_expecting_drops`](Self::arm_expecting_drops)' credit is the
|
||||
/// reassembler's delayed bookkeeping of a loss this gate already armed for — it must neither
|
||||
/// re-arm (an LTR-RFI anchor may have healed the stream in the meantime; re-freezing it is the
|
||||
/// double-arm race) nor ask again (the gap already fired the precise RFI, and if THAT recovery
|
||||
/// was lost the overdue backstop still re-asks at the [`REANCHOR_FREEZE_MAX`] deadline the
|
||||
/// gap-arm set — which is also sooner than the deadline a re-arm here would push out to).
|
||||
pub fn poll(&mut self, frames_dropped: u64, now: Instant) -> bool {
|
||||
let mut want_keyframe = false;
|
||||
if frames_dropped > self.last_dropped {
|
||||
let climb = frames_dropped - self.last_dropped;
|
||||
self.last_dropped = frames_dropped;
|
||||
self.arm(now);
|
||||
want_keyframe = true;
|
||||
if self.drop_credit_expiry.is_some_and(|e| now >= e) {
|
||||
self.drop_credit = 0;
|
||||
self.drop_credit_expiry = None;
|
||||
}
|
||||
let credited = climb.min(self.drop_credit);
|
||||
self.drop_credit -= credited;
|
||||
if self.drop_credit == 0 {
|
||||
self.drop_credit_expiry = None;
|
||||
}
|
||||
if climb > credited {
|
||||
self.arm(now);
|
||||
want_keyframe = true;
|
||||
}
|
||||
}
|
||||
if self.awaiting && self.deadline.is_some_and(|d| now >= d) {
|
||||
self.deadline = Some(now + REANCHOR_FREEZE_MAX);
|
||||
@@ -542,6 +606,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_rfi_anchor_is_not_refrozen_by_the_same_losss_drop_climb() {
|
||||
// The double-arm race (field: "H265 freezes on every loss, AV1 fine"): a loss arms via
|
||||
// the frame-index gap at T+10ms, the LTR-RFI anchor heals at T+60ms, and the reassembler
|
||||
// books the SAME loss into frames_dropped at ~T+130ms. The credited arm must keep that
|
||||
// stale climb from re-freezing the healed stream (and from re-asking — the host would
|
||||
// swallow the ask as an RFI echo and the picture would freeze until a forced IDR).
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t + Duration::from_millis(10), 1); // gap of one lost frame + RFI
|
||||
assert_eq!(
|
||||
g.on_decoded(ANCHOR, false, t + Duration::from_millis(60)),
|
||||
GateVerdict::Present,
|
||||
"the anchor lifts"
|
||||
);
|
||||
assert!(
|
||||
!g.poll(1, t + Duration::from_millis(130)),
|
||||
"the credited climb must not ask again"
|
||||
);
|
||||
assert!(!g.is_holding(), "and must not re-freeze the healed stream");
|
||||
assert_eq!(
|
||||
g.on_decoded(0, false, t + Duration::from_millis(141)),
|
||||
GateVerdict::Present,
|
||||
"healthy P-frames keep presenting"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_climb_beyond_the_credit_is_a_fresh_loss_and_arms() {
|
||||
// The credit covers exactly the gap's frames; a bigger climb means MORE loss than the gap
|
||||
// accounted for (an interleaved partial-frame loss) — that part must still arm and ask.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t, 2);
|
||||
g.on_decoded(ANCHOR, false, t + Duration::from_millis(50)); // healed the credited loss
|
||||
assert!(
|
||||
g.poll(3, t + Duration::from_millis(130)),
|
||||
"one uncredited drop → ask"
|
||||
);
|
||||
assert!(g.is_holding(), "and re-arm for the uncredited part");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drop_credit_expires_so_a_late_climb_still_arms() {
|
||||
// A straggler can fill the gap late (no climb ever comes) — the leftover credit must not
|
||||
// linger and mask a genuinely NEW loss later. Past DROP_CREDIT_WINDOW the credit is void.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t, 1);
|
||||
g.on_decoded(ANCHOR, false, t + Duration::from_millis(50));
|
||||
let late = t + DROP_CREDIT_WINDOW + Duration::from_millis(1);
|
||||
assert!(
|
||||
g.poll(1, late),
|
||||
"an expired credit no longer absorbs climbs"
|
||||
);
|
||||
assert!(g.is_holding());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credited_climb_keeps_the_unhealed_freezes_original_deadline() {
|
||||
// When the recovery never arrives, consuming the climb must not silence the gate: the
|
||||
// overdue backstop still re-asks — at the deadline the GAP arm set, which is sooner than
|
||||
// the deadline a climb re-arm would have pushed out to.
|
||||
let mut g = ReanchorGate::new(0);
|
||||
let t = t0();
|
||||
g.arm_expecting_drops(t, 1); // RFI fired here; assume its anchor is lost in transit
|
||||
assert!(
|
||||
!g.poll(1, t + Duration::from_millis(130)),
|
||||
"credited climb: no early re-ask"
|
||||
);
|
||||
assert!(g.is_holding(), "still frozen — nothing healed it");
|
||||
assert!(
|
||||
g.poll(1, t + REANCHOR_FREEZE_MAX + Duration::from_millis(1)),
|
||||
"the overdue backstop still re-asks on the gap-arm's own deadline"
|
||||
);
|
||||
assert!(g.is_holding(), "and keeps holding, never resuming to gray");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_no_output_streak_trips_at_three() {
|
||||
let mut g = ReanchorGate::new(0);
|
||||
|
||||
@@ -11,24 +11,52 @@ profile="${1:-debug}"
|
||||
build_flag=""
|
||||
[ "$profile" = "release" ] && build_flag="--release"
|
||||
|
||||
echo ">> building punktfunk-core staticlib ($profile)"
|
||||
cargo build -p punktfunk-core $build_flag >/dev/null
|
||||
# PF_SAN=address instruments BOTH sides of the C boundary at once: the staticlib via
|
||||
# -Zsanitizer (nightly + -Zbuild-std, so std itself is instrumented) and the harness via
|
||||
# clang -fsanitize. LSAN rides along (detect_leaks=1) and is the only automated check on
|
||||
# the Box::into_raw/from_raw leak contract in abi.rs. Linux x86_64 only; -Zbuild-std
|
||||
# defeats sccache, so this belongs on a cron/dispatch job, not the per-push leg.
|
||||
san="${PF_SAN:-}"
|
||||
toolchain=""
|
||||
target_args=""
|
||||
target_sub=""
|
||||
if [ -n "$san" ]; then
|
||||
san_target="x86_64-unknown-linux-gnu"
|
||||
# -Zsanitizer/-Zbuild-std need a nightly; PF_SAN_TOOLCHAIN pins a dated one (CI does).
|
||||
toolchain="+${PF_SAN_TOOLCHAIN:-nightly}"
|
||||
target_args="-Z build-std --target $san_target"
|
||||
target_sub="$san_target/"
|
||||
export RUSTFLAGS="-Zsanitizer=$san${RUSTFLAGS:+ $RUSTFLAGS}"
|
||||
fi
|
||||
|
||||
staticlib="$ws/target/$profile/libpunktfunk_core.a"
|
||||
echo ">> building punktfunk-core staticlib ($profile${san:+, sanitizer=$san})"
|
||||
cargo $toolchain build $target_args -p punktfunk-core $build_flag >/dev/null
|
||||
|
||||
staticlib="$ws/target/${target_sub}$profile/libpunktfunk_core.a"
|
||||
header_dir="$ws/include"
|
||||
[ -f "$staticlib" ] || { echo "missing $staticlib"; exit 1; }
|
||||
[ -f "$header_dir/punktfunk_core.h" ] || { echo "missing generated header"; exit 1; }
|
||||
|
||||
# Ask rustc what native libs the staticlib needs to link into a C program.
|
||||
native_libs="$(cargo rustc -p punktfunk-core --lib --crate-type staticlib $build_flag -- \
|
||||
native_libs="$(cargo $toolchain rustc $target_args -p punktfunk-core --lib --crate-type staticlib $build_flag -- \
|
||||
--print native-static-libs 2>&1 | sed -n 's/.*native-static-libs: //p' | tail -1)"
|
||||
echo ">> native libs: ${native_libs:-<none>}"
|
||||
|
||||
out="$(mktemp -d)/punktfunk_harness"
|
||||
# Not mktemp: a debug+ASAN static binary can exceed a tmpfs /tmp; target/ is real disk.
|
||||
out="$ws/target/${target_sub}$profile/punktfunk_harness"
|
||||
cc="${CC:-cc}"
|
||||
cflags=""
|
||||
if [ -n "$san" ]; then
|
||||
cc="${CC:-clang}"
|
||||
cflags="-fsanitize=$san -fno-omit-frame-pointer"
|
||||
fi
|
||||
echo ">> compiling + linking harness"
|
||||
$cc -std=c11 -Wall -Wextra -O2 -I "$header_dir" \
|
||||
$cc -std=c11 -Wall -Wextra -O2 $cflags ${CFLAGS:-} -I "$header_dir" \
|
||||
"$here/harness.c" "$staticlib" $native_libs -o "$out"
|
||||
|
||||
echo ">> running"
|
||||
"$out"
|
||||
if [ -n "$san" ]; then
|
||||
ASAN_OPTIONS="detect_leaks=1${ASAN_OPTIONS:+:$ASAN_OPTIONS}" "$out"
|
||||
else
|
||||
"$out"
|
||||
fi
|
||||
|
||||
@@ -46,9 +46,6 @@
|
||||
//! `PUNKTFUNK_KEEP_DEFAULT`) leaves the user's chosen defaults untouched — the plan is still
|
||||
//! computed, since the mic must still pick a target.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::wiring_plan::{self, plan, plan_with_formats, Endpoint, MixFormat, Wiring};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::ffi::c_void;
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
//! endpoint of that name, and the probe restores the default playback/recording devices it
|
||||
//! disturbed before exiting.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::pad_endpoint as pe;
|
||||
use super::{audio_control, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
|
||||
@@ -24,9 +24,6 @@
|
||||
//! bundled one all carry no marker and are therefore untouchable here — uninstalling punktfunk
|
||||
//! removes what punktfunk created, and nothing else.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
|
||||
use anyhow::Result;
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
|
||||
|
||||
@@ -35,9 +35,6 @@
|
||||
//! COM discipline matches the sibling modules: WASAPI/COM objects live on the thread that made
|
||||
//! them (the provisioning worker, the capture thread); only channels and plain data cross.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{audio_control, AudioCapturer, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
@@ -27,18 +27,28 @@
|
||||
//! device pulls per-period) whose prime depth the mic pump sets from measured uplink jitter
|
||||
//! ([`VirtualMic::set_target_depth`]), filling silence when the client isn't talking. WASAPI
|
||||
//! objects are `!Send`, so they live entirely on that thread (mirrors `WasapiLoopbackCapturer`).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
//!
|
||||
//! **Idle stop (host must be able to SLEEP).** A RUNNING WASAPI stream makes the audio stack
|
||||
//! hold a kernel power request ("An audio stream is currently in use", attributed to the
|
||||
//! target device in `powercfg /requests`) that vetoes system sleep — and this pump is
|
||||
//! host-lifetime, so rendering silence 24/7 kept every idle Punktfunk box awake forever
|
||||
//! (field report 2026-08-12: "doesn't go to sleep anymore; powercfg shows the Steam Streaming
|
||||
//! Microphone"). So after [`IDLE_STOP_AFTER`] of silence-only output the render loop stops the
|
||||
//! stream (`IAudioClient::Stop` — the client stays initialized, the mic ENDPOINT keeps
|
||||
//! existing, only the power request is released) and parks on the queue's condvar; the next
|
||||
//! pushed frame resumes it within one device period. During a session the box is kept awake by
|
||||
//! the session's own `DisplayWakeRequest` (pf-frame), never by this silence.
|
||||
//! `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a virtual audio
|
||||
//! driver misbehaves when its render side pauses.
|
||||
|
||||
use super::{audio_control, MicBackendStats, VirtualMic, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{sync_channel, SyncSender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use wasapi::{Direction, SampleType, StreamMode, WaveFormat};
|
||||
|
||||
const CHANNELS: u32 = 2;
|
||||
@@ -63,9 +73,30 @@ const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 120 / 1000) * BLOCK_ALIGN
|
||||
/// Producer-side overflow headroom (~32 ms) over the render loop's prime threshold when the
|
||||
/// adaptive target drives the ring.
|
||||
const CAP_HEADROOM_BYTES: usize = (SAMPLE_RATE as usize * 32 / 1000) * BLOCK_ALIGN;
|
||||
/// Stop the render stream after this long of silence-only output (unprimed, nothing queued), so
|
||||
/// an idle host releases the audio stack's sleep-blocking power request (see the module docs).
|
||||
/// Long enough that mid-conversation pauses never cycle the stream; resume is one condvar
|
||||
/// notify + `IAudioClient::Start`, well under the jitter buffer's prime depth.
|
||||
const IDLE_STOP_AFTER: Duration = Duration::from_secs(10);
|
||||
/// While the stream is idle-stopped the render thread parks on the queue condvar; this timeout
|
||||
/// only bounds how long a host-shutdown `stop` can go unnoticed (same discipline as the pump's
|
||||
/// `drain_sleep`).
|
||||
const IDLE_WAKE_CHECK: Duration = Duration::from_millis(250);
|
||||
|
||||
/// The mic inject ring plus the wake signal for an idle-stopped render thread: `push` notifies
|
||||
/// on the empty→non-empty transition, which is exactly the moment a stopped stream must resume.
|
||||
type MicQueue = (Mutex<VecDeque<u8>>, Condvar);
|
||||
|
||||
/// `PUNKTFUNK_MIC_ALWAYS_ON=1`: never idle-stop the render stream (pre-2026-08 behaviour — the
|
||||
/// host then blocks system sleep for its whole life). Escape hatch in case a virtual audio
|
||||
/// driver's capture side misbehaves while its render side is paused.
|
||||
fn mic_always_on() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("PUNKTFUNK_MIC_ALWAYS_ON").is_some_and(|v| v != "0"))
|
||||
}
|
||||
|
||||
pub struct WasapiVirtualMic {
|
||||
queue: Arc<Mutex<VecDeque<u8>>>,
|
||||
queue: Arc<MicQueue>,
|
||||
stop: Arc<AtomicBool>,
|
||||
/// False once the render thread has exited (device error or stop) — the pump's reopen signal.
|
||||
alive: Arc<AtomicBool>,
|
||||
@@ -97,7 +128,7 @@ impl WasapiVirtualMic {
|
||||
channels == CHANNELS,
|
||||
"virtual mic is stereo-only (got {channels})"
|
||||
);
|
||||
let queue = Arc::new(Mutex::new(VecDeque::<u8>::new()));
|
||||
let queue = Arc::new((Mutex::new(VecDeque::<u8>::new()), Condvar::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let ring = Arc::new(RingShared::default());
|
||||
@@ -147,9 +178,11 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
if !self.alive.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut q) = self.queue.lock() else {
|
||||
let (lock, wake) = &*self.queue;
|
||||
let Ok(mut q) = lock.lock() else {
|
||||
return false;
|
||||
};
|
||||
let was_empty = q.is_empty();
|
||||
q.reserve(pcm.len() * 4);
|
||||
for &s in pcm {
|
||||
q.extend(s.to_le_bytes());
|
||||
@@ -174,6 +207,12 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
.overflow
|
||||
.fetch_add((excess / BLOCK_ALIGN) as u64, Ordering::Relaxed);
|
||||
}
|
||||
drop(q);
|
||||
if was_empty {
|
||||
// Empty→non-empty is the resume moment for an idle-stopped stream (the waiter
|
||||
// re-checks the queue under the lock, so this cannot be a lost wakeup).
|
||||
wake.notify_one();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -182,7 +221,7 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
}
|
||||
|
||||
fn discard(&self) {
|
||||
if let Ok(mut q) = self.queue.lock() {
|
||||
if let Ok(mut q) = self.queue.0.lock() {
|
||||
q.clear();
|
||||
}
|
||||
}
|
||||
@@ -202,7 +241,7 @@ impl VirtualMic for WasapiVirtualMic {
|
||||
if prime == 0 {
|
||||
return None; // render loop hasn't run yet
|
||||
}
|
||||
let q = self.queue.lock().ok()?;
|
||||
let q = self.queue.0.lock().ok()?;
|
||||
Some((q.len() / BLOCK_ALIGN, prime / BLOCK_ALIGN))
|
||||
}
|
||||
|
||||
@@ -390,7 +429,7 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
|
||||
}
|
||||
|
||||
fn render_thread(
|
||||
queue: Arc<Mutex<VecDeque<u8>>>,
|
||||
queue: Arc<MicQueue>,
|
||||
stop: Arc<AtomicBool>,
|
||||
shared: Arc<RingShared>,
|
||||
ready: SyncSender<Result<String>>,
|
||||
@@ -464,7 +503,40 @@ fn render_thread(
|
||||
// the target).
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut primed = false;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// Idle stop (see the module docs): `running` mirrors the stream's Start/Stop state, and
|
||||
// `idle_mark` is the queue length last seen while unprimed plus when it was first seen at
|
||||
// that length — IDLE_STOP_AFTER of unprimed output at an UNCHANGED length is the idle
|
||||
// verdict. Keying on the length (not just emptiness) covers a sub-prime tail a vanished
|
||||
// client left behind, while any genuinely new audio moves the length (a push appends a
|
||||
// whole frame and the drop-oldest cap sits above the prime threshold, so an unprimed queue
|
||||
// can never coincidentally return to its marked length) and restarts the window.
|
||||
let always_on = mic_always_on();
|
||||
let mut running = true;
|
||||
let mut idle_mark: Option<(usize, Instant)> = None;
|
||||
'render: while !stop.load(Ordering::Relaxed) {
|
||||
if !running {
|
||||
// Idle-stopped: park on the condvar until mic audio arrives (push notifies on the
|
||||
// empty→non-empty edge); the timeout only keeps `stop` responsive.
|
||||
{
|
||||
let (lock, wake) = &*queue;
|
||||
let mut q = lock.lock().unwrap();
|
||||
while q.is_empty() {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break 'render;
|
||||
}
|
||||
let (guard, _timed_out) = wake.wait_timeout(q, IDLE_WAKE_CHECK).unwrap();
|
||||
q = guard;
|
||||
}
|
||||
}
|
||||
// A resume failure means the endpoint died while we slept — propagate, so the
|
||||
// thread exits, `alive` flips, and the pump reopens (fresh plan) as for any death.
|
||||
audio_client
|
||||
.start_stream()
|
||||
.context("resume render stream")?;
|
||||
running = true;
|
||||
idle_mark = None;
|
||||
tracing::debug!("virtual mic stream resumed (client mic audio arrived)");
|
||||
}
|
||||
// The device signals when it wants more data; finite timeout keeps `stop` responsive.
|
||||
if h_event.wait_for_event(100).is_err() {
|
||||
continue;
|
||||
@@ -489,7 +561,7 @@ fn render_thread(
|
||||
// Silence base; overwrite with queued mic PCM once the cushion is primed.
|
||||
buf[..need].fill(0);
|
||||
{
|
||||
let mut q = queue.lock().unwrap();
|
||||
let mut q = queue.0.lock().unwrap();
|
||||
if !primed && q.len() >= prime {
|
||||
primed = true;
|
||||
}
|
||||
@@ -503,6 +575,35 @@ fn render_thread(
|
||||
shared.reprimes.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
if primed {
|
||||
idle_mark = None;
|
||||
} else if !always_on {
|
||||
// Unprimed = this period was pure silence. After IDLE_STOP_AFTER of that at an
|
||||
// unchanged queue length, stop the stream so the box can sleep. Decided under
|
||||
// the queue lock, and only when nothing has arrived for the whole window (see
|
||||
// `idle_mark`'s docs) — a burst landing at the boundary moves the length and
|
||||
// resets the window instead of being cleared. What IS cleared is ≥10 s stale
|
||||
// (the pump's stale-gap discard would drop it before the next real frame
|
||||
// anyway), which keeps "queue empty" ⇔ "nothing to play" for the wait above.
|
||||
match idle_mark {
|
||||
Some((len, since)) if len == q.len() => {
|
||||
if since.elapsed() >= IDLE_STOP_AFTER {
|
||||
q.clear();
|
||||
audio_client
|
||||
.stop_stream()
|
||||
.context("idle-stop render stream")?;
|
||||
running = false;
|
||||
idle_mark = None;
|
||||
tracing::debug!(
|
||||
"virtual mic stream idle-stopped (releases the sleep-blocking \
|
||||
audio power request; next mic frame resumes it)"
|
||||
);
|
||||
continue 'render;
|
||||
}
|
||||
}
|
||||
_ => idle_mark = Some((q.len(), Instant::now())),
|
||||
}
|
||||
}
|
||||
}
|
||||
render_client
|
||||
.write_to_device(space, &buf[..need], None)
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
//! data packets are consumed immediately and missing parity only costs loss recovery — so
|
||||
//! the validated stereo path stays byte-identical (data packets only, exactly as before).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows", test))]
|
||||
use crate::audio::SAMPLE_RATE;
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user