Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f06b84be63 | ||
|
|
d6dbb391d6 | ||
|
|
907080f92b | ||
|
|
9425c6d40a | ||
|
|
ab8c7ec37c | ||
|
|
64e2af17c5 | ||
|
|
72189b29ec | ||
|
|
339a1d70f9 | ||
|
|
79dba7f95a | ||
|
|
d7430fe2bd | ||
|
|
6f81ec24ba | ||
|
|
539236de91 | ||
|
|
118758ff0b | ||
|
|
dcde856178 | ||
|
|
77918674c3 | ||
|
|
faefbae830 | ||
|
|
44fa12a298 | ||
|
|
55a3d8b919 | ||
|
|
a02014ec19 | ||
|
|
5f55b820bc | ||
|
|
57703fe31c | ||
|
|
90a3304c2b | ||
|
|
017867f211 | ||
|
|
ef0af3b558 | ||
|
|
535e95c4c0 | ||
|
|
b385f0a031 | ||
|
|
7528fd48a9 | ||
|
|
c68e0be688 | ||
|
|
71c1970b93 | ||
|
|
f373dffb5e | ||
|
|
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."
|
||||
|
||||
+20
-3
@@ -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.)
|
||||
#
|
||||
@@ -180,7 +187,17 @@ jobs:
|
||||
|
||||
- name: Verify generated header is committed & up to date
|
||||
run: |
|
||||
cargo build -p punktfunk-core --locked
|
||||
cargo build -p punktfunk-core --locked >/tmp/core-build.log 2>&1 \
|
||||
|| { cat /tmp/core-build.log; exit 1; }
|
||||
cat /tmp/core-build.log
|
||||
# build.rs demotes a cbindgen failure to a warning and then writes NOTHING — the
|
||||
# checked-in header stays untouched and the drift check below stays green while the
|
||||
# header is silently stale. So first assert the regeneration actually happened.
|
||||
# (cargo replays build-script warnings from cache, so this holds on cached builds too.)
|
||||
grep -q "punktfunk-core: wrote" /tmp/core-build.log
|
||||
if grep -q "cbindgen failed" /tmp/core-build.log; then
|
||||
echo "cbindgen failed to parse the ABI surface — header NOT regenerated" && exit 1
|
||||
fi
|
||||
git config --global --add safe.directory "$PWD"
|
||||
git diff --exit-code include/punktfunk_core.h \
|
||||
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
|
||||
|
||||
@@ -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,42 @@ 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.
|
||||
|
||||
### Windows host — audio no longer costs local-game frame time
|
||||
|
||||
🛑 **The host could tank a locally-played game's frame lows** (field-reported 2026-08-12:
|
||||
Helldivers 2 at 1% lows of 2–5 FPS, cured by uninstalling). Two mechanisms, both fixed:
|
||||
|
||||
- **The minted-endpoint retry storm.** The virtual-mic resolve ran a FULL provisioning pass on
|
||||
every reopen with no cooldown, no in-flight guard, and no give-up — and the pass reached
|
||||
`UpdateDriverForPlugAndPlayDevicesW` even over an already-existing devnode. On a box where
|
||||
minting cannot converge, the pump's reopen backoff (capped 60 s) turned that into a SetupAPI
|
||||
sweep + PnP driver re-bind + default-device writes roughly once a minute, forever — each
|
||||
raising the system-wide device-change broadcast games service by rebuilding their audio
|
||||
graphs. Provisioning now short-circuits to a no-PnP fast path while the minted devices are
|
||||
healthy, waits on an in-flight pass instead of racing a second one, honours the 60 s retry
|
||||
cooldown from the blocking path too, and stops for the host lifetime after five unlatched
|
||||
passes (a service restart re-arms minting).
|
||||
- **Session tuning never reverted.** The first streaming session put the whole host process at
|
||||
HIGH priority class with a 1 ms global timer (`timeBeginPeriod`) and DWM MMCSS, documented as
|
||||
"reverts at process exit" — but the host is a 24/7 service, so after one stream it competed
|
||||
at HIGH priority against whatever the user played locally, forever. The process-wide tuning
|
||||
is now refcounted across the hot stream threads and reverts when the last one exits
|
||||
(= session teardown), the same lifetime the per-thread MMCSS effects already ride.
|
||||
|
||||
## 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)",
|
||||
]
|
||||
|
||||
|
||||
+13
-2
@@ -66,8 +66,8 @@ ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.27.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["unom"]
|
||||
repository = "https://git.unom.io/unom/punktfunk"
|
||||
@@ -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.
|
||||
|
||||
@@ -200,7 +200,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
|
||||
///
|
||||
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -214,7 +214,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -245,7 +245,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
|
||||
///
|
||||
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
|
||||
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
|
||||
@@ -60,7 +60,7 @@ const TAG_HID_RAW: u8 = 0x05;
|
||||
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
|
||||
/// back to the controller holding that wire `pad` index (multi-pad rumble). Run from a Kotlin
|
||||
/// poll thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -99,7 +99,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
|
||||
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
|
||||
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
|
||||
@@ -54,7 +54,7 @@ mod probe;
|
||||
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
|
||||
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
_vm: *mut jni::sys::JavaVM,
|
||||
_reserved: *mut std::ffi::c_void,
|
||||
@@ -74,7 +74,7 @@ pub extern "system" fn JNI_OnLoad(
|
||||
/// `NativeBridge.abiVersion(): Int` — the core's C-ABI version. A non-error return is the
|
||||
/// scaffold's proof that `System.loadLibrary` found the `.so`, the JNI symbol resolved, and the
|
||||
/// linked `punktfunk-core` is the one we expect.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -83,7 +83,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
|
||||
}
|
||||
|
||||
/// `NativeBridge.coreVersion(): String` — the crate version, proving JNI string marshaling works.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::time::Duration;
|
||||
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
|
||||
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
|
||||
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -40,7 +40,7 @@ fn client(handle: jlong) -> Option<&'static SessionHandle> {
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -53,7 +53,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupport
|
||||
|
||||
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
|
||||
/// clipboard-related happens on either side until an `enabled: true` crosses.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -68,7 +68,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl
|
||||
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
|
||||
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
|
||||
/// counter, newest wins.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -88,7 +88,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferTe
|
||||
|
||||
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
|
||||
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or −1.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -106,7 +106,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchTe
|
||||
|
||||
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
|
||||
/// clipboard's current text (the host is pasting our offer).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -125,7 +125,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeTe
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -144,7 +144,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
||||
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
|
||||
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
|
||||
/// can never split a UTF-8 sequence.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
|
||||
@@ -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
|
||||
@@ -36,12 +36,12 @@ fn note_error(e: &punktfunk_core::error::PunktfunkError) {
|
||||
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
|
||||
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
|
||||
/// handle / `""` fingerprint.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
|
||||
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(),
|
||||
@@ -51,7 +51,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastErr
|
||||
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
||||
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
||||
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -74,7 +74,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
||||
/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket
|
||||
/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the
|
||||
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -120,7 +120,7 @@ fn force_parts_sysprop() -> bool {
|
||||
/// budget: the normal path passes a short value, the no-PIN "request access" path a long one (≥ the
|
||||
/// host's approval-park window) so a slow operator approval lands on this same parked connection
|
||||
/// rather than timing the client out first. Returns an opaque handle, or 0 on failure.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
@@ -322,7 +322,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -344,7 +344,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -363,7 +363,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQ
|
||||
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
||||
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
||||
/// connect. `""` on a `0` handle.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -388,7 +388,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
|
||||
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
|
||||
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
|
||||
/// handle. Cheap (one atomic load); safe on the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -413,7 +413,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
|
||||
/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for
|
||||
/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one
|
||||
/// atomic load); safe on the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -433,7 +433,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
||||
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
|
||||
@@ -35,7 +35,7 @@ fn send_event(handle: jlong, kind: InputKind, code: u32, x: i32, y: i32, flags:
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -51,7 +51,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
|
||||
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
|
||||
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -68,7 +68,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
|
||||
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
||||
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -86,7 +86,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
|
||||
/// `NativeBridge.nativeSendScroll(handle, axis, delta)` — one scroll step. `axis`: 0=vertical,
|
||||
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -103,7 +103,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
|
||||
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
|
||||
/// (libei touchscreen / wlroots / SendInput).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -128,7 +128,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
|
||||
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
|
||||
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -151,7 +151,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
|
||||
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
|
||||
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -168,7 +168,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSu
|
||||
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
|
||||
/// Kotlin side splits stylus pointers out of the touch path onto the pen plane
|
||||
/// (design/pen-tablet-input.md §7). `0` handle → false.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -197,7 +197,7 @@ const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
|
||||
/// normalized 0..1; `distance`/`tilt_deg`/`azimuth_deg`/`roll_deg` < 0 = unknown. Call only
|
||||
/// against a [`nativeHostSupportsPen`] host; the client heartbeats the last sample ≤100 ms
|
||||
/// while in range (Kotlin side — see `StylusStream`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -264,7 +264,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
|
||||
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
|
||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -296,7 +296,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
|
||||
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
|
||||
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -318,7 +318,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
|
||||
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
|
||||
/// (−32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -343,7 +343,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
|
||||
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
|
||||
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -373,7 +373,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
///
|
||||
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
|
||||
/// the `Auto` rule inside the predicate itself.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -399,7 +399,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionRe
|
||||
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -415,7 +415,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
|
||||
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
|
||||
/// own report rate (~250–500 Hz) — the direct-buffer read avoids a JNI array copy per report.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -455,7 +455,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
|
||||
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
|
||||
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
|
||||
/// the capture diffs, the host holds per-slot state.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -485,7 +485,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
|
||||
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
|
||||
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
|
||||
/// controller's report rate.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
|
||||
_env: JNIEnv,
|
||||
|
||||
@@ -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`
|
||||
@@ -19,7 +19,7 @@ use super::{jni_guard, SessionHandle};
|
||||
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
|
||||
/// No-op if already started.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -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
|
||||
}
|
||||
@@ -91,7 +91,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -116,7 +116,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
|
||||
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
|
||||
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -140,7 +140,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecL
|
||||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||||
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
|
||||
/// device).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -161,7 +161,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecode
|
||||
|
||||
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
||||
/// session). No-op on `0`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -210,7 +210,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -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
|
||||
@@ -312,7 +312,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
|
||||
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
|
||||
/// build too. Cheap; safe on the UI thread.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -346,7 +346,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
|
||||
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
|
||||
/// pure `jni` + an atomic store, so it links on the host build too.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -373,7 +373,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
||||
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
||||
/// streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -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
|
||||
}
|
||||
@@ -398,7 +398,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
|
||||
/// closing the session). No-op on `0`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -422,7 +422,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
|
||||
/// the rest of the session streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -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
|
||||
}
|
||||
@@ -457,7 +457,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
|
||||
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -484,7 +484,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
@@ -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,
|
||||
@@ -530,7 +530,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
@@ -553,7 +553,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSel
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
@@ -594,7 +594,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi
|
||||
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
|
||||
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
|
||||
/// no captured audio leaves the process.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -617,7 +617,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
|
||||
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
|
||||
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
|
||||
#[cfg(target_os = "android")]
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const PROBE_RESULT_LEN: usize = 6;
|
||||
/// **briefly pausing video**. Non-blocking: poll
|
||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] until its `done` element is 1.
|
||||
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -54,7 +54,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
|
||||
///
|
||||
/// Layout (doubles so one array carries both the counts and the percentages):
|
||||
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -10,7 +10,7 @@ use jni::JNIEnv;
|
||||
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
|
||||
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
|
||||
/// Returns true if at least one datagram went out.
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -215,9 +215,10 @@ export function useHosts() {
|
||||
const [views, setViews] = useState<HostView[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
|
||||
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// any of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// "client-outdated" — the installed client predates `punktfunk discover`
|
||||
// "client-unavailable" — there is no client installed at all
|
||||
// "list-failed" — the refresh itself blew up (backend down, call threw)
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -236,7 +237,11 @@ export function useHosts() {
|
||||
);
|
||||
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
|
||||
// Inline, not a toast: the panel remounts (and refreshes) on every QAM open, so while
|
||||
// the backend is unhappy a toast here nagged on each open. The panel row also sits next
|
||||
// to the Refresh button that retries it, which is where the eyes already are.
|
||||
console.warn("punktfunk: host list refresh failed", e);
|
||||
setProblem("list-failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -454,9 +459,12 @@ export async function startStream(
|
||||
): Promise<void> {
|
||||
try {
|
||||
await launchStream(v.ref, opts);
|
||||
// No success toast: the user just pressed the button that names this host/card, the QAM
|
||||
// closes, and Steam's own launch UI takes over — a toast here fired on EVERY launch and
|
||||
// then sat on top of the starting stream. Failure still toasts (the QAM may already be
|
||||
// closed, so inline error state would go unseen).
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed${label ? ` (${label})` : ""}: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,15 +46,23 @@ import { OsMark } from "./os-icon";
|
||||
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
|
||||
import { TrustSheet } from "./trust";
|
||||
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut
|
||||
// and sweeps duplicate entries (the piles a boot race used to mint, one per Steam start).
|
||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
|
||||
// that works even without a reload. Always ends in a toast so the tap has feedback.
|
||||
async function recreatePunktfunkShortcut(): Promise<void> {
|
||||
const appId = await recreateShortcuts();
|
||||
const { appId, removedDuplicates } = await recreateShortcuts();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
|
||||
body:
|
||||
appId == null
|
||||
? "Couldn't create the shortcut"
|
||||
: removedDuplicates > 0
|
||||
? `Shortcut restored — removed ${removedDuplicates} duplicate ${
|
||||
removedDuplicates === 1 ? "entry" : "entries"
|
||||
}`
|
||||
: "Shortcut restored to your library",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,12 +230,16 @@ const QamPanel: FC = () => {
|
||||
label={
|
||||
problem === "client-unavailable"
|
||||
? "Punktfunk isn’t installed"
|
||||
: "Update the Punktfunk client"
|
||||
: problem === "list-failed"
|
||||
? "Couldn’t scan for hosts"
|
||||
: "Update the Punktfunk client"
|
||||
}
|
||||
description={
|
||||
problem === "client-unavailable"
|
||||
? "This panel launches the Punktfunk app, which isn’t on this Deck yet. Install it in Desktop Mode."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
: problem === "list-failed"
|
||||
? "Something went wrong while scanning — Refresh tries again."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
@@ -313,7 +325,7 @@ const QamPanel: FC = () => {
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Missing the Punktfunk entry in your library? This puts it back."
|
||||
description="Missing the Punktfunk entry in your library, or seeing several? This puts one back and removes the rest."
|
||||
onClick={() => void recreatePunktfunkShortcut()}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
|
||||
+220
-39
@@ -44,6 +44,7 @@ declare const SteamClient: {
|
||||
): Promise<unknown>;
|
||||
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
||||
TerminateApp(gameId: string, _b: boolean): void;
|
||||
RemoveShortcut(appId: number): void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,29 +63,114 @@ declare const collectionStore:
|
||||
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
|
||||
// entry never comes back.
|
||||
declare const appStore:
|
||||
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
|
||||
| {
|
||||
GetAppOverviewByAppID?: (appId: number) => unknown | null;
|
||||
allApps?: SteamAppOverviewLike[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
|
||||
* we can't tell, so assume it exists — better to keep reusing than risk a duplicate library
|
||||
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||
function shortcutStillExists(appId: number): boolean {
|
||||
// The overview surface we read when scanning the library — Steam internals, so everything is
|
||||
// optional and accessed defensively.
|
||||
interface SteamAppOverviewLike {
|
||||
appid?: number;
|
||||
display_name?: string;
|
||||
BIsShortcut?: () => boolean;
|
||||
}
|
||||
|
||||
// Steam-injected global whose WaitForServicesInitialized resolves once the client's app
|
||||
// services are up (the MoonDeck-verified readiness signal). Services-init alone doesn't
|
||||
// guarantee the overview map is populated, so it's paired with the hydration witness below.
|
||||
declare const App:
|
||||
| { WaitForServicesInitialized?: () => Promise<boolean> }
|
||||
| undefined;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let servicesInitialized: Promise<void> | undefined;
|
||||
function waitForServicesInitialized(): Promise<void> {
|
||||
servicesInitialized ??= (async () => {
|
||||
try {
|
||||
if (typeof App !== "undefined" && App?.WaitForServicesInitialized) {
|
||||
await App.WaitForServicesInitialized();
|
||||
}
|
||||
} catch {
|
||||
/* no signal — the hydration witness still gates the verdict */
|
||||
}
|
||||
})();
|
||||
return servicesInitialized;
|
||||
}
|
||||
|
||||
/** Has appStore demonstrably finished its initial load? An empty `allApps` means "not yet":
|
||||
* any account that ever had our shortcut has at least one app, so a populated map is the
|
||||
* witness that a null overview lookup is an ANSWER rather than a not-loaded-yet. null =
|
||||
* can't tell (missing global, API drift). */
|
||||
function appStoreHydrated(): boolean | null {
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !appStore) {
|
||||
return null;
|
||||
}
|
||||
const apps = appStore.allApps;
|
||||
return Array.isArray(apps) ? apps.length > 0 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One overview lookup: true = live, false = absent, null = can't tell. */
|
||||
function queryShortcutAlive(appId: number): boolean | null {
|
||||
try {
|
||||
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
|
||||
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
|
||||
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
|
||||
// "true". That is not a stale-data bug but a total one: the guard then answers "still
|
||||
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
|
||||
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
|
||||
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
|
||||
// one is a ReferenceError that optional chaining does NOT prevent.
|
||||
// "can't tell". `typeof` first: `appStore` is a Steam-injected global, and a bare
|
||||
// reference to a missing one is a ReferenceError that optional chaining does NOT prevent.
|
||||
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
|
||||
return true; // no way to verify — preserve the reuse path
|
||||
return null;
|
||||
}
|
||||
return appStore.GetAppOverviewByAppID(appId) != null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// How long to wait for the app store before conceding liveness can't be verified. A Deck boot
|
||||
// hydrates the store within a few seconds of plugin mount; 30 s is comfortably past any real
|
||||
// boot, and the wait only burns on the absent/unverifiable paths — a live overview answers on
|
||||
// the first query. Overview registration can trail the bulk hydration by a beat, so a
|
||||
// "hydrated but absent" verdict gets one grace recheck before it counts as deleted.
|
||||
const STORE_WAIT_MS = 30_000;
|
||||
const STORE_POLL_MS = 1_000;
|
||||
const STORE_GRACE_MS = 2_000;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut.
|
||||
*
|
||||
* The dangerous verdict is FALSE — it sends the caller to AddShortcut, so a wrong "deleted"
|
||||
* mints a duplicate library entry. And a bare null-overview check gets it wrong on EVERY
|
||||
* boot: the plugin mounts while Steam is still starting up, before appStore has registered
|
||||
* its overviews, so the remembered (perfectly live) appId looks up as null and each boot
|
||||
* added another visible "Punktfunk" — the field-reported duplicate pile. Absent is therefore
|
||||
* only believed once the store is demonstrably hydrated; if that can't be established within
|
||||
* budget the answer is true, because a false "alive" merely no-ops Set-calls until the next
|
||||
* ask (and the recreate button re-asks when the store IS ready) while a false "dead"
|
||||
* duplicates forever. */
|
||||
async function shortcutStillExists(appId: number): Promise<boolean> {
|
||||
if (queryShortcutAlive(appId) === true) {
|
||||
return true;
|
||||
}
|
||||
// Race the init signal against the same budget the poll loop gets: a signal that never
|
||||
// resolves must not wedge the guard (the single-flight ensure would stay occupied forever).
|
||||
await Promise.race([waitForServicesInitialized(), sleep(STORE_WAIT_MS)]);
|
||||
for (let waited = 0; waited < STORE_WAIT_MS; waited += STORE_POLL_MS) {
|
||||
if (queryShortcutAlive(appId) === true) {
|
||||
return true;
|
||||
}
|
||||
if (appStoreHydrated() === true) {
|
||||
await sleep(STORE_GRACE_MS);
|
||||
return queryShortcutAlive(appId) !== false; // null = unverifiable → reuse
|
||||
}
|
||||
await sleep(STORE_POLL_MS);
|
||||
}
|
||||
return true; // store never became inspectable — reusing beats duplicating
|
||||
}
|
||||
|
||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||
@@ -156,6 +242,67 @@ async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
||||
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
|
||||
const SHORTCUT_NAME = "Punktfunk";
|
||||
|
||||
/** Find an existing "Punktfunk" shortcut to ADOPT instead of minting a new library entry — the
|
||||
* healing path for a lost/wiped appId, and for the duplicate piles the boot race left behind
|
||||
* in the field: rebind one of the existing entries to the role rather than adding an N+1th.
|
||||
* (The caller rewrites exe/dir/opts/visibility anyway, so any of them serves.) Only overviews
|
||||
* Steam itself says are shortcuts qualify, and the other role's remembered id is excluded so
|
||||
* the two roles never collapse onto one shortcut. */
|
||||
function findAdoptableShortcut(excludeAppId: number | null): number | null {
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
|
||||
return null;
|
||||
}
|
||||
for (const app of appStore.allApps) {
|
||||
if (
|
||||
app?.display_name === SHORTCUT_NAME &&
|
||||
typeof app.appid === "number" &&
|
||||
app.appid !== excludeAppId &&
|
||||
app.BIsShortcut?.() === true
|
||||
) {
|
||||
return app.appid;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* Steam internals drifted — AddShortcut is the fallback */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Remove every "Punktfunk" shortcut beyond the two remembered role ids — the cleanup for
|
||||
* piles already minted by the boot race. Deliberately reachable ONLY from the user-pressed
|
||||
* recreate button, never from mount: automatic library deletion at boot is a bigger hazard
|
||||
* than the mess it would tidy. Returns how many entries were removed. */
|
||||
function removeDuplicateShortcuts(): number {
|
||||
let removed = 0;
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
|
||||
return 0;
|
||||
}
|
||||
const keep = [recall(STORAGE_KEY_STREAM), recall(STORAGE_KEY_UI)];
|
||||
// Snapshot before removing — RemoveShortcut mutates the store's list under the iteration.
|
||||
const surplus = appStore.allApps.filter(
|
||||
(app) =>
|
||||
app?.display_name === SHORTCUT_NAME &&
|
||||
typeof app.appid === "number" &&
|
||||
!keep.includes(app.appid) &&
|
||||
app.BIsShortcut?.() === true,
|
||||
);
|
||||
for (const app of surplus) {
|
||||
SteamClient.Apps.RemoveShortcut(app.appid as number);
|
||||
try {
|
||||
localStorage.removeItem(artKey(app.appid as number));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
removed++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: duplicate-shortcut sweep incomplete", e);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
|
||||
// backend can't chmod it back on. Passing the script as an argument to the always-executable
|
||||
@@ -223,7 +370,7 @@ async function ensureControllerConfig(): Promise<void> {
|
||||
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
|
||||
* across reinstalls, and pre-two-shortcut installs had this one visible).
|
||||
*/
|
||||
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
async function doEnsureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
@@ -232,25 +379,38 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
|
||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||
|
||||
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
|
||||
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
|
||||
// outlived it across a reinstall) must fall through, not be silently repointed. On a lost id,
|
||||
// ADOPT an existing same-named shortcut before AddShortcut so a wiped key never duplicates.
|
||||
const remembered = recall(STORAGE_KEY_STREAM);
|
||||
if (remembered != null && shortcutStillExists(remembered)) {
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(remembered);
|
||||
return { appId: remembered, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
let appId =
|
||||
remembered != null && (await shortcutStillExists(remembered)) ? remembered : null;
|
||||
if (appId == null) {
|
||||
appId =
|
||||
findAdoptableShortcut(recall(STORAGE_KEY_UI)) ??
|
||||
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
setShortcutHidden(appId, true);
|
||||
setShortcutHidden(appId, true); // also migrates pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(appId);
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
return { appId, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
}
|
||||
|
||||
// Concurrent ensure calls share one run per role — two ensures racing past the liveness check
|
||||
// would each AddShortcut, which is exactly the duplicate class this file exists to prevent (and
|
||||
// the store-readiness wait makes the window real: mount's fire-and-forget ensure can be mid-wait
|
||||
// when a QAM press arrives). Sequential calls still re-run, so per-launch repointing is kept.
|
||||
let streamEnsureInFlight: Promise<{ appId: number; runner: string; clientBin: string }> | null =
|
||||
null;
|
||||
function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
streamEnsureInFlight ??= doEnsureStreamShortcut().finally(() => {
|
||||
streamEnsureInFlight = null;
|
||||
});
|
||||
return streamEnsureInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry
|
||||
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
|
||||
@@ -258,7 +418,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
|
||||
* kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays
|
||||
* repointed to the current plugin dir. Best-effort: returns null on any failure.
|
||||
*/
|
||||
export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
async function doEnsureGamepadUiShortcut(): Promise<number | null> {
|
||||
try {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
@@ -275,18 +435,20 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
|
||||
// library entry actually comes back instead of repointing a dead id.
|
||||
// localStorage key survived a plugin reinstall) falls through so the visible library entry
|
||||
// actually comes back instead of repointing a dead id. On a lost id, ADOPT an existing
|
||||
// same-named shortcut (a boot-race duplicate, or the entry whose key was wiped) before
|
||||
// AddShortcut — creation is the last resort, never the response to a mere lookup miss.
|
||||
let appId = recall(STORAGE_KEY_UI);
|
||||
if (appId != null && shortcutStillExists(appId)) {
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
} else {
|
||||
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
if (appId == null || !(await shortcutStillExists(appId))) {
|
||||
appId =
|
||||
findAdoptableShortcut(recall(STORAGE_KEY_STREAM)) ??
|
||||
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
|
||||
remember(STORAGE_KEY_UI, appId);
|
||||
}
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
|
||||
setShortcutHidden(appId, false); // the visible library entry
|
||||
void applyArtwork(appId);
|
||||
@@ -297,18 +459,32 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Same single-flight rule as the stream role (see ensureStreamShortcut).
|
||||
let uiEnsureInFlight: Promise<number | null> | null = null;
|
||||
export function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
uiEnsureInFlight ??= doEnsureGamepadUiShortcut().finally(() => {
|
||||
uiEnsureInFlight = null;
|
||||
});
|
||||
return uiEnsureInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the visible "Punktfunk" library entry back into existence — the recovery button for
|
||||
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
|
||||
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
|
||||
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
|
||||
* self-heal-on-mount can't — deleting the shortcut WITHOUT reinstalling (no mount → no ensure).
|
||||
* Returns the (new or existing) visible appId, or null on failure.
|
||||
* Also sweeps surplus "Punktfunk" entries (the piles the boot race minted before the store-
|
||||
* readiness gate existed) — the button is where that cleanup lives, never mount. Returns the
|
||||
* (new or existing) visible appId (null on failure) plus how many duplicates were removed.
|
||||
*/
|
||||
export async function recreateShortcuts(): Promise<number | null> {
|
||||
export async function recreateShortcuts(): Promise<{
|
||||
appId: number | null;
|
||||
removedDuplicates: number;
|
||||
}> {
|
||||
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
|
||||
const id = recall(key);
|
||||
if (id != null && !shortcutStillExists(id)) {
|
||||
if (id != null && !(await shortcutStillExists(id))) {
|
||||
try {
|
||||
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
|
||||
localStorage.removeItem(key);
|
||||
@@ -317,8 +493,13 @@ export async function recreateShortcuts(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
|
||||
return ensureGamepadUiShortcut();
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next
|
||||
// launch. Sweep AFTER the ensure so the remembered ids are fresh — and only when the ensure
|
||||
// succeeded: on a failed ensure the "keep" list can't be trusted, and deleting candidates a
|
||||
// later ensure would adopt could leave the library with no entry at all.
|
||||
const appId = await ensureGamepadUiShortcut();
|
||||
const removedDuplicates = appId != null ? removeDuplicateShortcuts() : 0;
|
||||
return { appId, removedDuplicates };
|
||||
}
|
||||
|
||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||
|
||||
@@ -870,6 +870,23 @@ fn deliver_deep_link(url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The crate's one runtime env mutation, isolated so `main.rs`'s `deny(unsafe_code)` covers
|
||||
/// everything else and the exemption is a named function rather than a whole call site.
|
||||
#[allow(unsafe_code)]
|
||||
fn clear_steam_sdl_device_filter() {
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
// SAFETY: called at the top of `run()`, before GTK init or any other thread
|
||||
// exists in this process — nothing reads the environment concurrently.
|
||||
unsafe { std::env::remove_var(var) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
@@ -879,15 +896,7 @@ pub fn run() -> glib::ExitCode {
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
||||
// physical pad Steam Input has virtualized; the Settings controller list needs the
|
||||
// real devices (same rationale as the session binary).
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
}
|
||||
clear_steam_sdl_device_filter();
|
||||
// Headless paths (no GTK window).
|
||||
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||
return crate::cli::headless_pair(&pin);
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||
#![forbid(unsafe_code)]
|
||||
// `deny`, not `forbid`, since edition 2024: clearing Steam's SDL device filter and the spawn
|
||||
// test's `HOME` scoping mutate the process env, which is now an unsafe call. Both carry a named
|
||||
// `#[allow(unsafe_code)]` with the proof at the site; everything else stays compiler-refused.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
||||
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
|
||||
|
||||
@@ -128,6 +128,8 @@ mod tests {
|
||||
/// that is merely capped. One test, one `HOME` — the stores are read from it, so this
|
||||
/// deliberately does not split into several that would race over the same env var.
|
||||
#[test]
|
||||
// The crate's one test env mutation (the `HOME` scoping below) — see main.rs's deny note.
|
||||
#[allow(unsafe_code)]
|
||||
fn the_plan_carries_resolved_settings_not_defaults() {
|
||||
use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile};
|
||||
use pf_client_core::trust::{KnownHost, KnownHosts, Settings};
|
||||
@@ -135,7 +137,10 @@ mod tests {
|
||||
let home = std::env::temp_dir().join(format!("pf-spawn-test-{}", std::process::id()));
|
||||
let cfg = home.join(".config/punktfunk");
|
||||
std::fs::create_dir_all(&cfg).unwrap();
|
||||
std::env::set_var("HOME", &home);
|
||||
// SAFETY: the only env-mutating test in this binary (see the doc above — one test, one
|
||||
// `HOME`, deliberately not split). Parallel tests may `getenv` concurrently; glibc keeps
|
||||
// replaced environ storage alive, and every reader tolerates either value.
|
||||
unsafe { std::env::set_var("HOME", &home) };
|
||||
|
||||
// A device whose owner has set a bitrate, and a host bound to a profile that raises it
|
||||
// further — the two layers the spec has to carry.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! (portrait paths starting with `/` load from disk), the GPU-only dev path.
|
||||
|
||||
use crate::session_main::{
|
||||
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, window_pos,
|
||||
arg_flag, arg_value, fullscreen_mode, parse_host_port, session_params, stats_tier, window_pos,
|
||||
};
|
||||
use pf_client_core::gamepad::is_steam_deck;
|
||||
use pf_client_core::{discovery, library, trust, wol};
|
||||
@@ -141,11 +141,18 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
let json_status = arg_flag("--json-status");
|
||||
let settings_at_start = trust::Settings::load();
|
||||
// The console's window and its input models are built ONCE, from the global defaults, and
|
||||
// live across every launch — so the presentation-tier fields below (stats tier, touch and
|
||||
// mouse model, shortcut inhibit, match-window, render scale) are latched here and a per-host
|
||||
// profile cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec,
|
||||
// audio, pad) is re-resolved per launch and does honor the binding. Closing that gap means
|
||||
// rebuilding the presenter's models per launch — profiles P4 territory, not P0.
|
||||
// live across every launch — so the presentation-tier fields below (touch and mouse model,
|
||||
// shortcut inhibit, match-window, render scale) are latched here and a per-host profile
|
||||
// cannot move them in this mode. Everything the HOST is told (mode, bitrate, codec, audio,
|
||||
// pad) is re-resolved per launch and does honor the binding. Closing the rest of that gap
|
||||
// means rebuilding the presenter's models per launch — profiles P4 territory, not P0.
|
||||
//
|
||||
// ⚠ The STATS TIER used to be latched here too, and that was a bug people hit: the console's
|
||||
// own settings screen writes the tier to the file and redraws its row, so the choice looked
|
||||
// taken while every stream kept the tier the process started on — "no matter what I select
|
||||
// the overlay is stuck on Detailed", cured only by restarting the app. It now rides
|
||||
// `SessionParams` per launch (`stats_verbosity`), so the value below only seeds the loop
|
||||
// until the first stream. Anything else moved off this snapshot has to travel the same way.
|
||||
let latched_mouse = settings_at_start.mouse_mode();
|
||||
|
||||
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
|
||||
@@ -162,11 +169,8 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
),
|
||||
fullscreen: fullscreen_mode(),
|
||||
window_pos: window_pos(),
|
||||
// `--stats` forces the overlay visible without demoting a richer chosen tier.
|
||||
stats_verbosity: match settings_at_start.stats_verbosity() {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
// Seeds the loop only — every launch carries its own freshly resolved tier.
|
||||
stats_verbosity: stats_tier(&settings_at_start),
|
||||
touch_mode: settings_at_start.touch_mode(),
|
||||
mouse_mode: settings_at_start.mouse_mode(),
|
||||
invert_scroll: settings_at_start.invert_scroll,
|
||||
|
||||
+88
-11
@@ -13,7 +13,13 @@
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
|
||||
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
|
||||
#![forbid(unsafe_code)]
|
||||
// `deny`, not `forbid`: edition 2024 makes the std process-environment mutators unsafe
|
||||
// (WP20 — the env-mutation class made visible; named-API mentions here would count against
|
||||
// the unsafe-hygiene gate C baseline, which tracks this file's real call sites), and this
|
||||
// bin's three single-threaded-startup env writes carry documented SAFETY comments under
|
||||
// localized `#[allow(unsafe_code)]` (the pf-update idiom). A `forbid` cannot be overridden
|
||||
// at those sites and refuses the file.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
@@ -116,6 +122,28 @@ mod session_main {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// The stats-overlay tier a session starts on: the resolved setting, except that
|
||||
/// `--stats` (tooling/debug runs) forces the overlay VISIBLE without demoting an
|
||||
/// explicitly chosen richer tier.
|
||||
///
|
||||
/// One helper because three callers need the identical rule — both run modes' presenter
|
||||
/// options and the per-launch [`session_params`] — and a fourth reading of it would be
|
||||
/// the bug this is here to prevent.
|
||||
pub(crate) fn stats_tier(settings: &trust::Settings) -> trust::StatsVerbosity {
|
||||
stats_tier_with(settings.stats_verbosity(), arg_flag("--stats"))
|
||||
}
|
||||
|
||||
/// [`stats_tier`]'s rule, with argv lifted out so it is testable.
|
||||
pub(crate) fn stats_tier_with(
|
||||
chosen: trust::StatsVerbosity,
|
||||
stats_flag: bool,
|
||||
) -> trust::StatsVerbosity {
|
||||
match chosen {
|
||||
trust::StatsVerbosity::Off if stats_flag => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
|
||||
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
|
||||
/// "auto" policy keys off this.
|
||||
@@ -420,6 +448,12 @@ mod session_main {
|
||||
connect_timeout: connect_timeout(),
|
||||
force_software,
|
||||
profile,
|
||||
// Presentation-tier, carried per launch rather than read once by the run loop:
|
||||
// the console streams many sessions through ONE loop, so this is the only way a
|
||||
// tier the user picked between streams (or one a host's profile carries) reaches
|
||||
// the overlay before the app is restarted. Single mode passes the same value its
|
||||
// presenter options already hold, so it changes nothing there.
|
||||
stats_verbosity: stats_tier(settings),
|
||||
// Phase-locked capture (design/phase-locked-capture.md, Apple/Android parity):
|
||||
// advertised only when the presenter has real on-glass latch stamps
|
||||
// (VK_KHR_present_wait) — without them there is no latch grid to report. The
|
||||
@@ -505,12 +539,18 @@ mod session_main {
|
||||
/// initialises, so a call placed after them leaves the triage tool describing a device
|
||||
/// that cannot decode while the streaming path decodes on it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unsafe_code)] // the two SAFETY-commented single-threaded-startup env writes below
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
match std::env::var("RADV_PERFTEST") {
|
||||
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
|
||||
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
|
||||
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
|
||||
// SAFETY: called at the very top of `run()`, before this process creates any
|
||||
// thread — the Vulkan loader, SDL, and the session runtime all start later.
|
||||
Ok(v) if !v.is_empty() => unsafe {
|
||||
std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}"))
|
||||
},
|
||||
// SAFETY: as above — single-threaded startup.
|
||||
_ => unsafe { std::env::set_var("RADV_PERFTEST", TOKEN) },
|
||||
}
|
||||
tracing::info!(
|
||||
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
|
||||
@@ -804,7 +844,13 @@ mod session_main {
|
||||
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
|
||||
] {
|
||||
if std::env::var_os(var).is_none() && !value.is_empty() {
|
||||
std::env::set_var(var, value);
|
||||
// SAFETY: still the single-threaded startup stretch of `run()` — the
|
||||
// early-exit probes above return out of the process, and everything that
|
||||
// spawns threads (the session, the console, SDL) only starts below.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::set_var(var, value)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -818,7 +864,12 @@ mod session_main {
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
// SAFETY: as the settings block above — single-threaded startup, before SDL
|
||||
// (the reader of these variables) or any other thread exists.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::remove_var(var)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,12 +977,7 @@ mod session_main {
|
||||
window_title: format!("Punktfunk · {title}"),
|
||||
fullscreen,
|
||||
window_pos: window_pos(),
|
||||
// `--stats` forces the overlay visible (tooling/debug runs) without
|
||||
// demoting an explicitly chosen richer tier.
|
||||
stats_verbosity: match settings.stats_verbosity() {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
stats_verbosity: stats_tier(&settings),
|
||||
touch_mode: settings.touch_mode(),
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
@@ -1000,6 +1046,37 @@ mod session_main {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use trust::StatsVerbosity as V;
|
||||
|
||||
/// `--stats` is a floor, never a ceiling: it lifts Off to Normal and leaves every
|
||||
/// richer chosen tier alone. Both run modes' presenter options AND the per-launch
|
||||
/// params read this one rule, which is the point of having it.
|
||||
#[test]
|
||||
fn the_stats_flag_lifts_off_and_demotes_nothing() {
|
||||
assert_eq!(stats_tier_with(V::Off, true), V::Normal);
|
||||
assert_eq!(stats_tier_with(V::Off, false), V::Off);
|
||||
for chosen in [V::Compact, V::Normal, V::Detailed] {
|
||||
assert_eq!(stats_tier_with(chosen, true), chosen);
|
||||
assert_eq!(stats_tier_with(chosen, false), chosen);
|
||||
}
|
||||
}
|
||||
|
||||
/// The console reads the file ONCE for its window, so a tier changed between streams
|
||||
/// can only reach the overlay by riding the launch. Guards the wiring the field exists
|
||||
/// for: whatever settings a launch resolved is what the params carry.
|
||||
#[test]
|
||||
fn a_launch_carries_the_tier_its_settings_resolved() {
|
||||
let mut s = trust::Settings::default();
|
||||
for chosen in [V::Off, V::Compact, V::Normal, V::Detailed] {
|
||||
s.set_stats_verbosity(chosen);
|
||||
assert_eq!(stats_tier_with(s.stats_verbosity(), false), chosen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "punktfunk-client-windows"
|
||||
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
# Not workspace-inherited (1.82): windows-reactor at the pinned rev declares rust-version 1.95+
|
||||
# Not workspace-inherited (1.85): windows-reactor at the pinned rev declares rust-version 1.95+
|
||||
# and edition 2024. rust-toolchain.toml pins 1.96, so this records reality rather than raising it.
|
||||
rust-version = "1.96"
|
||||
license.workspace = true
|
||||
|
||||
@@ -318,10 +318,10 @@ fn edit_editor(
|
||||
if !addr.is_empty() {
|
||||
h.addr = addr;
|
||||
}
|
||||
if let Ok(p) = port_draft.borrow().trim().parse::<u16>() {
|
||||
if p != 0 {
|
||||
h.port = p;
|
||||
}
|
||||
if let Ok(p) = port_draft.borrow().trim().parse::<u16>()
|
||||
&& p != 0
|
||||
{
|
||||
h.port = p;
|
||||
}
|
||||
let mac = mac_draft.borrow().trim().to_string();
|
||||
h.mac = if mac.is_empty() {
|
||||
@@ -1094,12 +1094,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.close_button_text("Cancel")
|
||||
.is_open(pending.is_some())
|
||||
.on_closed(move |r: ContentDialogResult| {
|
||||
if r == ContentDialogResult::Primary {
|
||||
if let Some((fp, _)) = &pending {
|
||||
let mut known = KnownHosts::load();
|
||||
known.remove_by_fp(fp);
|
||||
let _ = known.save();
|
||||
}
|
||||
if r == ContentDialogResult::Primary
|
||||
&& let Some((fp, _)) = &pending
|
||||
{
|
||||
let mut known = KnownHosts::load();
|
||||
known.remove_by_fp(fp);
|
||||
let _ = known.save();
|
||||
}
|
||||
sf.call(None); // re-renders the page; the row is gone on the next load
|
||||
})
|
||||
|
||||
@@ -515,35 +515,37 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
move || {
|
||||
std::thread::Builder::new()
|
||||
.name("pf-probe".into())
|
||||
.spawn(move || loop {
|
||||
// A spawned session/browse child is running: the shell is hidden
|
||||
// (nobody sees the pips) and one of these hosts is mid-stream —
|
||||
// probing it is pure noise. Sleep through and sweep after it ends.
|
||||
if shared.session.lock().unwrap().is_running() {
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
continue;
|
||||
}
|
||||
let handles: Vec<_> = KnownHosts::load()
|
||||
.hosts
|
||||
.into_iter()
|
||||
.filter(|h| !h.addr.is_empty())
|
||||
.map(|h| {
|
||||
std::thread::spawn(move || {
|
||||
(
|
||||
h.fp_hex,
|
||||
NativeClient::probe(
|
||||
&h.addr,
|
||||
h.port,
|
||||
Duration::from_millis(2500),
|
||||
),
|
||||
)
|
||||
.spawn(move || {
|
||||
loop {
|
||||
// A spawned session/browse child is running: the shell is hidden
|
||||
// (nobody sees the pips) and one of these hosts is mid-stream —
|
||||
// probing it is pure noise. Sleep through and sweep after it ends.
|
||||
if shared.session.lock().unwrap().is_running() {
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
continue;
|
||||
}
|
||||
let handles: Vec<_> = KnownHosts::load()
|
||||
.hosts
|
||||
.into_iter()
|
||||
.filter(|h| !h.addr.is_empty())
|
||||
.map(|h| {
|
||||
std::thread::spawn(move || {
|
||||
(
|
||||
h.fp_hex,
|
||||
NativeClient::probe(
|
||||
&h.addr,
|
||||
h.port,
|
||||
Duration::from_millis(2500),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let map: HashMap<String, bool> =
|
||||
handles.into_iter().filter_map(|h| h.join().ok()).collect();
|
||||
set_probed.call(map);
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
.collect();
|
||||
let map: HashMap<String, bool> =
|
||||
handles.into_iter().filter_map(|h| h.join().ok()).collect();
|
||||
set_probed.call(map);
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -560,14 +562,15 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let anim_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
|
||||
let (anim, set_anim) = cx.use_async_state((Option::<Screen>::None, 1.0f64));
|
||||
cx.use_effect(screen.clone(), {
|
||||
let (s, set_anim, gen) = (screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
|
||||
let (s, set_anim, generation) =
|
||||
(screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
|
||||
move || {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let mine = gen.fetch_add(1, SeqCst) + 1;
|
||||
let mine = generation.fetch_add(1, SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
const STEPS: u32 = 14;
|
||||
for i in 0..=STEPS {
|
||||
if gen.load(SeqCst) != mine {
|
||||
if generation.load(SeqCst) != mine {
|
||||
return; // a newer navigation superseded this tween
|
||||
}
|
||||
let p = f64::from(i) / f64::from(STEPS);
|
||||
@@ -593,18 +596,18 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let nav_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
|
||||
let (nav_anim, set_nav_anim) = cx.use_async_state((String::new(), 1.0f64));
|
||||
cx.use_effect(settings_nav.clone(), {
|
||||
let (s, set_nav_anim, gen) = (
|
||||
let (s, set_nav_anim, generation) = (
|
||||
settings_nav.clone(),
|
||||
set_nav_anim.clone(),
|
||||
nav_gen.borrow().clone(),
|
||||
);
|
||||
move || {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let mine = gen.fetch_add(1, SeqCst) + 1;
|
||||
let mine = generation.fetch_add(1, SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
const STEPS: u32 = 14;
|
||||
for i in 0..=STEPS {
|
||||
if gen.load(SeqCst) != mine {
|
||||
if generation.load(SeqCst) != mine {
|
||||
return; // a newer section switch superseded this tween
|
||||
}
|
||||
let p = f64::from(i) / f64::from(STEPS);
|
||||
@@ -628,10 +631,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let add_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
|
||||
let (add_anim, set_add_anim) = cx.use_async_state(0.0f64);
|
||||
cx.use_effect(show_add, {
|
||||
let (set_add_anim, gen) = (set_add_anim.clone(), add_gen.borrow().clone());
|
||||
let (set_add_anim, generation) = (set_add_anim.clone(), add_gen.borrow().clone());
|
||||
move || {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let mine = gen.fetch_add(1, SeqCst) + 1;
|
||||
let mine = generation.fetch_add(1, SeqCst) + 1;
|
||||
if !show_add {
|
||||
set_add_anim.call(0.0);
|
||||
return;
|
||||
@@ -639,7 +642,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
std::thread::spawn(move || {
|
||||
const STEPS: u32 = 12;
|
||||
for i in 0..=STEPS {
|
||||
if gen.load(SeqCst) != mine {
|
||||
if generation.load(SeqCst) != mine {
|
||||
return; // reopened/closed mid-tween — a newer run owns the value
|
||||
}
|
||||
let p = f64::from(i) / f64::from(STEPS);
|
||||
|
||||
@@ -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.
|
||||
@@ -83,10 +82,11 @@ fn main() {
|
||||
// where the user's hosts already are. A hand-off that finds nobody falls through and this
|
||||
// process becomes the shell that opens it, so the link is never simply lost.
|
||||
let link = deeplink::positional_url(&args);
|
||||
if let Some(url) = &link {
|
||||
if !deeplink::claim_primary() && deeplink::forward_to_primary(url) {
|
||||
return;
|
||||
}
|
||||
if let Some(url) = &link
|
||||
&& !deeplink::claim_primary()
|
||||
&& deeplink::forward_to_primary(url)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if flag("--discover") {
|
||||
|
||||
@@ -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,7 +7,7 @@
|
||||
[package]
|
||||
name = "pf-capture"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host frame capture: Linux PipeWire portal + Windows IDD direct-push capturers behind one Capturer trait."
|
||||
|
||||
@@ -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
|
||||
@@ -67,7 +64,7 @@ pub(crate) fn hybrid_hook_hits() -> u64 {
|
||||
// on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a
|
||||
// different core), so the "same-thread, no flush needed" assumption was wrong.
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn FlushInstructionCache(h: *mut c_void, base: *const c_void, size: usize) -> i32;
|
||||
fn GetCurrentProcess() -> *mut c_void;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -164,10 +164,10 @@ fn read_appid(conn: &RustConnection, root: Window, atom: Atom) -> Option<u32> {
|
||||
.ok()?
|
||||
.reply()
|
||||
.ok()?;
|
||||
// Bound rather than returned inline: the iterator borrows `reply`, and as a tail
|
||||
// expression its temporary would outlive it.
|
||||
let id = reply.value32()?.next();
|
||||
id
|
||||
// Inline is sound since edition 2024: tail-expression temporaries now drop BEFORE the
|
||||
// block's locals, so the iterator borrowing `reply` no longer outlives it (the 2021 rule
|
||||
// forced a `let` binding here).
|
||||
reply.value32()?.next()
|
||||
}
|
||||
|
||||
/// The whole decision, separated from X so it can be tested: an overlay is up exactly when
|
||||
|
||||
@@ -104,6 +104,19 @@ pub struct SessionParams {
|
||||
/// above; it rides along so the stats overlay can answer "which profile am I on?" without
|
||||
/// re-reading any store (design/client-settings-profiles.md §5.2).
|
||||
pub profile: Option<String>,
|
||||
/// The stats-overlay tier THIS launch resolved to — the globals, or the profile bound to
|
||||
/// this host. Presentation-tier, like [`profile`](Self::profile): the session controller
|
||||
/// never reads it, it rides along so the presenter can adopt it when a browse-mode launch
|
||||
/// starts.
|
||||
///
|
||||
/// That adoption is the whole point. The console (Gaming Mode / Decky) builds its window
|
||||
/// and its run loop ONCE and streams many sessions through them, so a tier taken only from
|
||||
/// the loop's start-of-process options could never change again — a user picking a tier in
|
||||
/// the console's settings screen saw the row move, the file updated, and every stream keep
|
||||
/// the old overlay until the app was restarted. Carrying it per launch is what lets the
|
||||
/// choice land on the next stream, and it makes a profile's `stats_verbosity` reach the
|
||||
/// console too. The in-stream cycle chord still wins for the rest of the stream it moved.
|
||||
pub stats_verbosity: crate::trust::StatsVerbosity,
|
||||
/// Advertise `quic::CLIENT_CAP_PHASE_LOCK`: this embedder's presenter has REAL on-glass
|
||||
/// latch stamps (`VK_KHR_present_wait`) and will feed [`latch_grid`](Self::latch_grid),
|
||||
/// so the pump sends the ~1 Hz `PhaseReport`s the host phase-locks its capture tick to
|
||||
@@ -885,7 +898,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
|
||||
|
||||
@@ -2241,7 +2241,7 @@ mod parity {
|
||||
// `desc.Height` rows at `RowPitch` and the chroma plane follows at byte
|
||||
// offset `RowPitch * desc.Height`, so `total` below is exactly the mapped
|
||||
// extent and every sub-slice read is inside it. `Unmap` pairs the `Map`.
|
||||
let out = unsafe {
|
||||
unsafe {
|
||||
let src: ID3D11Resource = pool.cast().expect("pool -> resource");
|
||||
let dst: ID3D11Resource = staging.cast().expect("staging -> resource");
|
||||
self.ctx
|
||||
@@ -2268,8 +2268,7 @@ mod parity {
|
||||
}
|
||||
self.ctx.Unmap(&staging, 0);
|
||||
out
|
||||
};
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[package]
|
||||
name = "pf-clipboard"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host shared clipboard: per-OS session-clipboard backends behind one HostClipboard + the QUIC clipboard-plane coordinator."
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -382,13 +382,13 @@ impl SettingsScreen {
|
||||
}
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
};
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
return match msg {
|
||||
ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1054,12 +1054,16 @@ mod tests {
|
||||
fn fake_home() {
|
||||
use std::sync::OnceLock;
|
||||
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
|
||||
let dir = HOME.get_or_init(|| {
|
||||
HOME.get_or_init(|| {
|
||||
let dir = std::env::temp_dir().join(format!("pf-settings-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// SAFETY: runs at most once, inside `get_or_init` — concurrent `fake_home` callers
|
||||
// block until it returns, and nothing else in this binary mutates `HOME`. (The old
|
||||
// set after the closure ran on EVERY call, so two parallel tests could race the
|
||||
// write; setting once under the OnceLock is what makes this sound.)
|
||||
unsafe { std::env::set_var("HOME", &dir) };
|
||||
dir
|
||||
});
|
||||
std::env::set_var("HOME", dir);
|
||||
}
|
||||
|
||||
/// Render the screen once so its strip and list carry real geometry, then hand back a
|
||||
|
||||
@@ -49,13 +49,16 @@ fn motion_matches_the_shared_vectors() {
|
||||
fn fake_home() {
|
||||
use std::sync::OnceLock;
|
||||
static HOME: OnceLock<std::path::PathBuf> = OnceLock::new();
|
||||
let dir = HOME.get_or_init(|| {
|
||||
HOME.get_or_init(|| {
|
||||
let dir = std::env::temp_dir().join(format!("pf-console-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_var("HOME", &dir);
|
||||
dir.clone()
|
||||
// SAFETY: runs at most once, inside `get_or_init` — concurrent `fake_home` callers
|
||||
// block until it returns, and nothing else in this binary mutates `HOME`. (The old
|
||||
// re-set after the closure ran on EVERY call, so two parallel tests could race the
|
||||
// write; setting once under the OnceLock is what makes this sound.)
|
||||
unsafe { std::env::set_var("HOME", &dir) };
|
||||
dir
|
||||
});
|
||||
std::env::set_var("HOME", dir);
|
||||
}
|
||||
|
||||
fn hosts() -> Vec<HostRow> {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
[package]
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Shared host<->driver binary contract for the punktfunk pf-vdisplay virtual display (control IOCTLs + IDD-push frame transport)."
|
||||
publish = false
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[package]
|
||||
name = "pf-encode"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host video encode: NVENC/VAAPI/AMF/QSV/Vulkan-Video/PyroWave/openh264 backends behind one Encoder trait."
|
||||
|
||||
@@ -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,
|
||||
@@ -1654,7 +1652,7 @@ impl NvencCudaEncoder {
|
||||
return Err(nvenc_status::call_err(
|
||||
"register_resource (CUDADEVICEPTR)",
|
||||
e,
|
||||
))
|
||||
));
|
||||
}
|
||||
}
|
||||
self.ring.push(RingSlot {
|
||||
@@ -2781,6 +2779,20 @@ mod tests {
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use pf_zerocopy::cuda::DeviceBuffer;
|
||||
|
||||
/// Env knob for the `#[ignore]`d hardware spikes, which every caller's doc says to run ALONE
|
||||
/// with `--test-threads=1` (they mutate process env and own the GPU).
|
||||
fn set_env(key: &str, val: impl AsRef<std::ffi::OsStr>) {
|
||||
// SAFETY: only reached from the manually-run `--test-threads=1` hardware tests, so no
|
||||
// other thread exists in this process to read or write the environment concurrently.
|
||||
unsafe { std::env::set_var(key, val) };
|
||||
}
|
||||
|
||||
/// [`set_env`]'s companion; the same single-threaded-run contract.
|
||||
fn remove_env(key: &str) {
|
||||
// SAFETY: as `set_env` — single-threaded manual test run, no concurrent env access.
|
||||
unsafe { std::env::remove_var(key) };
|
||||
}
|
||||
|
||||
/// The 10-bit input mapping is load-bearing in a way a smoke test can't reach: pick the wrong
|
||||
/// NVENC format for a packed 2:10:10:10 capture and the encoder reads the words as 8-bit
|
||||
/// `ARGB` — a picture that decodes, looks *almost* right, and is silently 8-bit with the
|
||||
@@ -3317,8 +3329,8 @@ mod tests {
|
||||
|
||||
// Isolate the split variable: sub-frame off, and open explicitly split-DISABLED so the
|
||||
// switch below is a real change rather than a no-op.
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
@@ -3430,8 +3442,8 @@ mod tests {
|
||||
}
|
||||
|
||||
enc.flush().ok();
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **spike S1b**, the other half of S1: an in-place `splitEncodeMode` change that
|
||||
@@ -3470,7 +3482,7 @@ mod tests {
|
||||
const SETTLE: u32 = 16;
|
||||
let two = M::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
|
||||
// Separate buffers rotated per frame, so identical content can't let the encoder
|
||||
// skip-code everything and erase the difference we are trying to measure.
|
||||
@@ -3484,7 +3496,7 @@ mod tests {
|
||||
|
||||
// Returns (early-half p50 µs, late-half p50 µs, median bytes/AU).
|
||||
let run_leg = |open_split: &str, switch_to: Option<u32>| -> (u128, u128, usize) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", open_split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", open_split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
@@ -3556,9 +3568,15 @@ mod tests {
|
||||
|
||||
println!("S1b @ {W}x{H}@60 HEVC 8-bit, {} Mbps CBR:", BPS / 1_000_000);
|
||||
println!(" (early = first half of the measured window, late = second half)");
|
||||
println!(" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU");
|
||||
println!(" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU");
|
||||
println!(" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU");
|
||||
println!(
|
||||
" A fresh DISABLE : early {a_early:>6} late {a_late:>6} us/frame, {a_bytes:>8} B/AU"
|
||||
);
|
||||
println!(
|
||||
" B fresh TWO_FORCED : early {b_early:>6} late {b_late:>6} us/frame, {b_bytes:>8} B/AU"
|
||||
);
|
||||
println!(
|
||||
" C DISABLE→TWO in situ: early {c_early:>6} late {c_late:>6} us/frame, {c_bytes:>8} B/AU"
|
||||
);
|
||||
if c_early > c_late + c_late / 8 {
|
||||
println!(
|
||||
" ⇒ leg C SETTLES ({c_early} → {c_late} us): the in-place switch is not \
|
||||
@@ -3585,8 +3603,8 @@ mod tests {
|
||||
}
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
let _ = (a_bytes, b_bytes, c_bytes);
|
||||
}
|
||||
|
||||
@@ -3620,8 +3638,8 @@ mod tests {
|
||||
|
||||
// Open split-DISABLED, and leave sub-frame at its Linux default (ON where the GPU
|
||||
// advertises SUBFRAME_READBACK) — that is the fleet shape the arbitration starts from.
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
@@ -3666,7 +3684,7 @@ mod tests {
|
||||
"S1c SKIPPED: sub-frame is off at open on this GPU/driver, so there is no pair to \
|
||||
flip — the arbitration reduces to S1a's plain split switch here."
|
||||
);
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3717,7 +3735,7 @@ mod tests {
|
||||
}
|
||||
|
||||
enc.flush().ok();
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **the D5 confirm** (design §2 defect D5), the one claim in that list that was
|
||||
@@ -3757,12 +3775,12 @@ mod tests {
|
||||
// produced a spurious "D5 REFUTED" on the first run of this test.
|
||||
let run = |split: Option<&str>, subframe: Option<&str>| -> (u128, bool) {
|
||||
match split {
|
||||
Some(v) => std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", v),
|
||||
None => std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"),
|
||||
Some(v) => set_env("PUNKTFUNK_SPLIT_ENCODE", v),
|
||||
None => remove_env("PUNKTFUNK_SPLIT_ENCODE"),
|
||||
}
|
||||
match subframe {
|
||||
Some(v) => std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", v),
|
||||
None => std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"),
|
||||
Some(v) => set_env("PUNKTFUNK_NVENC_SUBFRAME", v),
|
||||
None => remove_env("PUNKTFUNK_NVENC_SUBFRAME"),
|
||||
}
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
@@ -3843,8 +3861,8 @@ mod tests {
|
||||
}
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **what is the real split ceiling on this GPU?** Feeds WP1.1: we want to use
|
||||
@@ -3872,13 +3890,13 @@ mod tests {
|
||||
const WARMUP: u32 = 8;
|
||||
const MEASURED: u32 = 24;
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
|
||||
|
||||
// → (requested mode, mode actually opened, p50 µs, engines the driver reports)
|
||||
let run = |split: &str| -> (u32, u128, i32) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
@@ -3942,11 +3960,7 @@ mod tests {
|
||||
};
|
||||
println!(
|
||||
" req {label} → opened_mode={opened:<2} {} {us:>6} us/frame{vs} [engines={engines}]",
|
||||
if honoured {
|
||||
"HONOURED"
|
||||
} else {
|
||||
"FELL BACK"
|
||||
}
|
||||
if honoured { "HONOURED" } else { "FELL BACK" }
|
||||
);
|
||||
}
|
||||
println!(
|
||||
@@ -3954,8 +3968,8 @@ mod tests {
|
||||
HONOURED but no faster than DISABLE was accepted and did nothing."
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — the live split arbitration end to end (WP3). Opens a 4K session that the
|
||||
@@ -3977,9 +3991,9 @@ mod tests {
|
||||
const H: u32 = 2160;
|
||||
let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
set_env("PUNKTFUNK_NVENC_SPLIT_ARBITRATE", "1");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let frames: Vec<CapturedFrame> = (0..4).map(|i| nv12_frame(W, H, i)).collect();
|
||||
@@ -4047,8 +4061,8 @@ mod tests {
|
||||
max_forced_split_mode(enc_engines)
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SPLIT_ARBITRATE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
// The verdict cache is process-global: leaving this session's result in it would steer
|
||||
// every later test that opens the same config with the split env unset (the D5 legs do
|
||||
// exactly that).
|
||||
@@ -4088,14 +4102,14 @@ mod tests {
|
||||
})
|
||||
.unwrap_or((3840, 2160, 60));
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
// 10-bit input: the packed 2:10:10:10 PQ path is how a Main10 session is actually fed here
|
||||
// (`bit_depth`/`hdr` are DERIVED from the input format, never trusted from the args).
|
||||
let frames: Vec<CapturedFrame> = (0..4).map(|i| rgb10_frame(w, h, i)).collect();
|
||||
|
||||
let run = |split: &str| -> (u128, u8, usize) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::X2Rgb10,
|
||||
@@ -4159,8 +4173,8 @@ mod tests {
|
||||
}
|
||||
);
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE — **THE BITS/FRAME CURVE**, the measurement this whole programme has been blind
|
||||
@@ -4192,7 +4206,7 @@ mod tests {
|
||||
})
|
||||
.unwrap_or((3840, 2160, 60));
|
||||
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
// Sweep CONTENT DETAIL, not nominal bitrate. Pure noise is incompressible, so a low
|
||||
// bitrate target simply overshoots (measured: 719 KB/AU against a 104 KB quota) and every
|
||||
@@ -4209,7 +4223,7 @@ mod tests {
|
||||
let frames: Vec<CapturedFrame> =
|
||||
(0..4).map(|i| noise_nv12_frame(w, h, i, block)).collect();
|
||||
let run = |split: &str| -> (u128, usize) {
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
set_env("PUNKTFUNK_SPLIT_ENCODE", split);
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
@@ -4252,8 +4266,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_SPLIT_ENCODE");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
|
||||
/// A pre-session RFI request and nonsense ranges all correctly decline (→ caller forces IDR).
|
||||
@@ -4660,12 +4674,12 @@ mod tests {
|
||||
struct EnvGuard;
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
}
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "4");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "1");
|
||||
set_env("PUNKTFUNK_NVENC_SLICES", "4");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "1");
|
||||
let _guard = EnvGuard;
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
@@ -4772,8 +4786,8 @@ mod tests {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
// Defaults under test — make sure another test's knobs aren't leaking in.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
@@ -4868,8 +4882,8 @@ mod tests {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
// The ceiling under test is the negotiated one, not the operator override.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
@@ -4913,16 +4927,16 @@ mod tests {
|
||||
struct EnvGuard;
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
}
|
||||
let _guard = EnvGuard;
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
|
||||
// Escape 1: explicit single slice — no boundaries to cut, chunked poll disarmed.
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SLICES", "1");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
set_env("PUNKTFUNK_NVENC_SLICES", "1");
|
||||
remove_env("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
let mut enc = open_h265();
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0).expect("submit");
|
||||
@@ -4945,8 +4959,8 @@ mod tests {
|
||||
|
||||
// Escape 2: sub-frame readback vetoed — slices stay (default 4) but chunked poll
|
||||
// disarms and the plain poll path carries the session.
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SLICES");
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
remove_env("PUNKTFUNK_NVENC_SLICES");
|
||||
set_env("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
let mut enc = open_h265();
|
||||
let frame = nv12_frame(W, H, 0);
|
||||
enc.submit_indexed(&frame, 0).expect("submit");
|
||||
|
||||
@@ -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;
|
||||
@@ -428,11 +425,11 @@ pub fn run_from_args(args: &[String]) -> Result<()> {
|
||||
/// the priority intent (it arrives explicitly in `Hello`) and the worker path itself (nothing here
|
||||
/// spawns a worker, and a stale value in a core dump is just noise).
|
||||
fn sanitize_env() {
|
||||
// Single-threaded — this runs before anything in this process creates a thread, which is the
|
||||
// one situation where mutating the environment is sound (the `getenv` race the house rule
|
||||
// about `set_var` is about needs a second thread).
|
||||
for k in ["PYROWAVE_QUEUE_PRIORITY", "PUNKTFUNK_ENCODE_WORKER"] {
|
||||
std::env::remove_var(k);
|
||||
// SAFETY: single-threaded — this runs before anything in this process creates a thread,
|
||||
// which is the one situation where mutating the environment is sound (the `getenv` race
|
||||
// `remove_var`'s contract is about needs a second thread).
|
||||
unsafe { std::env::remove_var(k) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -1618,7 +1616,9 @@ impl Encoder for NvencD3d11Encoder {
|
||||
let frame = match &captured.payload {
|
||||
FramePayload::D3d11(f) => f,
|
||||
FramePayload::Cpu(_) => {
|
||||
bail!("NVENC D3D11 encoder needs a GPU texture frame (use the software encoder for CPU frames)")
|
||||
bail!(
|
||||
"NVENC D3D11 encoder needs a GPU texture frame (use the software encoder for CPU frames)"
|
||||
)
|
||||
}
|
||||
};
|
||||
// The capturer recreates its D3D11 device on a desktop switch (secure/Winlogon) and may come
|
||||
@@ -2884,8 +2884,12 @@ mod tests {
|
||||
let two = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;
|
||||
|
||||
// Isolate the split variable exactly as the Linux spike does.
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
// SAFETY: this `#[ignore]`d hardware spike is run alone (manual RTX-box run, one test),
|
||||
// so no other thread exists to read or write the environment concurrently.
|
||||
unsafe {
|
||||
std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
|
||||
std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
|
||||
}
|
||||
|
||||
// SAFETY: (test-only) the same straight-line D3D11/DXGI setup as `nvenc_reconfigure_no_idr`.
|
||||
unsafe {
|
||||
@@ -3009,8 +3013,11 @@ mod tests {
|
||||
enc.flush().ok();
|
||||
}
|
||||
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
// SAFETY: as the set above — single-threaded manual test run, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
|
||||
std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
|
||||
}
|
||||
}
|
||||
|
||||
/// ON-GLASS (RTX box): the measurement gating the AYUV 4:4:4 work — encodes the probe
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[package]
|
||||
name = "pf-frame"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host shared frame/format vocabulary: CapturedFrame, PixelFormat, HDR metadata, thread QoS, and the Windows DXGI capture identity."
|
||||
|
||||
+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;
|
||||
|
||||
@@ -8,27 +8,27 @@
|
||||
//!
|
||||
//! Raw C-ABI FFI (winmm/kernel32/dwmapi/avrt) rather than the `windows` crate so it builds without
|
||||
//! pulling new windows-rs features. No-op on non-Windows. Per-thread effects (MMCSS, execution
|
||||
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
|
||||
//! state) auto-revert at thread exit (= session end); the process-wide bits are refcounted over
|
||||
//! the hot threads and revert when the LAST one exits — the host must not keep HIGH priority and
|
||||
//! a 1 ms global timer while a local game runs and nobody streams (2026-08-12 field report).
|
||||
//! 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)]
|
||||
use std::ffi::c_void;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::Mutex;
|
||||
|
||||
type Handle = *mut c_void;
|
||||
type Bool = i32;
|
||||
|
||||
#[link(name = "winmm")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn timeBeginPeriod(uPeriod: u32) -> u32;
|
||||
fn timeEndPeriod(uPeriod: u32) -> u32;
|
||||
}
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn GetCurrentProcess() -> Handle;
|
||||
fn SetPriorityClass(hProcess: Handle, dwPriorityClass: u32) -> Bool;
|
||||
fn SetThreadExecutionState(esFlags: u32) -> u32;
|
||||
@@ -49,15 +49,16 @@ mod imp {
|
||||
simple_reason: *const u16,
|
||||
}
|
||||
#[link(name = "dwmapi")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn DwmEnableMMCSS(fEnableMMCSS: Bool) -> i32; // HRESULT
|
||||
}
|
||||
#[link(name = "avrt")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn AvSetMmThreadCharacteristicsW(TaskName: *const u16, TaskIndex: *mut u32) -> Handle;
|
||||
}
|
||||
|
||||
const HIGH_PRIORITY_CLASS: u32 = 0x0000_0080;
|
||||
const NORMAL_PRIORITY_CLASS: u32 = 0x0000_0020;
|
||||
const ES_CONTINUOUS: u32 = 0x8000_0000;
|
||||
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
|
||||
const ES_DISPLAY_REQUIRED: u32 = 0x0000_0002;
|
||||
@@ -117,16 +118,19 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
static PROCESS_TUNED: OnceLock<()> = OnceLock::new();
|
||||
/// Live hot (session) threads. A Mutex, not an atomic: the 0↔1 transitions carry the
|
||||
/// apply/revert side effects, and an interleaved fetch_add/fetch_sub pair could otherwise
|
||||
/// finish with a running session untuned (transitions are rare — thread start/exit only).
|
||||
static HOT_THREADS: Mutex<usize> = Mutex::new(0);
|
||||
|
||||
/// Process-wide tuning, applied exactly once. Reverts at process exit. Best-effort: each call is
|
||||
/// independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
||||
fn tune_process_once() {
|
||||
/// Process-wide tuning, applied when the FIRST hot thread registers. Best-effort: each call
|
||||
/// is independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
||||
fn tune_process() {
|
||||
// SAFETY: each call is a C-ABI FFI into winmm/kernel32/dwmapi declared with a matching
|
||||
// `extern "system"` signature; every argument is a plain integer (no pointers/buffers escape),
|
||||
// and `GetCurrentProcess()` returns the current-process pseudo-handle (a constant, always valid,
|
||||
// never closed). The body runs inside `get_or_init`, so it executes exactly once per process.
|
||||
PROCESS_TUNED.get_or_init(|| unsafe {
|
||||
// never closed).
|
||||
unsafe {
|
||||
// 1 ms timer granularity (default ~15.6 ms) — the floor for precise frame pacing and the
|
||||
// encode|send split's sub-ms sleeps.
|
||||
timeBeginPeriod(1);
|
||||
@@ -137,16 +141,66 @@ mod imp {
|
||||
// control/capture/encode/send threads on the CPU (Apollo does the same).
|
||||
SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS);
|
||||
tracing::info!("windows session tuning applied (timer 1ms, DWM MMCSS, HIGH priority)");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Call at the start of each capture/encode/send (hot stream) thread. Applies the process-wide
|
||||
/// tuning once, registers the calling thread with MMCSS ("Games"), and asserts the display/system
|
||||
/// must stay awake for as long as this thread lives. The MMCSS handle is intentionally leaked and
|
||||
/// the execution-state assertion is bound to this thread — both are reverted by the OS when the
|
||||
/// thread exits, so a session that ends tears them down without explicit bookkeeping.
|
||||
/// The mirror of [`tune_process`], run when the LAST hot thread exits. Leaving the tuning in
|
||||
/// place used to be the design ("reverts at process exit") — but the host is a 24/7 service,
|
||||
/// so after one stream it competed at HIGH class with a 1 ms global timer against whatever
|
||||
/// the user played locally, forever.
|
||||
fn untune_process() {
|
||||
// SAFETY: same FFI surface as `tune_process` — plain-integer arguments, constant
|
||||
// pseudo-handle, no pointers or buffers.
|
||||
unsafe {
|
||||
timeEndPeriod(1); // pairs the timeBeginPeriod(1)
|
||||
DwmEnableMMCSS(0);
|
||||
SetPriorityClass(GetCurrentProcess(), NORMAL_PRIORITY_CLASS);
|
||||
tracing::info!("windows session tuning reverted (timer, DWM MMCSS, NORMAL priority)");
|
||||
}
|
||||
}
|
||||
|
||||
/// One per hot thread, parked in TLS by [`on_hot_thread`]; its Drop runs at thread exit
|
||||
/// (= session teardown), the same lifetime the MMCSS/execution-state effects already ride.
|
||||
struct HotThreadGuard;
|
||||
|
||||
impl Drop for HotThreadGuard {
|
||||
fn drop(&mut self) {
|
||||
// A poisoned lock skips the revert (best-effort, like every call here) instead of
|
||||
// panicking inside a TLS destructor.
|
||||
if let Ok(mut n) = HOT_THREADS.lock() {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
untune_process();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOT_THREAD: std::cell::OnceCell<HotThreadGuard> =
|
||||
const { std::cell::OnceCell::new() };
|
||||
}
|
||||
|
||||
/// Call at the start of each capture/encode/send (hot stream) thread. Registers the thread in
|
||||
/// the process-tuning refcount (first in applies, last out reverts), registers it with MMCSS
|
||||
/// ("Games"), and asserts the display/system must stay awake for as long as this thread lives.
|
||||
/// The MMCSS handle is intentionally leaked and the execution-state assertion is bound to this
|
||||
/// thread — both are reverted by the OS when the thread exits, and the refcount guard's TLS
|
||||
/// Drop runs there too, so a session that ends tears everything down without explicit
|
||||
/// bookkeeping.
|
||||
pub fn on_hot_thread() {
|
||||
tune_process_once();
|
||||
HOT_THREAD.with(|slot| {
|
||||
if slot.get().is_none() {
|
||||
{
|
||||
let mut n = HOT_THREADS.lock().unwrap();
|
||||
*n += 1;
|
||||
if *n == 1 {
|
||||
tune_process();
|
||||
}
|
||||
}
|
||||
let _ = slot.set(HotThreadGuard);
|
||||
}
|
||||
});
|
||||
// SAFETY: C-ABI FFI declared with matching `extern "system"` signatures. SetThreadExecutionState
|
||||
// takes only flag bits. `task` is a local NUL-terminated UTF-16 buffer ("Games\0") alive for the
|
||||
// whole block, so `task.as_ptr()` is a valid LPCWSTR for the call, and `&mut idx` is a live local
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[package]
|
||||
name = "pf-gpu"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host GPU vendor/adapter enumeration, selection preference, and active-session accounting."
|
||||
publish = false
|
||||
|
||||
+28
-29
@@ -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};
|
||||
@@ -170,7 +169,7 @@ mod kmt {
|
||||
}
|
||||
|
||||
#[link(name = "gdi32")]
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn D3DKMTOpenAdapterFromLuid(arg: *mut OpenAdapterFromLuid) -> i32;
|
||||
fn D3DKMTQueryAdapterInfo(arg: *mut QueryAdapterInfo) -> i32;
|
||||
fn D3DKMTCloseAdapter(arg: *mut CloseAdapter) -> i32;
|
||||
@@ -501,12 +500,12 @@ pub fn pick(
|
||||
env_substr: Option<&str>,
|
||||
) -> Option<(usize, PickSource)> {
|
||||
let mut preference_missing = false;
|
||||
if pref.mode == GpuMode::Manual {
|
||||
if let Some(want) = &pref.gpu {
|
||||
match find_preferred(gpus, want) {
|
||||
Some(i) => return Some((i, PickSource::Preference)),
|
||||
None => preference_missing = true,
|
||||
}
|
||||
if pref.mode == GpuMode::Manual
|
||||
&& let Some(want) = &pref.gpu
|
||||
{
|
||||
match find_preferred(gpus, want) {
|
||||
Some(i) => return Some((i, PickSource::Preference)),
|
||||
None => preference_missing = true,
|
||||
}
|
||||
}
|
||||
if let Some(sub) = env_substr.filter(|s| !s.is_empty()) {
|
||||
@@ -561,17 +560,17 @@ pub fn selected_gpu() -> Option<SelectedGpu> {
|
||||
let gpus = enumerate();
|
||||
let pref = prefs().get();
|
||||
let mut preference_missing = false;
|
||||
if pref.mode == GpuMode::Manual {
|
||||
if let Some(want) = &pref.gpu {
|
||||
match find_preferred(&gpus, want) {
|
||||
Some(i) => {
|
||||
return Some(SelectedGpu {
|
||||
info: gpus.into_iter().nth(i)?,
|
||||
source: PickSource::Preference,
|
||||
})
|
||||
}
|
||||
None => preference_missing = true,
|
||||
if pref.mode == GpuMode::Manual
|
||||
&& let Some(want) = &pref.gpu
|
||||
{
|
||||
match find_preferred(&gpus, want) {
|
||||
Some(i) => {
|
||||
return Some(SelectedGpu {
|
||||
info: gpus.into_iter().nth(i)?,
|
||||
source: PickSource::Preference,
|
||||
});
|
||||
}
|
||||
None => preference_missing = true,
|
||||
}
|
||||
}
|
||||
let source = if preference_missing {
|
||||
@@ -579,13 +578,13 @@ pub fn selected_gpu() -> Option<SelectedGpu> {
|
||||
} else {
|
||||
PickSource::Auto
|
||||
};
|
||||
if linux_nvidia_present() {
|
||||
if let Some(i) = gpus.iter().position(|g| g.vendor_id == VENDOR_NVIDIA) {
|
||||
return Some(SelectedGpu {
|
||||
info: gpus.into_iter().nth(i)?,
|
||||
source,
|
||||
});
|
||||
}
|
||||
if linux_nvidia_present()
|
||||
&& let Some(i) = gpus.iter().position(|g| g.vendor_id == VENDOR_NVIDIA)
|
||||
{
|
||||
return Some(SelectedGpu {
|
||||
info: gpus.into_iter().nth(i)?,
|
||||
source,
|
||||
});
|
||||
}
|
||||
let node = linux_render_node();
|
||||
let i = gpus
|
||||
@@ -621,10 +620,10 @@ pub fn manual_selection() -> Option<GpuInfo> {
|
||||
/// (a deliberate live env read — see `config.rs` module docs) > `/dev/dri/renderD128`.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_render_node() -> PathBuf {
|
||||
if let Some(g) = manual_selection() {
|
||||
if let Some(node) = g.handle.render_node {
|
||||
return node;
|
||||
}
|
||||
if let Some(g) = manual_selection()
|
||||
&& let Some(node) = g.handle.render_node
|
||||
{
|
||||
return node;
|
||||
}
|
||||
std::env::var("PUNKTFUNK_RENDER_NODE")
|
||||
.ok()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[package]
|
||||
name = "pf-host-config"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Process-wide punktfunk host configuration (env-parsed HostConfig behind a OnceLock)."
|
||||
publish = false
|
||||
|
||||
@@ -84,17 +84,17 @@ impl AudioOutputMode {
|
||||
/// first (it is the more restrictive promise — "do not touch my devices" must not be overridden
|
||||
/// by a stale `PUNKTFUNK_HOST_AUDIO` in the same `host.env`).
|
||||
fn from_env() -> AudioOutputMode {
|
||||
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE") {
|
||||
if !raw.trim().is_empty() {
|
||||
if let Some(m) = AudioOutputMode::parse(&raw) {
|
||||
return m;
|
||||
}
|
||||
// Never silently fall through to a different routing than the operator asked for.
|
||||
eprintln!(
|
||||
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
|
||||
client_only/host_and_client/follow_default — using client_only"
|
||||
);
|
||||
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE")
|
||||
&& !raw.trim().is_empty()
|
||||
{
|
||||
if let Some(m) = AudioOutputMode::parse(&raw) {
|
||||
return m;
|
||||
}
|
||||
// Never silently fall through to a different routing than the operator asked for.
|
||||
eprintln!(
|
||||
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
|
||||
client_only/host_and_client/follow_default — using client_only"
|
||||
);
|
||||
}
|
||||
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
|
||||
return AudioOutputMode::FollowDefault;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[package]
|
||||
name = "pf-inject"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host input injection: per-OS keyboard/mouse injectors + the virtual-gamepad HID backends behind one InputInjector trait."
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[package]
|
||||
name = "pf-paths"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Host config-directory resolution + owner-private file/dir creation (0600/0700 or SYSTEM/Admins DACL)."
|
||||
publish = false
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -1280,6 +1280,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
opts.render_scale_max_dim,
|
||||
);
|
||||
}
|
||||
// Adopt the tier this launch RESOLVED (globals or the host's
|
||||
// profile) instead of keeping the one the process started on.
|
||||
// `opts.stats_verbosity` only ever seeds the loop: the console
|
||||
// outlives every stream, so without this a settings change
|
||||
// reached the file and the settings row and nothing else until
|
||||
// the app was restarted.
|
||||
//
|
||||
// Deliberately HERE and not in `StreamState::new`: the
|
||||
// codec-fallback retry rebuilds the state from a clone of these
|
||||
// params mid-stream, and doing it there would snap the overlay
|
||||
// back every time a session fell down the codec ladder, undoing
|
||||
// a cycle the user had just made with the chord.
|
||||
stats_verbosity = params.stats_verbosity;
|
||||
// A live pump here would be DETACHED by the assignment
|
||||
// below — `StreamState` has no `Drop`, so its thread
|
||||
// would keep decoding onto the shared Vulkan device that
|
||||
|
||||
@@ -63,7 +63,17 @@ pub(crate) fn stamp_window_icon(window: &sdl3::video::Window) {
|
||||
let module = GetModuleHandleW(std::ptr::null());
|
||||
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
|
||||
let px = GetSystemMetrics(metric);
|
||||
let icon = LoadImageW(module, 1 as *const u16, IMAGE_ICON, px, px, LR_DEFAULTCOLOR);
|
||||
// MAKEINTRESOURCE(1): an integer resource ordinal smuggled through the name
|
||||
// pointer, never dereferenced — `without_provenance` says exactly that (and
|
||||
// `1 as *const u16` reads as a dangling pointer to clippy 1.96).
|
||||
let icon = LoadImageW(
|
||||
module,
|
||||
std::ptr::without_provenance(1),
|
||||
IMAGE_ICON,
|
||||
px,
|
||||
px,
|
||||
LR_DEFAULTCOLOR,
|
||||
);
|
||||
if !icon.is_null() {
|
||||
SendMessageW(hwnd, WM_SETICON, which as WPARAM, icon as LPARAM);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -398,7 +398,10 @@ mod linux_main {
|
||||
}
|
||||
|
||||
// One libc symbol, declared directly — not worth a libc dependency in a root helper.
|
||||
extern "C" {
|
||||
// (Edition 2024 spells extern blocks `unsafe extern`, which the `unsafe_code` lint now
|
||||
// counts — the same one-named-exemption rule as `effective_uid` below applies.)
|
||||
#[allow(unsafe_code)]
|
||||
unsafe extern "C" {
|
||||
#[link_name = "geteuid"]
|
||||
fn libc_geteuid() -> u32;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[package]
|
||||
name = "pf-vdisplay"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "punktfunk host virtual-display orchestration: per-compositor Linux backends + the Windows IddCx driver backend behind one VirtualDisplay trait."
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user